   
                                             
                     
                                                                                 
                                                                                                                                                                                                                                             
                  
                     
            
                                                                           
                                                                         
   
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:

```c {4-5,8-10,15-16}
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`](https://github.com/php/php-src/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:

```c {12-15}
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 {7,13}
<?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

| Date | Event |
|---|---|
| **2010-05** | Stefan 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-22** | PHP 5.2.14 ships my own first interruption finding fix as **CVE-2010-2484** — `strrchr()` 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()`. |
| **2010** | PHP 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. |
| **~2010** | Around 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–2023** | The 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-04** | Reported externally as [bugs.php.net **#79836**](https://bugs.php.net/bug.php?id=79836) — *"Segfault in concat_function"* — by `andrey at php dot net`. No CVE assigned. |
| **2021-12-26** | Reported again as [bugs.php.net **#81705**](https://bugs.php.net/bug.php?id=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-04** | **Niels Dossche** lands commit `727e26f9f2` ("Fix #97836 and #81705"), GH-10049, in php-src master. Co-authored by `changochen1@gmail.com` and `cmbecker69@gmx.de` for the regression tests. |
| **2023-11-23** | Fix 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-04** | I 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.
