--report [--username ]\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/, 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 /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// 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-) 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//../../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//../../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);