[前端逆向] Shinym@s 初探(7) 入口、脚本加载与 Webpack

enza 已抛弃本文中的 eval 脚本执行方案,本文仅作为历史文件供考古使用。


对于 SC,或者说对任何 Webpack 打包的应用而言,最重要的一步就是加载脚本。Webpack 将目录合并成了文件,减小了整体的请求次数的同时也增加了逆向的分析难度。这种难度增加体现在多个维度:一、Webpack 通常生成单个较大文件,对浏览器开发者工具而言难以解析;二、Webpack 将模块以数字的形式加以处理,在阅读时需要将数字重新与模块对应,较为复杂。

当然了,Webpack 的流行也为逆向增加了方便之处。由于打包格式大抵一致,我们可以从任意未混淆的打包中反推出已混淆脚本的内容。并且,由于第三方库的广泛使用,大部分代码实际是开源的,我们可以在 GitHub 上根据相应的特征搜索到对应的结果。

扯了这么多,来看 SC 的吧。SC 的对应文件是 commons.chunk-9a1878f7dac7f1ff26f2.js,后面一串十六进制可能会随着版本更新改变,我们不用去管。这里我们就把它叫做 common.chunk.js 好了。

当时写的时候还是有 commons.chunk 的。时过境迁,自从 2020 年 4 月 30 日之后的版本开始,这一部分的内容已经被整合进了主代码中。这里不得不跨几句 enza,这样做是对的。

以及这次更新引入的新 wasm,都是在安全角度上的再次考量。高山你终于想到这个了(

这句当我没夸,之前版本就有了只是我没注意到(

嘛,万变不离其宗,其实本质都没什么区别。既然 Webpack 移到里面去了,那我们就先看看怎么到里面去呗(

入口

任何程序都要有入口,SC 也不例外,但作为一款网页游戏,如何保护自己就成了一门学问。据说过去 SC 的代码是不设防的,但现在显然不是这样了。

通过前几篇文章我们知道,SC 用到了 WebAssembly,但其实并不止。针对不支持 WASM 的浏览器,SC 也没有放弃治疗,而生选择了 asm.js 这一兼容方案。没错,Mozilla 的方案在 WASM 的大背景下已经只能作为兼容方案存在了(

作为兼容方案,纵使它的效率可能没有 WASM 那么高(对不支持加速的浏览器来说),但这显然是作为入口的不二之选。所以入口其实很简单,就是一个 asm.js 的模块执行的过程。

ASM.js

作为 asm.js,它和 WASM 有着高度的相似性(毕竟都是同一个工具链生成的,至少对于 SC 是如此),而相比之下有一个比较大的区别就是初始内存。WebAssembly 是在程序末,而 asm.js 则是一个 base64 字符串:

没错,就是中间这串

我们把它 atob,于是发现了熟悉的身影:

并且在最后发现了一些疑似 JS 代码的东西:

我们先来看这个代码,整理一下:

if (
  JSON.parse.toString().replace(/\s|\n|\t/g, "") !==
  "functionparse(){[nativecode]}"
) {
  return;
}
if (
  JSON.stringify.toString().replace(/\s|\n|\t/g, "") !==
  "functionstringify(){[nativecode]}"
) {
  return;
}
if (
  window.eval.toString().replace(/\s|\n|\t/g, "") !==
  "functioneval(){[nativecode]}"
) {
  return;
}
if (
  window.decodeURIComponent.toString().replace(/\s|\n|\t/g, "") !==
  "functiondecodeURIComponent(){[nativecode]}"
) {
  return;
}
if (
  window.escape.toString().replace(/\s|\n|\t/g, "") !==
  "functionescape(){[nativecode]}"
) {
  return;
}
var userAgent = window.navigator.userAgent.toLowerCase();
var isAvailableSourceURLBrowser = false;
if (userAgent.indexOf("edge") !== -1) {
  isAvailableSourceURLBrowser = true;
} else if (userAgent.indexOf("chrome") !== -1) {
  isAvailableSourceURLBrowser = true;
} else if (userAgent.indexOf("safari") !== -1) {
  isAvailableSourceURLBrowser = false;
} else if (userAgent.indexOf("firefox") !== -1) {
  isAvailableSourceURLBrowser = true;
} else {
  isAvailableSourceURLBrowser = false;
}
var json = {};
json.parse = JSON.parse;
json.stringify = JSON.stringify;
if (isAvailableSourceURLBrowser) {
  eval("//# sourceURL=" + window.location.origin + "/%s");
  eval(
    decodeURIComponent(escape(unescape(encodeURIComponent(src)))) +
      "\n" +
      "//# sourceURL=" +
      window.location.origin +
      "/%s"
  );
  eval("//# sourceURL=" + window.location.origin + "/%s");
} else {
  eval(decodeURIComponent(escape(unescape(encodeURIComponent(src)))));
}
window.JSON = json;

看到 48 行,我们不难发现:主程序代码执行是通过 eval 实现的。这也就是我针对这次 commons 失效的解决方案:劫持 eval 修改脚本内容实现内容输出。

接下来就是最重要的问题了:eval 的脚本哪里来?

入口(真)

事实上,在上面这串代码的正后方,就有一个有趣的东西:

事实也证明,这的确就是我们想要的文件。那这个文件是怎么加密的呢?还记得之前提到的完全一致的 key 吗?没错,这个文件就直接使用 decryptResponse 解密就行了。

事实上到了这里已经可以停下来了,但我们还有不明白的东西:这个字符串是怎么来的?

根据之前 encryptPath 的经验以及字符串的长度,我们判断出这是 sha256;那文件名是什么呢?要不要加 /assets 呢?这都是需要我们去尝试的。

经过试验,结果出炉了:

至此,我们拿到了 SC 的实际源文件。

Webpack

经过了这次更新,SC 的模块系统已经完全可以说是滴水不漏了。(改代码是犯规的,这一点必须指出(

和上一个版本相比,这个版本打包的特点就是可读性更强了。我们来看:

/******/ !(function (e) {
  function t(n) {
    if (r[n]) return r[n].exports;
    var o = (r[n] = { i: n, l: !1, exports: {} });
    return e[n].call(o.exports, o, o.exports, t), (o.l = !0), o.exports;
  } // webpackBootstrap
  /******/
  var n = window.primJsp;
  window.primJsp = function (t, r, i) {
    for (var a, s, u = 0, l = []; u < t.length; u++)
      (s = t[u]), o[s] && l.push(o[s][0]), (o[s] = 0);
    for (a in r) Object.prototype.hasOwnProperty.call(r, a) && (e[a] = r[a]);
    for (n && n(t, r, i); l.length; ) l.shift()();
  };

这里有两个函数:t(n)primJsp(t, r, i)。前者相当于 require,或者准确地说,__webpack_require__;而后者,则是负责向整个 Webpack 中增加新模块的。

原本的代码中 primJsp 本身就有返回值,这次修改的结果是把返回值去掉了。安全性大大增加不说,还让这个函数的实现更漂亮了。(当然了,估计是自动生成的,enza 估计没想到这个((

接下来是一大堆 sha256 值,作用是定位脚本路径和确保脚本不被篡改,我们就跳过了。

然后是这个函数:t.e

  (t.e = function (e) {
    function n() {
      (u.onerror = u.onload = null), clearTimeout(l);
      var t = o[e];
      0 !== t &&
        (t && t[1](new Error("Loading chunk " + e + " failed.")),
        (o[e] = void 0));
    }
    var r = o[e];
    if (0 === r)
      return new Promise(function (e) {
        e();
      });
    if (r) return r[2];
    var a = new Promise(function (t, n) {
      r = o[e] = [t, n];
    });
    r[2] = a;
    var s = document.getElementsByTagName("head")[0],
      u = document.createElement("script");
    (u.type = "text/javascript"),
      (u.charset = "utf-8"),
      (u.async = !0),
      (u.timeout = 12e4),
      (u.crossOrigin = "anonymous"),
      t.nc && u.setAttribute("nonce", t.nc),
      (u.src =
        t.p +
        "" +
        {/* ... */}[e] +
        ".chunk.js");
    var l = setTimeout(n, 12e4);
    return (
      (u.onerror = u.onload = n),
      (u.integrity = i[e]),
      (u.crossOrigin = "anonymous"),
      s.appendChild(u),
      a
    );
  }),

这里我们很明显能看出是创建 <script> 用的。这里指定了一系列属性以加载脚本,看看就好(

    (t.m = e),
    (t.c = r),
    (t.d = function (e, n, r) {
      t.o(e, n) ||
        Object.defineProperty(e, n, {
          configurable: !1,
          enumerable: !0,
          get: r,
        });
    }),
    (t.n = function (e) {
      var n =
        e && e.__esModule
          ? function () {
              return e.default;
            }
          : function () {
              return e;
            };
      return t.d(n, "a", n), n;
    }),
    (t.o = function (e, t) {
      return Object.prototype.hasOwnProperty.call(e, t);
    }),
    (t.p = "/"),
    (t.oe = function (e) {
      throw e;
    }),
    t((t.s = 535));

最后是一堆算是常规内容的东西,也没什么好说的。最后设置了起始模块为 535 号,整个 Webpack 的初始化就结束了。

结语

事出偶然,这篇原本是计划在资源获取之前写的,但机缘巧合之下留到了今天,留到了这个 commons 直接被合并的日子。

想办法注入使汉化脚本恢复的过程确实有点让人头秃,期间试了各种东西,加深了我对 JS 作用域链的理解(笑)。不过也正是这一个多小时的思维碰撞(指一个不行碰撞另一个不行),才有了劫持 eval 这个想法的出现。这个想法本身也很简陋,又是因为不知为什么想到的 Proxy,才使得现在的代码能够做到如此优雅。整个过程堪比一道 CTF Web 题:

嘛,就是这样(

暂无评论

发送评论 编辑评论


				
上一篇
下一篇