isCorunaHeader($data)) { throw new RuntimeException('not a Coruna header-obfuscated 7z archive'); } $nextHeaderOffset = unpack('P', substr($data, 0, 8))[1] ^ self::HEADER_XOR; $nextHeaderSize = unpack('P', substr($data, 8, 8))[1] ^ self::HEADER_XOR; $nextHeaderStart = 32 + $nextHeaderOffset; $nextHeaderEnd = $nextHeaderStart + $nextHeaderSize; if ($nextHeaderSize === 0 || $nextHeaderEnd > strlen($data)) { throw new RuntimeException('invalid Coruna 7z bounds'); } $repaired = $data; $repaired = substr_replace($repaired, self::STANDARD_PREFIX."\x00\x04", 0, 8); $repaired = substr_replace($repaired, pack('P', $nextHeaderOffset), 12, 8); $repaired = substr_replace($repaired, pack('P', $nextHeaderSize), 20, 8); $nextCrc = crc32(substr($repaired, $nextHeaderStart, $nextHeaderSize)) & 0xFFFFFFFF; $repaired = substr_replace($repaired, pack('V', $nextCrc), 28, 4); $startCrc = crc32(substr($repaired, 12, 20)) & 0xFFFFFFFF; $repaired = substr_replace($repaired, pack('V', $startCrc), 8, 4); return $repaired; } /** * Patch an on-disk archive header without loading the file body. */ private function patchCorunaHeaderFile(string $path): void { $size = filesize($path); if (! is_int($size) || $size < 32) { return; } $fh = fopen($path, 'r+b'); if (! is_resource($fh)) { return; } try { $head = fread($fh, 32); if (! is_string($head) || strlen($head) < 32 || str_starts_with($head, self::STANDARD_PREFIX)) { return; } if (! $this->isCorunaHeader($head)) { return; } $nextHeaderOffset = unpack('P', substr($head, 0, 8))[1] ^ self::HEADER_XOR; $nextHeaderSize = unpack('P', substr($head, 8, 8))[1] ^ self::HEADER_XOR; $nextHeaderStart = 32 + $nextHeaderOffset; if ($nextHeaderSize <= 0 || $nextHeaderSize > 8_000_000 || $nextHeaderStart + $nextHeaderSize > $size) { return; } if (fseek($fh, $nextHeaderStart) !== 0) { return; } $nextHeader = fread($fh, (int) $nextHeaderSize); if (! is_string($nextHeader) || strlen($nextHeader) !== (int) $nextHeaderSize) { return; } $patched = substr_replace($head, self::STANDARD_PREFIX."\x00\x04", 0, 8); $patched = substr_replace($patched, pack('P', $nextHeaderOffset), 12, 8); $patched = substr_replace($patched, pack('P', $nextHeaderSize), 20, 8); $nextCrc = crc32($nextHeader) & 0xFFFFFFFF; $patched = substr_replace($patched, pack('V', $nextCrc), 28, 4); $startCrc = crc32(substr($patched, 12, 20)) & 0xFFFFFFFF; $patched = substr_replace($patched, pack('V', $startCrc), 8, 4); if (fseek($fh, 0) !== 0) { return; } fwrite($fh, $patched); } finally { fclose($fh); } } public function extract(string $wireData, string $destDir, string $batchBase = '0'): array { if (! is_dir($destDir)) { mkdir($destDir, 0755, true); } $archive = $destDir.'/capture.7z'; file_put_contents($archive, $wireData); try { $this->patchCorunaHeaderFile($archive); } catch (\Throwable) { } return $this->runSevenZip($archive, $destDir, $batchBase); } /** * Extract from a path without loading the whole archive into PHP. */ public function extractPath(string $srcPath, string $destDir, string $batchBase = '0'): array { if (! is_file($srcPath)) { throw new RuntimeException('archive missing'); } if (! is_dir($destDir)) { mkdir($destDir, 0755, true); } $archive = $destDir.'/capture.7z'; $in = fopen($srcPath, 'rb'); $out = fopen($archive, 'wb'); if (! is_resource($in) || ! is_resource($out)) { throw new RuntimeException('cannot copy archive'); } try { stream_copy_to_stream($in, $out); } finally { fclose($in); fclose($out); } try { $this->patchCorunaHeaderFile($archive); } catch (\Throwable) { } return $this->runSevenZip($archive, $destDir, $batchBase); } /** * @return array{ok: bool, stderr: string, files: list, password_recipe: string} */ private function runSevenZip(string $archive, string $destDir, string $batchBase): array { $password = $this->crypto->archivePassword($batchBase); $membersDir = $destDir.'/members'; @mkdir($membersDir, 0755, true); $bin = $this->resolveSevenZipBinary(); try { $result = Process::timeout(120)->run([ $bin, 'x', '-y', '-p'.$password, '-o'.$membersDir, $archive, ]); } finally { @unlink($archive); } $files = []; if (is_dir($membersDir)) { $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator( $membersDir, \FilesystemIterator::SKIP_DOTS )); foreach ($it as $file) { if ($file->isFile()) { $files[] = $file->getPathname(); } } } return [ 'ok' => $result->successful() && count($files) > 0, 'stderr' => $result->errorOutput(), 'files' => $files, 'password_recipe' => 'session_key||'.$batchBase, ]; } public static function forgetWorkDir(string $dir): void { if ($dir === '' || ! is_dir($dir)) { return; } File::deleteDirectory($dir); $parent = dirname($dir); if (is_dir($parent) && File::isEmptyDirectory($parent)) { @rmdir($parent); } } /** * Resolve 7z path without probing outside open_basedir. * is_executable('/usr/bin/7z') fatals under typical panel open_basedir. */ private function resolveSevenZipBinary(): string { $configured = trim($this->sevenZip); $candidates = array_values(array_unique(array_filter([ $configured, // Prefer a binary vendored inside the Laravel root (within open_basedir). base_path('bin/7z'), '7z', ]))); foreach ($candidates as $candidate) { if ($candidate === '7z') { return '7z'; } if (! $this->isPathInsideOpenBasedir($candidate)) { // Still try absolute configured path: exec() is often allowed even when // is_executable() is blocked. Skip the filesystem probe. if ($candidate === $configured && str_starts_with($candidate, '/')) { return $candidate; } continue; } if (@is_file($candidate) && @is_executable($candidate)) { return $candidate; } } return $configured !== '' ? $configured : '7z'; } private function isPathInsideOpenBasedir(string $path): bool { $basedir = (string) ini_get('open_basedir'); if ($basedir === '') { return true; } $real = realpath($path); $check = $real !== false ? $real : $path; foreach (explode(PATH_SEPARATOR, $basedir) as $root) { $root = rtrim($root, DIRECTORY_SEPARATOR); if ($root === '') { continue; } if ($check === $root || str_starts_with($check, $root.DIRECTORY_SEPARATOR)) { return true; } } return false; } }