FiveM

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.lua
  • server/main.lua
  • server/gamemodes/xzdd.lua
  • server/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

稳健的字段推断流程是:

  1. 在 entry closure 的 upvalue 中寻找唯一的“11 字段、9 table、2 number”对象。
  2. 用静态分发器找到的 opcode 数组对象做引用相等比较,确定 opcode 字段。
  3. 在嵌套 table 中递归寻找相同形态的 descriptor,确定子 Prototype 字段。
  4. 从密集数值数组中排除 opcode,再根据是否被虚拟 PC 索引区分三组 operand 与 line info。
  5. 根据小型绑定记录识别 upvalue 字段。
  6. 对剩余稀疏字段逐 PC 受限索引,提取字符串、数字、布尔值、nil 和 Prototype 引用。
  7. 根据数值标量和根函数特征区分 maxstack 与 numparams。

稀疏字段可能带 __index 解码逻辑,读取它们也要设置单次指令上限。直接遍历 table 往往只能看到编码容器,不能得到某个 PC 实际使用的常量。

第六步:提取虚拟指令分发器

VM loader 中真正执行字节码的部分是一个寄存器机。每次调用虚拟函数时,它会创建或复用:

  • 虚拟寄存器数组;
  • 当前 Prototype descriptor;
  • 虚拟 PC,初始为 1;
  • 参数个数、寄存器 top 和 vararg 数据;
  • 开放 upvalue cell;
  • 在多条虚拟指令之间传递状态的 scratch locals。

主循环每轮读取 opcodeArray[PC],再进入一棵很大的 if/elseif 分发树。当前提取器不依赖随机局部变量名,而是从 AST 中寻找同时满足这些特征的最大循环:

  1. 循环体先把某个数组的 PC 项读入局部变量。
  2. 后面紧跟大量针对该变量的数值比较。
  3. 分支主体包含寄存器、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 重写。一个可靠的提升顺序是:

  1. 从字符串常量定位事件名、导出名、SQL、UI message 和提示文本。
  2. 从 closure 创建点建立 Prototype 调用图,把回调关联到 RegisterNetEvent、AddEventHandler、命令、线程和 export。
  3. 折叠纯寄存器搬运、常量加载、table 构造和固定 _ENV 查找。
  4. 根据 PC 跳转建立 CFG,恢复 if、循环、提前返回和错误分支。
  5. 按 upvalue 绑定关系恢复局部状态,而不是把所有值都变成全局变量。
  6. 为函数、参数和状态赋予基于用途的新名字,并在注释里标出推断来源。
  7. 按 FiveM 生命周期拆回客户端、服务端和两种玩法模块。
  8. 保持网络事件名、参数顺序、返回值、计时和副作用顺序不变。

自动提升器对动态 PC、循环、运行时寄存器目标或未解析自修改 handler 应当拒绝猜测。遇到这类 Prototype,保留可执行 VM 片段并交给人工逐路径改写,比输出看似整洁但语义错误的 Lua 更可靠。

一条可重复的反编译流水线

针对这一家族,完整流程可以固定成以下步骤:

  1. 记录原文件路径、大小和 SHA-256,所有产物写到独立工作目录。
  2. 只从 Lua 长字符串中识别以 LPH 开头的候选流,并要求正好两条。
  3. 正常路径丢掉四字符前缀,展开 z,执行五字符到四字节的小端 Base85 解码。
  4. 用固定的 lc/lp/pb 和受限字典范围解开 raw LZMA1。
  5. 根据内容确认一份是 Lua VM loader,另一份是二进制 blob。
  6. 解析 loader AST,结构性定位虚拟 PC、opcode 数组和大型分发树。
  7. 静态展开 0..255 的 handler,不执行游戏业务 closure。
  8. 在受限 Lua 子进程中只运行 loader(blob),取得 entry closure。
  9. 从 closure upvalue 中定位根 descriptor,并按对象身份和访问形态推断本构建 schema。
  10. 递归导出 Prototype、三 operand、line info、常量、closure 边和 upvalue 绑定。
  11. 先生成完整 PC/寄存器清单,再生成保留状态化 handler 的可执行 lift。
  12. 按事件、字符串、调用图和动态 trace,逐个 Prototype 提升为普通 Lua。
  13. 在另一套经过审计、拦截全部外部副作用的离线 FiveM 行为测试 harness 中,对原 VM 与重写源码做注册结果和场景差分。
  14. 最后在隔离测试服完成真实框架、数据库、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 生命周期都通过差分与测试服验证。