CVE-2024-2961 carries a small out-of-bounds write into a much larger object-lifetime problem. A stateful glibc conversion emits a few fixed bytes past a boundary; PHP stream filters change buffer lengths, ownership, and allocation timing; Zend later interprets data in freed blocks as list pointers. The semantics at each layer matter more than the write's size alone.
The historical environment is Ubuntu 22.04, PHP 8.1.2, and glibc 2.35-0ubuntu3. Pinned source, the upstream fix, and offline models explain the boundaries below. No historical PHP service, vulnerable converter, or command payload was run. Charles Fol reported the vulnerability, and the upstream fix became public on 2024-04-17.
A four-byte designation lacks a bounds check
ISO-2022-CN-EXT can emit a character-set designation before the character data. glibc checked output space in its SO designation path but omitted the corresponding checks for SS2 and SS3 designations. The four-byte sequences have these forms:
| Branch | Sequence |
|---|---|
| SS2 designation | 1b 24 2a 48 |
| SS3 designation | 1b 24 2b 49 through 1b 24 2b 4d |
The upstream patch describes one-, two-, or three-byte overflows in these branches, using fixed suffix bytes. The advisory overview also uses the broader wording "up to four bytes." Four bytes is the complete designation length, not an arbitrary four-byte write primitive in this branch model. glibc fix and tests
With three bytes remaining, the final 48 of the SS2 sequence crosses the logical boundary:
in_bounds0x00–0x02out_of_bounds0x030x48
A character-to-path distinction matters here: the upstream regression test uses U+5284, encoded as UTF-8 e5 8a 84, for the SS3 path. The 48 model below represents SS2 state; it does not equate that character with 1b 24 2a 48.
The fix checks outptr + 4 > outend before writing in both missing branches and reports __GCONV_FULL_OUTPUT when space is insufficient. An incorrectly decremented size_t can also wrap: 64-bit 3 - 4 becomes 0xffffffffffffffff. Record the count with a matching format, the return value, and errno, rather than interpreting a signed display of -1 alone.
Follow the resource parameter to the actual converter
If an application lets the caller control the resource being read, that input may select wrappers and filters rather than a plain path:
flowchart TD A["Read-resource parameter"] --> B["Stream wrapper"] B --> C["Bucket brigade"] C --> D["Read filters"] D --> E["convert.iconv"] E --> F["iconv implementation used by this process"]
Reachability depends on the input reaching the parser intact, the required wrappers and extensions being available, and the actual converter being affected. Support for php://filter establishes filter capability, not reachability of the glibc overwrite. A patched process can support the same legitimate features.
Process mappings help identify the loaded libc and conversion module. An anonymous writable mapping, 2 MiB alignment, or region size does not uniquely identify a Zend heap. Stable debugger addresses may simply reflect ASLR settings. Address information and object identity need separate checks.
Data length, allocation request, and capacity differ
A php_stream_bucket carries its data pointer, valid length, ownership, and reference count. It has no capacity field that directly reports allocator capacity:
char *buf;
size_t buflen;
uint8_t own_buf;
uint8_t is_persistent;
int refcount;PHP 8.1.2 does configure a 0x8000 internal zlib output buffer. When producing a bucket, however, it calls estrndup(..., bucketlen), which requests bucketlen + 1 bytes for a terminating NUL. A valid length of 0x8000 therefore does not imply a data allocation of exactly 0x8000.
The dechunk filter first calls php_stream_bucket_make_writeable. It retains the original bucket only when refcount == 1 && own_buf; otherwise, it copies the descriptor and data. php_dechunk then compacts the contents in place and returns the new length, without automatically shrinking the allocation.
For ordinary input, the same-encoding iconv path initializes its output size from buf_len and calls pemalloc. Bytes can remain unchanged while the output object and allocation request change. Flush handling, buffered input, failures, and growth paths still require separate analysis.
| Stage | Valid length | Allocation or ownership change |
|---|---|---|
| inflate output | 0x8000 | Data-copy request is 0x8001; an internal staging buffer also exists |
| First dechunk | 0x100 | Compaction in the existing buffer; address retained under exclusive ownership |
| Same-encoding iconv | 0x100 | New 0x100 request; old input released according to its reference count |
| Second dechunk | 0x10 | Smaller valid length does not immediately change the allocation class |
| Another same-encoding iconv | 0x10 | New 0x10 request; the old 0x100 block may return to its free list |
This models suitable input, ownership, and successful conversion conditions; it is not a newly captured address trace. Combining request size, size class, and buflen into one value hides the allocation behavior that matters.
A fixed low byte changes list interpretation
PHP 8.1.2's zend_alloc_sizes.h assigns 256-byte blocks to bin 15 and 16-byte blocks to bin 1. A 257-byte request already moves to the 320-byte bin 16. Zend's small-block lists are not glibc tcache.
For an existing free node, the allocation relationship reduces to:
p = heap->free_slot[bin_num];
heap->free_slot[bin_num] = p->next_free_slot;
return p;If the overwrite reaches the least significant byte of a pointer in an adjacent free block, the fixed 48 changes that pointer as follows:
def replace_low_byte(pointer):
return (pointer & ~0xff) | 0x48
assert replace_low_byte(0x123400) == 0x123448
assert replace_low_byte(0x1234f0) == 0x123448The changes are +0x48 and -0xa8, respectively. Replacing the low byte is not uniformly adding 0x48. That description only holds when the original low byte is zero. The resulting address must also remain mapped, fall in the intended object range, and contain data the allocator will interpret as the next list pointer.
A constrained byte can thus change object interpretation without immediately becoming an arbitrary-address write. Adjacency, size class, retained data, pointer high bytes, and subsequent allocation order all remain necessary conditions.
Error returns continue the object lifecycle
The PHP iconv output-failure path frees out_buf; the outer filter also decrements the input bucket's reference count. Ordinary E2BIG handling may instead grow the output buffer. Follow the actual branch rather than treating every iconv error as the same cleanup event.
Suppose B is the output block, C is the adjacent affected free node, and A holds data that may be interpreted as another node. One particular cleanup can produce a logical relationship like this:
flowchart LR B["Freed B"] --> C["C"] C --> A["Interior of A"] A --> N["Next address interpreted from retained data"]
This is a conditional object relationship, not a universal failure-path ordering. Returning an error does not undo a prior write. A snapshot taken only at the overwrite also misses subsequent frees. Likewise, an absence of crashes does not establish an absence of corruption.
Zend's custom allocator has a separate selection condition. use_custom_heap works with custom_heap.std._malloc / _free / _realloc, while debug builds have another branch with debug arguments. Changing one callback does not establish that dispatch has switched. ZEND_MM_CUSTOM, statistics, storage, and limit-related build options also affect the structure; offsets such as +0x168 are not stable ABI.
Validate parser semantics before heap traces
PHP's dechunk state machine skips extension content after the length digits until CR or LF. Additional hexadecimal characters still change the length, and line-ending bytes change boundaries. The length line is therefore not a transparent binary container. Quoted-printable can restore bytes later, but introduces its own output objects, so filter order continues to matter.
For a local check, the PHP 8.1.2 php_dechunk function and state definitions were extracted unchanged into a bounded C harness, compiled with MinGW GCC using -std=c11 -O2 -Wall. Tests cover ordinary chunks, extensions, bare LF, and an invalid first character. A valid input with an extension was split into two buffers at all 19 possible positions:
PASS: 4 dechunk cases; 19 two-bucket splits; PHP 8.1.2 parser extracted unchanged
PASS: 24 designation boundaries; 256 low-byte substitutions; 2 binary round trips
Zend source: 30 bins; 256 -> bin15; 257 -> bin16; 16 -> bin1
U+5284 UTF-8: e58a84; size_t64 wrap: 0xffffffffffffffffThe 24 boundary cases combine six designation sequences with four remaining capacities. The two binary round trips cover all 256 byte values through quoted-printable and raw DEFLATE. These tests check parsing, arithmetic, and encoding, not a PHP service's Zend heap, and never call vulnerable glibc.
A live-environment investigation should record buf, buflen, reference counts, allocation requests, and the relevant free_slot after each filter and cleanup step. Establish the object changes in a short chain before interpreting a longer input.
Verify the distribution package and the loaded process
Ubuntu lists 2.35-0ubuntu3.7 as fixed for Ubuntu 22.04. Distribution backports mean that "glibc 2.35" or "below 2.40" alone is insufficient to determine exposure. The 2.35-0ubuntu3 package identifies the historical environment analyzed here. Ubuntu CVE-2024-2961
Keep three operational checks distinct:
- Update affected packages supplying libc and gconv modules, and confirm that service processes have loaded the fixed files. An on-disk upgrade does not replace an existing process's mappings.
- Accept application-defined resource identifiers and map them to allowed files, rather than treating complete wrapper and filter expressions as ordinary filenames.
- Verify library state, converter reachability, and application impact separately. Disabling one URL-related option is not proof that every local wrapper and filter path is covered.
The conclusion is not that every file-read primitive implies code execution. Stateful conversion errors can cross application-level object transformations. Fixing the overwrite and removing unnecessary resource interpretation is more reliable than depending on a particular heap layout to break a chain.