这条链是某师傅在先知对一个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; } } 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; } } 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
| 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
流程大概是
第一次写入文件,文件内容不可控,然后进入setTagItem方法第二次写文件
第二次写文件,文件内容为第一次序列化后的文件名其中包含rot13的字符串和php死亡exit的代码,文件名为options[“path”]+md5(tag_md5(tag))+.php
但是经过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']); }
|
至于与WIndows文件写入有啥关系,我们继续向下跟
我们可以绕一下走thinkphp\library\think\cache\driver\Memcached.php
101行下也有set方法其中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| public function set($name, $value, $expire = null) { if (is_null($expire)) { $expire = $this->options['expire']; } if ($expire instanceof \DateTime) { $expire = $expire->getTimestamp() - time(); } if ($this->tag && !$this->has($name)) { $first = true; } $key = $this->getCacheKey($name); $expire = 0 == $expire ? 0 : $_SERVER['REQUEST_TIME'] + $expire; if ($this->handler->set($key, $value, $expire)) { isset($first) && $this->setTagItem($key); return true; } return false; }
|
这里**$this->handler->set** 我们把handler设置为File类 然后回顾上面我们File类中set方法写文件过程
这里第一次写入的文件不用理(也就是第一次调用File的set方法)
这时候进入setTagItem方法$key 此时作为第二次set的参数 也就是说参数此时可控了
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); } }
|
因为上一步处理的文件名的方法中可以看到options[‘prefix’] 我们是可控的 赋值为Base64的恶意代码
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; }
|
等于传入File的set方法我们的值可控 我们只需要将死亡exit去除
Memcache里面的set方法进入File里的set方法
1
| if ($this->handler->set($key, $value, $expire))
|
此时$key为setTagItem方法里面的’tag_’ . md5($this->tag); $value为上一步传入的$key 即我们可控制的值 此时控制File set中getCacheKey 方法中options[‘path’]将我们的文件名改为php://filter/convert.base64-decode/resource=./
即可将死亡exit去除