本文主要记录 Akamai sensor_data 的生成与还原思路。整体请求流程可以概括为:先通过页面 HTML 获取 Akamai JS 地址,再请求 JS 文件,随后连续 POST 三次 sensor_data,看本文建议自己同步尝试。
第一次 JS 地址通常可以直接从 HTML 中提取,示例代码如下:
js_url = f"{urlparse(url).scheme}://{urlparse(url).netloc}" + re.search(
'type="text/javascript" {2}src="(.*?)">', response.text
).group(1)

本文的目标是复现后续三次 POST sensor_data 的流程。由于 Akamai 每次下发的 HTML 和 JS 都可能变化,为了方便调试,可以先把代码替换到本地环境中运行。
在正式还原之前,建议先处理 toString Hook,否则部分格式化检测可能无法通过。这里不需要做得太复杂,重点处理长函数名的替换即可;其他格式化检测可以按常规混淆还原思路处理。

1. 定位 sensor_data 来源
先在浏览器里打一个 XHR 断点,观察 sensor_data 的生成链路。从调用栈往上追,可以看到这里的 dcK 是关键位置;继续向上会发现它来源于 RNK,再全局搜索 RNK。

继续追踪后,可以定位到下图位置。这里先做一层简单还原:

这段代码简单还原后如下图。后续对下图中的 13b 对象做一系列处理。我们的重点不是最终加密成 sensor_data,而是还原 13b 对象中各个属性的来源。

2. 解混淆思路
这里的解混淆核心思路是把类似下面这种动态属性访问:
a[x()[b(c)]]
替换成带记录能力的形式:
a[obj["x()[b(c)]"] = x()[b(c)]]
因为 JS 会先执行右侧的 x()[b(c)],再把结果赋值到 obj 对应的 key 中,随后原逻辑仍然可以继续正常执行。等整体流程跑完后,把 obj 导出,就能得到类似下面的映射:
obj = {
"x()[b(c)]": "charCodeAt"
}
接着再用 AST 把原来的:
a[x()[b(c)]]
替换成:
a["charCodeAt"]
这样就能得到更多明文属性,方便后续定位。具体实现时,可以把规则和几个样例交给 Codex 生成 AST 替换代码,并要求它做验证。
运行完替换 obj 的 AST 脚本后,再放到浏览器中执行一遍。注意需要晃几下鼠标,因为 Akamai 会监听 MouseEvent。随后把导出的 obj 拷贝回来,再跑一遍 AST 替换,就能得到一份还原后的 JS。
需要注意:不是所有位置都会被还原。被还原的位置说明运行时确实跑到了;未还原的位置则说明当前流程没有覆盖到。
3. 参数分析
继续在 13b 对象处打断点。本文不会展开所有参数,如果是浏览器指纹类参数,只简单说明来源;后续可以根据关键字继续追。
3.1 ajr
后续基本都采用类似的搜索方法:先定位 ajr,可以看到它来源于 ORb,而 ORb 的来源如下:
var ORb = IdK()(nz(IZ, [
"startTimestamp", tZ["window"].bmak["startTs"],
"deviceData", XKK(Xr, [X9b]),
"mouseMoveData", HRb,
"totVel", UBK,
"deltaTimestamp", Jqb
]));

nz(xxx) 的结果如下图,然后继续进入 IdK()。
可以看到 IdK() 中的关键逻辑:TwK 返回 Math.floor(Math.random() * 100000 + 10000),totVel 在第一次 POST 时为 0,mbK 只是字符串累加。到这里,ajr 的生成逻辑基本就明确了。这里传入的 totVel 只有第一次 POST 时才是 0;startTs 则是在第一次加载 Akamai JS 时生成的时间戳。
还有一个小点:ajr 不是每天都保持同一种格式。目前我观察到的格式包括:
num|num|num
1,68344,2,34172,4,17086|68344
另外还有两种,目前总共观察到四种。后续继续补充如何判断当天 JS 使用的是哪一种 ajr 格式。
3.2 fpt / fpc
fpt: ["", -1, "dis", "", true, true, true, -480, true, screen.colorDepth, screen.pixelDepth, navigator.cookieEnabled, navigator.javaEnabled, -1].join(';')
fpc: "" + sumAscii(fpt)
function sumAscii(str) {
if (str == null) return -1;
let sum = 0;
for (let i = 0; i < str.length; i++) {
let code = str.charCodeAt(i);
if (code < 128) sum += code;
}
return sum;
}
3.3 din
din 内部字段是有顺序的。在不替换js的情况下,每次看到的顺序可能不同。这个顺序和 ajr 一样,也需要根据当前 JS 来判断。
下面开始分析 din 中的参数:

这里不再逐个关键字展开,方法都是一样的:通过关键字定位来源即可。比如 year_div = 2016,这类值的来源并不难追。
function updateDin(startTs, din) {
updateDinField(din, 'she', screen.height);
updateDinField(din, 'swi', screen.width);
updateDinField(din, 'nps', "20030107");
updateDinField(din, 'ash', screen.availHeight);
updateDinField(din, 'nap', "Gecko");
updateDinField(din, 'ua', navigator.userAgent);
updateDinField(din, 'dau', 0);
updateDinField(din, 'wdr', 0);
updateDinField(din, 'hz1', parseInt((startTs / (year_div * year_div)), 10));
updateDinField(din, 'npl', 0);
updateDinField(din, 'asw', screen.availWidth);
updateDinField(din, 'wow', window.outerWidth);
updateDinField(din, 'adp', "cpen:0,i1:0,dm:0,cwen:0,non:1,opc:0,fc:0,sc:0,wrc:1,isc:0,vib:1,bat:1,x11:0,x12:1");
updateDinField(din, 'pha', 0);
updateDinField(din, 'nal', navigator.language);
updateDinField(din, 'ibr', 0);
updateDinField(din, 'wiw', window.innerWidth);
updateDinField(din, 'tsd', 0);
updateDinField(din, 'ucs', sumAscii(navigator.userAgent).toString());
updateDinField(din, 'wih', window.innerHeight);
updateDinField(din, 'ran', "".concat(Math.random()).slice(0, 11) + parseInt(Math.random() * 1000 / 2, 10));
updateDinField(din, 'hal', startTs / 2);
updateDinField(din, 'xag', 12147);
}
其中:
"eem": "do_en,dm_en,t_en" // DeviceOrientationEvent、DeviceMotionEvent、TouchEvent 都存在时就是这个结果
"ffl": js_url.split('/').slice(-4)[0]
3.4 fwd.ssh
ssh 对应的是 nBK。找到 nBK 赋值的位置后打断点。

下图是 nBK 的赋值位置,参数就是 speechSynthesis.getVoices() 的值拼接后的结果。

VKK 是下面这个函数:
function arrayToHexString(numbers) {
let hexString = "";
for (let i = 0; i < numbers.length; i++) {
const hex = numbers[i].toString(16);
hexString += hex.length === 2 ? hex : "0" + hex;
}
return hexString;
}
下图是 fSK 函数:

3.5 mst
mst 中有很多字段,这里先看 jsrf1 和 jsrf2。
全局搜索 jsrf1,然后一直向上追到对应函数,重写即可。

几个字段说明:
mst.dd2: parseInt(parseInt(startTs / (year_div * year_div), 10) / 23, 10)
mst.ww8: 0
mst.mc: mouse_finger.length // 鼠标指纹数量,第一次没有鼠标轨迹,直接是 0
mst.dvc: 'aciY9hfhidiicdxfraiY,614501,f+c+k+i+j+b+a+d+h+e+g+l+'
mst.dvc 的第一段是 VMP 跑出来的结果,第二段是 delt 时间,第三段后面再讲。VMP 部分我在另一篇文章中已经展开过,这里不再深入。
mst.nfas 解密后可以直接查到 nfas 关键字,浏览器里采集即可:
function get_nfas() {
const apis = [
{ obj: navigator, key: 'credentials' },
{ obj: navigator, key: 'appMinorVersion' },
{ obj: navigator, key: 'bluetooth' },
{ obj: navigator, key: 'storage' },
{ obj: Math, key: 'imul' },
{ obj: navigator, key: 'getGamepads' },
{ obj: navigator, key: 'getStorageUpdates' },
{ obj: navigator, key: 'hardwareConcurrency' },
{ obj: navigator, key: 'mediaDevices' },
{ obj: navigator, key: 'mozAlarms' },
{ obj: navigator, key: 'mozConnection' },
{ obj: navigator, key: 'mozIsLocallyAvailable' },
{ obj: navigator, key: 'mozPhoneNumberService' },
{ obj: navigator, key: 'msManipulationViewsEnabled' },
{ obj: navigator, key: 'permissions' },
{ obj: navigator, key: 'registerProtocolHandler' },
{ obj: navigator, key: 'requestMediaKeySystemAccess' },
{ obj: navigator, key: 'requestWakeLock' },
{ obj: navigator, key: 'sendBeacon' },
{ obj: navigator, key: 'serviceWorker' },
{ obj: navigator, key: 'storeWebWideTrackingException' },
{ obj: navigator, key: 'webkitGetGamepads' },
{ obj: navigator, key: 'webkitTemporaryStorage' },
{ obj: Number, key: 'parseInt' },
{ obj: Math, key: 'hypot' }
];
return apis.reduce((fingerprint, api, index) => {
const exists = Boolean(api.obj[api.key]);
return fingerprint + (exists << index);
}, 0);
}
mouse_finger 的格式如下图。鼠标指纹只需要 21 个点:第一个值是下标,第二个值固定是 1,第三个值是时间戳。

下面这部分是鼠标指纹相关字段。第一次请求时,这些参数基本都是固定写死的;第二、三次 sensor_data 才需要真实鼠标轨迹。
var mev = mouse_finger.slice(0, 21).join(';') + ';'
mst.dmvl: new Date().getTime() - startTs
mst.devl: new Date().getTime() - startTs
mst.mevl: 32 + count_fingerprint(mev) // count_fingerprint 计算 mev 中所有数字的总和
mst.tovl: count_fingerprint(mev) + devl + dmvl
mst.tst: devl + dmvl + count_time_fingerprint(mev) // count_time_fingerprint 计算 mev 第三位的总和
4. 借助 AI 和 AST 自动化还原
可以通过少量补环境、替换代码、运行截获的方式生成下面这些关键内容。只有这些内容组合完整后,最终的 sensor_data 才是有效的。

最终目标是找到最可能产出 din 的调用点,然后构造一个足够运行的环境,在运行时把结果直接截获出来。
整体方法可以概括为:
AST 锁点 + 条件去干扰 + VM 补环境执行 + Hook 截结果
打个比方,代码中有 A/B/C 三段逻辑,其中 B 是环境检测。我们可以直接替换检测条件,让代码成功跳过 B,继续跑到 C。
比如下图中,ZWK 是 dvc 后半部分,但它是拼接出来的。它上面有一堆环境检测,只有条件为 true 才会进入。那么只要能定位这种位置,就可以把 if 条件替换成恒真表达式,让主流程正常跑下去。如果只是检测一些简单的 navigator 字段,也可以通过补环境解决。

可以看到,ZWK 经常出现在 try / if / else / catch 这样的结构中,这就是它的特征。我们可以用 AST 匹配这个特征来锁定 ZWK。
拿到一份新的 JS 时,通常还没有做还原,也没有 send、window、xhr 这类可读特征。虽然不一定只有一个 ZWK 符合条件,但能同时满足 try / if / else / catch 结构约束的候选不会太多。同时,真正的 ZWK 一定会在多个位置出现,实际数量通常不止六七处。
正常流程基本都是走 try 里面 if 为 true 的分支。所以可以把 if() 中的表达式替换成恒真表达式,例如 if(true)。为了减少被格式化检测影响,表达式长度最好保持和原始代码接近。这里可以让 AI 根据思路生成代码,但必须要求它验证结果。
function buildTruthyExprOfLength(length) {
if (length < 1) {
throw new Error(`Cannot build a truthy expression with length ${length}`);
}
if (length === 1) return "1";
if (length === 2) return "[]";
if (length === 3) return "~[]";
if (length === 4) return "true";
if (length === 5) return "!![] ";
return `!0/*${"a".repeat(length - 6)}*/`;
}
在这个 try / if / else / catch 模块中,需要记录两个参数:一个是 dvc 后缀的 ZWK,另一个是它后面的 S7K。接着找到 S7K 的消费点。S7K 只会在这里作为第二个参数使用,如下图:

比较巧的是,这里的 Wrb 刚好就是 din。同时也可以打印 ZWK;这里的函数就是 dvc 前缀部分的 VMP,不过本文不继续研究它。

所以,只要流程能跑到这个位置,就能拿到 din 和 dvc 后缀。这个方法最终会走到下图指向的位置:
后面还会执行一个 func(a, b, c, d),固定四个参数。我们不需要完整跑到那里,只需要让 ORb 这一段跑完即可。跑完后可以得到类似:
22596|20383|54245
这就是需要的 ajr 格式。
到这里,还剩下 ver 和 previousValue 没有生成。它们用于加解密 sensor_data,同样很重要。
在 rSK 函数中:

我这里是FAK(previousValue):

继续找它的生成位置,会发现代码非常混乱,几乎看不出规律。但如果看还原后的代码,就能发现 previousValue 实际上就在 ver 前面。

因此可以反过来理解:先找到 ver 的消费点,再追它的声明,就能找到 previousValue的参数名。
从下面可以看到,z2K 总是在 func(a, [b, c, ...]) 中 c 的位置出现,而 z2K 自身被消费的位置并不多,形态类似:
obj = cb(something, [qbt, ver, ...])
约束条件包括:
-
• 第二个参数必须是数组; -
• 数组的第 2 个元素,也就是下标 1; -
• 该元素必须是标识符。

当然,单独看 obj = cb(something, [qbt, ver, ...]) 这个规律并不强,因为很多地方都可能符合。但放大范围看,这部分代码和前面 ver、dvc 前半段对应的 func 在同一作用域下。多个规律叠加约束后,命中结果就比较稳定了。

这些规律都是通过观察多份 JS 总结出来的,不是凭一份源码就能稳定归纳。实际操作时,可以收集多份源码,把规律描述给 AI 生成算法,然后让它在另一份源码上验证是否能成功截取需要的内容。
到目前为止,我们可以通过 AST、代码注入和运行时截获,还原出:
-
• din -
• ajr -
• dvc前半段 -
• previousValue -
• ver
除此之外,只需要补少量环境即可。由于目标参数都是全局存在即可,不需要深度补原型链。

function XMLHttpRequest() {}
XMLHttpRequest.prototype.open = function open() {};
XMLHttpRequest.prototype.send = function send() {
return undefined;
};
const location = {
href: urlValue,
};
const document = {
URL: urlValue,
location,
addEventListener() {},
documentElement: {
getAttribute() {
return null;
},
},
createElement() {
return {
getAttribute() {
return "";
},
};
},
getElementsByTagName() {
return [
{
src: srcValue,
getAttribute() {
return "";
},
},
];
},
};
5. 继续构造 sensor_data
5.1 ajr
目前暂时观察到四种 ajr 格式。传入参数包括 ajr 和下面这个对象:
{
"startTimestamp": startTs,
"deviceData": getDinAllValues([din]).join(','),
"mouseMoveData": "",
"totVel": tovl,
"deltaTimestamp": deltaTimestamp
}
其中 ajr 是上面生成的,用来区分当前 JS 使用的是哪种格式;tovl (fk2)的逻辑前面也已经说明。
function genAjr(ajr, fk2) { // fk2 就是上面的对象
if (isAjrHashValid(ajr)) {
var FM9 = fk2['totVel'] || xr2()
var p3 = fUT(arrayToHexString(Exw(FM9)))
var p39 = getDivisors(p3)
return [p39, FM9].join('|')
}
if (isValid(ajr)) {
var FM9 = fk2['totVel'] || xr2()
var p39 = getDivisors(FM9)
return [p39, FM9].join('|')
}
if (ajr.split('|').length === 3) {
try {
atob(ajr.split('|')[0])
var A2 = fk2['totVel'] || xr2();
var Dt = xr2();
var bx = [btoa(Dt * A2), Dt, A2];
return bx['join']('|');
} catch (e) {
var B92 = fk2["totVel"] || xr2();
var G12 = xr2();
var V92 = B92 * G12;
var m92 = parseInt(Math["sqrt"](V92), 10);
var v72 = [m92, G12, B92];
return v72["join"]("|")
}
}
}
5.2 ver / din / dvc
ver 前面已经生成,这里不再展开, ver对于同一个js是固定的。
din 部分就是按前面的 updateDin 逻辑更新对象中的对应值。注意不要改变 din 对象的字段顺序。
dvc 我在另一篇文章里讲过,这里贴一下 VMP 相关代码,通过看日志就可以还原了。参数说明:
-
• token传ajr; -
• UA是userAgent; -
• post_count表示第几次sensor_data,第一次是0,第三次是2。
function vmp_encode(token, UA, startTs, deltaTimestamp, tovl, post_count) {
let frist = tovl.toString() + startTs.toString() + UA.slice(-32) + post_count.toString();
r = 5381
for (i = 0; i < frist.length; i++) { r = (r * 33) ^ frist.charCodeAt(i) } var first_r = r >>> 0
var b_str = first_r.toString(2).split('')
var s_str = 'a3cd9efghiYjklm7opqrs1uvwQxyBz2'.split('')
var res = []
for (i = 0; i < s_str.length; i++) {
let j = i % b_str.length
if (b_str[j] == '1') {
res.push(s_str[i])
} else {
if (i % 3 == 0) {
res.push(s_str[i])
}
}
}
var time_2 = (post_count.toString() + (startTs + deltaTimestamp)).split('')
var res_2 = []
for (i = 0; i < time_2.length; i++) {
res_2.push(res[time_2[i]])
}
first = deltaTimestamp.toString() + token + post_count.toString()
r = 5381
for (i = 0; i < first.length; i++) { r = (r * 33) ^ first.charCodeAt(i) } second_r = (r >>> 0) + first_r
res_str = res.join('')
second_r_b = second_r.toString(2)
var res_3 = []
for (i = 0; i < 6; i++) { let index = parseInt(second_r_b.charAt(i)) let char = res_str.charCodeAt(i) let code_first = char >> index
let code_second, code_third
let code_forth = (char << 5) | code_first
if (index == 1) {
code_second = (char + 2) ^ 7
code_third = (char << 3) - char
} else {
code_second = (char + 2)
code_third = (char << 0) - char
}
res_3.push(res_str.charAt(Math.abs((code_third * code_forth - code_second)) % res_str.length))
}
return res_2.join('') + res_3.join('')
}
6. 第二次 sensor_data
拿第一次和第二次发送的 JSON 做对比,见下面几张图。




对比后可以看到:
-
• ajr变了,但生成逻辑不变,只是tovl发生变化; -
• ran变了,它本身就是随机数,直接更新即可; -
• din的字段顺序没有变化; -
• ajt变成11.1。第一次和第二次对比不明显,但多采几次第二次请求就会发现,每次第二次都是11.1;也可以直接从代码里确认; -
• dme和doe本质上是一组固定数字,中间加入了两个时间差。

mev 是鼠标轨迹,tovl 前面已经讲过,delt 也是时间差。需要注意的是,这个时间差最好不要小于鼠标指纹最后一个时间戳;这一点是观察结果,未进一步验证。
继续看几个字段:
mc: 鼠标轨迹数量,21 个即可
ww8: parseInt(parseInt(startTs / (year_div * year_div), 10) / 23 / 6, 10)
tst: 前面已经说明
dvc: 后缀不变,按前面的逻辑重新生成一次即可
其中 444322244 是浏览器 navigator.permissions.query 的结果,其他部分是固定拼接。per 第一次是 8,后面都是这个值,可以自行对照代码确认。
fwd.fmz: "1.75" // 固定值
7. 第三次 sensor_data
第三次内容较长,这里不完整展开。拿一份第三次数据对着 second_sensor_data 看即可。
相同逻辑不再重复,几个差异点如下:
-
• ajt固定为10.2; -
• 会多一个 dsi字段。

dsi 也可以写死。但如果要还原算法,需要注意它使用的是 iframe.contentWindow。iframe.contentWindow 是 window,但不等于当前页面的 window,因为中间多了一层 iframe。

"wsl": "4395630592,291017801,225932613,100,22,-1,1,-1,0,1,,,,,,,,,1,1"
这一段对应:
jsHeapSizeLimit,totalJSHeapSize,usedJSHeapSize,navigator.connection.rtt,window.speechSynthesis.getVoices().length,1,1,1,0,1,,,,,,,,,1,1
继续看 sww,里面有大量指纹信息。
直接搜 sww 可能搜不到结果,可以搜索 ext,定位到下图位置。如果打断点,会在一堆 Promise 中跳来跳去。下面那些 s0.concat 就是在拼接字符串,最终拼出 s045 这类字段名。

先打断点观察,发现 Promise 完成之后会得到我们想要的数据。继续看 data.gpu,里面有 gpuVendor,可以把它作为关键字继续追。

会来到下面这个位置。这里的 jdK 在上面可以看到等于 SharedWorker。第一次看到可能不熟悉,可以查 MDN 或让 AI 解释它的行为。

这里有一段 JS,可以猜测它是否会发出这个 message。封装后放到浏览器执行即可验证。

浏览器执行结果和前面看到的指纹信息一致。

可以按下面的结构整理这段 JSON:
"sww": {
"s024": 0,
"ext": userAgentData.ext,
"swrt": 2,
"wrt": 16,
"s025": chinaDate, // 就是 new Date().toString()
"s026": chinaDate,
"s027": userAgentData.oscpu,
"s028": userAgentData.oscpu,
"s029": userAgentData.tz,
"s030": userAgentData.tz,
"s031": userAgentData.la,
"s032": userAgentData.la,
"s033": userAgentData.las,
"s034": userAgentData.las,
"s035": userAgentData.dm,
"s036": userAgentData.dm,
"s037": userAgentData.hc,
"s038": userAgentData.hc,
"s039": userAgentData.net,
"s040": userAgentData.net,
"s041": userAgentData.ua,
"s042": userAgentData.ua,
"s043": userAgentData.av,
"s044": userAgentData.av,
"s045": userAgentData.pl,
"s046": userAgentData.pl,
"s047": userAgentData.uad.brands,
"s048": userAgentData.uad.brands,
"s049": userAgentData.uad.mobile,
"s050": userAgentData.uad.mobile,
"s051": userAgentData.uad.architecture,
"s052": userAgentData.uad.architecture,
"s053": userAgentData.uad.bitness,
"s054": userAgentData.uad.bitness,
"s055": userAgentData.uad.model,
"s056": userAgentData.uad.model,
"s057": userAgentData.uad.platform,
"s058": userAgentData.uad.platform,
"s059": userAgentData.uad.platformVersion,
"s060": userAgentData.uad.platformVersion,
"s061": userAgentData.uad.uaFullVersion,
"s062": userAgentData.uad.uaFullVersion,
"s063": userAgentData.uad.wow64,
"s064": userAgentData.uad.wow64,
"s065": userAgentData.uad.fullVersionList,
"s066": userAgentData.uad.fullVersionList,
"s067": userAgentData.gpu.gpuVendor,
"s068": userAgentData.gpu.gpuVendor,
"s069": userAgentData.gpu.gpuRenderer,
"s070": userAgentData.gpu.gpuRenderer,
"s071": userAgentData.gpu.gpu2Vendor,
"s072": userAgentData.gpu.gpu2Vendor,
"s073": userAgentData.gpu.gpu2Renderer,
"s074": userAgentData.gpu.gpu2Renderer
},
剩下还有一些字段,要么是固定值,要么是随机数。通过 s148、s151 这类关键字可以直接查到。

到这里,三次 sensor_data 的主要内容就已经完成了。
8. 加密 JSON
要形成最终的 sensor_data,还需要做一层加密。这部分网上资料较多,这里需要注意:
-
• bm_sz来自第一次GET HTML时返回的 Cookie; -
• previousValue前面已经生成,稍微步入几个函数即可还原。
function BbJ(XmJ, ver) {
return ["3", "0", 1, 0, XmJ[0], ver].join(";");
}
function Qj(bm_sz, previousValue) {
var dH = [8888888, previousValue];
var rF = bm_sz;
var Gl = decodeURIComponent(rF).split("~");
var dj = parseInt(Gl[2], 10);
dj = isNaN(dj) ? previousValue : dj;
dH[0] = dj;
return dH;
}
function Ng(Eh) {
var dX = Eh[0];
var p7 = Eh[1];
var TO = ":";
var Oj = dX.split(TO);
for (var sS = 0; sS < Oj.length; sS++) { var pm = ((p7 >> 8) & 65535) % Oj.length;
p7 *= 65793;
p7 &= 4294967295;
p7 += 4282663;
p7 &= 8388607;
var Sj = ((p7 >> 8) & 65535) % Oj.length;
p7 *= 65793;
p7 &= 4294967295;
p7 += 4282663;
p7 &= 8388607;
var ET = Oj[pm];
Oj[pm] = Oj[Sj];
Oj[Sj] = ET;
}
return Oj.join(TO);
}
var WO = [];
var N2 = "";
function P7(Tj, QX) {
if (!N2) {
for (var sl = 0; sl < 127; ++sl) {
if (sl < 32 || sl === 39 || sl === 34 || sl === 92) {
WO[sl] = -1;
} else {
WO[sl] = N2.length;
N2 += String.fromCharCode(sl);
}
}
}
var Xb = "";
for (var kn = 0; kn < Tj.length; kn++) { var Eb = Tj.charAt(kn); var XF = (QX >> 8) & 65535;
QX *= 65793;
QX &= 4294967295;
QX += 4282663;
QX &= 8388607;
var Kl = WO[Tj.charCodeAt(kn)];
var ws = Eb.codePointAt(0);
if (ws >= 32 && ws < 127) { Kl = WO[ws]; } if (Kl >= 0) {
var Y2 = XF % N2.length;
Kl += Y2;
Kl %= N2.length;
Eb = N2[Kl];
}
Xb += Eb;
}
return Xb;
}
function encrypt_sensor_data(sensor_data, bm_sz, previousValue, ver) {
var XfJ = Qj(bm_sz, previousValue);
var JWJ = JSON.stringify(sensor_data);
JWJ = Ng([JWJ, XfJ[1]]);
JWJ = P7(JWJ, XfJ[0]);
var vmJ = 0;
var LlJ = Math.floor(Math.random() * 4);
var PsJ =
String(Math.floor(Math.random() * 6) + 8) +
",0,0," +
vmJ +
"," +
LlJ +
",0";
var cvJ = BbJ(XfJ, ver);
JWJ = cvJ + ";" + PsJ + ";" + JWJ;
var VkJ = JSON.stringify(JWJ);
return '{"sensor_data":' + VkJ + "}";
}
9. 总体流程
整体流程可以整理为:
-
1. GET HTML; -
2. 从 HTML 中提取 Akamai JS 地址; -
3. GET JS; -
4. 通过 JS 获取 ver、din等关键参数; -
5. 直接计算三次 sensor_data; -
6. 分批次 POST 即可。
有些 Akamai 版本还会出现 ffs、vev、inf 等参数。只要完成解混淆,这些参数通常都比较容易继续还原。
下面提供一个 Python 请求流程示例:
import json
import subprocess
from pathlib import Path
from bs4 import BeautifulSoup
from curl_cffi import requests
from loguru import logger
from urllib.parse import urlparse
import re
session = requests.Session(impersonate="chrome")
def get_tracking(url):
response = session.get(url, http_version=3)
bm_sz = response.cookies["bm_sz"]
with open('./home.html', 'w', encoding='utf-8') as f:
f.write(response.text)
# 获取 ffs
# ffs = generate_fingerprint(response.text)
ffs = "" # 有些 Akamai 需要,有些不需要,解混淆后可以直接从代码中看到
# logger.info("FFS: {}", ffs)
js_url = f"{urlparse(url).scheme}://{urlparse(url).netloc}" + re.search(
'type="text/javascript" {2}src="(.*?)">', response.text
).group(1)
logger.info("JS URL: {}", js_url)
logger.info("第一次请求 HTML,Cookies: {}", dict(response.cookies))
return bm_sz, ffs, js_url
if __name__ == "__main__":
url = 'https://www.***.com/cn/zh/home?adults=1&flight-type=2&origin=PVG'
bm_sz, ffs, js_url = get_tracking(url)
logger.info("bm_sz: {}", bm_sz)
logger.info("ffs: {}", ffs)
logger.info("js_url: {}", js_url)
# GET 请求 JS 文件
response = session.get(js_url)
with open('./home.js', 'w', encoding='utf-8') as f:
f.write(response.text)
# 这里直接生成三次 sensor_data
sensor_result = run(bm_sz, url, js_url, ffs, str(BASE_DIR / "home.js"))
parsed = urlparse(url)
origin = f"{parsed.scheme}://{parsed.netloc}"
post_headers = {
"accept": "*/*",
"content-type": "text/plain;charset=UTF-8",
"origin": origin,
"referer": url,
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"user-agent": headers["User-Agent"],
}
# 第一次 POST 请求
logger.info("第一次 sensor_data 长度: {}", len(sensor_result['first']))
response = session.post(js_url, headers=post_headers, data=sensor_result['first'])
logger.info('_abck 长度: {}, _abck: {}', len(response.cookies.get("_abck", "")), response.cookies.get("_abck", ""))
# 第二次 POST 请求
logger.info("第二次 sensor_data 长度: {}", len(sensor_result['second']))
response = session.post(js_url, headers=post_headers, data=sensor_result['second'])
logger.info('_abck 长度: {}, _abck: {}', len(response.cookies.get("_abck", "")), response.cookies.get("_abck", ""))
# 第三次 POST 请求
logger.info("第三次 sensor_data 长度: {}", len(sensor_result['third']))
response = session.post(js_url, headers=post_headers, data=sensor_result['third'])
logger.info('_abck 长度: {}, _abck: {}', len(response.cookies.get("_abck", "")), response.cookies.get("_abck", ""))
10. 补充说明
由于 Akamai 会校验 TLS 指纹,普通 requests 会被拦截,因此请求部分建议使用 curl_cffi,并通过 impersonate="chrome" 让 TLS / HTTP 指纹更接近真实 Chrome。同时一个js有几分钟复用时间,所以不需要每次都从js中还原新的ajr,din这部分内容。同时我们的请求是纯算出来的,只要把一些浏览器指纹鼠标指纹自己写一个配置文件就可以了。
本文由 子虚 投稿
稿费 1000-2000 元 / 篇,长期有效 。征集爬虫逆向、数据行业、爬虫副业、合规避坑类原创首发文章。详情见:


我的公众号:猿人学 Python 上会分享更多心得体会,敬请关注。
***版权申明:若没有特殊说明,文章皆是猿人学 yuanrenxue.con 原创,没有猿人学授权,请勿以任何形式转载。***

说点什么吧...