~/posts/web/cve-2024-2961-iconv-php-bucket-lifetimes.md

CVE-2024-2961: iconv writes and PHP object lifetimes

Trace a fixed-byte encoding overflow through PHP stream filters. Separate valid length, allocation requests, and ownership, then examine Zend free lists and error cleanup.

date[31:24]
read[23:16]
8 min
cat[15:8]
Web security
Contents
  1. 0x00A four-byte designation lacks a bounds check
  2. 0x01Follow the resource parameter to the actual converter
  3. 0x02Data length, allocation request, and capacity differ
  4. 0x03A fixed low byte changes list interpretation
  5. 0x04Error returns continue the object lifecycle
  6. 0x05Validate parser semantics before heap traces
  7. 0x06Verify the distribution package and the loaded process
  8. 0x07Official references

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:

Designation sequences and their fixed suffixes2 rows
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:

SS2 designation and logical boundary
000000001B242A48
  1. in_bounds0x00–0x02
  2. out_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:

PHP 8.1.2 bucket — relevant fieldsc
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.

Illustrative transitions for an exclusively owned data buffer5 rows
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:

Zend small-allocation relationshipc
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:

Low-byte replacement modelpython
def replace_low_byte(pointer):
    return (pointer & ~0xff) | 0x48

assert replace_low_byte(0x123400) == 0x123448
assert replace_low_byte(0x1234f0) == 0x123448

The 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:

Parser and offline model resultslog
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: 0xffffffffffffffff

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

Official references

NORMAL~/posts/web/cve-2024-2961-iconv-php-bucket-lifetimes.md§--
0%en