--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', ]; $findings = []; $cpanelUsername = null; $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); } 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; $archivePath = $parts[5]; $target = $right; if ($target === '' || $target[0] !== '/') continue; if ($cpanelUsername === null) { if (preg_match('#^cpmove-([^/]+)/#', $archivePath, $m)) { $cpanelUsername = $m[1]; } } // (1) user-internal — accept symlinks pointing into the customer's // own /home// tree. The panel rewrites these on extract. $userInternal = false; $usernames = []; if ($cpanelUsername !== null && $cpanelUsername !== '') $usernames[] = $cpanelUsername; if ($username !== '') $usernames[] = $username; foreach ($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; // (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, ]; } pclose($fh); $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"); if ($dangerousCount > 0) { fwrite(STDERR, "scan-symlinks: $dangerousCount DANGEROUS finding(s); refusing tarball\n"); foreach ($findings as $f) { if ($f['type'] === 'DANGEROUS') { fwrite(STDERR, sprintf(" %s -> %s (%s)\n", $f['archive_path'], $f['target'], $f['reason'])); } } exit(1); } fwrite(STDERR, "scan-symlinks: clean (uncertain=$uncertainCount, dangerous=0)\n"); exit(0);