Skip to content
PaxDynamics.Let’s talk
English
Latest post:PHP after the patch
Back to Blog

One byte past the buffer

A local lab record of an out-of-bounds write in PHP mbstring's SJIS-mac substring path: ASAN evidence on 8.4.27, stock-build observations, prior art, and limits.

A call can return the expected value and still warrant a memory-safety investigation. In our PHP 8.4.27 ASAN build, an out-of-range mb_substr() call with SJIS-mac reported a one-byte write beyond its output allocation. The tested stock builds returned an empty string and continued.

This is a follow-up to Khashayar Fereidani's June 2026 underflow report. Our additions are the observed write traces, a six-encoding comparison, allocator correction, and application reachability checks. The full failure mechanism still needs a validated patch and rerun.

Date: 2026-10-31 · Scope: lab-only, 127.0.0.1 · Proof: a single mb_substr() call with a fixed literal.

Companion finding to the PHP after the patch series.

Lab boundary. Everything below ran inside local containers against stock and instrumented PHP builds. The trigger is a string literal, not a shell. This work demonstrates an out-of-bounds write under ASAN, not code execution: no RCE, no ROP, no payload is claimed or shown, and nothing here touches a remote host.

INTERACTIVE RESEARCH / 06

The allocation ends here.

Follow the recorded substring call to the ASAN-observed write. Allocation geometry is schematic.

SELECT AN OBJECT TO INSPECT GEOMETRY + TIMING SCHEMATIC
Input and requested start

The recorded call uses five input characters, an offset beyond the input, no explicit length, and SJIS-mac.

Recorded evidence ↗

Reduced motion: select steps to change the diagram. ASAN-observed write; no RCE or stock-build crash established.

Transcript and recorded profiles
  1. Input and requested start. The recorded call uses five input characters, an offset beyond the input, no explicit length, and SJIS-mac. Evidence
  2. Review the conversion path. Unsigned offset subtraction and converter capacity are review targets. The full failure mechanism has not been validated with a patch. Evidence
  3. The output allocation. The ASAN trace identifies a 24-byte allocation. The tile grid shows its valid extent without representing real heap placement. Evidence
  4. Cross the boundary. The minimal ASAN trace records the terminator write at the first byte after the allocation. Stock builds returned normally. Evidence
  5. Stop at the measured result. The result is an ASAN-observed write. No code execution or stock-build crash is established. A validated fix and release rerun remain open. Evidence
  • ASAN / PHP 8.4.27: ASAN-observed write. The instrumented build reported a one-byte write immediately beyond a 24-byte output allocation. Evidence
  • Stock build observation: Normal return observed. The tested stock builds returned an empty string and continued. Normal return does not establish the underlying memory behaviour. Evidence

1. A call that looks wrong and returns fine

mb_substr("hello", 6, null, "SJIS-mac");

A start offset of 6 on a 5-character string, no length, and an encoding most people have never used. In the tested stock builds, the call returns "" with no warning, exception, or visible crash. The result alone gives no indication of the instrumented observation.

Under AddressSanitizer, on 8.4.27, the same call produces an ASAN report: a one-byte write past the end of a 24-byte heap allocation.

The recorded byte is the output buffer's NUL terminator. The allocation is the buffer that the substring path hands to the encoding converter. Nothing about the call is exotic: no ini setting, no upload, no session, no multipart body, no unserialize(). One userland function call, one caller-chosen offset, and a write that the instrumented build reports.

The important asymmetry is that the same call returns normally on the stock builds we tested. Normal return does not establish whether the same write occurred on those builds; it only establishes that nothing visible happened.

2. What ASAN saw

Visible output and instrumented evidence differ

∅STOCK: EMPTY RETURNNO STOCK CRASH ESTABLISHED!ASAN-OBSERVED WRITEONE BYTE PAST 24-BYTE EXTENT∅STOCK: EMPTY RETURNNO STOCK CRASH ESTABLISHED!ASAN-OBSERVED WRITEONE BYTE PAST 24-BYTE EXTENT

Normal return. The tested stock builds return an empty string and continue. A normal return does not establish whether the same write occurred internally.

All recorded cases
  • Stock builds: Normal return. The tested stock builds return an empty string and continue. A normal return does not establish whether the same write occurred internally.
  • ASAN / 8.4.27: ASAN-observed write. The instrumented build reports a one-byte write just beyond a 24-byte allocation. This is an ASAN-observed write, with no RCE or stock-build crash established.
Select a case to replay. Schematic; recorded results only. Read the evidence ↗

Variant (a), the minimal call above:

ERROR: AddressSanitizer: heap-buffer-overflow ... WRITE of size 1
    #0 mb_convert_buf_result_raw  ext/mbstring/libmbfl/mbfl/mbfl_encoding.h:257
    ...
0x... is located 0 bytes to the right of 24-byte region
allocated by ... malloc (mb_convert_buf_init)

Variant (b), when the input ends in an illegal byte (0xFF):

ERROR: AddressSanitizer: heap-buffer-overflow ... WRITE of size 1
    #0 mb_convert_buf_add        ext/mbstring/libmbfl/mbfl/mbfl_encoding.h:204
    #1 mb_wchar_to_sjismac       ext/mbstring/libmbfl/filters/mbfilter_cjk.c:5886
    #2 mb_illegal_output         ext/mbstring/libmbfl/mbfl/mbfl_convert.c:433
    #3 mb_wchar_to_sjismac       ext/mbstring/libmbfl/filters/mbfilter_cjk.c:5883
    #4 mb_get_substr_slow        ext/mbstring/mbstring.c:2136
    #5 mb_get_substr             ext/mbstring/mbstring.c:2175
    #6 zif_mb_substr             ext/mbstring/mbstring.c:2400
0x... is located 0 bytes to the right of 24-byte region (malloc)

Two recorded variants: the terminator path, where the byte is fixed, and the illegal-tail path, where mb_substitute_character() selects a one- or two-byte substitute output. The notes record both lengths. The malloc frame in these traces does not establish which allocator a normal PHP build uses; Section 5 addresses that directly.

The ASAN build was release + ASAN with NDEBUG, matching stock semantics rather than a debug configuration, because a debug-only overflow would tell us nothing about production.

3. The arithmetic that precedes the write

The call reaches mb_get_substr_slow() (mbstring.c:2136) with the length encoded as MBFL_SUBSTR_UNTIL_END. The function estimates the initial output capacity with MIN(len, in_len - from).

For the minimal reproducer:

  • →from is the caller's start offset, 6;
  • →in_len is 5;
  • →in_len - from is computed in an unsigned size_t, so it does not become -1.

The subtraction wraps. This appears to lead to inconsistent allocation and output-buffer bounds. The exact failure mechanism still needs validation: we have the ASAN trace and the arithmetic, not a patched build that confirms the whole path.

Two observations narrow the question, without settling it:

  1. →The shape depends on the no-length branch. An explicit length, even an absurd one, did not produce the same report in the recorded tests.
  2. →The output path may not re-check capacity. mb_illegal_output() and mb_convert_buf_result_raw() rely on the converter's own notion of how much input it consumed. Whether that is the mechanism here is still a hypothesis.

What we can say is narrower: the offset arithmetic is not safe for from > in_len, and the write lands past the allocated output buffer. A complete explanation needs the patched-build check we have not run.

mb_strimwidth() is not reachable this way: PHP throws ValueError: start is out of range before the slow path runs. mb_trim() is reachable only in a narrow shape: the trim charlist must contain 0xFF and the input must end with it.

4. Only one encoding was affected in the tested matrix

Six encodings, one recorded input shape

SJIS-macASAN-OBSERVED WRITESJISSAME REPORT ABSENTEUC-JPSAME REPORT ABSENTBIG-5SAME REPORT ABSENTGB18030SAME REPORT ABSENTUTF-8SAME REPORT ABSENTSJIS-macASAN-OBSERVED WRITESJISSAME REPORT ABSENTEUC-JPSAME REPORT ABSENTBIG-5SAME REPORT ABSENTGB18030SAME REPORT ABSENTUTF-8SAME REPORT ABSENT

ASAN-observed write. For the recorded out-of-range offset and no-length input shape, SJIS-mac reports a heap-buffer-overflow write under ASAN.

All recorded cases
  • SJIS-mac: ASAN-observed write. For the recorded out-of-range offset and no-length input shape, SJIS-mac reports a heap-buffer-overflow write under ASAN.
  • Five comparison encodings: Same report absent. SJIS, EUC-JP, BIG-5, GB18030, and UTF-8 return an empty string without the same report in the recorded matrix. Other encodings and input shapes are outside that result.
Select a case to replay. Schematic; recorded results only. Read the evidence ↗

We ran the same from > mb_strlen() shape across six encodings under ASAN:

EncodingSame input shapeResult
SJIS-macfrom > len, no lengthheap-buffer-overflow WRITE
SJISfrom > len, no lengthempty string, clean
EUC-JPfrom > len, no lengthempty string, clean
BIG-5from > len, no lengthempty string, clean
GB18030from > len, no lengthempty string, clean
UTF-8from > len, no lengthempty string, clean

The reason is visible in the source. The 2026-06 public report on this encoding identified the guard that yields the empty string:

if (len == 0 || (from >= in_len && enc != &mbfl_encoding_sjis_mac))

The enc != &mbfl_encoding_sjis_mac disjunct exempts exactly one encoding from the check. On the date we checked the source, 2026-10-30, the exception was present on PHP-8.4, PHP-8.5, PHP-8.6, and master. That is a dated source observation. The exception alone does not prove that the complete defect remains unfixed on every branch.

MacJapanese is unusual because a trailing 0xFF decodes to two codepoints. That is what makes the illegal-tail variant easy to arrange. But the two-codepoint behaviour is not required for the minimal write: variant (a) fires with plain ASCII input.

5. The allocator is emalloc, not a separate glibc heap

The report and an earlier version of this post claimed that mb_convert_buf_init() allocates with glibc malloc, placing the overflow in a separate process heap. The source says otherwise: the buffer is allocated through emalloc, PHP's own allocator.

An ASAN malloc frame does not establish the allocator used by a normal PHP build, because ASAN interposes allocation in the instrumented binary. Neither does an unchanged memory_get_peak_usage(true) value establish a separate glibc heap; it is not a heap-identity measurement.

So the earlier allocator argument is withdrawn, and with it the proposed glibc chunk-header target. We have not established an adjacent-object or heap-metadata corruption target. The demonstrated result is an out-of-bounds write in the instrumented build. Exploitation on a normal build remains unproved.

6. What an application has to do to be reachable

This is an application-input bug, not a network-layer one. The minimal shape is a user-controlled start with an offset past the end, and any code path that pins SJIS-mac:

mb_substr($s, $userStart, null, 'SJIS-mac');   // hardcoded encoding
mb_substr($s, $userStart, null, $userEnc);     // attacker picks encoding
mb_internal_encoding('SJIS-mac');
mb_substr($s, $userStart);                     // no encoding argument at all

We surveyed two real codebases on 2026-10-30 for those shapes:

  • →WordPress 7.1.3 — not reachable in core or bundled vendor code without a plugin or an admin-changed charset. wp-trackback.php does expose a charset parameter, but it uppercases it and allowlists mb_list_encodings(), which rejects SJIS-mac. blog_charset set to SJIS-mac by an administrator would arm default-encoding calls.
  • →Joomla 5.4 — same conclusion: no core path passes an attacker-controlled substring offset together with MacJapanese.

That is a negative result for those two trees and those shapes, not a claim that no application is exposed. A CMS plugin that exposes a substring offset and hardcodes a Japanese encoding is plausible; we did not find one, and we did not invent one.

7. Impact and detection limits

The observed effect and the application survey support the following limits:

  • →No RCE. The demonstrated result is an out-of-bounds write under ASAN. We did not turn it into control of a call target, and nothing here claims that.
  • →No demonstrated stock crash. On stock 8.4.27 and 8.5.12 the call returns "" and the process continues in the runs we recorded. Normal return does not establish that the same write occurred on those builds.
  • →No network signature. There is no packet, header or body that says "mb_substr with MacJapanese". Detection is source-level: grep for SJIS-mac and read the offset handling.
  • →No INI precondition. The recorded test did not require a special INI setting, which distinguishes it from the rfc1867 getword over-read (GH-24498 / f6852befa3); that one is an over-read gated behind an directive.

We are not assigning a severity score in this report. The observed effect is a heap out-of-bounds write in the ASAN build, reachable through application code that forwards a suitable offset to this encoding. Its practical impact depends on conditions we have not established, including behavior on normal builds and a real application attack path.

8. Prior art and contribution

The underflow is not ours. On 2026-06-19, Khashayar Fereidani published the SJIS-mac size_t underflow on the Full Disclosure list, describing OOM/DoS and a hypothetical overflow. That report is the prior art, and it is cited first because it is the reason the guard exists at all.

This follow-up records:

  1. →An observed write. ASAN traces with source lines for two write variants, on a build configured to match stock semantics.
  2. →The encoding scope in the tested matrix. The six-encoding comparison above, not a universal claim about all encodings.
  3. →Dated source observations. The exception was present on the branches we checked on 2026-10-30; that is not the same as proving every branch still carries the complete defect.
  4. →Reachability negatives. WordPress and Joomla core were checked for these shapes and reported as negatives, rather than left implied.

The underlying underflow was public before this work. The earlier allocator interpretation is withdrawn; a stock-build crash and exploitable corruption target remain unestablished.

9. Areas to review, not a validated fix

We have not built or tested a patch.

The capacity calculation subtracts a character offset from an input byte count. A fix must make that calculation safe while preserving MacJapanese sequences that decode to more than one code point per byte. That constraint rules out a simple "clamp to the byte count" change: it would break valid MacJapanese behavior.

Suggested areas for review are the initial capacity arithmetic, the output capacity checks, and the final NUL write. Regression coverage should preserve valid MacJapanese multi-code-point sequences as well as the out-of-range start case.

10. Publication status and research record

Publication status as of November 2, 2026: This article was published on October 31. The source record does not establish vendor acknowledgement, a reviewed fix, or a CVE for this finding. Publication alone establishes none of those events.

The research milestones recorded in the source are:

DateEvent
2026-06-19Fereidani publishes the underflow as OOM/DoS plus hypothetical overflow
2026-10-27Our ASAN run records a write; upstream source check
2026-10-30Encoding matrix and WordPress/Joomla survey; dated source observation
2026-10-31Report drafted and reviewed; article published

The vendor report and full build record remain private. The inline call and trace let readers inspect the reported behaviour, but the public repository does not yet provide a complete rebuild package for this finding.

11. Reproducing it in the lab

The ASAN build is php-fuzz-relasan-8427 — release tree, compiled with ASAN and NDEBUG so the code path matches production rather than a debug build.

# inside the instrumented image
php -d memory_limit=-1 -r 'var_dump(mb_substr("hello", 6, null, "SJIS-mac"));'

Expected under ASAN: a heap-buffer-overflow WRITE of size 1 at mbfl_encoding.h:257, on a 24-byte region reported as allocated by mb_convert_buf_init.

Expected on the stock builds we tested: string(0) "" and a clean exit. That asymmetry is why the reproduction instructions specify the instrumentation rather than just the call. It is also why a clean stock run is not a negative control for the write itself.

The caveats, in one place

  • →ASAN proof, not a stock crash. The write is reported in the instrumented build; normal return on stock does not establish whether the same write occurred there.
  • →No allocator claim. The buffer uses emalloc; the ASAN malloc frame is not evidence of a separate glibc heap, and no chunk-header target is claimed.
  • →Root cause is partial. The unsigned subtraction wraps, but the complete failure mechanism is still a hypothesis; the proposed fix is untested.
  • →Prior art. The underflow was published in June 2026. This is an observed write, an encoding matrix, dated source observations, and reachability negatives, not a new bug.
  • →Reachability. Not found in WordPress or Joomla core for these shapes. A negative for those trees, not a claim of no exposure anywhere.

Andrei Popa, PaxDynamics · pax@paxdynamics.com

The source review, controlled reproductions, and stated limits in this investigation are also part of our security research and application testing work.