xxe-boot.dat 加解密过程
一开始对着 xxe-boot.dat 看十六进制,前 16 个字节还能读,后面全是噪声。文件 81960 字节,熵快到 8 了,字符串扫出来也没什么像样的 ASCII。旁边同目录有 xxe-BOOT.sys,日志名还带 xxe-boot-,基本就是这个驱动在读。
驱动字符串表里搜不到 xxe-boot.dat。文件名是栈上拼的宽字符,sub_14000EBB0 里写死了两个后缀:\xxe-boot.dat 和 \xxe-bootd.dat。读文件走的是通用例程 sub_14003B550(ZwCreateFile + ZwReadFile),读完交给 sub_1400451E0,这个东西是个 thunk,真正干活的在 .tvm0 段,sub_140187A78。那段被 VM 糊过,反编译会跳、会 not/xchg 一堆寄存器,看着很烦,不过调用点还在,顺着 call 往下扒就行。
文件不是整包糊一层
头 16 字节小端:
00: 10 00 00 00 version = 16
04: 80 00 00 00 128
08: 90 00 00 00 144
0C: 98 3F 01 00 81816
一开始把 0x80、0x90 当成偏移,也对得上:签名从 +0x10 开始 128 字节,密文从 +0x90 开始 81816 字节,16 + 128 + 81816 = 81960。后来核对校验函数,这两个 dword 其实是长度和密文起点,值刚好长成偏移的样子。sub_1400454C0 要求 version 必须是 16,另外三个字段各有上限,对不上直接走人。
+0x10 那 128 字节别当密钥。公钥在驱动 .rdata,unk_140058640,1024 bit,指数 65537。拿密文做 SHA-1,PKCS#1 v1.5 解开这段,digest 对得上。签的是密文本身,文件头不进哈希。
流密码在 VM 里
真正变换数据的是 sub_140188C77。调用约定糊在花指令里,第三个参数是立即数 0xE628382B,这就是种子。模数 0xDC6A2367。按 dword 走,加密解密同一套:
state = (seed * cipher_len) mod MOD
for each dword w:
out = state XOR w
t = (out + w) mod MOD
state = (t * t) mod MOD
模运算不是直接 div。循环前用 0x94A6A233 做 32 位乘法取高再右移 31;循环里乘数是把 0xB5AAD7E69418C57E 按位取反得到的 64 位数,乘完 rdx 右移 30,再减模。手写的时候得跟汇编同一套,用 Python 的 % 对这个模也能过,但别自己发明另一套约减。
走完以后,最后一个明文 dword 当填充长度,合法值 1~7,驱动按这个数把尾巴砍掉。这份样本砍 7 字节。加密方向要把填充 dword 完全放在明文后面,长度凑成 4 的倍数,常用 4~7。这份明文 81809,补 7 刚好 81816,跟原文件对得上。
种子写死在驱动里,没有第二把钥匙。
解开是什么
砍完填充,81809 字节,UTF-8 XML:
<?xml version='1.0' encoding='utf-8'?>
<config 8a46f2a810856326="drv-boot-u" f9f0327b95503039="201001205">
根上那两个属性名看着像哈希,其实是驱动把 64 位立即数格式化成小写十六进制。sub_14003FB90 干这个,sub_140011310 拿这两个串去树上查 drv-boot-u 和版本号 201001205。标签名运行时解码,IDA 字符串窗口里搜不到 function、block_sign 这种。
下面一堆 <f>,这份大概 797 条,分段大概是:
function 规则开关,编号从 400 起
block_sign 拦截的签名者名字
block_file / block_file2 / block_file3 文件大小、MD5、拼在一起的 hash,size
block_sernum 序列号哈希
hook_device 设备名,GIO、WinRing0、WinIo、inpoutx64 这类
trust_* / known_sign 白名单路径和发布者
game_sign / game_path 游戏侧,Valorant、CrossFire、DeltaForce 能对上
加载成功之后 sub_140010440 把这棵树丢进双缓冲,五分钟内不重复读。失败就记一笔错误码,配置不换。
加载顺序
读文件 → 看头 → RSA 解开 128 字节 → 比对 SHA-1(密文) → 流密码还原 → 砍填充 → 当 XML 解析。
验签在 sub_1400490D0(新一点的构建地址会漂,逻辑没变)。PKCS#1 填充按教科书来:00 01 FF... 00 + DigestInfo + 20 字节 SHA-1。对不上返回错误,上层映射成 101,sub_140010440 到此为止,不会去 sub_140029D20 装新表。
流密码改明文再加密,密文 SHA-1 必变,原签名立刻废。驱动里只有公钥,没有私钥。--sig 把旧签名拷过去,密文没动才过;动过一个字节,加载路径一样拒。
总结
只能看,改了没用,没私钥,已卸载。
算法如下:
"""
Encrypt and decrypt XXE-boot.dat.
Recovered from XXE-BOOT.sys:
sub_14000EBB0 reads \\XXE-boot.dat / \\XXE-bootd.dat
sub_140187A78 parses the container and verifies the RSA signature
sub_140188C77 DWORD stream cipher (seed immediate 0xE628382B)
sub_1400490D0 PKCS#1 v1.5 / SHA-1 / RSA-1024 verify
Container, little-endian:
+0x00 u32 version 16
+0x04 u32 rsa_len 0x80
+0x08 u32 cipher_off 0x90 (header + signature)
+0x0C u32 cipher_len ciphertext size, multiple of 4
+0x10 128 bytes RSA-1024 signature of SHA-1(ciphertext)
+0x90 ... ciphertext
The stream cipher is symmetric. Encryption needs no key beyond the
constants in the driver. Re-signing needs the RSA private key, which is
not in the driver; `encrypt` keeps the original signature when the
ciphertext is unchanged, and otherwise writes 128 zero bytes and reports
that verify will fail.
Usage:
python XXE-boot-crypt.py dec XXE-boot.dat XXE-boot.dec.xml
python XXE-boot-crypt.py enc XXE-boot.dec.xml XXE-boot.dat --sig XXE-boot.dat
python XXE-boot-crypt.py verify XXE-boot.dat
"""
from __future__ import annotations
import argparse
import hashlib
import struct
import sys
from pathlib import Path
VERSION = 16
# Header dwords are sizes, not file offsets:
# [1] = 0x80 RSA signature length
# [2] = 0x90 bytes from start of file to ciphertext
RSA_SIZE = 0x80
HEADER_AND_SIG = 0x90
MOD = 0xDC6A2367
SEED = 0xE628382B
MAGIC = 0x94A6A233
REDUCE_MUL = (~0xB5AAD7E69418C57E) & 0xFFFFFFFFFFFFFFFF
# unk_140058640, RSA-1024 modulus. Exponent is 65537 (unk_1400586C0).
RSA_N = int(
"807667c0dc670e25e132db5676bd3def"
"59aa24f640262bca90b73be6e20fb683"
"89e47636ae5b8d6eb798acfe7937a8ea"
"7bb0a4456cb21d2205e7c93bf5e7cea9"
"35ae1c173d2890b9a1042f5b4979d435"
"2436e5c954c78d4d28b0063088c048bd"
"b7cfbbf57dd57f936319fd0bc58bb65b"
"7224acba5309fbb2fad8bc9dddd0b4df",
16,
)
RSA_E = 65537
SHA1_DIGESTINFO = bytes.fromhex("3021300906052b0e03021a05000414")
def reduce32(value: int) -> int:
"""(value mod MOD) as implemented before the cipher loop."""
q = ((MAGIC * (value & 0xFFFFFFFF)) >> 32) >> 31
return (value - q * MOD) & 0xFFFFFFFF
def reduce64(value: int) -> int:
"""(value mod MOD) as implemented inside the cipher loop."""
q = ((REDUCE_MUL * value) >> 64) >> 30
return value - q * MOD
def crypt_dwords(data: bytes) -> tuple[bytes, int]:
"""XOR stream. Same function encrypts and decrypts.
Returns (transformed_bytes, last_dword). For a real ciphertext the
last dword is the 1..7 pad length stored by the encryptor.
"""
if len(data) % 4:
raise ValueError(f"length {len(data)} is not a multiple of 4")
state = reduce32((SEED * len(data)) & 0xFFFFFFFF)
out = bytearray(len(data))
last = 0
for i in range(len(data) // 4):
word = struct.unpack_from("<I", data, 4 * i)[0]
mixed = (state ^ word) & 0xFFFFFFFF
struct.pack_into("<I", out, 4 * i, mixed)
state = reduce64(reduce64((mixed + word) & 0xFFFFFFFFFFFFFFFF) ** 2) & 0xFFFFFFFF
last = mixed
return bytes(out), last
def default_pad(length: int) -> int:
"""4..7 bytes, so the pad dword sits entirely past the plaintext.
sub_140188C77 reads the last plaintext dword as the strip count. That
dword can be written freely only when it does not overlap the payload,
which means the pad is at least 4. The value is also chosen so
len(plain)+pad is a multiple of 4.
"""
return 4 + ((4 - (length % 4)) % 4)
def pad_plain(plain: bytes, pad_len: int | None = None) -> tuple[bytes, int]:
if pad_len is None:
pad_len = default_pad(len(plain))
if not 4 <= pad_len <= 7:
raise ValueError("pad must be 4..7; a shorter pad overlaps the plaintext dword")
if (len(plain) + pad_len) % 4:
raise ValueError("len(plain)+pad must be a multiple of 4")
total = len(plain) + pad_len
buf = bytearray(total)
buf[: len(plain)] = plain
struct.pack_into("<I", buf, total - 4, pad_len)
return bytes(buf), pad_len
def parse_container(blob: bytes) -> tuple[int, bytes, bytes]:
if len(blob) < HEADER_AND_SIG:
raise ValueError("file smaller than header")
version, rsa_len, cipher_off, cipher_len = struct.unpack_from("<IIII", blob, 0)
if version != VERSION:
raise ValueError(f"version {version}, expected {VERSION}")
if rsa_len != RSA_SIZE or cipher_off != HEADER_AND_SIG:
raise ValueError(f"unexpected layout rsa_len={rsa_len:#x} cipher_off={cipher_off:#x}")
if cipher_off + cipher_len > len(blob) or cipher_len % 4 or cipher_len < 4:
raise ValueError("bad cipher span")
signature = blob[0x10:0x10 + rsa_len]
ciphertext = blob[cipher_off:cipher_off + cipher_len]
return version, signature, ciphertext
def pack_container(signature: bytes, ciphertext: bytes) -> bytes:
if len(signature) != RSA_SIZE:
raise ValueError(f"signature must be {RSA_SIZE} bytes")
if len(ciphertext) % 4 or len(ciphertext) > 0xFFFFFF:
raise ValueError("ciphertext length must be a multiple of 4 and <= 0xFFFFFF")
header = struct.pack("<IIII", VERSION, RSA_SIZE, HEADER_AND_SIG, len(ciphertext))
return header + signature + ciphertext
def decrypt_file(blob: bytes) -> bytes:
_version, _signature, ciphertext = parse_container(blob)
plain, pad = crypt_dwords(ciphertext)
if not 1 <= pad <= 7:
raise ValueError(f"pad dword {pad} is not 1..7; not an XXE-boot ciphertext")
return plain[: len(plain) - pad]
def encrypt_file(plain: bytes, signature: bytes | None = None, pad_len: int | None = None) -> bytes:
padded, _pad = pad_plain(plain, pad_len)
ciphertext, _last = crypt_dwords(padded)
if signature is None:
signature = b"\x00" * RSA_SIZE
return pack_container(signature, ciphertext)
def verify_signature(blob: bytes) -> bool:
"""RSA-1024 PKCS#1 v1.5 verify of SHA-1(ciphertext). Public key only."""
_version, signature, ciphertext = parse_container(blob)
encoded = pow(int.from_bytes(signature, "big"), RSA_E, RSA_N).to_bytes(RSA_SIZE, "big")
digest = hashlib.sha1(ciphertext).digest()
expect = b"\x00\x01" + b"\xff" * (RSA_SIZE - 3 - len(SHA1_DIGESTINFO) - 20) + b"\x00" + SHA1_DIGESTINFO + digest
return encoded == expect
def sign_ciphertext(ciphertext: bytes, private_d: int) -> bytes:
"""PKCS#1 v1.5 SHA-1 sign. private_d is the RSA private exponent."""
digest = hashlib.sha1(ciphertext).digest()
body = SHA1_DIGESTINFO + digest
pad = RSA_SIZE - 3 - len(body)
if pad < 8:
raise ValueError("digest info does not fit RSA-1024")
encoded = b"\x00\x01" + b"\xff" * pad + b"\x00" + body
signed = pow(int.from_bytes(encoded, "big"), private_d, RSA_N)
return signed.to_bytes(RSA_SIZE, "big")
def _read_sig(path: Path | None) -> tuple[bytes | None, bytes | None]:
"""Return (signature, plaintext of that container).
The second value is set only when path is a full XXE-boot container, so
the caller can tell a copied signature from a signature of different bytes.
"""
if path is None:
return None, None
blob = path.read_bytes()
if len(blob) == RSA_SIZE:
return blob, None
_version, signature, _ciphertext = parse_container(blob)
return signature, decrypt_file(blob)
def _report_stale_sig(plain: bytes, signed_plain: bytes | None) -> None:
print("signature: absent or stale; loader sub_1400490D0 will reject this file")
if signed_plain is None:
print(" --sig was not an XXE-boot container, or was omitted")
return
if plain == signed_plain:
print(" plaintext matches the signed file; signature bytes themselves do not verify")
return
print(f" plaintext {len(plain)} bytes, signed source {len(signed_plain)} bytes")
n = min(len(plain), len(signed_plain))
diffs = [i for i in range(n) if plain[i] != signed_plain[i]]
extra = abs(len(plain) - len(signed_plain))
print(f" {len(diffs)} different bytes in the overlap, length delta {extra}")
for i in diffs[:3]:
a = plain[max(0, i - 16): i + 24]
b = signed_plain[max(0, i - 16): i + 24]
print(f" at {i}: input {a!r}")
print(f" signed {b!r}")
if len(plain) != len(signed_plain):
print(f" input tail {plain[-8:]!r}")
print(f" signed tail {signed_plain[-8:]!r}")
print(" RSA covers SHA-1(ciphertext). A copied signature verifies only when these bytes match.")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = parser.add_subparsers(dest="cmd", required=True)
dec = sub.add_parser("dec", help="decrypt XXE-boot.dat to XML/plaintext")
dec.add_argument("src", type=Path)
dec.add_argument("dst", type=Path)
dec.add_argument("--check-sig", action="store_true", help="require a valid RSA signature")
enc = sub.add_parser("enc", help="encrypt plaintext back to XXE-boot.dat")
enc.add_argument("src", type=Path)
enc.add_argument("dst", type=Path)
enc.add_argument("--sig", type=Path, help="128-byte signature, or an existing XXE-boot.dat to copy it from")
enc.add_argument("--key", type=Path, help="text file containing the RSA private exponent (hex)")
enc.add_argument("--pad", type=int, default=None, help="strip count 4..7 (default: 4..7 chosen so the pad dword does not overlap plaintext)")
ver = sub.add_parser("verify", help="check the RSA-1024 SHA-1 signature")
ver.add_argument("src", type=Path)
info = sub.add_parser("info", help="print container fields")
info.add_argument("src", type=Path)
args = parser.parse_args(argv)
if args.cmd == "dec":
blob = args.src.read_bytes()
if args.check_sig and not verify_signature(blob):
print("signature: FAIL", file=sys.stderr)
return 1
plain = decrypt_file(blob)
args.dst.write_bytes(plain)
print(f"decrypted {len(blob)} -> {len(plain)} bytes: {args.dst}")
if verify_signature(blob):
print("signature: OK")
else:
print("signature: FAIL (stream decrypt still completed)")
return 0
if args.cmd == "enc":
plain = args.src.read_bytes()
signature, signed_plain = _read_sig(args.sig)
blob = encrypt_file(plain, signature, args.pad)
if args.key is not None:
private_d = int(args.key.read_text().strip(), 16)
_version, _old_sig, ciphertext = parse_container(blob)
blob = pack_container(sign_ciphertext(ciphertext, private_d), ciphertext)
args.dst.write_bytes(blob)
print(f"encrypted {len(plain)} -> {len(blob)} bytes: {args.dst}")
if verify_signature(blob):
print("signature: OK")
else:
_report_stale_sig(plain, signed_plain)
return 0
if args.cmd == "verify":
ok = verify_signature(args.src.read_bytes())
print("signature:", "OK" if ok else "FAIL")
return 0 if ok else 1
version, signature, ciphertext = parse_container(args.src.read_bytes())
print(f"version={version} rsa_len={RSA_SIZE:#x} cipher_off={HEADER_AND_SIG:#x} cipher_len={len(ciphertext)}")
print(f"signature={signature[:16].hex()}...")
print("signature:", "OK" if verify_signature(args.src.read_bytes()) else "FAIL")
return 0
if __name__ == "__main__":
raise SystemExit(main())