Luraph VM 虚拟化:从 LPH 双流到可读 FiveM Lua
基于实际 FiveM 样本,完整梳理 LPH 包装、Base85、LZMA、Prototype、虚拟指令调度和源码还原验证流程。
这篇文章分析的是一组实际 FiveM Lua 样本所使用的 Luraph VM 包装与虚拟化流程。目标不是猜测 Luraph 官方打包器的内部源码,而是把样本中能够重复验证的运行时结构拆开,说明怎样从一行 LPH... 包装恢复到可检查、可执行,再到适合人工维护的 FiveM Lua。
这类分析只应针对自有资源或已经取得明确授权的代码。本文讨论格式识别、反虚拟化和行为验证,不涉及绕过第三方服务器授权。
本文中的“Luraph v14.x-style”是当前工具对这一双流、11 字段 split-array VM 家族的分类,不是来自包装头的官方版本字段。方法也不代表能够处理所有 Luraph 版本。
先给结论:它不等于传统密码学加密
这批文件的保护由多层机制叠加而成:
| 层次 | 样本中的实际机制 | 主要作用 |
|---|---|---|
| 外层标记 | 四字符 LPH? 类前缀与环境探测 |
识别载荷并干扰被修改的 load 环境 |
| 文本编码 | 自定义 Base85 变体 | 把二进制负载放进 Lua 长字符串 |
| 压缩 | Raw LZMA1 | 缩小 VM loader 和数据 blob |
| 序列化 | 自定义小端 typed stream | 保存 Prototype、指令、常量和 upvalue 绑定 |
| 虚拟化 | 随机化寄存器 VM、分发树和 descriptor 字段 | 隐藏原 Lua 指令和控制流 |
在已经审计的外层 wrapper、VM loader 和数据读取链中,没有发现 AES、RSA、ChaCha、XXTEA,也没有可识别的 password、固定 XOR key、IV 或认证标签。准确的说法应当是“编码、压缩、自定义序列化和 VM 虚拟化”,不能把这些层统称为某个并不存在的标准加密算法。
服务端业务代码解出以后会通过 PerformHttpRequest 向 cfx.fivemcn.cc/v1/verify 和 cfx.fivemcn.cc/v1/version 发起 HTTPS POST + JSON 的授权、版本业务请求。外层解包、LZMA 解压、blob 反序列化和 entry closure 的创建都可以在没有网络的受限环境中完成,所以这些请求不参与 Luraph 解包或反虚拟化。两个端点的请求字段、响应分支和安全边界见 cfx.fivemcn.cc/v1:FiveM 授权协议与保护层全流程。
本次样本范围
分析覆盖四个受保护文件:
client/main.luaserver/main.luaserver/gamemodes/xzdd.luaserver/gamemodes/jpmj.lua
四个原文件都只有一行主要包装代码,每个文件包含两条 Lua 长字符串形式的 LPH 载荷。解码后的规模如下:
| 文件 | 两个观察到的前缀 | VM loader | VM blob | Prototype 数量 | 虚拟指令数量 | 初始出现的 raw opcode 唯一数量 |
|---|---|---|---|---|---|---|
client/main.lua |
LPH? / LPH- |
38,231 B | 69,064 B | 68 | 4,809 | 189 |
server/main.lua |
LPH! / LPH% |
39,513 B | 90,792 B | 52 | 7,180 | 198 |
xzdd.lua |
LPH/ / LPH> |
40,587 B | 151,006 B | 76 | 13,144 | 222 |
jpmj.lua |
LPH+ / LPH~ |
40,325 B | 144,966 B | 72 | 13,009 | 224 |
第四个前缀字符会随构建变化,不能拿它当固定版本号。四组样本的 Base85 数据都能整组消费,没有残留字符。
从一行 wrapper 到业务 closure
把随机局部变量名折叠后,整个加载链可以表示为:
受保护 Lua wrapper
├─ load/pcall 环境探测
├─ LPH stream A ─ Base85 ─ raw LZMA1 ─ VM loader (Lua source)
└─ LPH stream B ─ Base85 ─ raw LZMA1 ─ VM data blob (binary)
│
▼
load(VM loader)(VM data blob)
│
▼
11 字段 Prototype 图
│
▼
寄存器 VM 的 entry closure
│
▼
通过 UP[0] = _ENV 调用 FiveM API
原 wrapper 最后的行为等价于:
local vm_loader = decompress(decode_lph(stream_a))
local vm_blob = decompress(decode_lph(stream_b))
local builder = assert(load(vm_loader, "Luraph "))
local entry = builder(vm_blob)
return entry(...)
这里的 builder(vm_blob) 只负责构建受保护程序的 entry closure;wrapper 随后把当前 chunk 的 ... 转交给它。安全分析会停在两步之间:取得 entry closure,但不执行真正的 FiveM 业务逻辑。
第一步:环境探测和四字符前缀
wrapper 先缓存 load、pcall、string.sub、string.pack 等标准函数,再用几个小片段测试当前 load 环境。正常路径把截取下标设为 5;Lua 字符串从 1 开始计数,所以 string.sub(stream, 5) 会丢掉前四个字符:
LPH?xxxxxxxx...
^^^^
移除这四个字符,从第五个字符开始解码
如果环境探测认为 load 或执行语义被改动,截取下标会改成构建预设的错误值:本次 client、server、XZDD、JPMJ 样本分别是 15、10、20、20。后续 Base85 数据因此错位,解压自然失败。这是一层反 hook、反插桩干扰,不是密码密钥。
分析器不应把前四个字符硬解释成 LPH 加版本号。当前样本只有前三个字符稳定为 LPH,第四个字符在不同文件、不同流之间都不同。
第二步:LPH Base85 变体
去掉四字符前缀后,解码器先把每个 z 展开为五个感叹号:
z -> !!!!!
然后每五个字符组成一组。每个字符先减去 ASCII 33,得到 0..84 的 digit,再按 85 进制累积:
value = ((((d1 * 85) + d2) * 85 + d3) * 85 + d4) * 85 + d5
value = value & 0xFFFFFFFF
每组最终不是按常见 ASCII85 的大端顺序输出,而是用 Lua 的 string.pack("<I4", value) 写成四字节 Little Endian。一个等价的最小实现是:
def decode_lph_base85(stream: str) -> bytes:
encoded = stream[4:].replace("z", "!!!!!")
if len(encoded) % 5 != 0:
raise ValueError("truncated Base85 group")
output = bytearray()
for offset in range(0, len(encoded), 5):
value = 0
for char in encoded[offset : offset + 5]:
digit = ord(char) - 33
if not 0 <= digit < 85:
raise ValueError(f"invalid Base85 digit at {offset}")
value = ((value * 85) + digit) & 0xFFFFFFFF
output.extend(value.to_bytes(4, "little"))
return bytes(output)
如果需要复刻原 wrapper,可以单独记录未满五字符的尾部;用于取证和批处理的实现则应像上例一样显式拒绝截断数据。本次四个样本的尾部均为空。
第三步:Raw LZMA1
Base85 输出不是带标准 .xz 或 .lzma 文件头的容器,而是 raw LZMA1 流。已经验证的参数为:
lc = 3
lp = 0
pb = 0
dictionary size = 2^16 ... 2^27 逐个尝试
raw 流本身没有容器头可直接告诉分析器字典大小,因此当前工具从 64 KiB 到 128 MiB 逐个尝试,取解码器接受的结果。这表示“找到了能够解开该流的参数”,不应扩大解释为恢复了打包器中的全部原始配置。
原 wrapper 没有依赖系统 LZMA 库,而是内嵌了 Lua range coder 和 LZMA 状态机。可观察到的组成包括:
- 五字节 range coder 初始化;
- 初始概率值
1024; - literal、match、rep 和距离解码;
0xFFFFFFFF结束距离;- 逐段把解压字节重新拼成 Lua 字符串。
两条流分别解出一份可打印的 Lua VM loader 和一份二进制 VM blob。工具按内容分类,并要求结果必须恰好是一份源码和一份二进制;不能只依赖它们在原文件中的先后位置。
Python 中对应的 raw 解压配置可以写成:
filters = [{
"id": lzma.FILTER_LZMA1,
"lc": 3,
"lp": 0,
"pb": 0,
"dict_size": 1 << dictionary_bits,
}]
decoder = lzma.LZMADecompressor(
format=lzma.FORMAT_RAW,
filters=filters,
)
decoded = decoder.decompress(payload)
第四步:VM loader 反序列化 blob
解压出的 VM loader 仍然经过控制流混淆,但它的核心职责已经很明确:读取二进制 blob,创建函数 Prototype 描述符,最后返回 entry closure。
实际读取中观察到了这些 string.unpack 格式:
| 格式 | 含义 |
|---|---|
<I2 / <I4 |
小端无符号 16/32 位整数 |
<i2 / <i4 / <i8 |
小端有符号 16/32/64 位整数 |
<f / <d |
小端单精度/双精度浮点数 |
不同样本不一定使用全部格式,例如 server/main.lua 的本次 trace 没有出现 <d。loader 中还存在逐字节读取和带 continuation bit 的变长整数路径,因此 blob 不是标准 Lua bytecode,也不能直接交给 luac 或通用 Lua 反编译器。
这一阶段适合在独立 Lua 子进程中受限执行,因为 loader 本身最擅长解释它自己的随机化数据格式。安全边界应至少包括:
- 不提供
io和os; - 不提供网络或 FiveM API;
- 把动态
load替换为明确报错; - 未知全局读取和写入直接失败;
- 设置 Lua 指令 hook 上限和外部进程超时;
- 只取得返回的 closure,绝不调用它。
在这个环境里,loader 只凭 blob 和标准 Lua 数据函数就能创建 entry closure。这也是“Luraph 解包不需要 cfx.fivemcn.cc 或外部下发密钥”的直接证据。
第五步:识别 11 字段 Prototype descriptor
entry closure 的 upvalue 中可以找到根 Prototype descriptor。这个家族的 descriptor 固定表现为 11 个连续字段:9 个 table 和 2 个 number,但字段语义的位置会按构建随机化。
九个 table 共同承载:
- 一组 opcode 数组;
- 三组 operand 数组;
- 一组源码行号数组;
- upvalue 绑定记录;
- 三组按 PC 延迟读取的稀疏字段,其中一组同时保存子 Prototype。
两个 number 分别是 maxstack 和 numparams。下表的 f1 到 f11 表示 descriptor 的第 1 到第 11 个槽位。字段不能按某一个文件的位置硬编码,四个样本已经给出了四种布局:
| 文件 | Opcode | Operands | Line | Children | Upvalues | Max stack | Params | Sparse fields |
|---|---|---|---|---|---|---|---|---|
| client | f5 | f4, f7, f9 | f6 | f11 | f1 | f3 | f10 | f2, f8, f11 |
| server/main | f4 | f7, f8, f9 | f1 | f6 | f3 | f10 | f5 | f2, f6, f11 |
| xzdd | f3 | f4, f5, f10 | f11 | f8 | f2 | f6 | f1 | f7, f8, f9 |
| jpmj | f2 | f1, f3, f6 | f7 | f10 | f9 | f8 | f11 | f4, f5, f10 |
稳健的字段推断流程是:
- 在 entry closure 的 upvalue 中寻找唯一的“11 字段、9 table、2 number”对象。
- 用静态分发器找到的 opcode 数组对象做引用相等比较,确定 opcode 字段。
- 在嵌套 table 中递归寻找相同形态的 descriptor,确定子 Prototype 字段。
- 从密集数值数组中排除 opcode,再根据是否被虚拟 PC 索引区分三组 operand 与 line info。
- 根据小型绑定记录识别 upvalue 字段。
- 对剩余稀疏字段逐 PC 受限索引,提取字符串、数字、布尔值、nil 和 Prototype 引用。
- 根据数值标量和根函数特征区分
maxstack与numparams。
稀疏字段可能带 __index 解码逻辑,读取它们也要设置单次指令上限。直接遍历 table 往往只能看到编码容器,不能得到某个 PC 实际使用的常量。
第六步:提取虚拟指令分发器
VM loader 中真正执行字节码的部分是一个寄存器机。每次调用虚拟函数时,它会创建或复用:
- 虚拟寄存器数组;
- 当前 Prototype descriptor;
- 虚拟 PC,初始为
1; - 参数个数、寄存器 top 和 vararg 数据;
- 开放 upvalue cell;
- 在多条虚拟指令之间传递状态的 scratch locals。
主循环每轮读取 opcodeArray[PC],再进入一棵很大的 if/elseif 分发树。当前提取器不依赖随机局部变量名,而是从 AST 中寻找同时满足这些特征的最大循环:
- 循环体先把某个数组的
PC项读入局部变量。 - 后面紧跟大量针对该变量的数值比较。
- 分支主体包含寄存器、operand 数组、PC 和 upvalue 操作。
确定分发树后,对 0..255 的每个候选 opcode 静态求值分支条件,就能把大树展开成完整的 256 项 handler 表。这样即使一个样本初始只出现 189 个 raw opcode,也不会丢掉运行时自修改以后才可能进入的 handler。
这里有两个容易误判的细节。
第一,分发器在每个 handler 结束后统一执行一次 PC = PC + 1。所以 handler 内写入 PC = N 时,真正的下一条是 N + 1。
第二,raw opcode 不是 Lua 5.4 opcode 的简单随机排列。一个源语言操作可能被拆成多条 handler,并通过 scratch local 传递中间状态;本样本中的部分 handler 还会改写 descriptor 中当前 PC 的 opcode。因此不能制作一张固定的 raw opcode -> Lua opcode 表后套用全部文件。
Closure、upvalue 与 FiveM 全局环境
这个 VM 自己实现了 closure、开放 upvalue、CALL、RETURN、tail call 和 vararg。upvalue 绑定大致包含三种来源:
- 按引用捕获父寄存器,父函数返回前保持 open cell;
- 直接复制父寄存器当前值;
- 从父 closure 的 upvalue 再捕获。
根 closure 的 UP[0] 指向 _ENV,因此被保护代码最终仍能访问 FiveM 提供的全局函数。例如 VM 中的:
REG[18] = UP[0]["RegisterNetEvent"]
在高层 FiveM 源码里通常就应该还原为直接调用:
RegisterNetEvent("mahjong:example")
AddEventHandler("mahjong:example", function(payload)
-- 根据后续指令恢复业务逻辑
end)
类似 M.api = api or _ENV 的兼容层只适合通用 lift 在普通 Lua 与 FiveM 环境之间兜底,不是人工源码最终应保留的风格。完成语义确认后,应把 _ENV 查表恢复成普通的 RegisterNetEvent、TriggerServerEvent、exports、Citizen native 和框架 API 调用。
从寄存器级 lift 到人类可读源码
“能运行”和“像作者手写的源码”是两个不同阶段。
第一阶段的可执行 lift 会重建 Prototype 数组、寄存器机、upvalue cell、完整 256 handler 和可写 descriptor。它移除了 Base85、LZMA 和二进制 blob,已经可搜索、可断点和可插桩,但仍然保留大量 REG[x]、PC 和随机 VM 局部变量。
第二阶段才是面向人工维护的 FiveM 重写。一个可靠的提升顺序是:
- 从字符串常量定位事件名、导出名、SQL、UI message 和提示文本。
- 从 closure 创建点建立 Prototype 调用图,把回调关联到
RegisterNetEvent、AddEventHandler、命令、线程和 export。 - 折叠纯寄存器搬运、常量加载、table 构造和固定
_ENV查找。 - 根据 PC 跳转建立 CFG,恢复
if、循环、提前返回和错误分支。 - 按 upvalue 绑定关系恢复局部状态,而不是把所有值都变成全局变量。
- 为函数、参数和状态赋予基于用途的新名字,并在注释里标出推断来源。
- 按 FiveM 生命周期拆回客户端、服务端和两种玩法模块。
- 保持网络事件名、参数顺序、返回值、计时和副作用顺序不变。
自动提升器对动态 PC、循环、运行时寄存器目标或未解析自修改 handler 应当拒绝猜测。遇到这类 Prototype,保留可执行 VM 片段并交给人工逐路径改写,比输出看似整洁但语义错误的 Lua 更可靠。
一条可重复的反编译流水线
针对这一家族,完整流程可以固定成以下步骤:
- 记录原文件路径、大小和 SHA-256,所有产物写到独立工作目录。
- 只从 Lua 长字符串中识别以
LPH开头的候选流,并要求正好两条。 - 正常路径丢掉四字符前缀,展开
z,执行五字符到四字节的小端 Base85 解码。 - 用固定的
lc/lp/pb和受限字典范围解开 raw LZMA1。 - 根据内容确认一份是 Lua VM loader,另一份是二进制 blob。
- 解析 loader AST,结构性定位虚拟 PC、opcode 数组和大型分发树。
- 静态展开
0..255的 handler,不执行游戏业务 closure。 - 在受限 Lua 子进程中只运行 loader(blob),取得 entry closure。
- 从 closure upvalue 中定位根 descriptor,并按对象身份和访问形态推断本构建 schema。
- 递归导出 Prototype、三 operand、line info、常量、closure 边和 upvalue 绑定。
- 先生成完整 PC/寄存器清单,再生成保留状态化 handler 的可执行 lift。
- 按事件、字符串、调用图和动态 trace,逐个 Prototype 提升为普通 Lua。
- 在另一套经过审计、拦截全部外部副作用的离线 FiveM 行为测试 harness 中,对原 VM 与重写源码做注册结果和场景差分。
- 最后在隔离测试服完成真实框架、数据库、NUI、牌局与资源生命周期回归。
批处理工具还应具备这些工程保护:单文件失败不终止全目录、永不覆盖输入、普通 Lua 原样复制、非 Lua 资源按字节校验复制、每个目标保存中间 JSON 和失败报告。
如何证明重写没有改坏行为
只通过 luac -p 不能证明反编译正确。这里还必须区分两个隔离阶段:结构提取沙箱只运行 loader,绝不调用 entry closure;行为验证 harness 才会执行原 VM,但 FiveM、HTTP、数据库、文件和计时等外部能力都必须换成可记录的 stub。建议把验证分成四层:
| 层次 | 检查内容 | 能证明什么 | 不能证明什么 |
|---|---|---|---|
| 语法 | luac -p、静态解析 |
文件可被 Lua 解析 | FiveM 行为正确 |
| 结构 | Prototype、closure、事件和常量清单 | 关键入口没有遗漏 | 分支副作用完全一致 |
| 离线差分 | 同一 stub、同一输入、比较事件/回调/返回值 | 已覆盖场景的可观察行为一致 | 真实框架和网络时序 |
| 测试服 | 实际 FXServer、框架、数据库、NUI 和完整牌局 | 目标部署环境中的运行结果 | 未执行到的隐藏路径 |
离线 stub 至少应记录:
- 注册的 net event、普通 event、command、export 和线程;
- 触发事件的名称、方向、参数个数与顺序;
- timer、callback、HTTP、数据库和 NUI 调用;
- 玩家加入、资源启动/停止、断线和一局游戏关键状态变化;
- 错误类型以及发生错误前已经产生的副作用。
原 VM 和高层重写应使用完全相同的 fake clock、随机种子、玩家数据与 API 返回值。对于麻将资源,还要分别覆盖 xzdd 与 jpmj 的开局、操作、结算、重连和异常退出路径。
PhoenixZeng/LuraphDeobfuscator 能参考什么
PhoenixZeng/LuraphDeobfuscator 展示了早期 Luraph 版本常见的分析思路:找到反序列化器、理解 VM 指令格式、恢复常量并把虚拟操作映射回 Lua 语义。这些方法论仍然有参考价值。
但该项目主要面向 v11.5 到 v11.8.1 一带的格式,不能直接套到本文样本。当前样本额外具有这些已观察到的结构:
- 两条独立的 LPH/Base85/raw-LZMA 流;
- 11 字段 split-array descriptor;
- 每个构建不同的字段顺序、局部符号和 runtime helper 槽位;
- 256 项大型条件分发树;
- 共享可写 descriptor 与运行时自修改 opcode;
- 延迟解码的稀疏常量字段。
因此兼容性判断必须基于实际包装、解压参数、descriptor 形态和分发器结构。识别失败时应该明确停止并保留原件,不能强行按旧版本格式解释。
能恢复什么,不能恢复什么
这条链可以可靠恢复或重建:
- 解压后的 VM loader 与数据 blob;
- Prototype 图、指令、operand、行号和大量常量;
- closure 与 upvalue 捕获关系;
- 当前构建的 256 个 handler;
- 等价执行所需的寄存器 VM;
- 经过逐函数审计后的人类可读 FiveM 业务实现。
但下面这些信息在混淆时已经被删除或改写,不能声称“原样找回”:
- 作者原来的局部变量名和参数名;
- 原注释、空白与文件内部排版;
- 完全相同的
if、循环和表达式写法; - 被编译器折叠或移除的源码结构;
- Luraph 服务端打包器的随机种子和官方内部实现。
最终的人类可读版本应被描述为“基于 VM 行为重建的等价源码”,而不是逐字节恢复的作者原文件。真正的完成标准也不是删掉 LPH 字符串,而是事件协议、状态机、副作用和完整 FiveM 生命周期都通过差分与测试服验证。
