AI 逆向分析国航 AirChina FECU 参数来源并实现离线生成

1. 背景与目标
本次分析的目标是定位网页请求中 FECU 参数的来源,并将其从浏览器环境中剥离出来,最终通过 Python 的 execjs 调用 JS 运行时离线生成。最初直接运行 catch.py 时虽然可以发起请求,但服务端返回 body: error;同时本地生成的 FECU 长度和网页实时生成结果不一致,因此需要继续追踪 FECU 的真实生成链路。
用户在浏览器中验证的调用方式如下:
js
a0_0x411f3a('/g/invoke.json222')
返回形式类似:
text
/g/invoke.json222?FECU=3logsbxN7vnp5HFjIqafz0N%2Fu%2BQt...
这说明 FECU 并不是接口固定参数,而是由页面加载的混淆 JS 动态生成并追加到 URL 中。
2. 定位核心函数:a0_0x411f3a 与 a0_0x56ab93
通过分析提取出的 JS 文件 hxk_fec_bc29daa3.raw.js 和格式化后的 test.js,可以确认两个关键函数:
js
a0_0x411f3a(url) // 包装 URL,追加 FECU 参数
a0_0x56ab93() // 生成 FECU 原始值
在可读化后的逻辑中,a0_0x411f3a 对应类似 ca(c),内部会调用 _() 生成 FECU,再通过 da(c, e) 拼接到 URL:
js
function ca(c) {
var f = _();
return da(c, f);
}
function da(c, e) {
var b = c.split("?").length > 1 ? "&FECU=" : "?FECU=";
return c.replace(/^\s+|\s+$/g, "") + b + encodeURIComponent(e);
}
这里要注意:最终 URL 中看到的 FECU 是经过 encodeURIComponent 编码后的值。
3. FECU 的真实生成结构
核心生成函数 _() 的逻辑大致如下:
js
function _() {
O = u(); // 当前时间戳
a = $(); // 读取服务 cookie
aa(); // 初始化 fingerprint
T["0"] = c(a); // cookie md5
T["8"] = sessionStorage.getItem("fi") || c(JSON.stringify("none"));
T["9"] = c(navigator.userAgent);
T["10"] = m(); // 浏览器类型检测
T["2"] = w(); // webdriver 检测
T["3"] = d; // debugger 检测
T["4"] = I(); // 调试检测
T["11"] = O - P + Q * 1000;
T["12"] = p(); // 5 位随机字符串
return Z(o(T)); // 序列化并加密
}
可以看到 FECU 不是单纯哈希,而是多个环境字段拼接后再加密,字段包括:
text
FECW / FECL / FECN cookie
sessionStorage.fi
navigator.userAgent
浏览器检测结果
webdriver 检测结果
debugger 检测结果
服务端时间差
随机字符串
页面配置派生出的 key
其中 Z(o(T)) 是最终加密出口,o(T) 负责序列化,Z() 使用页面配置中的 R.key 派生密钥进行加密。
4. 关键问题:离线环境缺少 #wsyzwdbq
最初离线运行时生成的是 173 位左右的错误值,例如:
text
dU+iHqmfHJ/cElrxC0tD3S...
而网页生成的值解码后是 194 位。通过 Node 直接运行 fecu_runtime.js 发现报错:
text
getConfig error
继续追踪 r() 函数后发现,JS 会从页面隐藏配置中解析出 R.key、server_time、secure 等字段:
js
function r() {
var a = window.fecBaseConfig_wsyzwdbq ||
document.getElementById("wsyzwdbq").innerHTML.replace(/[\r\n]/g, "");
var b = a.split(",");
// 经过固定索引重排,派生 key、server_time、is_debugger、secure
return {
key: r,
server_time: j,
is_debugger: k,
secure: l
};
}
离线补环境中没有 window.fecBaseConfig_wsyzwdbq,也没有 document.getElementById("wsyzwdbq"),导致 R.key 为空,生成结果自然走错分支。
5. 使用 MCP 从真实浏览器采集配置
通过启动带远程调试端口的 Chrome:
powershell
chrome.exe --remote-debugging-port=9222 `
--user-data-dir=D:\Desktop\codex_api\GH\chrome-debug-profile `
https://m.airchina.com.cn/c/invoke/booking/showFlights@pg
然后用 js-reverse-mcp 在页面上下文中执行:
js
(() => {
return {
wrapped: a0_0x411f3a('/g/invoke.json222'),
cookie: document.cookie,
fecBaseConfig:
window.fecBaseConfig_wsyzwdbq ||
document.getElementById('wsyzwdbq')?.innerHTML.replace(/[\r\n]/g, ''),
fi: sessionStorage.getItem('fi'),
ua: navigator.userAgent
};
})()
得到真实页面中的关键配置:
text
#wsyzwdbq:
3dc6cadce1502814c1a8516993f11e2c,
1ec374c607689d420fb3dc7d87169dbf,
...
6c39eb66402fff30b91bb61f08db4c64
cookie:
FECW=54b6ab449c43b66b9c9a9ff0c8762dd...
这一步确认了:离线生成缺的不是算法,而是页面运行时配置和 cookie 环境。
6. 补环境实现离线生成
在 fecu_runtime.js 中补上必要浏览器环境:
js
var DEFAULT_FEC_BASE_CONFIG = '3dc6cadce1502814c1a8516993f11e2c,...';
global.fecBaseConfig_wsyzwdbq = DEFAULT_FEC_BASE_CONFIG;
document.getElementById = function (id) {
if (String(id) === 'wsyzwdbq') {
return {
innerHTML: global.fecBaseConfig_wsyzwdbq || DEFAULT_FEC_BASE_CONFIG
};
}
return null;
};
document.forms = [];
window.chrome = { runtime: {} };
global.chrome = window.chrome;
同时增加统一参数入口:
js
function applyOptions(options) {
options = options || {};
if (options.fecBaseConfig) {
global.fecBaseConfig_wsyzwdbq =
String(options.fecBaseConfig).replace(/[\r\n]/g, '');
window.fecBaseConfig_wsyzwdbq = global.fecBaseConfig_wsyzwdbq;
}
if (options.cookies) setCookies(options.cookies);
if (options.localStorage) {
Object.keys(options.localStorage).forEach(function (key) {
localStorage.setItem(key, options.localStorage[key]);
});
}
if (options.sessionStorage) {
Object.keys(options.sessionStorage).forEach(function (key) {
sessionStorage.setItem(key, options.sessionStorage[key]);
});
}
}
这样 execjs 调用时就可以把真实网页参数传进去。
7. Python 侧通过 execjs 调用
catch.py 中保留 JS 编译缓存,并通过 payload 传入 cookie、页面配置和 storage:
python
@lru_cache(maxsize=1)
def _load_fecu_ctx():
return execjs.compile(FECU_RUNTIME_PATH.read_text(encoding='utf-8'))
def generate_fecu_offline(url: str = '/g/invoke.json') -> str:
payload = {
'cookies': cookies,
'fecBaseConfig': os.getenv('AIRCHINA_FEC_BASE_CONFIG', DEFAULT_FEC_BASE_CONFIG),
'localStorage': {
'H5_KEY': os.getenv('AIRCHINA_H5_KEY', '2CB6E10B73D101E7'),
'tnum': os.getenv('AIRCHINA_TNUM', '"hk6pLR0O2iwLwY0Yl/9Tzw=="'),
'fVFlag': os.getenv('AIRCHINA_FVFLAG', '"W6muJhRLQKHltvIqWvW2HA=="'),
},
'sessionStorage': {
'fi': os.getenv('AIRCHINA_FI', 'c56a66b07a8623afbfe503ef428f5fe3'),
},
}
return _load_fecu_ctx().call('get_fecu', url, payload)
验证结果:
bash
python -m py_compile catch.py
python catch.py --fecu-only
此时本地可以稳定生成 194 位解码态 FECU,说明已经进入正确生成分支。
8. 为什么网页上看到 202 位以上?
提出疑问:网页实时测试很多组,最少都是 202 位,为什么离线只有 194 位?
原因是统计口径不同。网页 URL 中的 FECU 是编码态:
js
encodeURIComponent(fecu)
原始 FECU 中如果出现 / 或 +,会被编码成 3 个字符:
text
/ -> %2F
+ -> %2B
所以长度关系是:
text
编码后长度 = 原始长度 194 + 2 * (原始 FECU 中 / 和 + 的数量)
例如用户样本:
text
encoded length: 204
decoded length: 194
+ 数量: 3
/ 数量: 2
计算:
text
194 + 2 * (3 + 2) = 204
因此网页看到的 202、204、206、208 都是编码态长度波动;原始 FECU 解码后仍然是 194 位。
9. 固定前缀由什么决定?
继续分析发现,FECU 的固定前缀主要由 FECW 这个 cookie 决定,当前页面 R.secure === "2",因此 $() 函数读取的是:
js
function $() {
if (R.secure === "2") {
return s("FECW") || "";
} else if (R.secure === "3") {
return s("FECN") || "";
} else {
return s("FECL") || "";
}
}
而生成时第一项就是:
js
T["0"] = c(FECW);
实验结果:
text
原始 FECW -> 8BznAJJV3izo...
删除 FECW -> dZ3gV/tvDz61...
FECW 改成 abc -> 1+eXFlA2QX8n...
fi 改变 -> 前缀不变
所以前缀主因是:
text
FECW + 页面配置派生出的 R.key
随机串、时间戳、fi 等更多影响中后段,不决定最前面的固定前缀。
10. 总结
本次分析的关键不是单纯"抠算法",而是还原完整浏览器运行环境。最初离线生成错误,是因为缺少网页隐藏配置 #wsyzwdbq,导致 R.key 为空;补齐页面配置、cookie、storage、UA、Chrome 环境后,execjs 能够脱离浏览器生成正确结构的 FECU。最终确认:
text
a0_0x411f3a(url) 负责 URL 包装
a0_0x56ab93 / _() 负责生成 FECU
#wsyzwdbq 决定 R.key、server_time、secure
FECW 决定 FECU 前缀
FECU 原始长度为 194
URL 编码后通常显示为 202+
至于 python catch.py 返回 body: error,这不是 FECU 函数本身的异常,而是服务端还会校验 live session、cookie、checkToken、业务参数等上下文。FECU 只是接口校验链路中的一环。
