Kitmoe

某 GAL 汉化补丁 BGI.chs.exe 逆向分析记录

分析某GAL汉化补丁BGI.chs.exe,发现其使用自定义壳和LZMA压缩,通过内存Dump和调试分析得到其加密机制,成功提取中文资源与脚本。

封面
封面

起因

有一天同学 Call 我,大概意思是某游戏出了一个重制版,他想把旧版的某个作者的人工汉化搬到新版上,但是作者没有发布明文译文,他自己尝试了一下用 AI 逆向,但是因为汉化补丁加了壳,最终没有成功。

虽然我也没有逆向经验,是第一次接触逆向,但同学一说突然就有点兴趣,就拿这个老补丁当靶子学一回。

一、静态分析与内存探查

图片说明 如图,补丁由三个文件组成,直接让 LLM 从二进制方向检查一下 exe 与两个容器的内容。 经过一番分析:

  1. 壳不是任何公开且有流传的壳,没有检测到相关的指纹,这个壳要不然是内部流通、要不然是小众打包工具的结果。
  2. exe 二进制内搜不到常见的反调试 API,这可能是好事,也就是说壳没做什么反调试手段,也可能是反调试 API 调用本身也被混淆了。
  3. 两个 arc 文件虽然后缀和格式和游戏本体的资源是一样的,但是内部数据也加密了。

exe 结构静态分析

节 磁盘 RawSize 映像声明
.text 0 RVA 0x1000,VSize 0xCF908(851,208 B),可执行
.rdata 0 RVA 0xD1000,VSize 0x1EE1B(126,491 B),只读
.data 0 RVA 0xF0000,VSize 0x63528(406,824 B),可读写
'0' 0 RVA 0x154000,VSize 0xA411B96(172,039,062 B),可读可执行
'1' 149,805,568 RVA 0xA566000,VSize 0x8EDD840,RawPtr 0x400
.rsrc 100,352 RVA 0x13444000,VSize 0x18780
  1. .text、.rdata、.data:文件里没有这个节的内容,按 PE 规则加载后是 0 填充。
  2. 0:文件里没有内容,但是映像里声明了 164 MiB 的地址空间。
  3. '1':熵为 7.9999,可能是强加密,这是整个文件自身从 0x400 到 .rsrc 前的原样拷贝。
  4. .rsrc:标准 PE 资源目录,7 项 = 4 图标(82,304 / 9,640 / 4,264 / 1,128)+ 组图标(62)+ 版本信息(1,236,含 Ethornell - BURIKO General Interpreter 等串)+ manifest(1,140,XML 文本)

exe 数据结构:

0x400 (头) + 0x8EDDA00 (节'1') + 0x18800 (.rsrc) = 0x8EF6600 = 149,906,944

没有 overlay,也没有空洞。接下来运行一次,进到游戏 Dump 看看内存里面有什么。

任务管理器 -> 选中进程 -> 创建内存转储

运行时内存分析

功能层 地址范围 大小 内容 判定依据 置信度
原版引擎·代码 0x401000–0x4D0908 850,184 B BGI/Ethornell 1.614.1 机器码 3,823 个函数序言;E8/E9 83% 目标在节内;42,527 个 CC 高
原版引擎·只读数据 0x4D1000–0x4EFE1B 126,491 B 字符串 / RTTI / 导入名 src\DSSpeakerModel.cpp、BURIKO ARC20、system.arc 高
原版引擎·可写数据 0x4F0000–0x553528 406,824 B vtable / RTTI / 全局变量 945 个指向 .text 的指针;CBurikoWaveBox* RTTI 高
原版引擎·资源 0x13844000–0x1385C780 100,224 B 图标 / 版本信息 / manifest 版本信息 "Ethornell - BURIKO General Interpreter" 高
壳·加载器 stub 0x13600000–0x13843840 2.3 MB 混淆代码 + PE 元数据 入口点 0x1372462B 落在其中;熵 7.0–7.6 高
壳·加密载荷 0x0A966000–0x13600000 147.4 MB 密文/压缩数据 熵 7.9999;4 MB 采样 16 B 块零重复 高
壳·激活/反篡改 0x554000–0x580000 180 KB 激活客户端 + HOOK 检测 Error at hooking API "%S"、ActivationRequest 等 高
壳·未知 0x57F000–0x0A966000 171.9 MB 高熵填充 熵 7.99;~2% 的 16 B 块重复(非理想密文) 中高
系统 / 第三方模块 0x58690000–0x7C44F000 等 虚拟 505 MB / 提交约 198 MB 82 个 32 位 DLL 模块列表 高
运行时数据 0x1385D000–0x30D9F000 150.7 MB(私有) 脚本 / 音频 / 纹理 / 堆 / 栈 —— 高
保留 / 空闲 其余 2382 MB / 1025 MB — MemoryInfo 状态位 —

把屏幕上显示的台词直接在内存里面搜索,这次直接搜明文搜出来了:

0x2CA2F16B   映入眼帘的尽是飞散的樱花。 // 搜索的台词
0x2CA2F188   那副风景中,既没有悲伤,也没有痛苦。
0x2CA2F1AF   所以我才没有哭吗?
0x2CA2F1C4   随风舞动的那抹淡红确实很美丽,而我感受到的也仅此而已。
0x2CA2F3C3   在只有关系者才能参与的这个小小的葬礼结束的同时,父亲的人生也终于划上了句号。
0x2CA2F412   现在正伫立在这座樱之森的我,眼睛里丝毫没有泪水。
0x2CA2F445   而且也丝毫没有悲伤之情。
0x2CA2F460   这里的风景一点也感觉不到悲伤的气氛。
0x2CA2F487   周围的人又是怎样呢?
0x2CA2F49F   果然会悲伤吗?
0x2CA2F4B1   会心痛吗?
0x2CA2F4BE   只是……看着落樱下的尸体,有些事情无论如何都会想起。
0x2CA2F4F5   内心一瞬间有种撕裂的感觉。
......

再往前一点还能看到脚本引用的资源名:00_op_01.bss、Yuzu_2G.h、bgm004、sp0065a、map023… 说明这块 4.2 MB 里同时装着该场景的指令、资源引用和文本。从这里也可以知道,壳是使用虚拟文件交付引擎这种常见的手法,这些资源在磁盘上压根不存在。

好消息是有大片中文明文直接躺在内存里,坏消息是 Dump 出来中文只有 KB 级别,要完整获取估计得通关游戏,这个效率是不可接受的。

二、x32dbg 的调试分析

一开始尝试让 LLM 挂进程分析,结果游戏直接崩了,退出码:0xDEADC0DE,看来这个壳其实是有反调试的。最后发现直接上 ScyllaHide 就可以随便调了。

破解初始化过程

.text 是 PAGE_EXECUTE_READ,壳要写就必须先改保护,再加上已知明文的地址,所以:

bp VirtualProtect
bph 0x401000, w

跟了几轮,最后bph 0x401000, w断点进了壳区 成功抓到了

136CDD04 | 881439                   | mov byte ptr ds:[ecx+edi],dl

正在一个一个字节初始化空节,设置追踪跑一圈,看看算法是什么。

图片说明

;位流读取
135FE2B7  movzx ebx, byte ptr ds:[ebx]      ; 从 0x13662045 取一个字节
135FE2BB  shl   esi, 8                      ; 位缓冲左移
135FE2CF  or    esi, ebx                    ; 塞进低 8 位
135FE2D4  inc   dword ptr ss:[ebp-8]        ; 流指针 +1

;概率模型自适应
13712C29  shr   ecx, 0xB        ; >> 11  ← 概率总位数 kNumBitModelTotalBits
13825E70  imul  ecx, ebx        ; bound = (range>>11) * prob
13825E74  cmp   esi, ecx        ; code ? bound
137EFCB7  jae   ...
;...
137EFCC8  mov   ecx, 0x800      ; 2048 ← kBitModelTotal
137EFCD1  sub   ecx, ebx        ; 2048 - prob
137EFCD3  sar   ecx, 5          ; >> 5  ← kNumMoveBits

;输出是顺序字节
136E943B  mov  edi, dword ptr ss:[ebp+10]   ; edi = 0x401000(.text 基址,每轮重载)
136CDD04  mov  byte ptr ds:[ecx+edi], dl    ; [0x401000 + ecx] = dl

伪代码:

ecx++;                                  // 输出下标
if (ebx < 4) {
    bitbuf = (bitbuf << 8) | *stream++;  // ESI 喂位流
    save = dl;                           // 0x137B3585: [ebp-1] = dl
    ...
    // 用 EBX 里的概率查表:bound = (range>>11) * prob
    if (code < bound) { range = bound;  prob += (2048-prob)>>5; }
    else              { range -= bound; code -= bound; prob -= prob>>5; }
    // 表驱动解码出一个字节 -> DL
    ...
}
dl = decode_literal();
*((u8*)0x401000 + ecx) = dl;            // 写进 .text

好家伙,这是一台 LZMA 解压器,'1'节的高熵内容其实是压缩包。

bph 0x13825E74

trace 里 [ebp-8] 就是流指针(当时是 0x13662045,每读一字节 inc)。第一次命中时它是"已消费 5 字节之后"的位置,所以:

流起点 = [ebp-8] - 5 // 结果为 0x13662038

拿 0x13662038 起的数据暴力试 LZMA 参数,一次命中:

lc=3, lp=0, pb=2      (即标准 props 0x5D)
dict_size ≥ 1 MB      (64 KB 会在解到 72,571 字节时崩,说明有超过 64KB 的回溯引用)
流范围   0x13662038 – 0x136BCE8C,共 372,308 字节
解压输出 850,432 字节 = .text(850,184) + 248 字节零填充

试解

import lzma

d = open(r'BGI.chs.DMP', 'rb').read()
stream = d[0x133E9EBA:0x13444D0E]          # 372,308 字节

filt = [{'id': lzma.FILTER_LZMA1,
         'dict_size': 1 << 20,              # 1 MB 即可,实际值不影响结果
         'lc': 3, 'lp': 0, 'pb': 2}]

out = lzma.decompress(stream, format=lzma.FORMAT_RAW, filters=filt)
open('text.bin', 'wb').write(out[:0xCF908])  # 850,184 字节,就是 .text
print(len(out), out[:8].hex())               # 850432 558bec83e4f88b4d

输出前 8 字节 55 8B EC 83 E4 F8 8B 4D 和引擎入口一模一样,马上把整个条带解压出来

发现解压到0节的时候会报错,经过一番测试,解压参数改成 'dict_size': 1 << 26 就可以完整解压

四条流按文件偏移排列([起点, 压缩长度]),参数完全一致:lc=3, lp=0, pb=2,dict_size ≥ 1 MB(实测用 64 MB)。

节 流起点(文件) 压缩长度 解压长度
'0' 0x4C62B 147,055,757 B 172,039,168 B
.data 0x8CBAAA4 11,827 B 54,784 B
.text 0x8CFC438 372,308 B 850,432 B
.rdata 0x8E314E1 47,100 B 126,976 B

完整得出初始化代码

import lzma

FILT = [{"id": lzma.FILTER_LZMA1, "dict_size": 1 << 26,
         "lc": 3, "lp": 0, "pb": 2}]

with open("BGI.chs.exe", "rb") as f:
    img = f.read()

def un(off, n):
    return lzma.decompress(img[off:off + n], format=lzma.FORMAT_RAW, filters=FILT)

text  = un(0x8CFC438, 372308)[:0xCF908]   # 850,184   引擎代码
rdata = un(0x8E314E1,  47100)[:0x1EE1B]   # 126,491   常量/字符串
data  = un(0x8CBAAA4,  11827)[:0x63528]   # 406,824   其余补零
sec0  = un(0x4C62B, 147055757)            # 172,039,168  '0' 节

print(len(text), text[:8].hex())          # 850184 558bec83e4f88b4d
print(len(sec0), sec0[:8].hex())          # 172039168 568bf133c9b80100

产物与内存对照

运行时 '0' 映射在 0x554000,'1' 映射在 0xA966000(运行时节表被改写)。把解压结果按节 VSize 截断,与进程转储逐字节比:

节 差异字节 说明
.text 0 完全一致
.rdata 52 +0xDC 起是指针,运行期导入绑定改写(如 1E E9 0E 00 → 30 DC DE 76)
.data 411 +0x724 起同上
'0' 2,355(514 处) 运行期写入/随机化,分散在全段

'0' 解压结果比 VSize 多 106 字节零尾(0xA411C00,0x400 对齐)。开头 0x2C000(180 KB)是激活/反篡改区:UTF-16 Error at hooking API 位于 +0x22034,ASCII ActivationRequest 位于 +0x283E4。

尝试通过明文写者追踪解密器

尝试从明文写入者追踪解密器

从 Dump 分析台词块(其实大部分是空的)

块范围   0x2CA02000 + 0x407000(4.0 MB)
零字节   97.8%      → 真正有内容的只有约 120 KB
偏移 VA
+0x20 0x2CA02020
+0x190 0x2CA02190
+0x1B690 0x2CA1D690
+0x1B910 0x2CA1D910

成对出现,相隔 0x170 / 0x280 ,说明装的是两个脚本,每个带一份头,第一个脚本约 112 KB。

直接搜索屏幕上的中文,得到脚本池地址,然后

bph 0x2A02B020, w

抓到了

图片说明

004A7D78  8B C6              mov eax,esi
004A7D7A  83 E0 0F           and eax,0xF        ; 检查源对齐
004A7D85  8B D1              mov edx,ecx
004A7D87  83 E1 7F           and ecx,0x7F       ; ecx = 长度 % 128
004A7D8A  C1 EA 07           shr edx,7          ; edx = 长度 / 128
;...
004A7D92  66 0F 6F 06        movdqa xmm0,[esi]        ;┐
004A7D96  66 0F 6F 4E 10     movdqa xmm1,[esi+0x10]   ;│ 一次搬 128 字节
004A7D9B  66 0F 6F 56 20     movdqa xmm2,[esi+0x20]   ;│ 源 = ESI
004A7D9F  66 0F 6F 5E 30     movdqa xmm3,[esi+0x30]   ;│ 目的 = EDI
004A7DA3  66 0F 7F 07        movdqa [edi],xmm0        ;│
004A7DA7  66 0F 7F 4F 10     movdqa [edi+0x10],xmm1   ;│
004A7DAB  66 0F 7F 57 20     movdqa [edi+0x20],xmm2   ;│
;...
004A7DE0  8D B6 80 00 00 00  lea esi,[esi+0x80]
004A7DE6  8D BF 80 00 00 00  lea edi,[edi+0x80]
004A7DEC  4A                 dec edx
004A7DED  75 A3              jnz 0x4A7D92        ;┘

引擎正在把一块 95 KB 的编译脚本从 0x2F7BA130 搬进池子的 0x2A02B020。 接着往上游追

bph 0x2F7BA130, w

图片说明

得到

0x460B00  装载器
  ├─ size = [this+0x14]        ; 解压后长度,上限 64MB
  ├─ call 0x4A1B00             ; new(size) → dst
  └─ call 0x45F690             ; DSC 解码(dst=EDI, record=ESI)
       ├─ 0x45F340 建 Huffman 表
       │    eax = [record+0x10]        ← LCG key
       │    0x48BF70                   ← init: [0x4F8E18] = key
       │    0x48BF80                   ← step: 返回密钥流字节
       │    cl = record[0x20+i] - al   ← 解密,512 次
       └─ 0x45F570 逐符号输出
            0x45F640  LZ 回拷(count=(sym&0xFF)+2,距离 12 位)
            0x45F678  字面量
  ↓
0x4A2CB0 memmove → 0x4A7D71 快路径 → 4MB arena

是引擎侧的脚本解压解密器,追错了...... 从这里倒是可以得到日语的解密器

意外发现的引擎侧 VM 分配器

图片说明

经过了一番折腾才发现完全走错了,这一大块是引擎的临时缓冲池,用完就释放,写入时明文已经生成,所以明文写入的第一现场不在这里。顺着写入链条虽然追到了一块原始带中文的内存,但是和壳代码没什么关系。

尝试通过挂文件读取 API 定位解密器

结果非常让人惊讶,补丁的两个 arc 资源文件完全没有被读取过,尝试把两个 arc 文件直接替换为 0 字节文件,汉化仍然正常加载。这条路直接失败了,没想到两个 arc 文件是障眼法。

三、TTD 回放法

抓取明文写入目前证明十分困难,壳侧解密时间通过连续内存 Dump 目测只有亚秒级,解密完通过虚拟文件交付引擎立即释放,每次内存地址都不同。

目前接下来就两条路:

1、直接猛攻壳代码:壳的条带根本无法观察出来,而且有花指令、数据与代码混放、填垃圾字节等等混淆操作,壳都是通过各种跳板、失败路径回退与桩调用代码块,API 调用、常量等全部是运行时拼凑出来,直接猛攻代价极大,如果有逆向大佬倒是可以尝试,对我来说不可行。

2、抓读取密文的人:就是这一条路。

找到四个解压节的读取者

通过抓内存可知:读档前内存内无明文,但是读档后明文就躺在内存里面,直接使用 win32dbg 录制进程从读档前到读档后的过程,得到了 trace 文件 BGI.chs01.run(9.66 GB)与索引 BGI.chs01.idx(4.06 GB)

我盲猜密文就在壳初始化的时候解压的四个节里面,马上统计读取了四个节的 ip:

!tt 100
dx @$cursession.TTD.Memory(0x4d1000, 0x4d1100, "r").Count()
dx @$cursession.TTD.Memory(0x4d1000, 0x4d1100, "r").Select(r => r.IP).Take(10)

按 4 KB 窗口在全轨迹上采样:

节 / 窗口 读次数 读指令 IP 说明
.rdata @0x4D1000 6,183 0x45CC4F、0x499531 引擎读 IAT/全局
.rdata @0x4EE000 297 0x558CA7 '0' 内代码读 .rdata
.data @0x500000 1,062,583 0x40AE9F、0x40AE7A 引擎热全局
.text @0x401000 12 — 几乎无数据读
'0' @0x554000 828 0x8C31363 字节读+查表
'0' @0x560000 3,245 0xA8CBCAF 字节读循环头
'0' @0x580000 1,107 0x5552FB CRC 步进
'0' @0xA00000 4,096 0x7F7A555 1 读/字节
'0' @0x7000000 5,776 0x69E254 CRC(表 0xA8C68D0)
'0' @0x8000000 4,938 0xA895CEF、0x5CD26E dword 读+写回
'0' @0xA000000 4,096 0x7F9E088 CRC(表 0x7F73C48)
'0' @0xA800000 4,096 0x7FC0800 XOR 字节读+写回
'1' 全部 13 个窗口 0 — 整段未被数据读

把每个读点让 LLM 分析:

0x7F9E088、0x5552FB、0x7F7A555、0x69E254、0x8C31363、0xA8AB8DC、0xA8CBCAF 都是同一族校验循环,去花后如下:

movzx esi, byte [ptr]
xor   esi, state
and   esi, 0FFh
mov   esi, [table + esi*4]
shr   state, 8
xor   state, esi

不同点只是各用一张 256 项表(0x7F73C48、0xA8A7A58、0x6BCDFC、0xA8C68D0、0x8C270BC),属于分布式的完整性校验,这是壳的反修改,不是解密器。

继续检查,读了内存又写了内存的只有三处:0x7FC0800(xor bl,[eax+edi] 后 mov [esi+eax-1],bl)、0xA895CEF、0x5CD26E。

先看看0x7FC0800在干什么

bph 0x7FC0800

图片说明

该块被大量花指令包裹,去花后有效指令如下:

7FC07AA  33 C9              xor  ecx,ecx
7FC07B1  8B C6              mov  eax,esi
7FC07BC  03 45 0C           add  eax,dword ptr [ebp+0Ch]
7FC07CE  46                 inc  esi
7FC07E0  8B 4D 08           mov  ecx,dword ptr [ebp+8]
7FC07EA  8B 59 04           mov  ebx,dword ptr [ecx+4]
7FC07F0  8A C8              mov  cl,al
7FC07F4  D3 C3              rol  ebx,cl
7FC07F7  02 D8              add  bl,al
7FC0800  32 1C 38           xor  bl,byte ptr [eax+edi]
7FC0807  8B 45 14           mov  eax,dword ptr [ebp+14h]
7FC080B  88 5C 06 FF        mov  byte ptr [esi+eax-1],bl
7FC0811  3B F2              cmp  esi,edx
7FC0813  0F 82 91 FF FF FF  jb   7FC07AA

伪代码:

// esi=起始, edx=结束, edi=源基址
// [ebp+8]=ctx, [ebp+0Ch]=源偏移C, [ebp+14h]=目的基址A
for (i = esi; i < edx; i++) {
    u32 seed = *(u32 *)(ctx + 4);
    u8  x    = (u8)(i + C);
    u32 k    = ROL32(seed, x & 31);
    u8  ks   = (u8)k + x;
    dst[A + i]        = src[edi + C + i] ^ ks;
}

算法:

x    = (i + C) & 0xFF
ks   = (ROL32(seed, x & 31) & 0xFF) + x
out  = in ^ ks

其中:seed = 0xFF462BF0、ctx = *(u32*)([ebp+8])、C = [ebp+0Ch]、A = [ebp+14h]、edi = 源基址、esi/edx = 起止。本轮 L = edx - esi。

在断点处读到:seed=0xFF462BF0, edi=0x60D03C, C=0x27780, A=0x2A6A9DB8, esi=0x0D, edx=0x5FE0。输出开头为 00 00 00 10 00 14 20 11 04 0C 00 01 14 04 08 02 09 02 01 ...。与预期一致。

马上继续运行,观察写出内存,此代码块每运行一轮,就往内存写入一个字节明文。

!!!这就是解密器!!!

继续分析 seed 规律。

x32dbg 里在 0x7FC07A4 下静默日志断点,记录:

SEED={d:[[ebp+8]+4]} C={d:[ebp+C]} A={d:[ebp+14]} EDI={edi} ESI={esi} EDX={edx}

发现 seed 完全不变。

尝试解密完整条带

seed 既然不变,每个字节的密钥流就只取决于 (偏移 & 0xFF)。通过已知明文对 256 个相位分别构造密文,直接在内存 Dump 上搜:

def rol32(v, n):
    n &= 31
    return ((v << n) | (v >> (32 - n))) & 0xFFFFFFFF if n else v

table = [((rol32(SEED, x) & 0xFF) + x) & 0xFF for x in range(256)]

def cipher_pattern(magic, p):
    return bytes(magic[j] ^ table[(p + j) & 0xFF] for j in range(len(magic)))

def scan(img, magic):
    hits = {}
    for p in range(256):
        pat = cipher_pattern(magic, p)
        i = img.find(pat)
        while i >= 0:
            ok = bytes(img[i + j] ^ table[(p + j) & 0xFF]
                       for j in range(len(magic))) == magic
            if ok:
                hits[i] = p
            i = img.find(pat, i + 1)
    return hits

结果:

  • BurikoCompiledScriptVer1.00:133 处(131 处在脚本区,0x7BEC000–0x86B4000);
  • {0x10, len, 0, 0} 记录头:58 处(0x5D2AD1–0x66F13C);
  • BURIKO ARC20:5 处;CompressedBG___:114 处;OggS:15 处。

!!!解密成功!!!

四、收尾

解密器、初始化器已经完整破解,接下来就只要分析中文容器,写离线脚本就行了。

ARC20 结构

struct Arc {
    char magic[12];        // "BURIKO ARC20"
    u32  count;
    Entry entries[count];  // 每条 0x80 字节,从 +0x10 开始
    // 数据区起始 = 0x10 + count*0x80
};

struct Entry {             // 0x80 字节
    char name[16];         // +0x00
    ...
    u32  offset;           // +0x60,相对数据区
    u32  size;             // +0x64
};

解密规则:plain[rel] = cipher[rel] ^ table[rel & 0xFF],rel 为归档偏移(归档首字节相位 0)。

归档 基址 条目 数据区 导出体积 类型
ipl._bp 0x5D2941 3 0x190 24,845 bin ×3
anmmngrm._bp 0x60D03C 57 0x1C90 397,600 bin ×57
Att03 0x6E2D73 36 0x1210 9,563,229 CompressedBG ×35
00_op_01 0x813C420 131 0x4190 11,303,885 BurikoCompiledScript ×131
SGCG000000 0x8C67CEA 156 0x4E10 29,473,584 CompressedBG ×79 + bin ×77

这些也正好是壳交付的虚拟文件名

合计 383 个条目,实际导出 382 个(Att03 有 1 条 size 异常被跳过),约 50.7 MB。

导出代码

import lzma
import os
import struct

SEED = 0xFF462BF0
FILT = [{"id": lzma.FILTER_LZMA1, "dict_size": 1 << 26, "lc": 3, "lp": 0, "pb": 2}]
MAGIC = b"BURIKO ARC20"


def rol32(v, n):
    n &= 31
    return ((v << n) | (v >> (32 - n))) & 0xFFFFFFFF if n else v


TABLE = [((rol32(SEED, x) & 0xFF) + x) & 0xFF for x in range(256)]


def lzma_unpack(exe, off, n):
    return lzma.decompress(exe[off:off + n], format=lzma.FORMAT_RAW, filters=FILT)


def dec_arc(sec0, base, rel, n):
    return bytes(sec0[base + rel + j] ^ TABLE[(rel + j) & 0xFF] for j in range(n))


def find_arcs(sec0):
    pat = bytes(c ^ TABLE[i] for i, c in enumerate(MAGIC))
    arcs, i = [], sec0.find(pat)
    while i >= 0:
        hdr = dec_arc(sec0, i, 0, 0x10)
        if hdr[:12] == MAGIC:
            count = struct.unpack_from("<I", hdr, 12)[0]
            name = dec_arc(sec0, i, 0x10, 0x18).split(b"\x00")[0].decode("latin1")
            arcs.append((i, count, name))
        i = sec0.find(pat, i + 1)
    return arcs


def main(exe_path, out_dir):
    exe = open(exe_path, "rb").read()                       # 读文件
    text  = lzma_unpack(exe, 0x8CFC438, 372308)[:0xCF908]   # 解 exe 四条流
    rdata = lzma_unpack(exe, 0x8E314E1,  47100)[:0x1EE1B]
    data  = lzma_unpack(exe, 0x8CBAAA4,  11827)[:0x63528]
    sec0  = lzma_unpack(exe, 0x4C62B, 147055757)
    for base, count, arc_name in find_arcs(sec0):
        tab = dec_arc(sec0, base, 0, 0x10 + count * 0x80)
        data_start = 0x10 + count * 0x80
        arc_dir = os.path.join(out_dir, arc_name)
        os.makedirs(arc_dir, exist_ok=True)
        for k in range(count):
            e = 0x10 + k * 0x80
            name = tab[e:e + 16].split(b"\x00")[0].decode("latin1")
            off  = struct.unpack_from("<I", tab, e + 0x60)[0]
            size = struct.unpack_from("<I", tab, e + 0x64)[0]
            if not size or size > 0x400000:
                continue
            blob = dec_arc(sec0, base, data_start + off, size)
            with open(os.path.join(arc_dir, f"{k:03d}_{name}.bin"), "wb") as f:
                f.write(blob)                                # 明文写出

验证:

00_op_01 的第一个条目是完整的编译脚本(BurikoCompiledScriptVer1.00,脚本名 Yuzu_2G,入口 main),内文可直接读出:

Yuzu_2G / main
... bss.h  Function.h  Yuzu_2G.h  ic0004a
 暑假结束后,我成了英雄。
bg1003sa  bgm028
女生A「原来如此,说的也是。直到暑假前都要一直上学呢。要是发生点什么有趣的事情就好了」

Att03、SGCG000000 的条目是 CompressedBG___ 图像块,anmmngrm._bp 是 {0x10,len} 记录。

遗留问题

  • 脚本区 131 个 BurikoCompiledScript 头全部是 Yuzu_2G / main,表项 +0x34 处为 0,该区更像同一脚本的多版本/多块或目录项。
  • Att03 有 1 个条目的 size 字段异常,原因未知。
  • CRC 校验的具体参数与 patch 方法未研究('1' 节整段未被读,'0'、.rdata 有读)。
  • '1' 节在观察的运行期 0 次数据读,其作用未知。

本文授权:CC BY-NC 4.0 · 署名—非商业性使用

输入关键词,开始搜索。