FXAP 二进制结构与只读解析
根据当前工具代码整理 FXAP 外层封装、控制文件、内层偏移和资源 ID,并提供不触碰密钥的安全检查器。
.fxap 在实际资源目录中有两种含义。第一种是资源根目录下名为 .fxap 的控制文件,第二种是仍保留 .lua、.ydr、.ytd 等原扩展名,但文件内容以 FXAP 开头的受保护文件。
本文根据 CK 当前工具中的 decryptor-raw.js、escrow-decryptor.js 和 decrypt-eup-stream.js 整理可重复观察到的结构。它不是 Cfx.re 官方格式规范,也不包含密钥、解密常量、授权获取或绕过步骤。
先区分文件名与文件头
不能只根据文件名判断:
- 根目录的
.fxap通常承担资源控制和资源 ID 定位。 - 被封装的内容文件可以继续叫
client.lua、model.ydr或texture.ytd。 - 当前代码统一读取前 4 字节,只有 ASCII
FXAP才进入 FXAP 分支。 - 普通 RSC7、RSC8 或明文文件不会因为位于同一目录就自动被认定为 FXAP。
verifyFxap() 和 isFxapFile() 都只做 4 字节 magic 检查。因此它们是快速分类器,不是完整性验证器。
外层封装
当前代码稳定使用下面的外层偏移:
| 十进制偏移 | 十六进制偏移 | 长度 | 当前解释 |
|---|---|---|---|
| 0 | 0x00 |
4 | ASCII FXAP magic |
| 4 | 0x04 |
70 | 工具未命名的不透明头部区域 |
| 74 | 0x4A |
12 | 外层 IV 区域 |
| 86 | 0x56 |
到文件末尾 | 外层负载 |
因此要成为包含非空负载的完整候选,文件至少需要 87 字节。当前代码没有把 4 到 73 字节拆成稳定字段,也没有在这里读取版本号、总长度或校验和。公开解析器不应给这 70 字节编造字段名称。
外层处理后的内层布局
在合法授权环境已经得到内容文件的内层 buffer 后,代码优先尝试一个长度驱动布局:
| 内层偏移 | 长度 | 当前解释 |
|---|---|---|
| 0 | 4 | 未命名的内层前缀 |
| 4 | 2 | nameLength,UInt16 Little Endian |
| 6 | nameLength |
文件名或名称字段 |
6 + nameLength |
12 | 内层 IV 区域 |
18 + nameLength |
到末尾 | 内层负载 |
代码用 readUInt16LE(4) 读取名称长度,再计算后续两个边界。它并没有先验证名称编码,也没有确认每种资源都遵循相同版本。因此安全解析器必须做长度检查,不能直接相信 nameLength。控制 .fxap 的 decoded 元数据只可靠用于读取资源 ID,不应自动套用这张内容文件布局表。
旧分支中还存在固定偏移和按文件扩展名寻找边界的启发式回退。那些路径用于兼容样本差异,不是稳定格式字段,本文不会把它们写成正式规范。
控制文件中的资源 ID
根目录 .fxap 控制文件经过合法外层处理后,当前代码从内层的 74 到 77 字节读取资源 ID:
offset 0x4A
length 4 bytes
byte order big endian
源码使用 buffer.slice(74, 78).toString('hex') 再交给 parseInt(..., 16),等价于把这 4 个字节按大端无符号整数理解。
这个位置也被部分 stream-only 处理路径用作候选资源 ID。它是当前样本和代码路径验证过的约定,但仍不是官方承诺。解析结果为 0、buffer 少于 78 字节,或对应授权元数据不存在时,工具都会把它视为无法定位。
一个不解密的外层检查器
下面的 Node.js 示例只读取文件大小、magic 和结构边界。它不会读取密钥,也不会处理受保护负载:
const fs = require('node:fs');
function inspectFxapEnvelope(buffer) {
if (!Buffer.isBuffer(buffer)) {
throw new TypeError('input must be a Buffer');
}
if (buffer.length <= 86) {
throw new Error('file is too short for the observed FXAP envelope');
}
const magic = buffer.subarray(0, 4).toString('ascii');
if (magic !== 'FXAP') {
throw new Error('not an FXAP envelope');
}
return {
magic,
fileBytes: buffer.length,
opaqueHeader: { offset: 4, length: 70 },
outerIv: { offset: 74, length: 12 },
outerPayload: { offset: 86, length: buffer.length - 86 },
};
}
const file = process.argv[2];
if (!file) throw new Error('usage: node inspect-fxap.js <file>');
console.log(JSON.stringify(inspectFxapEnvelope(fs.readFileSync(file)), null, 2));
这个检查器能回答“它是否符合当前观察到的外层框架”,不能回答“文件是否拥有有效授权”或“负载是否能正确加载”。
解析已经取得的内层 buffer
如果调用方已经在合法授权流程中取得内层 buffer,可以只解析名称边界和控制文件资源 ID:
function inspectDecodedInner(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length < 18) {
throw new Error('decoded buffer is too short');
}
const nameLength = buffer.readUInt16LE(4);
const nameOffset = 6;
const innerIvOffset = nameOffset + nameLength;
const payloadOffset = innerIvOffset + 12;
if (payloadOffset >= buffer.length) {
throw new Error('nameLength points outside the decoded buffer');
}
return {
nameLength,
nameOffset,
innerIv: { offset: innerIvOffset, length: 12 },
payload: { offset: payloadOffset, length: buffer.length - payloadOffset },
};
}
function readDecodedControlResourceId(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length < 78) {
throw new Error('decoded control buffer is too short');
}
return buffer.readUInt32BE(74);
}
示例故意不把名称字段直接当 UTF-8 输出。二进制解析器应先确认版本、编码和可打印范围,再决定如何展示文本。
解析顺序
一个稳妥的只读分析流程是:
- 根据目录位置区分根控制文件和普通资源文件。
- 检查文件长度至少为 86 字节。
- 检查前 4 字节是否为
FXAP。 - 记录外层头部、IV 和负载的边界,不解释未知字段。
- 只有在已有合法授权结果时,才解析内层
nameLength和资源 ID。 - 对所有加法做越界检查,拒绝异常长度。
- 把 Lua、RSC7、RSC8 magic 检查看作 sanity check,而不是完整验证。
当前代码中最容易误解的地方
FXAP magic 不等于文件有效
任意文件都可以伪造前 4 字节。只检查 magic 无法证明头部长度、负载、授权或内容完整。
.fxap 控制文件不等于所有内容文件
控制文件帮助定位资源级元数据。真正的内容文件通常保留原扩展名,需要逐个检查文件头。
资源 ID 不等于密钥
资源 ID 只是授权数据中的索引。读出 ID 不会产生授权,也不能据此还原受保护负载。
文件签名只是一层快速检查
当前后处理把 Lua bytecode、RSC7 和 RSC8 的 4 字节头作为成功候选。它无法证明模型页表、纹理、碰撞、音频或 Lua 语义完整。
与 RPF 的关系
FXAP 和 RPF 解决不同层次的问题:
- FXAP 是资源文件或控制元数据的受保护封装标识。
resource.rpf是资源归档,内部有目录、条目和名称表。- RPF 展开后仍可能得到以
FXAP开头的文件。 - 判断 RPF 成功展开,不能替代对每个 FXAP 文件的边界检查。
这份结构图适合做检测、分类和故障定位。若未来样本出现不同头长、不同字段版本或官方格式更新,应先更新解析器的边界验证,再调整字段解释,不能依靠固定偏移静默读取。
