超星网页端阅读进度上报请求分析

关键字:超星学习通阅读进度分析;超星阅读进度上报接口;addPoint.js 逆向;jsjiami 混淆还原

超星的章节阅读任务做完之后,进度是怎么报上去的?页面安静地往 data-xxt.aichaoxing.com 打了一条带 enc 签名的 GET,里面还塞了一串压缩后的鼠标轨迹。整个过程被一份混淆到妈都不认识的 addPoint.js 包着。

这篇文章把整条链路拆开:抓包看请求长什么样 → 混淆还原看参数怎么组装 → 逐个字段讲清楚含义 → 最后还原出 enc 的签名公式。

说明

  • 适用平台:PC 浏览器(Web 端)
  • 分析对象:章节任务点阅读页
  • 实现功能:阅读进度上报接口的「全参数 + 签名」完全还原
  • 底层原理:抓包 + JS 混淆还原 + 断点调试
本文仅记录研究思路,请用于正规学习用途,任何使用造成的一切后果自负。

一、分析对象

章节任务点页面:

https://mooc1.chaoxing.com/coursedata/readjobv2/show?courseid=216769884&knowledgeid=413116808&userid=null&ut=s&clazzId=38291652&jobid=read-200841370&djobid=read-200841370&isphone=false&enc=dc64cdcd2f7d94295ed7b7ea6500caaa&utenc=3dc074a650414c299b24f1ea1b8fa717&cpi=109959280

阅读页面:

https://mooc1.chaoxing.com/course/200841370.html?_from_=216769884_38291652_119093614_347fdd80e8ae2e12d186d854654c2857&rtag=

打开阅读页,往下翻几页,抓包就能看到更新进度的请求:

GET https://data-xxt.aichaoxing.com/analysis/ac_mark?&f=readPoint&u=119093614&d=%7B...%7D&t=20230504160614987&enc=f0ec6c925db52834cfeaf16bfa0f8362
参数
freadPoint
u119093614
d(详情见下文)
t20230504160614987
encf0ec6c925db52834cfeaf16bfa0f8362

请求包全文:

f    readPoint
u    119093614
d    {"r":"200841370,114740787","t":"special","l":1,"f":0,"wc":1226,"ic":1,"v":2,"s":2,"h":918,"e":"H4sIAAAAAAAAA6XZwY4rKQyF4ae5ezAYU9s77/9Ok+Ckq+rwd2mk2UStTwhsAgbS7Y/943O+Put4f45S/rS/7UEP0qOgVlRDbagd1VHH0hAN1Il6gL7/Is3chqihNtSO6qgDNXNz0Yl6kNaCmll00YbaUR01s2iigTpRMwu7q1VUQ22oHTXjraKBOklbzm8RNdSG6qiBumLwQ/Qg7RV1xeBTtKMO1EA9SL2gVtSMTHahD9QcTXbLKKiGmhnLvshq5LJKssK4fPNZS0Qjq8ammVsRddSJepDWioqj5T7edKAGKsZgGEPuzU0xMsN5MIzMMDLDOTOMN3fspvi95T7etMEqiUZr56tNNDPu2FaV2zqs32jjQYdoPGiI5vxOUdrz0XN+D9Gs4DLrPeukzG/WqF9U5rc76kDNmiozmZVL1encDKc7QTjdYSIr15B5WDNbQ+Yh61lIxnm7OiSLrDvHPYaZ95Jjir4js1JEY2kVPZY20F7/mw5UA40V6a6OWklnoDbSoHgjOiqONnA0xywc+10ratNOc5ZrfdOGWTQcreFohlkYjlZxtJrxym6pRlomqqPmrOt5PFE76Wc9yDk/O2qOJreKCNSOWkg/q0RuNqOjNtRC6tivY789vze99w3UFYPJLXNV8E3XKdJN778NtZDm6tu0oxpprslNsd+K/daMTG54JVA7agXNl+Wu1G++eXfNfu/7LV/juzqqkQb2EAO1o2K/A/sd2G/uALvvefcDNVC5h0rasd+O/XZHNdRCuu5Guw5UzKLhaA1HM8zis4dc1FAraiGtE3WgOipGVhsqxlsx3ryB2BCdqIE6UB21o2YW8htT3nc2ragFtH9qiepEDdSB6qiUxVenqKFW1EL6qWeqEzVQM7dD1FE76vpsRdQetIrWBzXRIur/Q9/nDKmTvmYK9LUb7pG9/25d9T1zbWvrd82z5RcdqPGjjppt54NO0bVK1vuyvfb/t1/VbLvWzqru17aq2dYftIn2B+0ymmq2bae+6uC3rWq2NWxbT33V+G9b1kKaJ/pFV7/rXbu3ZY1Tm4k20X7Or7ef0S56aNt3LkedokXUf9f1grrq+FlRu4ZoPOgQPX720K7trnHWkqtmpZV446zKVz3PrKueN5CrZk3VGM579UVzz1eJIStilXmY58v9qvluMdG1dor0m+dFkX7zxFmn8q6SW55v5T5a3s+a3yPzrAxeRdfK7SG6Kll30WOpjLZ+/2uyovL22mSV5LvFRhddv8OMe2T5HjKfoqsHyW2sVWOSW/5Pwr65/QuN7UtUFxwAAA==","ext":"{\"_from_\":\"216769884_38291652_119093614_347fdd80e8ae2e12d186d854654c2857\",\"rtag\":\"\"}"}
t    20230504160614987
enc    f0ec6c925db52834cfeaf16bfa0f8362

二、加密JS分析

所有逻辑都在这个文件里:

http://data-xxt.aichaoxing.com/res/addPoint.js

jsjiami.com.v5:字符串全部 base64 编码塞进一个超长数组,再用 _0x37aa 之类的解码函数按索引取,变量名全部扁平化。

image-20230506004834537.png

还原思路:

  1. 用在线工具解混淆:https://ctf.bugku.com/tool/sojson5(专门针对 jsjiami v5),解出来的结果虽然不完美,但可读性已经天差地别
  2. 把解密后的 JS 覆盖替换原文件( Chrome DevTools Override ),方便直接断点调试
  3. 边断点边把 _0x406caa 这种变量改成人话,逐步理清调用链

解出来之后能看到脚本其实是个 browserify 打包产物:内置了 pako 的 deflate/inflate 全套(./lib/deflate./lib/inflate./lib/utils/common),对外暴露两个接口:

window._readlogaddpoint = factory(document.getElementsByClassName("reader")[0]);
window.setSetting = factory();
  • _readlogaddpoint:给阅读器实例用(reader 容器),自动采集行为并打点
  • setSetting:设置阅读参数(resourceID / passportUID / token 等)

三、参数逐个拆

3.1 f / u / t

var arr = {
    'f': fdata.BVpdL,              // 常量 "readPoint"
    'u': _0x112d77.passportUID,    // 用户 ID
    'd': fdata.hixtE(encodeURIComponent, JSON.stringify(_0x15df64)),  // 核心 JSON
    't': (_0x406caa = new Date(), fdata.DnBUr(fdata.qlrER(fdata.pOyZF('' + _0x406caa.getFullYear() + _0x5eb083(fdata.pOyZF(_0x406caa.getMonth(), 0x1)), fdata.UwBCf(_0x5eb083, _0x406caa.getDate())) + fdata.lyaBj(_0x5eb083, _0x406caa.getHours()), fdata.lyaBj(_0x5eb083, _0x406caa.getMinutes())), fdata.llxQK(_0x5eb083, _0x406caa.getSeconds())) + (_0x3d84c9 = _0x406caa.getMilliseconds(), fdata.BHAuN(_0x3d84c9, 0xa) ? fdata.dUmrl('00', _0x3d84c9) : fdata.BHAuN(_0x3d84c9, 0x64) ? fdata.EBWlA('0', _0x3d84c9) : _0x3d84c9))         // 年月日时分秒 + 3 位毫秒
};
  • f:写死的常量 readPoint
  • u:passportUID,用户 ID
  • t:本地时间,格式 yyyyMMddHHmmss + 3 位毫秒,共 17 位,如 20230504160614987
  • pids(sessionID)在代码里组装了,但实际请求包里不出现(值为 undefined 会被跳过)

3.2 参数 d

跟踪 _0x15df64,还原出来的结构:

var _0x15df64 = {
    'r':  _0x112d77.resourceID,    // 资源唯一ID(见下文)
    't':  _0x112d77.resourceType,  // 资源类型,如 "special"
    'l':  _0x112d77.location || 0x1,
    'f':  _0x112d77.from || 0x0,
    'p':  _0x112d77.curPage,       // 请求中未出现
    'tp': _0x112d77.totalPage,     // 请求中未出现
    'wc': _0x112d77.wordCount,     // 文字数目
    'ic': _0x112d77.imgCount,      // 图片数目
    'v':  0x2,                     // 固定值
    's':  sval,                    // 打点原因(见 3.3)
    'h':  fdata.RDAEI(_0x4c43a6),  // 当前 scrollTop
    'e':  ...                      // 鼠标轨迹(见 3.4)
};
  • rt

微信截图_20230506001553.png

微信截图_20230506001452.png

  • resourceID 形如 200841370,114740787(阅读页中的 courseId + knowledgeId),resourceType 为 special
  • wc / ic:页面文字数、图片数,setSetting 之后脚本自动统计
  • p / tp:当前页/总页数,这版抓包里没出现,忽略

3.3 s

原代码里 s 有一大段看着很唬人的东西:

'TgCEM': function _0x5c5f01(a, b) { return a ^ b; },
sval = _0x15df64;
for (var i = 0x0; i < 0x8; i++)
    sval = 0x1 & sval ? fdata.TgCEM(0xedb88320, sval >>> 0x1) : fdata.SBTVv(sval, 0x1);
_0x112d77[_0x15df64] = sval;

0xedb88320 是 CRC32 表生成用的多项式常数,看着像是给某个值算 CRC,但这段压根没跟到真实取值——是混淆里的干扰代码(那会儿还问了 GPT,没看懂,多次抓包发现 s 一直是 2)。

image-20230506005324357.png

后来使用https://jsnice.org/解混淆才真相大白,load(scale) 里是这么写的:

function load(scale) {
    ...
    var q = {
        a: parseQuery("refaid") || parseQuery("deptid"),
        r: data.resourceID,
        ...
        s: scale,          // 就是传入的 scale!
        h: parse(),
        ...
    };
}
...
load(1);
if (_0x1f6f41) {
aux = (new Date).getTime();
end = parse();
setInterval(function () {
  if (document && (!document.hidden && fragment)) {
    _0xc28a29++;
  }
  if (_0xc28a29 % 5 == 0) {
    var start = parse();
    if (end !== start) {
      load(2);
      end = start;
      aux = (new Date).getTime();
    } else {
      var max = (new Date).getTime();
      if (max - aux >= 3E4) {
        load(2);
        aux = max;
      }
    }
  }
}, 1E3);
window.onbeforeunload = function (e) {
  load(0);
};

s 就是「打点原因」,三个值:

触发时机
0页面离开(onbeforeunload)
1初始化(setSetting 后第一发)
2滚动变化 / 定时轮询(每 1s 检查一次,滚动位置变了或者 30s 没动也会发)

当初抓包都是滚动才触发的,所以 s 恒为 2,就把结论猜成了「定值」。混淆代码里的干扰项 + 没跟对的变量,是逆向里最浪费时间的东西。

3.4 e:鼠标轨迹

H4sIAAAAAAAAA6XZwY4... 解 base64 → gzip 解压 → urldecode,得到一串:

3,588,168,600;3,588,168,600;3,589,168,600;3,590,168,600;3,591,168,600;3,592,168,600;...

动态调试,第一个参数

微信截图_20230505112044.png

微信截图_20230505112121.png

第二个数是鼠标X值,第三个数是鼠标Y值

微信截图_20230505112057.png

第四个数,一路跟进,是scrollTop

微信截图_20230505113209.png

生成了第一组鼠标轨迹数据

微信截图_20230505113308.png

总结格式:

[鼠标事件代码],[X],[Y],[H];

[X]:鼠标在可视页面中的X坐标值

[Y]:鼠标在可视页面中的Y坐标值

[H]:document.body["scrollTop"]

事件代码枚举:

DOMMouseScroll: 6
click: 0
contextmenu: 9
keydown: 4
mousedown: 2
mousemove: 3
mouseup: 1
mousewheel: 5
touchcancel: 7
touchmove: 8

要点:

  • 坐标取 pageX/pageY,取不到就用 clientX + scrollLeft/scrollTop 换算;触摸类事件用 changedTouches[0]
  • 发送时最多取前 1000 组,超出截断(数组本身继续累积,发送成功后清空)
  • 轨迹记录数组会在每次打点成功后清空(guesses.length = 0
  • 页面不可见(document.hidden)时不发送打点(load() 只在可见时执行)

所以这接口上报的不只是「我看了多少字」,还有一整段真实鼠标轨迹——这就是防挂机浏览的护城河。 你模拟学习,但鼠标轨迹是假的,轨迹和历史记录对不上,风控一眼就能看出来。

3.5 ext:来源追踪

{"_from_":"216769884_38291652_119093614_347fdd80e8ae2e12d186d854654c2857","rtag":""}
  • _from_:带过来的来源参数(courseId_clazzId_uid_签名)
  • rtag:另一个路由参数,通常为空

image-20230506010705761.png


四、enc:请求签名公式

签名代码:

for (var arrdata in arr) fdata.KfDHm(void 0x0, arr[arrdata]) && (arr1.push(arrdata), url += fdata.Fbwfg(fdata.Fbwfg(fdata.lYeoG('&', arrdata), '='), arr[arrdata]));
arr1.sort();
for (var str1 = '', str2 = 0x0; fdata.BHAuN(str2, arr1.length); str2++) str1 += arr[arr1[str2]];
if (url += fdata.ODKzq("&enc=", fdata.llxQK(_0x596fa4, fdata.ODKzq(str1, 'NrRzLDpWB2JkeodIVAn4'))), 

直接断点看str1

微信截图_20230505153403.png

%7B%22r%22%3A%22200841370%2C114740787%22%2C%22t%22%3A%22special%22%2C%22l%22%3A1%2C%22f%22%3A0%2C%22wc%22%3A1226%2C%22ic%22%3A1%2C%22v%22%3A2%2C%22s%22%3A2%2C%22h%22%3A918%2C%22e%22%3A%22H4sIAAAAAAAAA6XZwY4rKQyF4ae5ezAYU9s77%2F9Ok%2BCkq%2Brwd2mk2UStTwhsAgbS7Y%2F943O%2BPut4f45S%2FrS%2F7UEP0qOgVlRDbagd1VHH0hAN1Il6gL7%2FIs3chqihNtSO6qgDNXNz0Yl6kNaCmll00YbaUR01s2iigTpRMwu7q1VUQ22oHTXjraKBOklbzm8RNdSG6qiBumLwQ%2FQg7RV1xeBTtKMO1EA9SL2gVtSMTHahD9QcTXbLKKiGmhnLvshq5LJKssK4fPNZS0Qjq8ammVsRddSJepDWioqj5T7edKAGKsZgGEPuzU0xMsN5MIzMMDLDOTOMN3fspvi95T7etMEqiUZr56tNNDPu2FaV2zqs32jjQYdoPGiI5vxOUdrz0XN%2BD9Gs4DLrPeukzG%2FWqF9U5rc76kDNmiozmZVL1encDKc7QTjdYSIr15B5WDNbQ%2BYh61lIxnm7OiSLrDvHPYaZ95Jjir4js1JEY2kVPZY20F7%2Fmw5UA40V6a6OWklnoDbSoHgjOiqONnA0xywc%2B10ratNOc5ZrfdOGWTQcreFohlkYjlZxtJrxym6pRlomqqPmrOt5PFE76Wc9yDk%2FO2qOJreKCNSOWkg%2Fq0RuNqOjNtRC6tivY789vze99w3UFYPJLXNV8E3XKdJN778NtZDm6tu0oxpprslNsd%2BK%2FdaMTG54JVA7agXNl%2BWu1G%2B%2BeXfNfu%2F7LV%2FjuzqqkQb2EAO1o2K%2FA%2Fsd2G%2FuALvvefcDNVC5h0rasd%2BO%2FXZHNdRCuu5Guw5UzKLhaA1HM8zis4dc1FAraiGtE3WgOipGVhsqxlsx3ryB2BCdqIE6UB21o2YW8htT3nc2ragFtH9qiepEDdSB6qiUxVenqKFW1EL6qWeqEzVQM7dD1FE76vpsRdQetIrWBzXRIur%2FQ9%2FnDKmTvmYK9LUb7pG9%2F25d9T1zbWvrd82z5RcdqPGjjppt54NO0bVK1vuyvfb%2Ft1%2FVbLvWzqru17aq2dYftIn2B%2B0ymmq2bae%2B6uC3rWq2NWxbT33V%2BG9b1kKaJ%2FpFV7%2FrXbu3ZY1Tm4k20X7Or7ef0S56aNt3LkedokXUf9f1grrq%2BFlRu4ZoPOgQPX720K7trnHWkqtmpZV446zKVz3PrKueN5CrZk3VGM579UVzz1eJIStilXmY58v9qvluMdG1dor0m%2BdFkX7zxFmn8q6SW55v5T5a3s%2Ba3yPzrAxeRdfK7SG6Kll30WOpjLZ%2B%2F2uyovL22mSV5LvFRhddv8OMe2T5HjKfoqsHyW2sVWOSW%2F5Pwr65%2FQuN7UtUFxwAAA%3D%3D%22%2C%22ext%22%3A%22%7B%5C%22_from_%5C%22%3A%5C%22216769884_38291652_119093614_347fdd80e8ae2e12d186d854654c2857%5C%22%2C%5C%22rtag%5C%22%3A%5C%22%5C%22%7D%22%7DreadPoint20230504160614987119093614

规律很清楚:对请求参数按 key 字典序排序,然后把对应的 value 顺序拼接

str1 = rawurlencode(d) + "readPoint" + t + u

enc = md5( rawurlencode(d) + "readPoint" + t + u + "NrRzLDpWB2JkeodIVAn4" )

用抓到的包验证一下,f0ec6c925db52834cfeaf16bfa0f8362 ✓ 完全吻合。


五、总结

字段说明默认值
f readPoint
uuid
djson
-r阅读页面中的courseId,knowledgeId
-t special
-l 1
-f 0
-wc文字数目
-ic图片数目
-v 2
-s0 :页面离开,1 : 初始化,2 :滚动变化 / 定时轮询
-egzip(urlencode(鼠标事件和轨迹))
-extjson
--\_from\_阅读HTML页面中的_from
--rtag [空]
t时间,年月日时分秒毫秒
encmd5(rawurlencode(d)+"readPoint"+t+u+"NrRzLDpWB2JkeodIVAn4")

写在最后

这篇分析写于 2023 年。那时朋友来找我玩,晚上一起去喝酒,但我喝不了,于是干脆去了趟网吧,随手找了一个加密样本,花两个小时做了这篇分析。现在属于冷饭热炒(?)还可能是馊了的那种。

回头看,2023 年也恰好处在这一轮 AI 浪潮刚刚起步的阶段。彼时的大模型对复杂语义的理解和分析能力还远没有今天这么成熟,更谈不上通过中间件,将自然语言的输出进一步转化为对计算机程序和工具的直接控制。

而到了今天,借助 MCP 等协议和工具生态,LLM 已经开始真正连接外部程序、数据与服务,算是打通了语言模型与计算机世界之间的第一堵墙。

那么下一堵墙,会不会是现实世界?

如今,各大厂商都在持续押注具身智能与人形机器人,希望让 AI 不再只是理解和操作数字世界,而是能够感知、行动,并真正参与物理世界。

从这个角度来看,那一天或许并没有想象中那么遥远。

参考

附录

用到的工具

  • 浏览器 DevTools(Network / Sources / 断点)
  • fiddler抓包(阅读进度上报接口抓包)
  • sojson5 在线解密
  • GPT

标签: none

添加新评论

行为验证™ 安全组件加载中...