进度记录请求抓取与分析

在观看超星的在线课程时,老师往往可以设置课程的观看限制,比如没看到的地方不能拖进度条,或者不能倍速播放等等。Global speed等调整倍速的插件也无法起作用。视频的进度在刷新页面、换设备等操作后仍然有记忆,因此推测在观看课程视频的时候,前端会定时(或触发式)将进度报告给后端进行记录。

打开Chrome开发者工具,尝试找到这个请求。发现有一个GET请求,在视频暂停、开始、以及播放一段时间后都会调用,请求的路径中有log的关键字,我们称之为log API。该GET请求中带有了大量路径参数,示例如下:

'clazzId': '94419407',
'playingTime': '452',
'duration': '566',
'clipTime': '0_566',
'objectId': '6ef71fa81ed5ae65e97622aff6256040',
'otherInfo': 'nodeId_833051294-cpi_390693438-rt_d-ds_1-ff_1-be_0_0-vt_1-v_6-enc_42b2ae55ec224adc6341760e9c8ad341',
'courseId': '241352647',
'jobid': '1687849865980143',
'userid': '129499717',
'isdrag': '0',
'view': 'pc',
'enc': 'bc6294cb60fe4cef16826675d226ea47',
'rt': '0.9',
'videoFaceCaptureEnc': '35968158568144a1aed350b4778421de',
'dtype': 'Video',
'_t': '1717044573019'

经过多次尝试,推测这些参数的含义,其中和视频进度比较相关的有:

  • playingTime:现在播放到的秒数
  • duration:视频的总长度(秒)
  • clipTime:可以理解为0_duration
  • otherInfo: 有可能和观看设备的识别有关
  • isdrag:命名非常离谱的一个变量,自动记录时为0,暂停视频记录时为3,恢复播放时为2.
  • enc:一个加密变量,用于验证请求是由前端产生和发送的。
  • _t: 当前的时间戳,可能会验证不能超过范围

所以刷课思路大致确定:模拟一个playingTime和duration相等的请求,直接把进度记录拉到最后。

对于响应,其中有一个变量isPassed,推测表示该课程是否已经通过。通过这个可以判断出刷课是否成功。

使用Python重放请求

首先用Python尝试重放一个刚才的请求。超星的服务器是使用Cookie进行身份认证的,将之前请求头中的Cookie,User-Agent、Host、Content-Type、Referer和刚才的参数复制过来,重新发送这个请求,200了,并看到了和之前一样的响应。

使用Python构造发送刷课请求

直接将之前请求playingTime修改为duration,发送请求,会收到403响应。比较多次不同的记录请求,发现除了playingTime,enc这个变量也在随之变化。推测enc是一个hash值。如果不知道enc具体是怎么生成的,就无法构造可以被后端认证的情况。所以后面的思路是从前端的js脚本中逆向分析,找出hash值的计算方法。

分析enc计算方法

在开发者工具的sources tab中直接搜索enc,出现的结果太多,不如换一个思路,直接去找请求体最后构造成型的位置。直接找最后的位置时,可以搜索playingTime关键字试试。结果找到了下面的内容:

var _0x14550f = Ext[_0x24f0b3(0x1e6)][_0x24f0b3(0x3e4)](_0x487294, _0xdb45db[_0x24f0b3(0x363)], _0xdb45db[_0x24f0b3(0x399)], _0xdb45db['jobid'] ? _0xdb45db[_0x24f0b3(0x1ec)] : '', _0xdb45db[_0x24f0b3(0x128)], _0x15f4f0, _0x24f0b3(0x1c5), _0xdb45db[_0x24f0b3(0x3b1)] * 0x3e8, _0x33b462)
              , _0x16bd34 = [_0xdb45db['reportUrl'], '/', _0xdb45db['dtoken'], _0x24f0b3(0x155), _0xdb45db['clazzId'], '&playingTime=', _0x3655e1, _0x24f0b3(0x300), _0xdb45db[_0x24f0b3(0x3b1)], _0x24f0b3(0x492), _0x33b462, _0x24f0b3(0x223), _0xdb45db[_0x24f0b3(0x128)], '&otherInfo=', _0xdb45db[_0x24f0b3(0x177)], _0x24f0b3(0x499), _0xdb45db[_0x24f0b3(0x1ec)], _0x24f0b3(0x15c), _0xdb45db[_0x24f0b3(0x399)], '&isdrag=', _0x1fbff3, _0x24f0b3(0x36c), '&enc=', md5(_0x14550f), _0x24f0b3(0x3ad), _0xdb45db['rt'], _0x24f0b3(0x205), _0x24f0b3(0x471), new Date()['getTime']()][_0x24f0b3(0x43c)]('');
            _0x67eb0b(_0x5da86e, _0x16bd34, _0x38a4d8);

第一眼差点被唬住,这应该是通过软件混淆过之后的js代码吧。按说超星的本意应该是看到这些大部分不怎么专业的爬虫家应该就知难而退了吧,不过人本来就是这样,有时候越是设置障碍给我,我反倒越起劲。代码乍一看很乱,但既然计算机还可以运行,说明在语法上也不过还是Javascript的语法,不会有太多魔法。

这里参考了之前2021年X1a0He老哥在博客上发布的当时的enc破解流程。当时似乎还没有用到混淆,因此代码的可读性很好。不过考虑到超星是的前端会用GET请求上传日志记录,这三年来我想除了加了一个混淆之外,代码的其他部分应该不会有太大变化。

下面是对我破解这个enc变量生成过程的步骤记录:

  1. 首先我要知道这里出现最多的_0x24f0b3和_0xdb45db是什么。前者看起来像一个函数,而后者看起来是一个数组或对象。函数获取一个数字类型的参数,应该得到另一个字符串或整数类型的输出,作为下标在数组和对象中查找内容。
  2. 查看_0x24f0b3函数的定义,发现中间有好几层赋值,找到最上面可让我大开眼界了,这还是JavaScript的方法吗,明明是接受两个参数的函数,到了下面调用的时候就每次只传一个参数。再次询问万能的ChatGPT,说是一种常见的混淆手段。
  3. _0x24f0b3函数的内部本质是接受一个整数,并在一个数组中进行查找。于是顺藤摸瓜找到这个数组,发现是一个巨大的数组,保存了似乎大部分脚本中对象的键。好家伙,那这不是直接搞定了,只需要用这个数组带入把脚本翻译一下就可以了呗。但是在Python脚本中尝试用这个数组去下标拿键时我发现得到的东西似乎都是乱的,事情没有想象的那么简单,我又差点在这里放弃。
  4. 如果一个函数直接返回一个数组,那调用者拿到的将是这个数组的拷贝,任何其他地方的调用都不会对这个数组造成修改。但是如果函数返回的是一个函数,函数中再返回一个数组,则这个数组是可能在其他地方再被修改的。按照这个思路找到了这个数组下面一个地方被修改过,看起来此函数的功能就是打乱这个数组。直接看懂这个函数又不是什么容易的事情,直接把这段函数复制下来,带入之前的数组运算一下,就得到了打乱顺序后的数组。后面直接用这个数组作为新的钥匙即可。
  5. 用新数组来解密,还原出了enc的计算公式:enc = md5(Ext.String.format([{0}][{1}][{2}][{3}][{4}][{5}][{6}][{7}],clazzId, userid, jobid ? jobid : '', objectId, playingTime * 1000,"d_yHJ!$pdA~5", duration * 1000, clipTime))
  6. 用这个公式自己计算enc的值填入到请求的参数中,发送请求,果然通过了后端的验证。

线程池同时处理多个视频

虽然可以通过后端验证来刷课了,但后端似乎还有其他验证方式。经过尝试,第一次发送刷课请求通常不能获得isPassed = True的响应,也就是一次很难刷过。多次尝试后发现,不同课程视频的情况区别很大,有些一次就可以刷过,有的需要等待大约2分钟后再发送刷课请求才能成功。

为了提高刷课的效率,可以使用线程池来同时处理刷多个课程视频。

附录

_0x24f0b3函数的代码部分:

function _0x27fc(_0xe172f5, _0x2d1c59) {
    var _0x2520b6 = _0x2520();
    return _0x27fc = function(_0x27fc67, _0x131769) {
        _0x27fc67 = _0x27fc67 - 0xd2;
        var _0x2ff44a = _0x2520b6[_0x27fc67];
        return _0x2ff44a;
    }
    ,
    _0x27fc(_0xe172f5, _0x2d1c59);
}

用于重排关键字数组的函数代码:

function(_0x4ab350, _0xbb7497) {
    var _0x2d21a4 = _0x27fc
      , _0x1d8c1a = _0x4ab350();
    while (!![]) {
        try {
            var _0x1ec8af = parseInt(_0x2d21a4(0x1d3)) / 0x1 + parseInt(_0x2d21a4(0x2d2)) / 0x2 + parseInt(_0x2d21a4(0xf1)) / 0x3 * (-parseInt(_0x2d21a4(0x324)) / 0x4) + parseInt(_0x2d21a4(0x2d0)) / 0x5 + parseInt(_0x2d21a4(0x41d)) / 0x6 * (-parseInt(_0x2d21a4(0x2df)) / 0x7) + parseInt(_0x2d21a4(0x1f3)) / 0x8 * (parseInt(_0x2d21a4(0x416)) / 0x9) + -parseInt(_0x2d21a4(0x28e)) / 0xa * (-parseInt(_0x2d21a4(0x460)) / 0xb);
            if (_0x1ec8af === _0xbb7497)
                break;
            else
                _0x1d8c1a['push'](_0x1d8c1a['shift']());
        } catch (_0x36b7fd) {
            _0x1d8c1a['push'](_0x1d8c1a['shift']());
        }
    }
}(_0x2520, 0xeebfa),

后话

后面又查了一下关于Javascript反混淆相关的技术,发现超星使用的这些混淆技术都是非常常见的,应该就是使用obfuscator.io之类的工具自动生成的。现在也有许多针对性的反混淆工具,例如webcrack工具,可以将混淆后的代码尽可能地恢复到之前的样貌。依我看,js代码混淆技术一种失败且无用的技术,他的出现并不会导致原本可以被知道的算法不被破解,并且随着现在大语言模型的发展,破解这些内容的代价会非常低廉。