FiveM

cfx.fivemcn.cc/v1:FiveM 授权协议与保护层全流程

基于已还原的 FiveM Lua 样本,逐步梳理 /v1/verify 与 /v1/version 的请求、响应、状态机、TLS 边界和安全缺口。

这篇文章分析的是一份已获授权的 FiveM 资源样本。样本版本为 1.0.2,服务端 Lua 会访问 cfx.fivemcn.cc/v1/verify 和 cfx.fivemcn.cc/v1/version。本文把资源启动、请求构造、响应处理、失败分支和外围代码保护逐层拆开,并明确哪些结论已经由原 VM 验证,哪些服务端细节仅凭请求端样本无法得知。

先说明最重要的结论:样本里不存在一套名为“cfx.fivemcn.cc/v1 加密算法”的应用层密码协议。请求体是普通 JSON,请求端也预期并按 JSON 解析响应 body;网络上的机密性来自 HTTPS/TLS。Luraph、Base85、LZMA 和虚拟机用于保护落盘 Lua,它们不参与 /v1 请求体加密。

本文不会公开真实授权密钥或完整资产标识,也不讨论绕过授权。示例中的 <REDACTED_KEY> 和 <ASSET_ID> 都是占位符。

一张表看清所有层

层次 样本中的实际机制 准确分类 是否属于 /v1 应用层加密
Lua 落盘保护 LPH、Base85、raw LZMA1、自定义 VM 编码、压缩、序列化、虚拟化 否
网络传输 https://cfx.fivemcn.cc/... TLS 传输加密 否,由 FiveM 运行时处理
请求体 json.encode({ ... }) JSON 序列化 否
响应体 json.decode(body) JSON 反序列化 否
授权判断 HTTP 状态码、error、asset 业务状态机 否
版本判断 HTTP 状态码、latest、latest_version 业务状态机 否

在恢复出的业务代码中,没有发现 AES、RSA、ChaCha、XXTEA、HMAC、数字签名、nonce、timestamp、IV、认证标签、密钥派生或响应体二次解密。不能因为源码本身经过了 VM 虚拟化,就把随后发出的 JSON 请求也称为“VM 加密协议”。

从资源启动到授权请求

资源的 manifest 按下面的顺序加载服务端文件:

server_scripts {
  'sv_config.lua',
  'server/bridge.lua',
  'server/gamemodes/xzdd.lua',
  'server/gamemodes/jpmj.lua',
  'server/main.lua'
}

escrow_ignore {
  'sv_config.lua',
  'shared/config.lua',
  'server/bridge.lua'
}

sv_config.lua 先把授权密钥写入全局变量 KEY,server/main.lua 随后读取它。sv_config.lua 位于 escrow_ignore 中,说明服主需要能够编辑这份配置。对资源进程而言,KEY 是普通 Lua 字符串,不是只存在于某个硬件密钥区的秘密。

受保护的 server/main.lua 则先经历本地恢复链:

LPH 文本载荷
  -> Base85 解码
  -> raw LZMA1 解压
  -> VM loader + 二进制 blob
  -> Prototype / 指令 / 常量反序列化
  -> 创建并运行 Lua VM entry closure
  -> 调用 FiveM PerformHttpRequest

前五步不依赖 cfx.fivemcn.cc,也没有从该域名领取“解密密钥”。关于这部分保护层的结构,可以参阅 Luraph VM 虚拟化:从 LPH 双流到可读 FiveM Lua。

完整启动时序可以压缩为:

FXServer
  -> 加载 sv_config.lua,取得 KEY
  -> 运行受 Luraph 保护的 server/main.lua
  -> 创建 authPromise
  -> POST /v1/verify
  -> 等待回调 resolve 或 reject
       -> resolve:继续注册麻将服务端逻辑,并启动版本检查
       -> reject:打印失败信息,进入不 yield 的无限循环

这也解释了为什么客户端 Lua 中找不到 cfx.fivemcn.cc:两个 /v1 请求都位于服务端 server/main.lua,不是客户端请求。

/v1/verify 请求格式

还原出的 method、URL、header 和 body 可以写成下面的伪 HTTP 形式;实际协商的 HTTP 版本不由这段 Lua 决定:

POST /v1/verify
Host: cfx.fivemcn.cc
Content-Type: application/json

{
  "key": "<REDACTED_KEY>",
  "aid": "<ASSET_ID>"
}

对应的 FiveM Lua 是:

PerformHttpRequest(
  'https://cfx.fivemcn.cc/v1/verify',
  callback,
  'POST',
  json.encode({
    key = KEY,
    aid = '<ASSET_ID>'
  }),
  { ['Content-Type'] = 'application/json' }
)

这里有几个容易误读的点:

  • key 来自服主可编辑的 sv_config.lua。
  • aid 是当前构建写死的 43 字符资产标识,不是随机数,也不是一次性挑战。它的外形符合无填充 Base64url 编码 32 字节数据的特征,但样本没有它的生成过程,不能据此断言原数据是哈希还是密文。
  • 请求体没有显式 IP 字段。403 的提示虽然提到绑定 IP,但服务端如何取得和比较 IP 只能由服务端实现决定。
  • Lua 显式传入的 headers 中没有 Authorization、自定义签名头或 Cookie;这不能排除运行时或代理层添加其他头。
  • 调用没有传入 options,按当前 FiveM Lua scheduler 的默认值会跟随重定向;具体重定向请求语义由运行时 HTTP 实现决定。
  • 回调只使用状态码和 body,忽略响应 headers 与 errorData,也不核对响应 Content-Type。
  • Lua table 序列化后的 JSON 字段顺序不应被当作协议要求。
  • 同一组字段可以由普通 HTTPS 客户端构造,不需要运行 Luraph VM;VM 只是隐藏了这段业务代码。

/v1/verify 响应算法

原 VM 的授权回调已经通过隔离 stub 中的离线差分验证;这不是线上接口实测。其高层逻辑是:

local response = json.decode(body)

if statusCode == 200 then
  print(('授权通过 · %s'):format(response and response.asset or ''))
  authPromise:resolve(true)
  return
end

authPromise:reject(
  response and response.error
    or AUTH_ERROR_MESSAGES[statusCode]
    or ('HTTP ' .. tostring(statusCode))
)

内置状态映射如下:

HTTP 状态 本地回退提示
401 密钥无效
402 授权已过期
403 与绑定的 IP 不符,请联系该资产创作者
410 授权已撤销
421 资产授权不匹配,请重新下载资产
500 服务器内部错误
其他 HTTP <status>

如果非 200 响应中的 response.error 为 Lua truthy 值,服务端返回的文本优先于这张本地映射表;值为 false 或 nil 时会继续使用本地回退。

这里的“通过条件”非常宽:只要 HTTP 状态是 200 且响应体能够被 json.decode 处理,promise 就会 resolve(true)。代码不会检查 success、不会要求 asset 存在、不会核对响应中的 aid,也不会验证响应签名。因此以下几个 200 响应在当前请求端状态机中没有区别:

{ "asset": "Br_Mahjong" }
{}
{ "error": "some error text" }

后两种写法不代表真实服务端会这样返回,只说明请求端没有更严格的语义校验。起始 URL 仍通过 HTTPS/TLS 按运行时信任策略认证对端;由于当前调用默认跟随重定向,最终响应是否经过其他目标和协议跳转还取决于运行时策略。“没有应用层签名”不等于“网络上完全没有服务端身份验证”。

授权失败状态机

回调先执行 json.decode(body),之后才检查 HTTP 状态。这一顺序决定了几个特殊分支:

收到 HTTP 回调
  -> json.decode(body) 成功?
       否:异常逃出回调,authPromise 可能一直 pending
       是:statusCode == 200?
              是:resolve(true)
              否:reject(error / 本地映射 / HTTP 状态)

Citizen.Await(authPromise)
  -> resolve:继续启动
  -> reject:pcall 返回 false,打印错误,进入 while true do end
  -> pending:继续等待

离线原 VM 与重建代码的十场景差分已经确认:无效 JSON 会逃出回调,promise 不会自动 resolve 或 reject。资源代码本身也没有设置授权超时。

Citizen.Await 外层使用了 pcall。promise 被 reject 后,脚本打印授权失败和“资源已停止运行”,但实际代码不是 return 或正常停止资源,而是:

while true do end

这是一个不 yield 的忙循环。它对不同 FXServer 构建和调度环境的真实影响尚未做实机验证,因此不能仅凭静态代码断言会卡住整个服务器;可以确认的是,这不是一种安全的失败退出方式。

/v1/version 请求格式

只有授权 promise 成功后,代码才会读取 manifest 中的版本:

local currentVersion = GetResourceMetadata(
  GetCurrentResourceName(),
  'version',
  0
)

当前样本得到 1.0.2,随后立即调用一次版本接口。下面仍是伪 HTTP 表示,不指定实际协商的 HTTP 版本:

POST /v1/version
Host: cfx.fivemcn.cc
Content-Type: application/json

{
  "key": "<REDACTED_KEY>",
  "version": "1.0.2"
}

请求仍然只是 json.encode,并会再次发送同一个 KEY。代码随后创建线程,每等待 3,600,000 毫秒,也就是一小时,再发起一次相同类型的版本请求:

checkVersion()

Citizen.CreateThread(function()
  while true do
    Citizen.Wait(3600000)
    checkVersion()
  end
end)

这一小时任务是版本检查,不是重新授权。当前资源只在首次启动时执行 /v1/verify。

/v1/version 响应算法

版本回调与授权回调的处理顺序不同:

if statusCode ~= 200 then
  return
end

local response = json.decode(body)
if response and response.latest == false then
  print(('发现新版本 %s,当前版本 %s')
    :format(response.latest_version or '?', currentVersion))
end

因此:

  • 非 200 响应被静默忽略,而且不会先解析 JSON。
  • 200 响应才会执行 json.decode。
  • 只有 latest 严格等于布尔值 false 才提示更新。
  • 新版本号来自 latest_version;字段缺失时显示 ?。
  • 响应没有签名或哈希校验。
  • 返回内容只进入日志,不会作为 Lua、JavaScript 或二进制代码执行。

静态扫描没有发现版本包 URL、下载器、自动覆盖文件、loadstring 或“下载后执行”链。Luraph 外层确实使用 load 来加载本地解压出的 VM loader,但那是源码保护的本地启动步骤,不能和版本接口混为一谈。

HTTPS 到底加密了什么

FiveM 的 Lua PerformHttpRequest 会把 URL、method、data、headers 和重定向选项交给运行时的内部 HTTP 实现,再通过内部响应事件把 status、body、headers 和错误信息交回 Lua callback。当前业务脚本只传了前四类数据,没有自行设置 TLS 参数。

从样本中能确认的是:

  1. URL 使用 https://,所以请求头和 JSON body 会进入 HTTPS/TLS 连接。
  2. KEY 在 TLS 建连前已经以 JSON 明文存在于 FXServer 进程内存中。
  3. 对端或中间的 TLS 终止代理解密后,也会看到普通 JSON。
  4. Lua 没有在 TLS 之外再包一层密文,也没有对 body 做认证签名。

从样本中不能确认的是某次线上连接最终协商的 TLS 版本、密码套件、证书链、反向代理实现或服务端私钥。这些都取决于当时的 FXServer 构建、操作系统、DNS、网络路径和线上服务配置,不能根据一段 Lua 静态代码编造。

所以“完整加密流程”的准确表述应是:

Lua table
  -> json.encode
  -> UTF-8 JSON 明文
  -> FiveM HTTP 运行时
  -> HTTPS/TLS 加密传输
  -> 服务端或重定向目标终止 TLS
  -> 对端取得 JSON 请求体并执行授权判断
  -> 对端返回响应 body,请求端预期其为 JSON
  -> HTTPS/TLS 加密传输
  -> FiveM HTTP 运行时解密
  -> Lua json.decode

TLS 内部当然会使用密码学算法,但具体算法是握手协商结果,不是这个资源自定义的 /v1 算法。

从请求端样本能推断什么

状态码和提示文本能证明请求端预期服务端至少区分这些业务结果:密钥无效、过期、IP 不匹配、撤销、资产不匹配和内部错误。它们可以形成服务端实现的候选流程:

读取 key 和 aid
  -> 查找授权
  -> 检查有效期
  -> 检查撤销状态
  -> 检查资产绑定
  -> 检查来源 IP 或相关绑定
  -> 返回 HTTP 状态与 JSON

但这只是依据请求端错误语义整理出的候选顺序。当前样本无法证明服务端实际按什么顺序检查,也无法恢复:

  • 授权数据库结构;
  • 密钥的生成算法、长度规则和存储形式;
  • 服务端保存的是明文 key 还是哈希;
  • IP 的取值、代理头信任规则和绑定策略;
  • 过期、撤销和资产关联的真实数据模型;
  • 成功响应的完整 JSON schema;
  • 当前线上接口是否仍与该 1.0.2 样本一致。

要回答这些问题,需要服务端源码、服务端日志或经过授权的实时观测。仅凭 Lua 请求端不能“反推出”不存在于样本中的后端实现。

与 FiveGuard、ac 'fg' 和 svadhesive 的边界

同一批分析中还出现过 FiveGuard VM、私有字节 codec、fg:Comm、函数引用桥、.net.dll 和 ac 'fg'。它们与这里的 /v1 授权协议没有静态关联:

  • /v1 的调用点只在麻将资源的服务端 Lua 中。
  • FiveGuard 的事件名、codec 和函数引用桥没有引用 cfx.fivemcn.cc。
  • 标准 FiveM 中,未知 manifest 指令会成为 metadata;仅凭 ac 'fg' 不能证明运行时自动注入某个 DLL。
  • 本次核查的特定 svadhesive.dll 样本只显示下载阶段的合规过滤或原地流处理,没有证据表明它构造了这两个 JSON 请求;这一结论不能自动套用到其他构建。

名称中都出现“cfx”或运行在同一个 FiveM 环境,不足以把这些机制拼成一套加密链。

协议安全性审计

下面只评价已经观察到的请求端设计,不推断线上服务是否另有防护:

观察项 当前行为 影响
授权密钥保存 可编辑 Lua 配置中的普通字符串 TLS 不能保护磁盘、日志或进程内存中的 key
请求认证 key + aid,无 nonce、时间戳或签名 应用层本身没有重放新鲜度证明
响应认证 依赖 HTTPS;无应用层签名 请求端信任 TLS 终止点返回的状态和 body
200 成功校验 只检查 HTTP 200 缺失字段或带 error 的有效 JSON 仍会通过
JSON 异常处理 授权回调先裸调用 json.decode 畸形响应可能让 promise 保持 pending
超时 资源层没有显式授权超时 网络或回调异常时启动可能长期等待
失败退出 while true do end 存在忙循环和调度风险
版本检查 每小时再次发送 key 扩大 key 被日志、代理或观测系统接触的次数
更新行为 只打印 latest_version 当前没有远程代码执行或自动覆盖风险

如果重新设计这套授权请求端,更稳妥的方向是:

  1. 对 JSON 解码使用受控错误处理,并保证每条回调路径都会 resolve 或 reject。
  2. 增加明确的授权超时和可恢复的失败策略,不使用不 yield 的无限循环。
  3. 对 200 响应验证固定 schema、业务成功字段、资产标识和有效期。
  4. 如果威胁模型要求独立于 TLS 的响应真实性,可使用服务端签名的短期授权结果,并把请求挑战值纳入签名;不要自创加密算法。
  5. 版本检查若不需要完整授权 key,就使用权限更小的令牌或公开版本元数据。
  6. 保持版本响应只作为数据处理,不把远端字符串直接交给 load、命令行或文件覆盖逻辑。

证据等级与验证边界

本文结论来自四层证据:

  1. 从原始 Luraph 包装中静态提取 VM loader、数据 blob、Prototype、指令和常量。
  2. 把授权与版本 Prototype 提升为普通 FiveM Lua,并核对 URL、method、字段、状态分支和调度顺序。
  3. 在隔离 stub 中对原 VM 与高层重建运行十组授权响应差分,包括成功、拒绝、空对象、多个 HTTP 状态和无效 JSON;已覆盖场景的可观察结果一致。
  4. 对照 FiveM 开源 Lua scheduler,确认 PerformHttpRequest 如何把 Lua 参数交给内部 HTTP 请求并分发回调。

公开运行时证据可以直接在固定版本源码中复核:

这些证据足以说明当前样本的请求端协议和状态机,但没有执行真实授权 key,也没有取得服务端源码。本文因此不声称恢复了后端密钥算法、数据库或当前线上部署配置。

最终结论

cfx.fivemcn.cc/v1 在这份资源中的完整可证流程只有两条:

启动授权:KEY + 固定 aid -> HTTPS POST /v1/verify
                         -> 按 JSON 解析的响应 body + HTTP 状态
                         -> resolve 后继续启动 / reject 后失败分支

版本检查:KEY + manifest version -> HTTPS POST /v1/version
                              -> latest / latest_version
                              -> 立即一次,之后每小时一次

它没有自定义请求加密、签名、随机挑战或响应解密,也没有从接口下载 Lua 源码。真正的密码学边界是 HTTPS/TLS;真正的代码保护边界是本地 Luraph VM。把这两层分开,才能准确理解这份资源,也能避免把普通 JSON、源码混淆和传输加密误写成同一种“加密算法”。