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:
1 parent
4a43d26de7
commit
a226b496a2
3 files changed
+79
-3
No files matched your search
@@ -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
@@ -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)"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user