Black Hat USA 2026 · Briefings

Burning Tears of
PHP's Memory
Hardening

A fully remote, generic exploit path that survives PHP's newest heap defenses, from one constrained byte.
Yifan Wu · Xiaochuan Yu · Zhiyun Qian  ·  UC Riverside · UC San Diego
This research is 100% LLM-free.
(except for making these slides)
Nebula SecurityNEBULΛSECURITY

Two researchers, one lab.

nebula@blackhat: ~/talks/burning-tears
nebula@blackhat:~$ whoami
Frank Wu · Nebula Security
Hacking Linux and Android
Xiaochuan Yu · Nebula Security
Hacking XNU and Browsers
nebula@blackhat:~$ cat recent_work.md
first public nginx RCE · remote, ASLR bypass, generic config
world’s first Android 17 root (GhostLock) · one URL, full device control
first public Android browser → kernel full-chain (IonStack) in 7 years
nebula@blackhat:~$ ./burning_tears.sh --target=php --remote

It started with a CTF.

Securinets CTF quals 2024, the I HATE PHP challenge thread
Thank you Securinets CTF 🥰🥰
1
The CTFAn ordinary PHP challenge.
2
The 0‑dayA real flaw. PHP patched it.an unplanned side‑find
3
The real questionOne defense cracks, all four?

One request. That's all you get.

Browser pwn

for(let i=0;i<0x4000;i++){
		foo(true);
}
new ArrayBuffer(0x7f00000);

Kernel pwn

for(int i=0;i<N;i++)
	msgsnd(qid[i],&msg,size,0);

PHP remote

POST /upload.php HTTP/1.1
Host: victim.tld
Content-Length: 8192

data=%00%02%be%ef...

How you used to pwn PHP.

ZendMM: singly-linked listoverflowin-use buffernext0x…30free slotnext0x…20free slotvictimobjectvictimobjectchunk returnedby allocatorread / write victim fields leak & overwrite a function pointer RCE

Then PHP fought back.

Shadow Pointer

An encrypted copy of every freelist next, re-checked on each allocation.

next encrypted free slot shadow encrypted = checked on every alloc
✕ freelist poisoning

Unlink Prevention

Safe-unlink fd/bk checks on the doubly-linked chunk list.

chunk A fd/bk chunk P fd/bk chunk B P->fd->bk == P P->bk->fd == P
✕ list-forgery writes

RO Metadata

Allocator hooks in zend_mm_heap made read-only at runtime.

write zend_mm_heap _malloc _free _realloc read-only at runtime
✕ metadata hook hijack

Heap Isolation

Request data ($_GET/$_POST) moved to a separate heap zone.

request zone $_GET $_POST application heap objects, strings
✕ raw-request spray
CVE-2024-2961 CVE-2022-31626 public CTF chain
Aimed at the exact techniques every public PHP exploit relied on.

The old exploits died.

freelist nextblocked by Shadow Pointermetadata hooksblocked by Read-only$_POST sprayblocked by Heap Isolation
Each new defense closes exactly one classic technique
freelist, metadata, and raw spray all gone.
PART I

The playing field

How PHP allocates, and why that lets us shape the heap at all, remotely.

Same request, same heap.

request #1freed pagesfreelist0x…300x…800x…d0request #2freed pagesfreelist0x…300x…800x…d0
Fresh chunk each request, best-fit + LIFO, no cross-request state,
so identical requests build identical heaps.

What fengshui buys us.

Anything (raw bytes)

Spray attacker-defined raw memory the interpreter will later trust.

Anywhere (placement)

Put a chosen object at any offset next to the victim, in one request.

Both are hard under the new defenses.
Earning them back is the rest of the talk.

So we stopped touching the allocator.

00bufferneighbour1 byte past the edge1-byte OOBthe weakest possible bug01CONTRIBUTIONBIGindex arrayfakeIndex Forgerycorrupt a table index, not a pointer02valuetypefake zval*p++ / −−Arbitrary ++/−−read = *p++, write = *p−−03leakprobehijackZOPthe interpreter is the gadget set
Every step lives in built-in PHP objects, never the allocator, never app classes.
II

Index Forgery

Turning the weakest possible bug, one constrained byte, into a strong primitive.

One byte in.

1-byte OOBthe inputindex forgeryone integerfake zvala controlled objectarbitrary ++/--write anywhereweakest bugstrong primitive
1-byte overflowvalue constrainedno OOB read
The nightmare bug, yet enough. Works here means it works for almost anything stronger.

Meet the zval.

value (8 bytes)+0x00u1.type_info+0x08IS_LONG= 4IS_DOUBLE= 5IS_STRING= 6IS_ARRAY= 7IS_OBJECT= 8flip the low type byte -> same 8 bytes reinterpretedflags byte: IS_TYPE_REFCOUNTED = 1<<0
Every PHP value is 8 bytes plus a type tag. Control a zval, and you control what PHP believes memory is.

The real target: arData.

zend_array (HashTable)

+0x00
gc · refcount
+0x0c
nTableMask
+0x10
arData → butterfly
+0x18
nNumUsed · size
+0x38
pDestructor · hijack target

arData is a butterfly

A separate allocation the overflow reaches. arData points to the split: index array on the left half, buckets on the right.

idx
idx
idx
Bucket[0]
Bucket[1]
← index grows  |  arData →   buckets grow →

A normal lookup.

$t[$key]victim bufoverflow sourcearData buffer (butterfly) · index | bucketsbuckets0x000x010x020x03Bucket[0]zvalBucket[1]zvalBucket[2]zvalarDataraw string bytesBucket[0].valuesprayed region
A zend_array reads from a separate arData buffer, index array in front of the buckets.

A normal lookup.

$t[$key]victim bufoverflow sourcearData buffer (butterfly) · index | bucketsbuckets0x000x010x020x03Bucket[0]zvalBucket[1]zvalBucket[2]zvalarDataraw string bytesBucket[0].valuesprayed region
hash(key) | mask → a slot → the integer there picks the Bucket. PHP never doubts that integer.

One byte, one redirect.

$t[$key]victim bufoverflow sourcearData buffer (butterfly) · index | bucketsbuckets1-byte OOB0x4D0x010x020x03Bucket[0]zvalBucket[1]zvalBucket[2]zvalarDataraw string bytesBucket[0].valuesprayed region
The overflow lands on index[0], not a pointer, just a small integer. One byte moves it far.

One byte, one redirect.

$t[$key]victim bufoverflow sourcearData buffer (butterfly) · index | bucketsbuckets1-byte OOB0x4D0x010x020x03Bucket[0]zvalBucket[1]zvalBucket[2]zvalarDataraw string bytesBucket[0].valuesprayed, known offsetfake Bucketfake zval
A large index reaches memory we sprayed, a fake Bucket holding a fake zval we control.

Make the fake pass.

forged Bucket (sprayed)+0x00zval.valueyou set+0x08zval.u1.type_infoyou set+0x10h (hash)MUST match+0x18keyMUST matchhow the check passeshash: DJBX33A is reversiblecraft a string whose hash == the key we look up (Alg.1)key: reoccupy the residual key pointerspray the same key bytes so the compare succeedslookup passes ✓$t[key] returns our fake zval
PHP re-checks the bucket key and hash. We forge a match with the reversible DJBX33A hash, and the lookup passes.

Now you own a zval.

fake zvalvalue -> *targetread $t[$key]*target ++write $t[$key]=$v*target --any address
Mark the fake zval refcounted, and a plain read/write does ++ / -- through your pointer, at any address.

But the pointer is constrained.

Why limited

The value pointer is sprayed as a string, encoding-restricted. We control only its low bytes.

So ++/-- lands near a chosen spot, not anywhere.

value = 0x…??
low-byte overlap
→ nearby sprayed memory

Lift to arbitrary.

One extra indirection

Steer the constrained value at the type_flags of another zval in a sprayed array.

++/-- on a type tag → make any zval refcounted → decrement anywhere.

fake zval.value ─┐
low-byte steer
└→ zval.type_flags
limited ++/-- → arbitrary ++/--

One corrupted index. Four primitives.

the one operation$t[key]LARGE205a corrupted index, the 1-byte overflow set index[0] to LARGEBucket[large]OOBthe large index reaches out of boundsfake Bucketwhat Bucket[large] lands on+0x00valuelowhigh+0x08type_infouninit+0x10huninit+0x18keyptrwritten by a sprayed stringuninitialized (UBI)UBIUse of Uninitializedtype · h · key · value(high), residual bytesUAFfreed zend_stringkey is dereferenced hereIFlow bytes steer the pointerarbitrary −− / free → anywhereANY addresswhole Zend heap
The out-of-bounds access lands on a fake Bucket that is mostly uninitialized (UBI), only the low bytes of value are string-written; its key is dereferenced into a freed string (UAF), and steering that value pointer gives IF, an arbitrary decrement anywhere.

OOB and UAF meet here.

OOB writevia Index ForgeryUAF writereclaim a freed zvalcontrol a zvalthe shared waypointZOPleakprobePC control
Different bugs, one waypoint. OOB via Index Forgery and UAF both reach a controlled zval, then run the same ZOP.
III

ZOP

Zend-Oriented Programming, the interpreter's own structures become the gadget set.

The interpreter is the gadget set.

From one primitive, everything

arbitrary ++/-- leak · probe · hijack

Leak

flip a type tag, PHP prints the pointer

Probe

++/-- as an oracle to defeat ASLR

Hijack

a destructor pointer you control

Let PHP print the pointer.

value = 0x7ffff46123f0type = IS_STRING (6)a heap pointertag 6 -> 5arb --6.9533461755114104e-310type = IS_DOUBLE (5)same bytes, a numberreturned in JSON/XML -> Zend heap address leaked
No OOB read. Flip a string zval to a double and the same bytes come back as a number in the response.

ASLR just probing.

PHP side (need php-fpm / zif_system)php-fpm .text0x000059adca400000r-xFPM_heap0x000059adf7410000rw-near each otherlibc side (need system)libc.so.60x00007ffff780d000r-xzend_heap0x00007ffff4600000rw-near each otherprobe page-by-page from a known heap anchorpage okpage okpage okpage okpage okunmappedunmappedunmapped0x7ffff4600000 ->crash -> 502 = boundary found
The heaps sit near known code, zend_heap by libc, FPM_heap by php-fpm, so we probe from an anchor until a page faults (502).

Hijack via a destructor.

1 · a nested-array pointerzval.value ->nested zend_arrayarb -- on low3 LSBytes2 · redirected into a packed integer array (spray)intintintintintintint3 · those bytes overlap a forged zend_arraygc . refcount = 1replacement -> 1->0arData -> arg0argument controlledpDestructor -> pccall target controlledpc(arg0)4 · destructor fires -> hijack
A forged array\'s refcount hits zero, PHP calls its destructor, and we control both the function and its argument.
IV

Doing it remotely

Everything so far needs a heap layout, built in one shot, with no raw bytes allowed.

Isolation has a gap.

user-input heap · isolated $_GET $_POST $_COOKIE ✕ cannot shape the app heap decode application heap · where the bug lives zend_string zend_array zend_object JSON / XML decode lands here ✓
Raw request fields are walled off, but the objects the app decodes from them land on the app heap, right where the bug is.

Duplicate keys = a heap script.

{ "k": "A"*0x3000, "k": "B"*0x4000, "k": target, "k": null }
Each value is allocated, all-but-one freed, an alloc/free script inside one request.

Integer arrays are a raw memory editor.

A packed, integer-keyed array stores a flat run of zvals.
Set each value → spray any 8-byte pattern.
0xdeadbeefdeadbeef
0x4141414141414141
0x0700000000000001
V

End to end

One real CVE, one constrained byte, fully remote, from HTTP request to shell.

One real bug, fully remote.

CVE-2024-2961

glibc iconv OOB via a normal PHP app. In practice: one byte, value pinned to 0x48–0x4D.

All four defenses on. No recon request.

Where the work lives

The victim app is a few lines. The exploit is a heap program encoded entirely in one JSON body.

Build, place, trigger. Once.

buildspray fake bucket + zvals
placepage-level fengshui
forge1-byte IF → fake zval
triggerdestructor → pc(arg0)
ONE HTTP REQUEST · SHARE-NOTHING RESET AFTER{ }one JSON bodydup keys = a heap scriptpacked ints = raw bytesAPPLICATION HEAPspray zvals / stringsfake bucket + fake zvalfake zend_arrayfree (duplicate keys)arData victim bufferforged array refcount → 0 · destructor runspc(arg0)
Leak, layout, and hijack all fold into a single HTTP body, there is no second request.

Quick demo.

VI

Does it hold up?

Real CVEs, all defenses on, measured for reliability, not a one-off crash.

We revived the dead CVEs.

Target
Goal
Old
Ours
Reqs
Success
CVE-2024-2961
RCE
246
100%
CVE-2022-31626
RCE
285
100%
CVE-2019-6977
SBE
2
100%
CTF Case A ("I hate php" from SecurinetsCTF)
RCE
3
100%
CTF Case B ("php master" from N1CTF)
RCE
371
100%
Old public exploits: dead under hardening.
Rebuilt with IF + ZOP: revived, under 300 requests, 100% over 100 runs.

The roadmap, graded.

Already merged & effective: Shadow Pointer · Unlink Abuse Prevention, they forced attackers off freelist poisoning
prototype

Read-only Metadata

Metadata falls, but by then the write is already strong, and the ASLR probe exposes writable structs anyway.

verdict: limited
in development

Further Heap Isolation

Per-type dedicated heaps disrupt remote page-level shaping, the assumption our whole path rests on.

verdict: strong
proposal

Guard Pages

Fixed-size gaps do little here. A randomized variant injects real page-level entropy.

only if randomized
proposal

Freelist Randomization

Reorders slots in a freelist, but page-level spraying still walks past it.

verdict: partial
Isolation + randomization move the needle. Metadata protection helps least.

The smallest fix.

Patch-hashtable

Check the index stays within num_used. Kills Index Forgery at the source.

0.17%
overhead

Patch-refcnt

Validate the target is an aligned zend_refcounted on the PHP heap. Kills arbitrary ++/--.

8.86%
overhead

Good hardening works.

What PHP got right

Good freelist hardening. Attackers are forced off the allocator.

What's left

Built-in objects and decoder-driven layout.

If you want a common defense, harden all common paths.
Generic object hardening
Object isolation
Randomization

Good hardening works.

If you want a common defense, harden all common paths.
Generic object hardening
Object isolation
Randomization
github.com/GhostFrankWu/
PHP-security-research

THANK
YOU

PHP's hardening moved the bar. We showed where it still needs to go, and shipped two fixes toward it.

Burning Tears of PHP's Memory Hardening
Yifan Wu · Xiaochuan Yu · Zhiyun Qian
Artifact + Docker: one-click reproduction
Black Hat USA 2026 Briefings