一、为什么要逆向js?

因为装逼是一辈子的事。随着越来越多的web网站或者小程序为了安全,都加入了加密或者防重放,可以防止百分之90的安全问题。你不会逆那就没饭吃。

那么,如果你会逆向js,解开密文=乱杀,因为很多开发以为做了加密,就会放松警惕,不会再对输入进行解密后的二次过滤。同时还有一个天然优势,waf无法识别密文,也就无法拦截。

如下所示:解开密文后,遍历id达到全站越权

二、常见的js加密

https://www.ssleye.com/ssltool/ 在线加解密网站

2.1 对称加密

比如AES、DES等,使用的加密和解密密钥都是同一个,所以叫对称加密。一般都存储在前端,或者后端动态获取,可以直接拿到。

2.2 非对称加密

比如RSA和ECDSA,使用非对称加密,也就是说,加密和解密使用两个密钥,公钥一般在前端,用于加密,私钥放在后端,用于解密。

三、如何逆向?

或者说,如何找到密钥和加密方式?先来看一个靶场,真实环境下演示。靶场:https://github.com/outlaws-bai/GalaxyDemo,用Galaxy插件自带的靶场,下载后启动

python3 manager.py 

访问点击查询功能点

抓包,发现是密文

发送的是密文,那么肯定是前端加密,要找到加密方法和密钥。

3.1 如何快速定位加密处代码

在实战中,肯定会有很多前端文件,所以不太可能一眼发现加密的地方

3.2 通过字段关键字

搜索data关键字

一个小技巧,只搜索data会出现多个,如果搜索data:就很快定位了

然后就找到了加密方法encrypted

在此代码全局搜索,就找到了加密的方式

3.3 通过js关键字

通过直接搜索,加密方法调用的关键字 CryptoJS.mode

或者,iv、key等等

3.4 通过路由

直接搜索请求的路由,也可以在上下文中找到加密方法

3.5 通过断点

如果以上都找不到呢?没有找到已知的加密方法,或者没有找到关键字呢?自写加密,那该怎么找到关键的加密代码?这就需要使用断点,先通过发送请求的js,找到功能所对应的代码,根据断点去判断。

例如此处,在功能点处打上断点,然后点击查询,跳到断点处,且功能还没触发,那么就可以慢慢看上下文,找到具体的实现方法

四、解密

可以通过在线网站直接解开,但是每次都需要反复加解密,很麻烦,而且也不好配合sqlmap等工具

但是像验证码爆破这种,可以通过自写脚本,来的更快

4.1 Galaxy 热加载

那么有没有更方便,更快捷的办法呢?有的兄弟,有的。

用Galaxy,下载最新版本

https://github.com/outlaws-bai/Galaxy/releases

选择解密的host

再下载对应的解密脚本,注意每次用完重新下一个覆盖掉,不然不知道一直报错是什么原因.....

https://github.com/outlaws-bai/GalaxyHttpHooker

注意这里除了对应的key和iv,还需要填入解密的字段,是整个请求的字段则不需要修改,部分加密则需要填写解密的字段,需要有一点编程和排错能力

然后启动后将密文发送至Galaxy插件解密,同时可以支持多个工具联动

解密后发送到Repeater模块

可以看到发送和响应已经自动解密

联动sqlmap,200状态吗,证明正常解析

注出结果

五、进阶

5.1小程序解包

案例:一次绕来绕去的小程序逆向

现在有两种方法去逆向,一个是强开f12打断点逆向,因为我是arm的mac,环境不支持注入小程序强开f12,所以选择第二个办法,解包

使用此项目解包,wx小程序

https://github.com/biggerstar/wedecode

设置源

npm config set registry https://registry.npmmirror.com

安装

# window
  npm i wedecode -g
# mac
  sudo npm i wedecode -g

输入wedecode选择小程序

然后把输出的源码直接丢到编辑器,开始逆向

5.2 小程序强开f12

有概率封号,建议使用小号

5.3 windows

直接看这个项目,根据版本下载对应的微信就行了

https://github.com/JaveleyQAQ/WeChatOpenDevTools-Python

5.4 arm mac

这个就麻烦了,如果版本匹配的上直接看这个项目,或者直接降级

https://github.com/f4l1k/WeChatOpenDevTools-Python-arm

如果匹配不上,就要根据作者给出的方法,自己去逆向拿到配置文件,比如我这里,就是不支持的版本

ps aux | grep 'WeChatAppEx' |  grep -v 'grep' | grep  "wmpf-mojo-handle"

ida下载以及破解

https://www.52pojie.cn/thread-1970020-1-1.html

Cc杰 师傅帮忙写的过程 https://www.yuque.com/u55777600/hix3f1/gviiqoyeb8tke2ib

按照作者给的方法去找到对应的值

1、ida打开/Applications/WeChat.app/Contents/MacOS/WeChatAppEx.app/Contents/Frameworks/WeChatAppEx Framework.framework/Versions/C/WeChatAppEx Framework;注意选择arm架构打开

2、JsonGetBoolFunc:搜索字符串“enable_vconsole”,找到交叉引用,找到“if ( sub_25F4C88((__int64)v53, "enable_vconsole", 0LL) & 1 )”中的sub_25F4C88地址

3、DevToolStringAddr:搜索字符串“closeNetLog”,下面的devTools就只要找的地址

4、WechatWebStringAddr:搜索字符串“https://applet-debug.com/devtools/wechat_app.html

5、xwebadress地址:搜索字符串“xweb-enable-inspect”

或者使用跟快捷的方法

https://mp.weixin.qq.com/s/NieEYK1jmDlURylGO0tUrg

然后修改config文件夹里的就行了,版本相差无几的话,可以试试是否通用,亲测911和910版本通用

运行脚本后


成功开启控制台

[图片上传错误...]

但是arm 的mac有的版本存在无法全局搜索的问题,暂时无法解决

5.5 无限debug

这个就很简单了。

注册后发现强加密

看到desjson关键字,准备打断点调试,然后就是无限debug

那怎么办呢?不慌有两种办法:

1.bp抓包直接修改回包js内容,去掉debug,或者直接加载本地js替换

2.火狐,简单粗暴,直接不在此处暂停即可

六、小程序逆向实战

6.1 定位字段

某医院小程序,对比几个数据包,发现是通过X-Api-Key这个字段来防止重放,X-Auth-Token来鉴权

还有几个不知道作用的字段

X-Hos-Id
X-Traffic
X-Api-Ver
X-Main-User-Id
X-User-Id
Request-No

重新加载一次功能,可以看到,只有下面两处地方,是变了的,其他没有变化。Request-No一眼时间戳,17开头不必多说。

6.2 定位加密代码

解包就不多说了,上面已经讲过了。解包后拿到代码,开始找加密点。

用编辑器打开,全局搜索,关键字X-Api-Key,因为这种强特征的字符串,所以很好快速定位。

关键代码

"X-Api-Key": (0, a.default)(e, t, n)

这里就是X-Api-Key的生成逻辑,我们继续追踪a这个模块,ctrl按住直接点击即可。

var a = n(520),
        i = n(256),
        o = "withSignHeader";

那么,a是根据n(520)的到的,而这个520则大概率是一个模块,通过webpack打包后就是这样,使用编号代表。这里是跳转不了的,所以我们全局搜索定义520:

520: function(e, t, n) {
      "use strict";
      var r = this && this.__spreadArray || function(e, t, n) {
        if (n || 2 === arguments.length)
          for (var r, a = 0, i = t.length; a < i; a++) !r && a in t || (r || (r = Array.prototype.slice.call(t, 0, a)), r[a] = t[a]);
        return e.concat(r || Array.prototype.slice.call(t))
      };
      Object.defineProperty(t, "__esModule", {
        value: !0
      }), t.requestParamsHash = void 0;
      var a = n(426),
//删除部分敏感代码

      function p(e, t) {
        var n, p = (0, i.requestParamsHandler)(e),
          h = [function() {
            var e = c((null == t ? void 0 : t.proxy) ? (0, i.removeProxyPrefix)(p.url) : p.url),
              n = e.pathname,
              r = e.query;
            return d.includes(r) ? n : "".concat(n).concat(r)
          }(), void 0 === p.header || null === p.header ? "" : o.REQUEST_HASH_HEADERS.reduce((function(e, t) {
            var n = p.header[t];
            return null != n ? r(r([], e, !0), ["".concat(t.toLowerCase(), "=").concat(n)], !1) : e
          }), []).join(","), function() {
            var e;
            if (!u.includes(null === (e = p.method) || void 0 === e ? void 0 : e.toUpperCase())) return "";
            if (void 0 === p.data || null === p.data) return "";
            var t = (0, i.getHeaderIgnoreCase)(p.header, "Content-Type");
            if (!s.includes(t)) return "";
            try {
              if (p.data instanceof FormData) return ""
            } catch (e) {
              console.log("FormData error", e)
            }
            return "object" == typeof p.data ? JSON.stringify(p.data) : p.data
          }()].filter((function(e) {
            return "" !== e
          })).join("&&");
        return (null == t ? void 0 : t.debug) && (0, i.printLog)(l, p.url, "生成请求摘要前", h), null === (n = ("function" == typeof p.md5 ? p.md5 : a.CryptoJS.MD5)(h)) || void 0 === n ? void 0 : n.toString()
      }
      t.requestParamsHash = p, t.default = function(e, t, n) {
        if ((null == n ? void 0 : n.debug) && (0, i.printLog)(l, t.url, " 签名参数", {
            signOptions: e,
            requestOptions: t,
            options: n
          }), !(null == n ? void 0 : n.disabledSign)) {
          ! function(e, t, n) {
           //删除部分敏感代码
          }(e, t, n);
          var r = (new Date).getTime(),
            c = (0, i.guid)(),
            s = {
              accessEntry: "patient",
              apiKey: o.E_API_KEY,
              timestamp: e.timestamp || r
            };
          return void 0 !== (null == n ? void 0 : n.signVersion) && 2 !== (null == n ? void 0 : n.signVersion) || (s.replayNo = e.uuid || c, s.hashDigest = p(t, n)), (null == n ? void 0 : n.debug) && (0, i.printLog)(l, t.url, "生成签名前", s), (0, o.encrypt)({
            word: JSON.stringify(s)
          }, a.CryptoJS)
        }(null == n ? void 0 : n.debug) && (0, i.printLog)(l, t.url, "签名已禁用")
      }
    },

此处就是,x-api-key的生成流程。根据请求的 URL、请求头、请求体等关键内容生成一个摘要(哈希值),然后将这些信息(包括时间戳、请求唯一 ID 等)打包成对象并加密,生成最终的 X-Api-Key。如下:

{
  "accessEntry": "patient",          // 访问端标识,表示这是一个“患者”相关的请求
  "apiKey": "xxxxx",                 // 固定 API 密钥,用于身份验证
  "timestamp": 1710000000000,        // 请求时间戳
  "replayNo": "ec5c-fbc1-58ad-48a1",  // 请求的唯一标识符,随机uuid
  "hashDigest": "md5_hash_value"     // 请求的 MD5 摘要,用于校验请求内容
}

重点就是这个md5_hash_value,因为还有apikey,其他的都是随机生成和固定的,现在要继续跟踪o = n(325),毫无疑问325也是一个模块

这块是经过混淆的,大概意思就是上面涉及的apikey以及核心加密涉及到的密钥,其实这里可以从代码中的iv,mode和中间那个列表中直接看出,是aes加密。

 325: function(e, t, n) {
      "use strict";

      function r(e, t) {
        var n = i();
        return (r = function(e, t) {
          return n[e -= 426]
        })(e, t)
      }
      var a = r;

      function i() {
        var e = ['','','']较敏感已删除
        return (i = function() {
          return e
        })()
      }(function(e, t) {
        for (var n = r, a = e();;) try {
          if //删除部分敏感代码
          a.push(a.shift())
        } catch (e) {
          a.push(a.shift())
        }
      })(i), Object[a(447) + a(432)](t, a(459), {
        value: !0
      }), t[a(433)] = t[a(460)] = t[a(452) + a(448)] = t[a(456)] = t[a(437)] = void 0;
      var o = a(426) + a(445),
        c = a(435) + a(461);
      t[a(437)] = a(464), t[a(456)] = a(465) + a(467) + a(442) + a(439), t[a(452) + a(448)] = [a(450), a(446), a(457) + "en", a(443)], t[a(460)] = function(e, t) {
        var n = a,
          r = t[n(455)][n(428)][n(438)](e[n(470)]),
          i = t[n(455)][n(428)][n(438)](e[n(469)] || o),
          s = t[n(455)][n(428)][n(438)](e.iv || c);
        return t[n(451)][n(460)](r, i, {
          iv: s,
          mode: t[n(462)][n(440)],
          padding: t[n(434)][n(463)]
        })[n(429)]()
      }, t[a(433)] = function(e, t) {
        var n = a,
          r = e[n(470)],
          i = t[n(455)][n(428)][n(438)](e[n(469)] || o),
          s = t[n(455)][n(428)][n(438)](e.iv || c);
        return t[n(451)][n(433)](r, i, {
          iv: s,
          mode: t[n(462)][n(440)],
          padding: t[n(434)][n(463)]
        })[n(429)](t[n(455)][n(428)])
      }
    },

那么我们已经知道第一层算法了,也就是将上面所有的信息,结合hashDigest进行aes加密。我们还需要知道hashDigest字段,是怎么来的。和上面提及的apikey。所有的信息都在,325这个模块里。这一段代码的意思就是,进行映射取值,所有字段的值包括加密算法,iv,key都要从上图那个列表中去取的。解开混淆后的代码大致如下:

encrypt(data, cryptoJS) {
  const key = cryptoJS.enc.Utf8.parse(data.key || defaultKey);
  const iv = cryptoJS.enc.Utf8.parse(data.iv || defaultIV);
  const word = cryptoJS.enc.Utf8.parse(data.word);

  return cryptoJS.AES.encrypt(word, key, {
    iv: iv,
    mode: cryptoJS.mode.CBC,//直接从字符串列表中提取,符合加密模式的只有这一个字符串
    padding: cryptoJS.pad.Pkcs7
  }).toString();
}

但是我们逆向不能使用这个解开混淆的代码,要使用原代码,这里只是方便我们识别代码的具体作用

对应的混淆的代码

i = t[n(455)][n(428)][n(438)](e[n(469)] || o),
s = t[n(455)][n(428)][n(438)](e.iv || c);

6.3 坑点一

由上面代码可知,key和iv来自e[n(469)] || oe.iv || c这两个地方,进行的或运算。如果你去找这个e,那么你就上当了。下面是e的定义。他是没有[n(469)]和iv具体属性的,如果你去跟踪这两个就会浪费大量时间。

var e = ['字符串1','字符串2','字符串3']

直接看代码中的o和c,可以看到上文中是直接给出了定义的,这两个才是真正的iv和key

var o = a(426) + a(445),
    c = a(435) + a(461);

6.4 坑点二

那么我们就可以根据字符串列表的映射关系去找,这两个具体的值。随之坑就来了。因为习惯性丢ai去自动查找,所以当ai给出映射出的字符串,下意识以为是对的,在这里浪费大量时间。我们可以看下ai给出的iv和key。

这就是mac小程序调试的不足之处,windows上是可以通过dev-tools直接动态调试的,mac只能一点点抠代码, 也请教下师傅们是否有更优雅的方式。

回到主题,我们可以看到ai去逆向给出的iv和key甚至连长度不符合aes的长度。那么到底是哪里有问题呢?继续扣代码。

看到关键代码,也就是导致结果不准的元凶。

a.push(a.shift())

意思如下:

1. 代码执行原理
a.shift() :删除数组的第一个元素并返回该元素。
例如 a = [1,2,3],执行后返回 1,数组变为 [2,3]。
a.push(value) :将值 value 添加到数组末尾。
接上例,传入 1 后,数组变为 [2,3,1]。

那么我们就只能手动去动态调试了,让代码按照逻辑走,最后拿到正确的iv和key。

解密,这就是完整的需要加密的json,可以看到我们还拿到的apikey,老方法,重新点击几次功能点,发现此值是固定的,replayno上面已经说过是随机的uuid。我们还需要将hashDigest逆向出来。

下面是完整的逆向代码

// 敏感只放部分代码
  function i() {
    var e = [//敏感内容,删除部分
       "4ljCFxBK1d", "1461670rWYcKw", "Utf8", "toString", "42nhIpEo", "15USbUuz", "erty"
    ];
    return (i = function () {
      return e;
    })();
  }

  // ★★★★★ 混淆扰乱机制 ★★★★★
  (function (e, t) {
    for (var n = r, a = e();;) try {
      if (758051 ===
        //删除部分代码
      a.push(a.shift());
    } catch (e) {
      a.push(a.shift());
    }
  })(i);

  // -------------------------------
  // 模块标志:__esModule
  // 混淆代码:
  // Object[a(447) + a(432)](t, a(459), { value: !0 })
  // 即:Object.defineProperty(t, "__esModule", { value: true });
  // -------------------------------
  const defineProperty = a(447) + a(432); // "defineProp" + "erty" → "defineProperty"
  const esModuleKey = a(459);            // "__esModule"
  console.log("✅ defineProperty key:", defineProperty);
  console.log("✅ __esModule key    :", esModuleKey);

  Object[defineProperty](t, esModuleKey, { value: true });

  // -------------------------------
  // AES 常量
  // 混淆代码:t[a(437)] = a(464);
  // a(437) = "AES"
  // a(464) = "AES"
  // -------------------------------
  const aesKey = a(437);
  const aesValue = a(464);
  t[aesKey] = aesValue;
  console.log(`✅ ${aesKey} =`, aesValue); // AES = AES

  // -------------------------------
  // E_VER 版本号
  // 混淆代码:t[a(456)] = a(465) + a(467) + a(442) + a(439);
  // 实际为:E_API_KEY = 拼接多个字段
  // 再往下是 E_VER
  // t[a(433)] = a(457)  → "E_VER" = "2.23.0"
  // -------------------------------
  const eVerKey = a(433);     // "E_VER"
  const eVerValue = a(457);   // "2.23.0"
  t[eVerKey] = eVerValue;
  console.log(`✅ ${eVerKey} =`, eVerValue);

  // -------------------------------
  // SH_HEADERS 数组
  // 混淆代码:
  // t[a(452) + a(448)] = [a(450), a(446), a(457)+"en", a(443)];
  //
  // -------------------------------


  // -------------------------------
  // E_API_KEY 拼接
  // 混淆代码:t[a(456)] = a(465) + a(467) + a(442) + a(439)
  // -------------------------------

  // -------------------------------
  // encrypt / decrypt 方法名
  // a(460) = "encrypt"
  // a(433) = "decrypt"
  // -------------------------------
  var o = a(426) + a(445);
  var c = a(435) + a(461);

  // 打印 key 和 iv
  console.log("✅ 动态获取的默认 KEY:", o);
  console.log("✅ 动态获取的默认 IV :", c);


})(null, t, null);

6.5 完整算法

同时我们还拿到了,完整的hashDigest算法中的,header头组成部分。有4个字段,参与了计算。需要注意的是,这里要严格按照顺序,否则计算出的md5hash会不一样

那么,现在我们回到520模块,再次来看,hashDigest的生成逻辑。

此处的REQUEST_HASH_HEADERS我们已经拿到了,也就是下面的几个请求头

['X-User-Id', 'X-Hos-Id', 'X-Auth-Token', 'X-Api-Ver']

继续往下,找到算法

总结下算法逻辑,如下:

hashDigest = MD5(
    pathAndQuery
    + "&&" +
    headerDigest
    + "&&" +
    dataDigest
)

pathAndQuery也就是请求的路径,注意此处只要路径,不需要http://aaa.com这一段。

/patientuser/v1/patinfo/123
或
/patientuser/v1/patinfo/123?token=abc

headerDigest也就是我们上面拿到header头,需要拼接起来。具体的值可以通过最上面的,请求包拿到。

x-user-id=...,x-hos-id=...,x-auth-token=...,x-api-ver=...

dataDigest则需要满足以下条件,才会被加入

method 是 "POST" 或 "PUT"
Content-Type 是 "application/json" 或 undefined
data 存在,且非 FormData
类型是 object 时将用 JSON.stringify

现在将X-Api-Key的算法梳理一遍

          请求 URL + method    
                  ↓
            提取 path + query       
          提取指定 headers 顺序拼接 
          提取 body(若满足条件)   
                  ↓
         拼接字符串: A && B && C       
                  ↓
                 MD5 
              hashDigest
                  ↓
     构建签名体(含 timestamp、uuid、digest 等)
                  ↓
         AES-CBC 加密 + Pkcs7 + Base64
                  ↓
         设为 X-Api-Key 头部   

自动化脚本

现在知道了完整的算法,写一个生成X-Api-Key的脚本

敏感只放部分核心代码

// ✅ 生成 X-Api-Key
function generateXApiKey({ url, method = 'GET', headers = {}, data = null, timestamp, uuid, debug = false }) {
  const ts = typeof timestamp === 'number' ? timestamp : Date.now();
  const rid = typeof uuid === 'string' ? uuid : uuidv4();

  const hashDigest = buildHashDigest({ url, method, headers, data });

  const payload = {
    accessEntry: 'patient',
    apiKey: E_API_KEY,
    timestamp: ts,
    replayNo: rid,
    hashDigest
  };

  if (debug) {
    console.log('📦 签名明文 payload:', JSON.stringify(payload, null, 2));
  }

  const encrypted = CryptoJS.AES.encrypt(
    CryptoJS.enc.Utf8.parse(JSON.stringify(payload)),
    CryptoJS.enc.Utf8.parse(KEY),
    {
      iv: CryptoJS.enc.Utf8.parse(IV),
      mode: CryptoJS.mode.CBC,
      padding: CryptoJS.pad.Pkcs7
    }
  ).toString();

  return {
    xApiKey: encrypted,
    payload
  };
}

// ✅ 示例请求(请根据实际请求替换)
const result = generateXApiKey({
  url: '',
  method: 'GET',
  headers: {
});

// ✅ 最终输出
console.log('🔐 X-Api-Key:', result.xApiKey);

对比下,上面aes解开后的hashDigest,一致,没问题。

测试重放,因为每一次生成的时间戳不一样,所以这个是一次性。没问题,成功重放。

那么不出意外的话,返回包也是这个aes

但是这还不算完全的自动化,我们要的是完全解放双手,像明文一样测试,但是galaxy好像并不能解决这个问题,于是有了下面的mitmproxy脚本。galaxy会将burp里的数据解密后重新发送到burp然后再发送给服务器,解密回显再到burp,所以不太适合。而mitmmitmproxy就相当于另一个burp,区别就是,burp是获取浏览器的流量进行测试,而mitmproxy是获取burp的流量进行修改x-api-key后再次发送,下面会给出一个完整的流程图。

敏感,只放部分核心代码吧
# X-Api-Key 签名算法
def generate_xapikey(url, method, headers, data):
    parsed = urlparse(url)
    path_and_query = parsed.path + (f"?{parsed.query}" if parsed.query else "")

    header_digest = ",".join(
    )

    data_digest = ""
    if method.upper() in ("POST", "PUT") and isinstance(data, dict):
        content_type = headers.get("content-type", "").lower()
        if content_type == "application/json":
            data_digest = json.dumps(data, separators=(",", ":"))

    joined = "&&".join(x for x in [path_and_query, header_digest, data_digest] if x)
    hash_digest = hashlib.md5(joined.encode()).hexdigest()

    payload = {
        *
    }

    payload_bytes = json.dumps(payload, separators=(",", ":")).encode()
    cipher = AES.new(KEY, AES.MODE_CBC, IV)
    encrypted = cipher.encrypt(pad(payload_bytes, AES.block_size))
    return base64.b64encode(encrypted).decode()

将burp流量转发过来

启动mitmproxy

mitmproxy -p 5080 -s 某医院小程序.py 

可以看到已经动态修改

流程图:

     [小程序]
        ↓
[Reqable 转发到127.0.0.1:8080]
        ↓
[Burp Proxy 监听 127.0.0.1:8080]
        ↓ 
[Upstream转发到127.0.0.1:5080]
        ↓
[mitmproxy 监听 127.0.0.1:5080]
        ↓
    [目标服务器]

现在我们测试,就是全自动更新x-api-key了,同时自动解密回显,很遗憾的是,在此处没有越权哈哈。

但是在另一处地方,存在全站越权,大量个人信息,包括但不限于三要素。一开始没有发现,下意识以为这个字段没有参与x-api-key的计算,就没测试,结果这里是靠这个去查询的。

七、参考

https://www.bilibili.com/video/BV1XQGmzKEaX/?share_source=copy_web&vd_source=019bcb6a6c87d8dc8a6e75d053dddab9

完结,撒花🎉🎉🎉