记一次命令执行的奇妙冒险

0x01

起源:朋友在做某项目但是一直没有突破,听闻百京的师傅整到一个RCE成功整下该项目的一个子域名,并扔出了一张打了马赛克的,奈何实在研究无果但又对马赛克后面的内容非常感兴趣(属实宅男天性),打算探究一番马赛克的图,找出这个RCE.

可以看到只能知道POST的路径和execSync,再根据返回包已经pwd命令返回的路径判断使用的Nodejs的Express框架,奈何本人对Nodejs没有任何了解,于是看了几篇相关的文章,觉得还是要看一下POST数据的参数情况,采用了各种方法测试Fuzz数据得到的仍然是

这点我想了很久根据这个业务判断应该有个入口提交数据到这个接口于是拜托朋友再去看了看,功夫不负有心人,朋友很快发来了一个使用该系统入口提交数据的地方使得我们能知道到底POST提交了那些数据

数据包的内容

1
2
POST /chat HTTP/1.1
data={"action":"getSocketServiceAddress","accessId":"71817ab0-237b-11e7-a165-910f06e6de4f","sid":"50358800-8607-11ea-b421-85952c69bc13"}

去掉了一些不相关的信息可以看到参数有data并且内容为json字符串,将该参数填入我们的目标进行测试

可以看到返回了一个Url,说明该参数正确,观察json 字符串中的action值为getSocketServiceAddress,怀疑该字符串作为了构造函数类似的东西去调用了,于是尝试使用Javascript中的eval函数尝试,测试发现accessld与sid并无多大作用所以去除测试。

使用eval函数发现出现undefined is not a function这个提示

当在浏览器控制台直接使用eval函数不传入任何参数时

值为undefined证实确实可以调用eval函数,于是采用nodejs命令执行的老办法child_process结合Dnslog平台判断,为啥要结合dnslog平台呢? 主要是因为执行后无回显无法判断只好采用Dnslog来测试

成功收到请求

但是无法用回显的方式就比较难受

如图这种情况下实际whoami命令成功执行但是无回显,尝试ls /xxx时发现报错,结合之前的经历,是否能通过报错将数据带出来呢?

在Linux中反引号是可以用来执行命令的,如图:

当执行

1
`ls`

ls执行后的结果作为命令再次执行而该结果并不是命令导致报错,于是我们可以使用反引号来报错带出数据,说干就干

可以看到成功带出执行结果,但是这种方式并不完美,比如执行cat /etc/passwd这个命令的时候只会带出文件的前一部分内容并不完整,回顾刚才我们看师傅的那张马赛克图片可以看到是在头部执行命令,并返回结果,猜想应该是使用了类似java反序列化回显的原理,利用上下文环境获取指定的HTTP头信息然后将执行结果写入到返回包中,可能有朋友会说为啥不弹个shell出来,确实是Ok的,只不过如果考虑不能出网的环境的话命令执行后的回显确实是我们要考虑的一个问题,奈何本人才疏学浅只能继续学习啦

0x02

在文章的最后感谢这位提供马赛克图片的师傅让我确确实实学到许多,同时也感谢朋友提供的这一个案例让我受益匪浅,文笔较浮躁,措辞可能不严谨希望师傅们指出让我加以改正,也希望师傅们带带我。


git推送命令记录

刚刚搭好博客,记录一下推送到git仓库的命令,防止忘记

1
2
3
4
5
6
git init //初始化
git add . //添加所有文件到暂存区
git commit -m '' //添加commit
git remote add origin 原创仓库地址 //关联原创仓库
git pull --rebase origin master
git push -u origin master //推送本地仓库

Thinkphp5.0.24 反序列化链学习

这条链是某师傅在先知对一个Tp5.0.24开发的CMS审计时发现的一个触发点的利用,本来该利用点只能完成SSRF与任意文件删除的,为了提升危害,于是又从TP5.0.24中挖掘出了这一条通过写入文件Getshell的的Pop链(师傅们实在tql,枯了)

关于这条链先知上已经有几篇分析过了,了解了几点:

1.当目标目录无写入权限时我们可以创建一个权限为0755的文件夹,然后写shell

2.使用php伪协议写入shell绕过死亡exit,因为文件名不可控所以Windows下无法利用(后面可以知道原因)

0x01

学习开始,首先开头还是Tp5.1.x中的那条链,具体看我的PaperBook仓库中有详细分析的过程,然后到removeFiles这个方法文件名传入对象触发__toString魔术方法然后到thinkphp/library/think/Model.php 853行的toArray方法开始,原来在5.1.x是利用访问某类中不存在的方法触发__call魔术方法,但是在5.0.x中hook变为静态属性(关于静态属性为何不能利用可以看php相关知识),所以得重新寻找可以利用的地方触发__call魔术方法,先看toArray这个方法在什么位置触发的__call,又是如何触发的 因为图片Base64显示太大了,所以直接代码吧 XD.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
if (!empty($this->append)) {
foreach ($this->append as $key => $name) {
if (is_array($name)) {
// 追加关联对象属性
$relation = $this->getAttr($key);
$item[$key] = $relation->append($name)->toArray();//这一处
} elseif (strpos($name, '.')) {
list($key, $attr) = explode('.', $name);
// 追加关联对象属性
$relation = $this->getAttr($key);
$item[$key] = $relation->append([$attr])->toArray();
} else {
$relation = Loader::parseName($name, 1, false);
if (method_exists($this, $relation)) {
$modelRelation = $this->$relation();
$value = $this->getRelationData($modelRelation);

if (method_exists($modelRelation, 'getBindAttr')) {
$bindAttr = $modelRelation->getBindAttr();
if ($bindAttr) {
foreach ($bindAttr as $key => $attr) {
$key = is_numeric($key) ? $attr : $key;
if (isset($this->data[$key])) {
throw new Exception('bind attr has exists:' . $key);
} else {
$item[$key] = $value ? $value->getAttr($attr) : null;//另?前的条件为真执行 $value->getAttr
}
}
continue;
}
}
$item[$name] = $value;
} else {
$item[$name] = $this->getAttr($name);
}
}
}
}

看代码,首先append变量我们可控,然后前两个分支第一个是判断$name是否为数组,第二个是判断$name中是否存在".",前两个分支显然是不满足我们的需求的,于是直接到else中,我们看看else的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$relation = Loader::parseName($name, 1, false);
if (method_exists($this, $relation)) {
$modelRelation = $this->$relation();
$value = $this->getRelationData($modelRelation);

if (method_exists($modelRelation, 'getBindAttr')) {
$bindAttr = $modelRelation->getBindAttr();
if ($bindAttr) {
foreach ($bindAttr as $key => $attr) {
$key = is_numeric($key) ? $attr : $key;
if (isset($this->data[$key])) {
throw new Exception('bind attr has exists:' . $key);
} else {
$item[$key] = $value ? $value->getAttr($attr) : null;//另?前的条件为真执行 $value->getAttr
}
}
continue;
}
}
$item[$name] = $value;
} else {
$item[$name] = $this->getAttr($name);
}

$relation = Loader::parseName($name, 1, false)追踪了一下意义不大,我们直接看满足条件后的赋值

1
2
$modelRelation = $this->$relation();
$value = $this->getRelationData($modelRelation);

我们要使$relation为我们当前类存在的方法(method_exists函数),且方法中返回的值我们可控,这里就找到了1608行的getError方法

1
2
3
4
public function getError()
{
return $this->error;
}

其中error可控,代表$modelRelation可控,$value = $this->getRelationData($modelRelation)

$modelRelation经过getRelationData方法处理后赋值给了$value,我们跟踪一下getRelationData方法,看看他是如何处理$modelRelation,跟踪到639行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
protected function getRelationData(Relation $modelRelation)
{
if ($this->parent && !$modelRelation->isSelfRelation() && get_class($modelRelation->getModel()) == get_class($this->parent)) {
$value = $this->parent;
} else {
// 首先获取关联数据
if (method_exists($modelRelation, 'getRelation')) {
$value = $modelRelation->getRelation();
} else {
throw new BadMethodCallException('method not exists:' . get_class($modelRelation) . '-> getRelation');
}
}
return $value;
}

可以看到传入类型需为Relation,需要满足$this->parent && !$modelRelation->isSelfRelation() && get_class($modelRelation->getModel()) == get_class($this->parent)这样$value就能返回我们控制的值,其中$value设置的类中需要有getBindAttr方法,才能满足条件进入,在if判断中的3个条件,我们寻找满足条件的类,可以找到HasOne类满足我们的条件,类型为Relation,同时isSelfRelation(),$this->parent,get_class($modelRelation->getModel()) == get_class($this->parent),这3个条件中的值我们都可控,可以满足if的判断,但是可能我们会发现该类中并不能找到getBindAttr方法,无法通过method_exists的判断,但是看师傅的文章确实是使用该类,于是留心一下注意到了该类第一行的代码class HasOne extends OneToOne,继承自OneToOne类,跟进OneToOne,搜索getBindAttr方法,在222行找到该方法,其中bindAttr值可控

1
2
3
4
5
    //thinkphp/library/think/model/relation/OneToOne.php Line:222
public function getBindAttr()
{
return $this->bindAttr;
}

到了我们触发__call魔术方法的关键之处

1
2
3
4
5
6
7
8
9
$bindAttr = $modelRelation->getBindAttr();
if ($bindAttr) {
foreach ($bindAttr as $key => $attr) {
$key = is_numeric($key) ? $attr : $key;
if (isset($this->data[$key])) {
throw new Exception('bind attr has exists:' . $key);
} else {
$item[$key] = $value ? $value->getAttr($attr) : null;
}

$bindAttr中的值是从OneToOne中的getBindAttr方法获取的,其中的值我们知道我们可控,$attr可控,$value可控我们就可以触发__call方法,寻找符合条件的类找到thinkphp/library/think/console/Output.php类208行中的__call方法,其中会调用该类中的block方法,需满足in_array($method, $this->styles)因为styles的值我们可控,设置为包含getAttr的数组即可

1
2
3
4
5
6
7
8
9
10
11
12
13
public function __call($method, $args)
{
if (in_array($method, $this->styles)) {
array_unshift($args, $method);
return call_user_func_array([$this, 'block'], $args);
}

if ($this->handle && method_exists($this->handle, $method)) {
return call_user_func_array([$this->handle, $method], $args);
} else {
throw new Exception('method not exists:' . __CLASS__ . '->' . $method);
}
}

所以成功进入return call_user_func_array([$this, 'block'], $args);,array_unshift这处在5.1的链中已经提过,在这条链中无太大影响故不提,我们继续追踪block方法,在Output类的122行

1
2
3
4
protected function block($style, $message)
{
$this->writeln("<{$style}>{$message}</$style>");
}

调用了writeln方法,我们跟踪writeln方法,在141行

1
2
3
4
public function writeln($messages, $type = self::OUTPUT_NORMAL)
{
$this->write($messages, true, $type);
}

随后又调用了write方法,在152行

1
2
3
4
public function write($messages, $newline = false, $type = self::OUTPUT_NORMAL)
{
$this->handle->write($messages, $newline, $type);
}

其中handle可控,也就是说我们能访问任意带有write方法的类,全局查找write方法(一般在继承File类的类中)

找到thinkphp/library/think/session/driver/Memcached.php(发现thinkphp/library/think/session/driver/Memcache.php文件也实现了相同功能,两者应该都能使用) 92行处

1
2
3
4
public function write($sessID, $sessData)
{
return $this->handler->set($this->config['session_name'] . $sessID, $sessData, $this->config['expire']);
}

这里调用了set方法,handler还是一样可控,我们可以访问带有set方法类,在文件thinkphp/library/think/cache/driver/File.php 141行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public function set($name, $value, $expire = null)
{
if (is_null($expire)) {
$expire = $this->options['expire'];
}
if ($expire instanceof \DateTime) {
$expire = $expire->getTimestamp() - time();
}
$filename = $this->getCacheKey($name, true);
if ($this->tag && !is_file($filename)) {
$first = true;
}
$data = serialize($value);
if ($this->options['data_compress'] && function_exists('gzcompress')) {
//数据压缩
$data = gzcompress($data, 3);
}
$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
$result = file_put_contents($filename, $data);
if ($result) {
isset($first) && $this->setTagItem($filename);
clearstatcache();
return true;
} else {
return false;
}
}

这里获取文件名是通过getCacheKey方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
protected function getCacheKey($name, $auto = false)
{
$name = md5($name);
if ($this->options['cache_subdir']) {
// 使用子目录
$name = substr($name, 0, 2) . DS . substr($name, 2);
}
if ($this->options['prefix']) {
$name = $this->options['prefix'] . DS . $name;
}
$filename = $this->options['path'] . $name . '.php';
$dir = dirname($filename);

if ($auto && !is_dir($dir)) {
mkdir($dir, 0755, true);
}
return $filename;
}

这里需将可控数组options中的值cache_subdir与prefix设置一下,$filename是从options数组取path的值与传入的$name进行拼接为文件名,然后通过dirname函数返回目录,并判断目录是否存在,不存在则调用mkdir函数创建并返回$filename,这里文件名部分已经弄清楚了,我们来看要写入的数据,$value通过序列化后赋值给了$data变量,然后进行数据压缩,这一步我们可以设置options数组中的data_compress值来跳过这一步骤,随后在数据拼接的时候又加入了死亡exit,这时候可以利用php中伪协议来处理数据绕过,然后就到了file_put_contents函数中,但是$data我们从前面跟踪下来发现是无法控制的(前面默认传入的参数为true),我们继续看代码,当执行完file_put_contents函数后将返回结果赋值给$result然后执行了isset($first) && $this->setTagItem($filename),我们看setTagItem方法做了什么,首先$filename传入了该方法即$name变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected function setTagItem($name)
{
if ($this->tag) {
$key = 'tag_' . md5($this->tag);
$this->tag = null;
if ($this->has($key)) {
$value = explode(',', $this->get($key));
$value[] = $name;
$value = implode(',', array_unique($value));
} else {
$value = $name;
}
$this->set($key, $value, 0);
}
}

此时tag变量可控,$key的值我们就可以知道,然后$name($filename)又赋值给$value,最后将$key与$value继续传入set方法(此时写入的内容可控即第一次写入的文件名),文件名为$key即tag_md5(tag)->getCacheKey()->文件名:(path+md5(tag_md5(tag))+.php),将path中的值设置为带有php代码的值并进行处理绕过死亡exit即可实现写入webshell也正是因为文件名中path的值为php代码导致该链只能在linux下使用

0x02

流程大概是

  1. 第一次写入文件,文件内容不可控,然后进入setTagItem方法第二次写文件

  2. 第二次写文件,文件内容为第一次序列化后的文件名其中包含rot13的字符串和php死亡exit的代码,文件名为options[“path”]+md5(tag_md5(tag))+.php

  3. 但是经过php://filter/write=string.rot13/resource=<?cuc cucvasb();?>+文件名将写入文件的exit代码rot13编码 而 前面文件名已经rot13编码过的字符则还原成php代码自此成功写入webshell且文件名我们可以计算得出

具体的Exp在师傅们的Blog中已经给出,就不写出来了,因为最近重新装了系统,linux环境没换上就没有经典的弹calc图了,求原谅 :)

师傅们tql Orz

0x03 补充

补充:在先知中已经看到有师傅提供了在Windows下利用的解决方法,这里补充一下

之前对Thinkphp5.0.24中存在的反序列化利用链进行了分析,说到因为写入方式的问题导致在Windows环境下不可用的问题,后面在先知看到某师傅的一篇文章,发现还是有办法在Windows环境下控制文件名进行写入,文章地址:关于 ThinkPHP5.0 反序列化链的扩展

重新跟踪

thinkphp/library/think/session/driver/Memcache.php文件的92行处进行了文件写入

1
2
3
4
public function write($sessID, $sessData)
{
return $this->handler->set($this->config['session_name'] . $sessID, $sessData, $this->config['expire']);
}

在这里我们可以看到session_name是通过config获取的,而config数组又可控,所以可以通过控制session_name的值为我们的php代码即可,至于与WIndows文件写入有啥关系,我们继续向下跟thinkphp/library/think/cache/driver/File.php 文件 141

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public function set($name, $value, $expire = null)
{
if (is_null($expire)) {
$expire = $this->options['expire'];
}
if ($expire instanceof \DateTime) {
$expire = $expire->getTimestamp() - time();
}
$filename = $this->getCacheKey($name, true);
if ($this->tag && !is_file($filename)) {
$first = true;
}
$data = serialize($value);
if ($this->options['data_compress'] && function_exists('gzcompress')) {
//数据压缩
$data = gzcompress($data, 3);
}
$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
$result = file_put_contents($filename, $data);
if ($result) {
isset($first) && $this->setTagItem($filename);
clearstatcache();
return true;
} else {
return false;
}
}

前面写的文章中说明了为啥只能在Linux环境下利用的原因,因为在getCacheKey方法中,options数组中的path值已经固定,且文件内容不可控导致无法getshell,于是找到了setTagItem方法,该方法中进行了二次文件写入,写入的内容就是第一次写入的文件名,然而options数组中的path的值一直没有改变(文件名开头一直为rao13编码后的php代码)所以导致虽然能控制文件写入的内容,但文件名中包含了特殊字符,导致不能在windows环境下利用成功

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
protected function getCacheKey($name, $auto = false)
{
$name = md5($name);
if ($this->options['cache_subdir']) {
// 使用子目录
$name = substr($name, 0, 2) . DS . substr($name, 2);
}
if ($this->options['prefix']) {
$name = $this->options['prefix'] . DS . $name;
}
$filename = $this->options['path'] . $name . '.php';
$dir = dirname($filename);

if ($auto && !is_dir($dir)) {
mkdir($dir, 0755, true);
}
return $filename;
}

但是看了师傅的方法是,刚刚开头的session_name处这里可以控制第一次写入的文件名,而options数组中的path值就不需要加入我们的rao13编码后的php代码,能实现控制第一次写入的文件名,不影响第二次写入的文件名,从而在Windows环境下的利用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected function setTagItem($name)
{
if ($this->tag) {
$key = 'tag_' . md5($this->tag);
$this->tag = null;
if ($this->has($key)) {
$value = explode(',', $this->get($key));
$value[] = $name;
$value = implode(',', array_unique($value));
} else {
$value = $name;
}
$this->set($key, $value, 0);
}
}

大概的过程

第一次进入set方法(session_name+$sessid)然后通过getCacheKey方法返回文件名 options数组path的值为php://filter/write=string.rot13/resource=即可,此时文件名中含有我们开头设置session_name的值为rot13编码后的php代码,然后进入第二次写文件也就是setTagItem方法,进行第二次写文件操作,进入getCacheKey,此时options数组中的path值已经正常,返回的文件名为md5('tag_' +md5($this->tag)).php,到了file_put_contents函数$filename为php://filter/write=string.rot13/resource=md5('tag_' +md5($this->tag)).php

这里文件名可控,写入的值也可控,就能成功在Windows下利用


CVE-2020-2551复现

影响范围:10.3.6.0.0,12.1.3.0.0,12.2.1.4.0,12.2.1.3.0 具体可以看宇师傅的博客,https://www.r4v3zn.com/posts/b64d9185/#more 写的非常详细,各种问题也已经说明,网上也已经有具体的Poc了,直接拷贝下来让后把相关的包添加即可,可以稍微修改一下代码自定义

Tips:这里会有一个坑就是包要与要打的目标相同,不然会出现Mismatched serialization UIDs错误,解决方法就从安装的Weblogic中复制到包的目录下即可

复现环境

  • windows2008 R2 x64

  • Weblogic版本:12.2.1.3.0

  • Jdk版本:8u112(本来是用最新的,后面因为高版本的jdk受trustURLCodebase影响不能远程加载class,导致无法复现成功)

    这里放一张经典图片来说明

JNDI注入这里没有使用marshalsec,而是使用JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar这个工具,个人认为比marshalsec方便快捷许多,少了一些繁琐的步骤 膜Welk1n师傅一下

复现过程

将Weblogic搭建好后去到目录user_projects\domains\base_domain运行startWeblogic.cmd,即可启动Weblogic,访问

image-20200313190937366

出现上面的Weblogic登录页面则Weblogic搭建成功

将JNDI访问启动命令:

1
java -jar JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar -C "calc.exe" -A "127.0.0.1"

-C为要执行的命令, -A为JNDI服务地址,成功运行后如图

运行我们的Poc将rmi地址填入,这里填jdk7的rmi地址也是可以复现成功的

成功利用,并弹出了我们期待的calc

在低版本jdk中编译可以用javac Poc.java -source 1.6 -target 1.6命令来编译,同样类的版本要对应,听Wyatu师傅说有通用版本的利用方式,只能感慨师傅们Tql,Orz


Jwt security issue

JWT介绍与jwt的结构

什么是JWT,jwt全称:Json Web Token,是一个开放标准(RFC 7519),它定义了一种紧凑的、自包含的方式,用于作为JSON对象在各方之间安全地传输信息。该信息可以被验证和信任,因为它是数字签名的,jwt可以用于授权与信息交换,作为跨域身份验证的一种方案.

JWT的结构:Header(头部) . Payload(负载) . Signature(签名) 由3部分组成

Header结构
1
2
3
4
5
{
"alg":"HS256"//默认HMAC SHA256 HS256
"typ":"JWT"
"kid":"/key/"//可选参数 指定加密算法的密钥
}

alg表示签名算法,typ表示令牌类型 后用Base64Url加密为字符串

Payload结构
1
{"jti":"1","iat":1528630988,"sub":"user1","exp":1528631588}

iss (issuer):签发人
exp (expiration time):过期时间
sub (subject):主题
aud (audience):受众
nbf (Not Before):生效时间
iat (Issued At):签发时间
jti (JWT ID):编号

Signature结构

Header指定的算法( base64UrlEncode(header) + “.” + base64UrlEncode(payload), secret) ,例如:

1
2
3
4
5
6
7
8
9
10
header
{
"alg":"HS256"//默认HMAC SHA256 HS256
"typ":"JWT"
"kid":"/key/"//可选参数 指定加密算法的密钥
}
payload
{"jti":"1","iat":1528630988,"sub":"user1","exp":1528631588}
signature
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

用来根据Header指定的算法和secret对header和payload加密作为客户端的Cookie

Base64Url算法

1
JWT 作为一个令牌(token),有些场合可能会放到 URL(比如 api.example.com/?token=xxx)。Base64 有三个字符+、/和=,在 URL 里面有特殊含义,所以要被替换掉:=被省略、+替换成-,/替换成_ 。这就是 Base64URL 算法。

最终:

header.payload.Signature 其中header 与 payload 可以解密 Signature用于保证 内容不可篡改

base64encode(header).base64encode(payload).Header指定的算法( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)