Files
shadowdaoandClaude Opus 5.5 36d4949d34 extract: own-account links judged re-rooted, normalized, for the trusted username only (N2-1 review 2)
- strip-escaping-links.py: the /home[N]/<user>/ exemption ran on the RAW
  target (`/home/u/../../etc/shadow` was exempt) and on usernames taken
  from the archive's own top-dir names (an archive could name itself
  cpmove-root). Now only the username WHP passes (extract.sh <username> =
  IMPORT_USERNAME, validated) is trusted; the target must be normalized; and
  it is RE-ROOTED at the link's <top>/homedir in the extract and kept only
  if that resolves (realpath strict) inside it. No trusted name: nothing
  exempt.
- scan-symlinks.php: same trusted-name rule; a non-normalized absolute
  target is DANGEROUS outright (never prefix-compared).
- tests: /home/u/../../etc/shadow, /home/u/./x, re-rooted dangling,
  re-rooted through an escaping link, another user's /home, an
  archive-named user (cpmove-root) via extract.sh, no/invalid trusted name.
  8 checks fail on 72b8fa9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:51:17 -07:00

269 lines
11 KiB
PHP

<?php
/**
* scan-symlinks.php — standalone port of
* CpanelBackupImporter::scanTarballForDangerousSymlinks().
*
* This is the same classification logic that ships in the WHP panel today
* (web-files/libs/CpanelBackupImporter.php, ~line 2438). Lifted into a
* standalone CLI so the container can run it as an independent pre-extract
* gate without dragging in the rest of the importer.
*
* Exit codes:
* 0 — scan completed successfully (with or without DANGEROUS findings).
* Findings are recorded in --report; extract.sh inspects the report
* to decide which entries to --exclude from `tar -xzf`. Sandbox-mode
* posture is "sanitize, don't refuse" — the container drops the
* dangerous symlinks from extraction and records the actions in
* report.json instead of aborting the whole import.
* 2 — usage / I/O error (couldn't read tarball, couldn't write report).
*
* Always writes a JSON report to --report describing every absolute-target
* symlink seen and the classification verdict.
*
* SECURITY NOTE — this differs from the panel implementation in ONE way:
* The panel uses file_exists($target) on the *host* to decide whether a
* target under a dangerous prefix is BENIGN_DANGLING vs DANGEROUS. We
* are running INSIDE the container so /etc and /usr DO exist (they're
* the container's own), but `--read-only --tmpfs /tmp` plus the worker
* running as UID 999 means even DANGEROUS targets cannot reach the host.
*
* We treat any absolute-target symlink under a dangerous prefix as
* DANGEROUS regardless of `file_exists()` — this is a stricter check
* than the panel's, because in the container we *can* safely refuse to
* even try the extract on a clearly malicious tarball.
*/
require __DIR__ . '/safety-net.php';
$opts = getopt('', ['tarball:', 'username:', 'report:']);
if (!isset($opts['tarball']) || !isset($opts['report'])) {
fwrite(STDERR, "usage: scan-symlinks.php --tarball <path> --report <out.json> [--username <u>]\n");
exit(2);
}
$tarPath = $opts['tarball'];
$reportPath = $opts['report'];
$username = $opts['username'] ?? '';
if (!is_file($tarPath) || !is_readable($tarPath)) {
fwrite(STDERR, "scan-symlinks: not a readable file: $tarPath\n");
exit(2);
}
// Threat model: an "ALFA TEaM Shell"-style payload links into a path that,
// when a recursive walker follows it (or when something writes through it),
// either ESCAPES the customer's account on the destination server OR
// CLOBBERS critical system state. The classification needs to be tight
// enough to catch those — and loose enough to NOT flag the dozens of
// standard cPanel-internal symlinks every customer tarball contains
// (access-logs -> /usr/local/apache/domlogs/<user>, var/cpanel/styled/...
// -> /usr/local/cpanel/base/frontend/..., mailman, etc.).
//
// Earlier versions of this file used the panel's broader list (everything
// under /etc, /usr, /bin, /sbin, /lib, /lib64, /var/lib, /var/log,
// /var/cache, /var/spool) which made the container REFUSE every cpmove
// from a real cPanel source server — including clean ones. The panel
// could afford to be permissive in UNCERTAIN handling because it never
// actually followed the links (removeDirectory now shell-rm's, not
// recursive PHP walk). The container is supposed to QUARANTINE the truly
// destructive ones and let the rest through.
//
// Real-world dangerous prefixes (escapes/clobbers):
// / exact root — ALFA "alfasymlink/root -> /"
// /etc config tampering, /etc/shadow exfil
// /root root home dir
// /boot bootloader / kernel
// /proc process info / kernel knobs
// /sys sysfs
// /dev device nodes
//
// Notably NOT in the list (cPanel-legitimate, kept as UNCERTAIN):
// /usr/local/apache/... access logs
// /usr/local/cpanel/... UI styling, plugins, mailman
// /var/log/... per-user mail logs
// /bin, /sbin customer "fix shell" symlinks (rare but seen)
$dangerousPrefixes = [
'/etc',
'/root',
'/boot',
'/proc',
'/sys',
'/dev',
];
/**
* Lexical resolution of a relative link from its own directory in the
* archive: a reason when it reaches/climbs above the archive root, when a
* link under <top>/homedir/ resolves outside that homedir, or when any
* component on the way is itself a symlink entry ($linkSet: normalized
* archive path => true); NULL otherwise. extract.sh's post-extract
* strip-escaping-links.py (realpath of every link) is the authority.
*/
function relative_symlink_escape(string $archivePath, string $target, array $linkSet = []): ?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;
// No chaining: a component that is itself a symlink entry in this
// archive would be expanded by the kernel, which this lexical walk
// cannot model (`a -> b/..` with `b -> x/y/z`).
if (isset($linkSet[implode('/', $stack)])) return 'passes through another symlink in the archive';
}
if ($home !== null && (count($stack) < 2 || $stack[0] !== $home[0] || $stack[1] !== 'homedir')) {
return 'escapes the account homedir';
}
return null;
}
$findings = [];
$cmd = 'tar -tvf ' . escapeshellarg($tarPath) . ' 2>/dev/null';
$fh = @popen($cmd, 'r');
if (!$fh) {
fwrite(STDERR, "scan-symlinks: failed to spawn tar -tvf on $tarPath\n");
exit(2);
}
// Pass 1: every symlink entry (the chain rule needs the whole set).
$links = [];
$linkSet = [];
while (($line = fgets($fh)) !== false) {
if ($line === '' || $line[0] !== 'l') continue;
$arrow = strpos($line, ' -> ');
if ($arrow === false) continue;
$left = substr($line, 0, $arrow);
$right = rtrim(substr($line, $arrow + 4), "\r\n");
$parts = preg_split('/\s+/', $left, 6);
if (count($parts) < 6) continue;
$links[] = [$parts[5], $right];
$linkSet[trim((string) preg_replace('#^(\./)+#', '', $parts[5]), '/')] = true;
}
pclose($fh);
// Pass 2: classify.
foreach ($links as [$archivePath, $target]) {
// 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, $linkSet);
if ($escape !== null) {
$findings[] = [
'type' => 'DANGEROUS',
'archive_path' => $archivePath,
'target' => $target,
'reason' => "relative target $escape",
];
}
continue;
}
if ($target === '') continue;
// (1) user-internal — accept symlinks pointing into the customer's
// own /home/<user>/ tree. The panel rewrites these on extract.
$userInternal = false;
$usernames = [];
// N2-1 review: only the TRUSTED --username (the WHP account WHP passes);
// a name read from the archive's top dir (cpmove-<name>) is never trusted.
if (preg_match('/\A[a-z_][a-z0-9_-]{0,31}\z/', $username)) $usernames[] = $username;
// N2-1 review: only a NORMALIZED target can be user-internal
// (`/home/<u>/../../etc/shadow` is not).
$normalized = !preg_match('#(^|/)\.{1,2}(/|$)|//#', $target);
foreach ($normalized ? $usernames : [] as $u) {
$prefix = '/home/' . $u . '/';
if (strpos($target, $prefix) === 0 || $target === rtrim($prefix, '/')) {
$userInternal = true;
break;
}
if (preg_match('#^/home\d+/' . preg_quote($u, '#') . '(/|$)#', $target)) {
$userInternal = true;
break;
}
}
if ($userInternal) continue;
// N2-1 review: a non-normalized absolute target ('.', '..', '//') is
// never compared by prefix (`/home/<u>/../../etc` would dodge /etc):
// DANGEROUS outright.
if (!$normalized) {
$findings[] = [
'type' => 'DANGEROUS',
'archive_path' => $archivePath,
'target' => $target,
'reason' => "absolute target is not normalized ('.', '..' or empty component)",
];
continue;
}
// (2) exact root.
$type = null;
$reason = '';
if ($target === '/') {
$type = 'DANGEROUS';
$reason = 'absolute target is root /';
} else {
// (3) — in container, every dangerous-prefix target is treated
// as DANGEROUS without a file_exists() check (see security note
// at top of file).
foreach ($dangerousPrefixes as $p) {
if ($target === $p || strpos($target, $p . '/') === 0) {
$type = 'DANGEROUS';
$reason = "absolute target resolves under system path $p";
break;
}
}
if ($type === null) {
// Target is absolute, not user-internal, not under a known
// dangerous prefix. Operators want to know about these.
$type = 'UNCERTAIN';
$reason = 'absolute target outside user tree and not on dangerous-prefix list';
}
}
$findings[] = [
'type' => $type,
'archive_path' => $archivePath,
'target' => $target,
'reason' => $reason,
];
}
$dangerousCount = count(array_filter($findings, fn($f) => $f['type'] === 'DANGEROUS'));
$uncertainCount = count(array_filter($findings, fn($f) => $f['type'] === 'UNCERTAIN'));
$report = [
'tarball' => $tarPath,
'total_findings' => count($findings),
'dangerous_count' => $dangerousCount,
'uncertain_count' => $uncertainCount,
'findings' => $findings,
];
@file_put_contents($reportPath, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
// Sandbox-mode posture: never refuse. Log every DANGEROUS finding to
// stderr so the panel sees them in the streamed [container] log, and let
// extract.sh inspect --report to decide which entries to exclude from
// the tar untar. Caller treats exit 0 as "scan completed; consult report".
if ($dangerousCount > 0) {
fwrite(STDERR, "scan-symlinks: $dangerousCount DANGEROUS finding(s) will be stripped during extract\n");
foreach ($findings as $f) {
if ($f['type'] === 'DANGEROUS') {
fwrite(STDERR, sprintf(" STRIP %s -> %s (%s)\n", $f['archive_path'], $f['target'], $f['reason']));
}
}
}
fwrite(STDERR, "scan-symlinks: scan complete (uncertain=$uncertainCount, dangerous=$dangerousCount)\n");
exit(0);