数瑞Cookie混淆是如何工作的

我的样本文件在此

本次分析文件特征

  • 请求HTML文件包含: set-cookie: 36501JSESSIONIDset-cookie: lD01YhBPHVTHO, 在dev-tools的应用面板中查到cookie: lD01YhBPHVTHP

  • HTML引入JS文件cIZgBeQvEQK9/AiHJXIs5GyJH.dee59c7.js

  • JS文件开头为$_ts...$_ts['dee59c7'], 其中dee59c7为版本

  • HTML中JS多为如下形式

    function _$lt(_$EZ) {
      var _$aS = _$EZ.length;
      var _$$N,
        _$VC = new _$XD(_$aS - 1),
        _$vC = _$EZ.charCodeAt(0) - 97;
      for (var _$Vw = 0, _$JP = 1; _$JP < _$aS; ++_$JP) {
        _$$N = _$EZ.charCodeAt(_$JP);
        if (_$$N >= 40 && _$$N < 92) {
          _$$N += _$vC;
          if (_$$N >= 92) _$$N = _$$N - 52;
        } else if (_$$N >= 97 && _$$N < 127) {
          _$$N += _$vC;
          if (_$$N >= 127) _$$N = _$$N - 30;
        }
        _$VC[_$Vw++] = _$$N;
      }
      return _$yn.apply(null, _$VC);
    }

大致工作原理

  • 获取HTML文件, HTML文件携带两个Cookie
  • HTML请求JS文件(JS文件为乱码)
  • HTML中JS解密JS文件, 得到JS字符串
  • 使用eval执行JS字符串, JS字符串计算并设置加密Cookie(lD01YhBPHVTHP)
  • 离谱的是RUISHUTESTFUNCTIONENTRY每次请求获取值不同(其中除了$_ts之外变量名都是变化的, 但是保证每次计算结果相同)

解决思路

将代码从压缩(单行)形式转为格式化后的多行模式, 方便debug

准备工作(解决动态JS)

由于JS与HTML中变量都是动态的, 打开网站后Ctrl+S保存全部文件. 在VSCode中打开HTML文件, 使用格式化工具格式化, 得到如下形式HTML

<!DOCTYPE html>
<html lang="en">
<head>
  <!-- ... -->
  <meta content="{qXVS74Mnw/*...*/03336qqqqqqqq">
  <script type="text/javascript" src="../cIZgBeQvEQK9/AiHJXIs5GyJH.dee59c7.js"></script>
  <script>
    (function () {
    var _$XI = 16,
    // _$_0 = [
      // [14, 10, 13, 15, 12, 1, 0, 8, 5, 12, 7, 3, 2, 9, 12, 11, 5],
      // [
    })()
  </script>
</head>
<!-- ... -->

其中

  • <meta>中字符串参与加密
  • 第一个<script>请求乱码JS
  • 第二个<script>中的立即执行函数是"解码器"

为了方便分析, 我们将立即执行函数分离到outer.js并引入

<meta content="{qXVS7/*...*/3703336qqqqqqqq">
<script type="text/javascript" src="../cIZgBeQvEQK9/AiHJXIs5GyJH.dee59c7.js"></script>
<script type="text/javascript" src="js/outer.js"></script>

outer.js立即执行函数前加入debugger这样我们可以在解密前暂停

我们使用本地JS文件进行分析, 房子变量换来换去

在保存文件夹下执行

python -m http.server 8081

这样在0.0.0.0:8081开启了一个web server

清空自带debugger(解决前端反调试)

瑞数的前端反调试就是注册了几个定时器, 每500ms去eval(function), 这个function获取一个时间戳并debugger. 两个解决方法

  • 在debugger的行号处右键, 选择一律不再此处暂停, 看到出现一个黄色问号表示忽略了这个debugger

  • 借助JS是单线程的, 在debugger时候执行

    for(let i = 0;i<999999;i++)
      clearInterval(i)

劫持cookieeval

在前面介绍中, 我们知道, 这段JS的作用就是定时set-cookie. 所以我们要将cookiesetget进行重写, 方便在读写cookie时暂停, 进行堆栈分析. 在outer.jsdebugger暂停的时候, 写入

var _cookie = document.__lookupSetter__('cookie');      // 移走cookie
document.__defineSetter__("cookie", function(c) {       // 重设cookie
  debugger;
  _cookie=c;
} );
document.__defineGetter__("cookie", function() {
  debugger;
  return _cookie;
} );

同样, 解密函数需要先解密加密串, 然后需要使用eval执行解密JS, 所以需要劫持eval

orig = window.eval;
window.eval=function(str){debugger;orig(str);}
window.eval.toString = function (){return orig.toString();}

分析eval入口

在dev-tools中执行代码, 代码从outer.js的首行开始执行, 暂停在了eval, 在调用堆栈中选到上一级_$XC, 看到正在执行的代码

_$aS = _$$N[_$6p[47]](_$qP, _$EZ);
  • _$$N: 未知函数

  • _$6p: 可以看到是一个变量替换表

    Array(56)
    0: "}"
    1: "$_ts"
    2: ","
    3: "random"
    4: "substr"
  • _$6p[47]: call

  • _$qP: window

  • _$EZ : 一个172K的代码

    (function(){var _$1U=0,_$f8=$_ts.scj,_$v4=$_ts.aebi;function _$yB(){var _$vI=[730];Array.prototype.push.apply(_$vI,arguments);return _$iv.apply(this,_$vI);}function _$9j(){var _$vI=[709];Array.prototype.push.apply(_$vI,arguments);return _$iv.apply(this,_$vI);}function _$b_(){var _$vI=[614];Array.prototype.push.apply(_$vI,arguments);return _$iv.apply(this,_$vI);}function _$Th(){var _$vI=[185];Array.prototype.push.apply(_$vI,arguments);return _$iv.apply(this,_$vI);}function _$IT(){var _$vI=[607];Array.prototype.push.apply(_$vI,arguments);return _$iv.apply(this,_$vI);}function _$Hj(){var _$vI=[810];Array.prototype.push.apply(_$vI,arguments);return _$iv.apply(this,_$vI);}/*...*/})()

是这个表达式调用的evla, 所以只能是_$$Neval, 这个表达式大概意思就是

_$aS = eval.call(window, '一个长长的String')

也就是说, 这个String就是待释放的代码. 保存String为inner.js, 格式化代码, 并替换导出

RUISHUTESTFUNCTIONENTRY = function(){
  // ...
}
  if (60 === 20 * _$LZ) {
-   _$aS = _$$N[_$6p[47]](_$qP, _$EZ);
+   RUISHUTESTFUNCTIONENTRY();
  } else if (_$LZ * 69 === 69) {

相当于我们手动释放了解密函数, 但是使用我们的方法后, 不会出现如下情形: 解密代码释放为单行, 返回堆栈时只能看到一行, 完全无法debug

debugger> XXX...

注意, 这不意味着我们可以直接删除AiHJXIs5GyJH.dee59c7.js, 打开就可以看到, 我们解密的字符串开头与其不同

  • 我们解密的

    function () {
      var _$1U = 0,
        _$f8 = $_ts.scj,
        _$v4 = $_ts.aebi;
      function _$yB() {
        var _$vI = [730];
        Array.prototype.push.apply(_$vI, arguments);
        return _$iv.apply(this, _$vI);
      }
    }
  • 原文件

    $_ts=window['$_ts'];if(!$_ts)$_ts={};$_ts.scj=[];$_ts['dee59c7']

找点特殊代码比对, 我找了

'=a"S%$Y\'tU9q.C,~NQy-^|6rXh:H?M[<@fK;0W+VI2RiJ(FencmskgL#OBT>\\4Gj`P&1_wD7oZxAb]}updv5Ez) *3{!l8/',

发现在AiHJXIs5GyJH.dee59c7.js也存在

可以大胆的猜测执行逻辑:

  • 加载AiHJXIs5GyJH.dee59c7.js, 释放变量(虽然变量值可能是乱码)
  • 加载解密器
  • 解密器解密AiHJXIs5GyJH.dee59c7.js并获得待释放JS字符查
  • 释放JS, 执行计算逻辑

我们做的事情就是劫持eval的内容, 并格式化代码, 手动释放, 方便调试. 为此, HTML应变为如下结构

<meta content="{qXVS7/*...*/3703336qqqqqqqq">
<script type="text/javascript" src="../cIZgBeQvEQK9/AiHJXIs5GyJH.dee59c7.js"></script>
<script type="text/javascript" src="js/inner.js"></script>
<script type="text/javascript" src="js/outer.js"></script>

重新加载网页, 继续捕获

var _$ET = _$Z5[_$SH[9]](_$qP[_$SH[43]], '; ');

检查变量

  • _$SH[9]: "call"

  • _$Z5: split

  • _$SH: 可以看到是一个变量替换表

    Array(723)
    [099]
    0: "prototype"
    1: "type"
    2: "toString"
    3: "readyState"
    4: "concat"
    5: "indexOf"
    6: "string"
    7: "body"
    8: "slice"
  • _$qP: document

  • _$SH[43]: cookie

也就是这句指令的意思是

_$ET = split['call'](document['cookie'],';')

值得注意到是3806行上面的代码, Chrome给出提示_$CG='lD01YhBPHVTHP='. 这就是我们要拼的头啊!

没什么意思...继续执行


暂停到了cookie.setter, 返回上级堆栈_$iv, 继续检查

  • _$qP: document
  • _$SH[43]: cookie
  • _$qS: "enable_lD01YhBPHVTH=true"

相当于设置了一个cookie, 这个cookie是一个临时的(多刷新几次就发现了), 继续执行


暂停到了eval, 返回上层堆栈_$iv

_$ET = _$qt(_$SH[615]);
  • _$SH[615]: "Z8XHJJY.bmF0aXZlRmlVyUHJ()"
  • _$SH是变量替换表
  • _$qt: ƒ (str){debugger;orig(str);} 这就是我们劫持的eval

可惜这次执行失败了, 因为没有Z8XHJJY, 程序进入下面的catch()并出来


继续执行

_$ET = _$qt(_$SH[661]);

同上, 落入catch


继续执行, 暂停在cookie.set, 查看堆栈_$iv

_$qP[_$SH[43]] =      // document.cookie =
      _$CG +          // 刚刚遇到过, 是我们需要的"lD01YhBPHVTHP"
      _$SH[47] +      // `=`
      _$41 +          // lD01YhBPHVTHP的值
      _$iv(994) +     // ''
      _$SH[589] +     // ; path...
      _$iv(983, _$s2);// '; expires=Wed, 06 Apr 2022 11:26:36 GMT; Secure'

也就是在此执行了一次拼串, 查看调用栈, 看到了我们命名的RUISHUTESTFUNCTIONENTRY()

所有操作都是在这里进行的

至此, 我们有了变量替换表, inner.js, outer.js, <meta>, 如何解密代码呢?

解决方案

  • 根据已有变量替换表, 一行一行理清逻辑...(费头发)

  • 既然我们知道了加密程序就是在反复执行inner.js, 不如欲擒故纵, 在Node中引入并执行, 但是Node中没有DOM/BOM方法, 所以需要我们实现几个假的, 尤其是query meta的时候(!这是一种非常不安全的方法, 例如加密者完全可以判断当前是否为Node环境, 如果是, 直接执行攻击指令)

  • 使用中间人攻击, 结合变量替换表, 实现解密JS

  • 使用模拟爬虫工具

  • 变量替换始终是一种猫鼠游戏. 更好的方法是: 使用jsdom模块在Node中实现轻量化浏览器环境, 我的实现代码如下

    function promiseStick() {
      let res, rej;
      const p = new Promise((resolve, reject) => {
        [res, rej] = [resolve, reject];
      });
      return Object.assign(p, { res, rej });
    }
    
    async function reqLoginToken(p = promiseStick()) {
      const cookieMap = new Map();
      let dom = await JSDOM.fromURL(
        'https://xxx.cn',
        {
          resources: 'usable',
          runScripts: 'dangerously',
        }
      );
      // 劫持cookie获取生成时机
      dom.window._cookie = dom.window.document.__lookupSetter__('cookie');
      dom.window.document.__defineSetter__('cookie', function (c) {
        _cookie = c;
        const cs = c.split('=', 2);
        cookieMap.set(cs[0], cs[1]);
        if (cookieMap.size === 2) {   // 一个enable_XX临时token, 一个加密的
          p.res({
            cookie: Object(dom.cookieJar.store.idx['uaaap.swu.edu.cn']),
            cookieE: cookieMap.get(config.login.encryptCookie),
          });
          dom.window.close();
          dom = null;
        }
      });
      dom.window.document.__defineGetter__('cookie', function () {
        return _cookie;
      });
      return p;
    }

值得学习的东西

  • cookie&eval劫持
  • dev-tool的一律不暂停
  • setInterval清除debugger