scan-symlinks: relative targets escaping the archive or the homedir are DANGEROUS (N2-1)

A relative symlink was skipped as safe (`if ($target[0] !== '/') continue`),
so `cpmove-u/sds -> ../../../../root/.my.cnf` survived extraction and WHP
parsed the host file as the subdomain list. Resolve relative targets
lexically from the link's own directory: reaching or climbing above the
archive root, or a link under <top>/homedir/ resolving outside that homedir,
is DANGEROUS (stripped by extract.sh). Same rule as WHP's
CpanelBackupImporter::relativeSymlinkEscape().

The clean fixture's about-shortcut (../public_html/about.txt resolved
outside homedir/) now points at public_html/about.txt as its comment meant.
New cpmove-relesc fixture + checks (5 fail on 4a43d26).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-20 10:42:15 -07:00
1 parent 4a43d26de7
commit a226b496a2
3 files changed
+79 -3

No files matched your search

+43 -1
View File
@@ -90,6 +90,32 @@ $dangerousPrefixes = [
'/dev',
];
/**
* Lexical resolution of a relative link from its own directory in the
* archive: a reason when it reaches/climbs above the archive root, or when a
* link under <top>/homedir/ resolves outside that homedir; NULL otherwise.
*/
function relative_symlink_escape(string $archivePath, string $target): ?string {
$ap = preg_replace('#^(\./)+#', '', $archivePath);
$dir = array_values(array_filter(explode('/', (string) $ap), fn($x) => $x !== '' && $x !== '.'));
array_pop($dir);
$home = (count($dir) >= 2 && $dir[1] === 'homedir') ? [$dir[0], 'homedir'] : null;
$stack = $dir;
foreach (explode('/', $target) as $seg) {
if ($seg === '' || $seg === '.') continue;
if ($seg === '..') {
if (count($stack) <= 1) return 'escapes the archive root';
array_pop($stack);
continue;
}
$stack[] = $seg;
}
if ($home !== null && (count($stack) < 2 || $stack[0] !== $home[0] || $stack[1] !== 'homedir')) {
return 'escapes the account homedir';
}
return null;
}
$findings = [];
$cpanelUsername = null;
@@ -111,7 +137,23 @@ while (($line = fgets($fh)) !== false) {
$archivePath = $parts[5];
$target = $right;
if ($target === '' || $target[0] !== '/') continue;
// N2-1: a RELATIVE target that climbs out of the archive (or out of
// the account's homedir/) is DANGEROUS (stripped), not safe: GNU tar
// extracts `cpmove-u/sds -> ../../../../root/.my.cnf` as-is. Same rule
// as WHP's CpanelBackupImporter::relativeSymlinkEscape().
if ($target !== '' && $target[0] !== '/') {
$escape = relative_symlink_escape($archivePath, $target);
if ($escape !== null) {
$findings[] = [
'type' => 'DANGEROUS',
'archive_path' => $archivePath,
'target' => $target,
'reason' => "relative target $escape",
];
}
continue;
}
if ($target === '') continue;
if ($cpanelUsername === null) {
if (preg_match('#^cpmove-([^/]+)/#', $archivePath, $m)) {
+21 -2
View File
@@ -53,8 +53,10 @@ EOF
echo "<?php phpinfo();" > "$root/cpmove-$USER/homedir/public_html/index.php"
echo "Hello world." > "$root/cpmove-$USER/homedir/public_html/about.txt"
# benign user-internal relative symlink — must NOT trigger the scan
ln -sf "../public_html/about.txt" "$root/cpmove-$USER/homedir/about-shortcut"
# benign user-internal relative symlink — must NOT trigger the scan.
# (It used to be ../public_html/about.txt, which resolves OUTSIDE
# homedir/ — cpmove-$USER/public_html — and is now DANGEROUS, N2-1.)
ln -sf "public_html/about.txt" "$root/cpmove-$USER/homedir/about-shortcut"
# one synthetic WordPress mysql dump with ENGINE=MyISAM + a clean siteurl
cat > "$root/cpmove-$USER/mysql/${USER}_wp.sql" <<EOF
@@ -117,6 +119,23 @@ ln -sf "/etc" "$ALFA_TMP/cpmove-$USER/homedir/public_html/$DOMAIN/ALFA_DATA/root
tar -C "$ALFA_TMP" -czf "$FIXTURES_DIR/cpmove-alfa.tar.gz" "cpmove-$USER"
echo "wrote $FIXTURES_DIR/cpmove-alfa.tar.gz ($(stat -c%s "$FIXTURES_DIR/cpmove-alfa.tar.gz") bytes)"
# ---- cpmove-relesc.tar.gz (N2-1) --------------------------------------------
#
# RELATIVE symlinks that climb out: `sds` (a metadata file WHP parses) out of
# the archive to /etc/passwd, and a docroot file out of homedir/ into the
# cpmove's own mysql/ dump. Both must be DANGEROUS (stripped), while
# homedir/www -> public_html (inside homedir) stays.
RELESC_TMP="$(mktemp -d)"
build_common_tree "$RELESC_TMP"
rm -rf "$RELESC_TMP/cpmove-$USER/sds"
ln -sf "../../../../../../../../etc/passwd" "$RELESC_TMP/cpmove-$USER/sds"
ln -sf "../../mysql/${USER}_wp.sql" "$RELESC_TMP/cpmove-$USER/homedir/public_html/dump.sql"
ln -sf "public_html" "$RELESC_TMP/cpmove-$USER/homedir/www"
tar -C "$RELESC_TMP" -czf "$FIXTURES_DIR/cpmove-relesc.tar.gz" "cpmove-$USER"
rm -rf "$RELESC_TMP"
echo "wrote $FIXTURES_DIR/cpmove-relesc.tar.gz"
# ---- cpmove-mail.tar.gz ---------------------------------------------------
MAIL_TMP="$(mktemp -d)"
+15
View File
@@ -62,6 +62,21 @@ check "opt-in: hard link crossing INTO the mail tree (message -> etc/shadow) str
check "opt-in: log says the mail tree was included" "grep -q 'including mail tree' '$LOG'"
check "opt-in: no 'skipping mail tree' line" "! grep -q 'skipping mail tree' '$LOG'"
echo "N2-1: relative symlinks that escape the archive / the homedir (cpmove-relesc)"
cp "$FIX/cpmove-relesc.tar.gz" "$WORK/"
php "$REPO/scripts/lib/scan-symlinks.php" --tarball "$WORK/cpmove-relesc.tar.gz" --report "$WORK/relesc-scan.json" --username testuser 2>/dev/null
relesc_type() { php -r '$r = json_decode(file_get_contents($argv[1]), true); foreach ($r["findings"] as $f) if ($f["archive_path"] === $argv[2]) { echo $f["type"]; exit; } echo "NONE";' "$WORK/relesc-scan.json" "$1"; }
check "relesc: sds -> ../../../../etc/passwd is DANGEROUS" "[[ \$(relesc_type '$U/sds') == DANGEROUS ]]"
check "relesc: homedir/public_html/dump.sql -> ../../mysql/... (escapes homedir) is DANGEROUS" "[[ \$(relesc_type '$U/homedir/public_html/dump.sql') == DANGEROUS ]]"
check "relesc: homedir/www -> public_html (inside homedir) is not a finding" "[[ \$(relesc_type '$U/homedir/www') == NONE ]]"
check "relesc: homedir/about-shortcut -> public_html/about.txt is not a finding" "[[ \$(relesc_type '$U/homedir/about-shortcut') == NONE ]]"
OUT="$WORK/ex-relesc"
env -u IMPORT_INCLUDE_MAIL bash "$REPO/scripts/extract.sh" "$WORK/cpmove-relesc.tar.gz" "$OUT" testuser "$WORK/ex-relesc.actions.json" >"$WORK/ex-relesc.log" 2>&1
check "relesc: sds stripped from the extract" "[[ ! -e '$OUT/$U/sds' && ! -L '$OUT/$U/sds' ]]"
check "relesc: dump.sql link stripped from the extract" "[[ ! -L '$OUT/$U/homedir/public_html/dump.sql' ]]"
check "relesc: homedir/www link kept" "[[ -L '$OUT/$U/homedir/www' ]]"
check "relesc: both strips recorded" "grep -q '$U/sds' '$WORK/ex-relesc.actions.json' && grep -q 'public_html/dump.sql' '$WORK/ex-relesc.actions.json'"
echo "hard link crossing OUT of the mail tree (cpmove-mail-xlink)"
cp "$FIX/cpmove-mail-xlink.tar.gz" "$WORK/"
OUT="$WORK/ex-xlink"