279 lines
9.1 KiB
PHP
279 lines
9.1 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Process;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* Repair Coruna obfuscated 7z headers and extract with p7zip.
|
|
*/
|
|
class CorunaArchive
|
|
{
|
|
private const STANDARD_PREFIX = "7z\xBC\xAF'\x1C";
|
|
|
|
private const HEADER_XOR = 0x1234567800ABCDEF;
|
|
|
|
private const HEADER_MARKER_1 = 0x000A000900010804;
|
|
|
|
private const HEADER_MARKER_2 = 0x009812000B0F0D0C;
|
|
|
|
public function __construct(
|
|
private readonly CorunaCrypto $crypto,
|
|
private readonly string $sevenZip = '',
|
|
) {}
|
|
|
|
public function isCorunaHeader(string $data): bool
|
|
{
|
|
if (strlen($data) < 32) {
|
|
return false;
|
|
}
|
|
if (str_starts_with($data, self::STANDARD_PREFIX)) {
|
|
return false;
|
|
}
|
|
$m1 = unpack('P', substr($data, 16, 8))[1];
|
|
$m2 = unpack('P', substr($data, 24, 8))[1];
|
|
|
|
return $m1 === self::HEADER_MARKER_1 && $m2 === self::HEADER_MARKER_2;
|
|
}
|
|
|
|
public function repairHeader(string $data): string
|
|
{
|
|
if (str_starts_with($data, self::STANDARD_PREFIX)) {
|
|
return $data;
|
|
}
|
|
if (strlen($data) < 33 || ! $this->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<string>, 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;
|
|
}
|
|
}
|