Under the hood of the October 2026 flaws: one byte too many, a forgotten pointer, a shared page
How one byte too many, a forgotten pointer or a shared page become code execution, explained through the Brotli, PHP2Shell and ZDI-CAN-33367 flaws.
This week I published four posts on four October 2026 flaws: PHP2Shell, the Brotli flaw, the Node.js fixes and the ZDI-CAN-33367 kernel flaw. Each explained the flaw and what I did on my servers. This one steps back and goes one level down: how memory corruption turns one byte too many, a forgotten pointer or a shared page into code execution, and why modern protections slow some of these flaws and not others. No exploitation details, only the mechanisms, at the level an administrator needs to judge severity and choose a measure.
| Brotli (Node2Shell) | PHP2Shell | ZDI-CAN-33367 | |
|---|---|---|---|
| bug class | out-of-bounds write | use-after-free, inconsistent state | logic error: two paths share a page |
| where | streaming decoder, C code | memory manager and request cleanup, C code | kernel network stack |
| memory corruption? | yes | yes | no: every write is “legitimate” |
| trigger | received compressed data | HTTP request | local user |
| slowed by ASLR, CFI… | yes, partly | yes, partly | no |

What “memory corruption” means
A program written in C manages its own memory: it requests an area of a given size, stores data there, gives it back when done. The language checks nothing: writing 9 bytes into an 8-byte area, or reading an area already given back, causes no immediate error. The program simply writes alongside, into whatever is there. The whole severity depends on what is there.
For an attacker, the typical path has three steps. First a primitive: the ability, often very limited at first, to write or read a little memory that shouldn’t be. Then widening: using that small write to modify a structure that controls other accesses (a length, a pointer), until reading and writing anywhere is possible. Finally hijacking: modifying an address the program will use to decide what code to run next. Each step is hard; the AI-assisted analysis tools that found PHP2Shell and Node2Shell are precisely good at chaining them.
Brotli: an error taken for a wait
The format
Brotli stream
├─ stream header: sliding window size (up to 16 MiB)
└─ meta-blocks, one after another
├─ header: length, last block?
├─ 3 block categories, each with up to 256 "block types":
│ literals · insert+copy lengths · distances
├─ prefix codes (Huffman) for each block type
├─ context maps: which code to use given the previous bytes
└─ commands: "insert N literals, then copy M bytes located D back"
… interleaved with BLOCK SWITCHES: "switch to type 3 for the next 500 symbols"
Brotli is a dense format. A stream is a sequence of meta-blocks. Data is described there by commands: insert a few literal bytes, then copy a sequence already produced further back in the window. Each element is encoded with prefix codes (Huffman codes, where frequent symbols take few bits). Brotli’s particularity: it can use several sets of codes, called block types, and switch between them along the way through block switch commands. That’s what lets it compress well a document whose nature changes (HTML, then embedded JavaScript, then text). It’s also extra complexity for the decoder, which must validate every switch: does the requested type exist, is the announced symbol count consistent?
Streaming decoding
call 1 : input = 4,096 bytes → decodes … 3 bits short of finishing the symbol
→ copies the remaining bytes into the small internal buffer (8 bytes)
→ returns NEEDS_MORE_INPUT
call 2 : input = 4,096 bytes → appends 1 byte to the buffer, retries
→ success: resumes normal decoding
--- with the bug ---
call n : INVALID block switch → returns NEEDS_MORE_INPUT instead of ERROR
→ appends 1 byte to the buffer, retries → still invalid → NEEDS_MORE_INPUT
→ appends 1 byte… 9th byte, 10th byte… → write past the 8 bytes
A streaming decoder receives data in chunks, as it arrives from the network. A symbol can straddle two chunks: the last bits of a chunk aren’t enough to decode it. The reference decoder then keeps the pending bytes in a very small internal buffer of eight bytes, tells the caller “I need more data”, then, on the next call, appends the new bytes one by one into that buffer and retries until the symbol is complete. That small buffer is always enough, since no valid symbol exceeds that size.
The bug lies in a confusion between two answers. Faced with an invalid block switch, the decoder answered “I need more data” instead of “this stream is invalid”. But an invalid switch will never become valid with more bytes: so the decoder appended a byte, retried, failed, appended another… and eventually wrote past the buffer’s eight bytes, into the neighbouring fields of its own state structure. Those fields describe the ongoing decoding: positions, lengths, pointers into the output window. And it’s the attacker who chooses the bytes written there. The fix adds a dedicated error code, returned instead of the request for data.
The lesson goes beyond Brotli: in a state machine, the boundary between “incomplete” and “invalid” is a security point. A parser that confuses the two ends up either looping forever, or, as here, accumulating without end.
PHP2Shell: memory that outlives the request
PHP’s allocator
a PHP request's memory (Zend Memory Manager)
├─ 2 MiB chunks, split into 4 KiB pages
├─ small allocations: 30 size classes (8, 16, 24 … 3,072 bytes)
│ a page is dedicated to one class; each free slot points to the next
└─ end of request: everything handed back at once, without freeing object by object
PHP doesn’t use the system allocator directly for script data. Its own manager, the Zend Memory Manager, reserves large chunks and splits them into pages, each page dedicated to one size class. A 40-byte allocation gets a slot of the 40 class; a freed slot joins its class’s free list, and will be the next one handed to an allocation of the same size. It’s fast and makes behaviour very predictable, which is an advantage for performance and a gift for an attacker: if they know a 48-byte slot was just freed, they know a 48-byte string they send will take its place.
Interrupting a cleanup
When a script exceeds memory_limit, PHP raises a fatal error and interrupts execution with a direct jump to the request’s resume point (the bailout mechanism, built on C’s longjmp). All the code that should have run in between is skipped. If the interruption happens while internal structures are being cleaned up, some are half dismantled: a reference count decremented without the object being freed, or the reverse. That’s PHP2Shell’s first bug, titled “RCE via OOM corruption”: running out of memory at the right moment leaves the manager in an inconsistent state.
State that survives
request 1 : object A (48 bytes) allocated; a GLOBAL variable keeps a pointer to A
end of request → A's memory is freed, the global pointer stays
request 2 (same process): the attacker sends a 48-byte string
→ the allocator puts it in the free slot… A's
→ code following the global pointer reads the string as if it were A
The second bug, “RCE via stale global state”, exploits process reuse. With PHP-FPM, one process handles thousands of requests. Each request’s memory is handed back in bulk at the end, but some global variables, belonging to the process, survive. If one of them keeps a pointer to data from the previous request, that pointer now designates a free slot, which the next request will reuse. The attacker then sends data of the right size, which takes the old one’s place. Code following the pointer interprets its bytes as an internal PHP structure: it reads, in place of a length or a pointer, what the attacker wrote. That’s type confusion, the classic starting point towards arbitrary read and write.
ZDI-CAN-33367: no corruption, a shared page
The kernel flaw is of another nature, and that’s what makes it so reliable. No area is overrun, no pointer is stale. A file’s cache page is referenced by a network packet thanks to a zero-copy optimisation; the marker saying “this page is shared” gets lost in a transfer function; the ESP module then decrypts “in place”, meaning it writes its result into that page. Each operation is correct in itself. It’s their combination that makes the kernel write into a read-only file, with content chosen by the attacker, who controls the key and the ciphertext.
Since nothing is corrupted, no protection against memory corruption applies. No address to guess, no execution flow to hijack: the attacker modifies a file (a setuid program, a configuration file), then runs it normally. It’s the same family as Dirty COW and Dirty Pipe, known for working almost every time.
Protections, and their limits
readelf -h /usr/sbin/php-fpm | grep Type → DYN (position-independent executable)
readelf -h /usr/bin/node | grep Type → EXEC (fixed address: ASLR doesn't move its code)
readelf -n /usr/sbin/php-fpm | grep -o 'IBT\|SHSTK' → IBT SHSTK (built for CET)
grep -wo 'ibt\|user_shstk' /proc/cpuinfo → (nothing: the CPU doesn't support CET)
ps -eZ | grep php-fpm → httpd_t (confined by SELinux)
Modern systems stack protections that make every exploitation step more costly. None fixes the bug; all make exploiting it harder.
- ASLR: stack, heap and library addresses change at every start, so the attacker must first find where what they want to modify lives, often through a second, read flaw. An executable not built position-independent keeps its code at a fixed address: that’s the case for the official Node.js binaries on my servers, unlike
php-fpm; - non-executable stack and data: you can no longer write code then jump to it; you have to reuse pieces of existing code;
- full RELRO: function address tables are made read-only after loading, one classic target fewer;
- CET (Intel): a shadow stack checks return addresses and jump-target marking limits hijacks.
php-fpmis built for it, but my server’s 2017 CPU doesn’t support it: the protection is in the binary and inactive in practice; - confinement: even when successful, exploitation gives the rights of the attacked process.
php-fpmruns in the SELinuxhttpd_tdomain, which limits what it can read, execute and contact. A service without a dedicated domain (unconfined_service_t) doesn’t have that net.
These protections slow Brotli and PHP2Shell, which must go from memory corruption to hijacking. They do nothing against ZDI-CAN-33367, which hijacks nothing. Against that family, the only defences are the fix, reducing the surface (blocked modules, restricted namespaces) and confining whatever could serve as a local entry point.
What I take from it to judge a flaw
- the bug class matters as much as the score: a logic error on a shared page is often more reliable than a higher-rated memory corruption;
- the trigger: who supplies the data? An unauthenticated remote client (PHP2Shell, Brotli request bodies), a server you contact (
fetch()), a local user (kernel); - predictability of the allocator and process reuse increase exploitability;
- the protections actually active, on the real hardware: a CET-built binary on a CPU that doesn’t support it isn’t protected;
- confinement decides what a successful exploitation costs: it’s the last line, and the only one that works against a flaw not yet known.
Sources: RFC 7932 (Brotli format); PHP and Node.js security advisories; Akamai and TuxCare analyses; php-src source code (Zend/zend_alloc.c) and Brotli’s (c/dec/decode.c).
This post is part of a series of sysadmin tutorials (tag “how-to”).
Series “How-to”, tutorial 7/77. · ← Previous tutorial · Next tutorial → (coming 4 Nov) · Version française
One Comment