Skip to content

发现一个小程序有个签到,抓包对应网址发现其中有个Sign加密参数,本想着随便试一下,没想到牵扯到那么多东西,就花了整整一个星期的时间来研究。过程可能不太详细,毕竟我也是现学现卖😂甚至js语法都没学通,都是头一次见到的技术 。

JS 层动态分析

网站地址: aHR0cHM6Ly91bS5pb3V0dS5jbi90eS9hc3NhbS9sdWNreS1kZXRhaWw=

首先网站,然后进行抓包

通过刷新多次请求,发现 x-ts x-sign x-nonce 这三个值是一直在变化的,x-ts 明显是时间戳,接下来需要分析 x-sign x-nonce 这两个值的来源。

一般直接 Ctrl+Shift+F 搜索字符串可以找到相关函数,但该网站的 js 文件是由 WebPack 打包过的,代码结构都和上图差不多,字符串全在同一个地方,不好寻找函数。而且打XHR断点也无效,所以找函数位置就花了不少时间。

打开 F12 查看请求的调用堆栈

进入第一个匿名函数,下断点

然后一直 F11 步入,直到发出请求也没发现那两个参数的生成,看来重点不在这个函数,那么再往上个函数试试,下断点,慢慢F10或F9。

步入过程发现目标url,应该离参数的生成不远,继续,调试 WebPack 生成的代码太费时间了,而且还没 Map文件,就找函数的过程花了我两三天,最终找到了生成这两个值的函数。

x-nonce

js
    3682: (t,e,r)=>{
        t = r.nmd(t);
        var n = o;
        function o(t, e) {
            var r = i();
            return (o = function(t, e) {
                return r[t -= 447]
            }
            )(t, e)
        }
        function i() {
            var t = ["169360Tjwntv", "502651XRPFzm", "toString", "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx", "25326684vkpEiT", "20PuHlQf", "243FimiQP", "11SFCgQb", "48OdOxac", "69604GYVZvv", "1598324QxtHuy", "70pKqhLY", "3552831YclxpX", "636306pSQgnQ", "replace", "random", "exports"];
            return (i = function() {
                return t
            }
            )()
        }
        (function(t, e) {
            for (var r = o, n = t(); ; )
                try {
                    if (790495 === parseInt(r(457)) / 1 + parseInt(r(456)) / 2 + -parseInt(r(462)) / 3 * (-parseInt(r(448)) / 4) + parseInt(r(450)) / 5 * (parseInt(r(452)) / 6) + parseInt(r(449)) / 7 * (-parseInt(r(447)) / 8) + parseInt(r(451)) / 9 * (parseInt(r(461)) / 10) + parseInt(r(463)) / 11 * (-parseInt(r(460)) / 12))
                        break;
                    n.push(n.shift())
                } catch (t) {
                    n.push(n.shift())
                }
        }
        )(i),
        t[n(455)] = function() {
            var t = n;
            return t(459)[t(453)](/[xy]/g, (function(e) {
                var r = t
                  , n = 16 * Math[r(454)]() | 0;
                return ("x" == e ? n : 3 & n | 8)[r(458)](16)
            }
            ))
        }
    }

其中类似 r(xxx),t(xxx) 的函数其实都是o函数,是将 t 数组的值取出,这段代码首先经过中间的那段代码调整 t 数组中各个值的位置,然后通过调用o函数,输出指定位置的值。t 数组也穿插了一堆无用值,自己复原t(xxx)非常不方便,可以在相应位置打上断点,然后直接在控制台输入,看返回值是什么。

js
o(459)
> 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'

把函数还原后,问了下 Chatgpt 这是什么算法,因为 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' 格式看起来好像和 UUID 有关系。

果然,这段代码的作用是生成一个随机的 UUID 值,既然是随机的那就好说了。

x-sign

sign 的值是40位字符串,盲猜是将时间戳,UUID 什么的进行SHA-1算出来的结果。

定位到相关代码,并还原参数。

js
for (m = function (t) {
        var e = A;
        return l.allocate(l.intArrayFromString(t), l.ALLOC_NORMAL)
    },
    E = l._getSign(I, m(t.skey), m(t.body), m(n), m(f.toString()), y, e.length),
    b = new Uint8Array(l.asm.memory.buffer), B = [], w = E; 0 !== b[w];) B.push(b[w]), w += 1;
return Q = (new TextDecoder).decode(new Uint8Array(B))

m 函数是将传入的 t 字符串转换为ASCII编码的数组,并为其分配内存,然后返回其所在的内存地址,l.ALLOC_NORMAL始终为0,这个函数容易自己实现。去查找 allocate 函数的实现,发现函数内部又牵扯到很多其他值,干脆就自己实现这个函数吧。

_getSign 函数依次接收以下值的内存地址,并返回固定值赋值给 E。

  • I: 不知道是哪里来的值,生成过程很复杂,不过好在一直是个固定值。
  • t.skey: 是一个空字符串。
  • t.body: 请求正文。
  • nonce: 也就是前面生成的随机UUID。
  • f.toString(): 其实就是时间戳转字符串。
  • y: 不知道是哪里来的值,也是固定值。

b 是将内存值转为Uint8Array的数组,然后从E位置向后遍历,如果不等于0就存入B数组,正好能存够40个值,再将B数组的ASCII码转回字符串,最后 return 的 Q 就是 sign 的值。

关键就在 _getSign 函数了,猜测是这个函数通过传入的几个内存地址值,读取相应内存的数据,进行某些计算后,将值写到了相应的 E 位置,然后给B数组遍历的。

WebAssembly 分析

js
function et(t) {
    return function () {
        var e = A.asm;
        return L(J, "native function `" + t + "` called before runtime initialization"),
            e[t] || L(e[t], "exported native function `" + t + "` not found"),
            e[t].apply(null, arguments)
    }
}

转到函数的定义,发现是这样的,没见过,问了问 Chatgpt 又搜了搜,才知道这是一个叫 WebAssembly 的东西。

WebAssembly或称wasm是一个低级编程语言。WebAssembly是便携式的抽象语法树,被设计来提供比JavaScript更快速的编译及执行。WebAssembly将让开发者能运用自己熟悉的编程语言编译,再藉虚拟机引擎在浏览器内执行。

上边这个et函数的作用是导出 wasm 文件中的 getSign 函数,所以获取sign值的逻辑就全在 wasm 里了。

wasm 文件可以在左侧栏里面找到,没有在网络请求里面发现,但是 js 代码里面有一大串 Base64,估计就是从这里读取转换来的。

定位到 getSign 函数,参数是6个值,那就是这个函数没错了,wasm 文件内容类似于汇编语言,基本上就是对内存地址里面的值进行 sub,add,and,or 和 入栈出栈 等操作。

比如:

global.get $global0 //取全局变量 $global0 的值
    local.set $var7 // 赋值为 $var7
    i32.const 256 // 定义值 256
    local.set $var8 // 把256赋值给 $var8 
    local.get $var7 //取变量 $var7
    local.get $var8 //取变量 $var8
    i32.sub //$var7 - $var8
    local.set $var9 //把结果设赋值为 $var9 
    --------------------------------------------------
    local.get $var9 //取变量 $var9
    local.get $var6 //取变量 $var6
    i32.store offset=228 //将变量 $var6 写入到 $var9+228 的地址
    local.get $var9  //取变量 $var9
    i32.load offset=252 //加载 $var9 + 252 地址的值
    local.set $var10    //赋值给 $var10

所以只要一步一步的在函数入口跟下去就能知道函数的逻辑,也就知道这个函数做了什么操作,毕竟是汇编语言,读起来没那么难,就是有点繁琐。

但是......我跟了将近3天,连40位 sign 的第一个值的逻辑都没捋清楚,在这个函数里面各种A套B,B套C,C又套D的操作,一点一点下断点调试及其耗时间。

像是第一个参数,顺藤摸瓜顺了那么长的藤也没摸到瓜。没办法,太浪费时间,只能放弃这个办法。

好在还有第二种方法,在 wasm 的算法分析不出来时可以用 nodejs 调用 wasm 导出需要的函数,直接拿来用。

导入函数

首先将浏览器内包含getSign函数的文件下载到本地,用 wabt 将 wat 转为 wasm。

bash
$ wat2wasm test.wat -o test.wasm

接着把wasm文件读入。

js
const fs = require('fs');
const wasmFile = 'test.wasm';
var importObject = {
    env: {
       /* 导入函数*/
    }
};
var wasmObject = new WebAssembly.Instance(new WebAssembly.Module(new Uint8Array(fs.readFileSync(wasmFile))), importObject);
var wasmMemory = wasmObject.exports.memory;

直接运行的话会报错,提示缺少 __assert_fail 导入函数。

importObject 对象是在使用 JavaScript 将 WebAssembly 模块与外部环境集成时的一种机制。它用于定义将从外部环境导入到 WebAssembly 模块中的函数、全局变量等实体。通过使用 importObject,可以在 WebAssembly 模块中使用来自外部环境的功能,实现模块与宿主环境之间的交互。

导入函数就要去原 js 文件中寻找,搜索字符串 __assert_fail,发现只有一个匹配项

把整个 dt 复制到 env 去试试,直接运行发现没有报错,可以正常读到内存。

js
console.log(wasmMemory)
> Memory [WebAssembly.Memory] {}

导出函数

之后可以试着调用 getSign 函数了,随便输入点内存地址,可以正常输出40位字符串。

js
console.log(wasmObject.exports.getSign(66264, 66264, 66264, 66264, 66264, 66368, 5));
> 05685ab5d3b988de577bdee6689fbbc43fa9be62

接下来就是要想办法实现前面的 m 函数,即传入字符串生成对应ASCII数组并返回数组所在位置。

js
memoryArray = new Uint8Array(wasmMemory.buffer) //将内存数据转换为数组
writeMark = 70000; // 写入起始位置

//将字符串写入内存
function writeStringToMemory(string) {
    function lengthBytesUTF8(str) {
        var len = 0;
        for (var i = 0; i < str.length; ++i) {
            var u = str.charCodeAt(i); 
            if (u >= 0xD800 && u <= 0xDFFF) u = 0x10000 + ((u & 0x3FF) << 10) | (str.charCodeAt(++i) & 0x3FF);
            if (u <= 0x7F) {
                ++len;
            } else if (u <= 0x7FF) {
                len += 2;
            } else if (u <= 0xFFFF) {
                len += 3;
            } else if (u <= 0x1FFFFF) {
                len += 4;
            } else if (u <= 0x3FFFFFF) {
                len += 5;
            } else {
                len += 6;
            }
        }
        return len;
    }

    function stringToUTF8Array(str, outU8Array, outIdx, maxBytesToWrite) {
        if (!(maxBytesToWrite > 0))
            return 0;

        var startIdx = outIdx;
        var endIdx = outIdx + maxBytesToWrite - 1;
        for (var i = 0; i < str.length; ++i) {
            var u = str.charCodeAt(i);
            if (u >= 0xD800 && u <= 0xDFFF) u = 0x10000 + ((u & 0x3FF) << 10) | (str.charCodeAt(++i) & 0x3FF);
            if (u <= 0x7F) {
                if (outIdx >= endIdx) break;
                outU8Array[outIdx++] = u;
            } else if (u <= 0x7FF) {
                if (outIdx + 1 >= endIdx) break;
                outU8Array[outIdx++] = 0xC0 | (u >> 6);
                outU8Array[outIdx++] = 0x80 | (u & 63);
            } else if (u <= 0xFFFF) {
                if (outIdx + 2 >= endIdx) break;
                outU8Array[outIdx++] = 0xE0 | (u >> 12);
                outU8Array[outIdx++] = 0x80 | ((u >> 6) & 63);
                outU8Array[outIdx++] = 0x80 | (u & 63);
            } else if (u <= 0x1FFFFF) {
                if (outIdx + 3 >= endIdx) break;
                outU8Array[outIdx++] = 0xF0 | (u >> 18);
                outU8Array[outIdx++] = 0x80 | ((u >> 12) & 63);
                outU8Array[outIdx++] = 0x80 | ((u >> 6) & 63);
                outU8Array[outIdx++] = 0x80 | (u & 63);
            } else if (u <= 0x3FFFFFF) {
                if (outIdx + 4 >= endIdx) break;
                outU8Array[outIdx++] = 0xF8 | (u >> 24);
                outU8Array[outIdx++] = 0x80 | ((u >> 18) & 63);
                outU8Array[outIdx++] = 0x80 | ((u >> 12) & 63);
                outU8Array[outIdx++] = 0x80 | ((u >> 6) & 63);
                outU8Array[outIdx++] = 0x80 | (u & 63);
            } else {
                if (outIdx + 5 >= endIdx) break;
                outU8Array[outIdx++] = 0xFC | (u >> 30);
                outU8Array[outIdx++] = 0x80 | ((u >> 24) & 63);
                outU8Array[outIdx++] = 0x80 | ((u >> 18) & 63);
                outU8Array[outIdx++] = 0x80 | ((u >> 12) & 63);
                outU8Array[outIdx++] = 0x80 | ((u >> 6) & 63);
                outU8Array[outIdx++] = 0x80 | (u & 63);
            }
        }
        outU8Array[outIdx] = 0;
        return outIdx - startIdx;
    }

    function intArrayFromString(stringy, dontAddNull, length) {
        var len = length > 0 ? length : lengthBytesUTF8(stringy) + 1;
        var u8array = new Array(len);
        var numBytesWritten = stringToUTF8Array(stringy, u8array, 0, u8array.length);
        if (dontAddNull) u8array.length = numBytesWritten;
        return u8array;
    }

    var intArray = intArrayFromString(string, 0);
    for (var i = 0; i < intArray.length; i++) {
        memoryArray[writeMark + i] = intArray[i];
    }
    var address = writeMark;
    writeMark += intArray.length;
    return address;
}

通过多次尝试,发现在内存地址 70000 之后写数据是不会与其他值冲突影响sign的输出的,所以writeMark是从70000开始的。

m函数实现了,getSign函数的第2到5个参数的来源也就搞定了。接下来就是研究 getSign 的1,6,7 这三个参数的意义了,1,6明显是和m函数返回值一样标注着数据的所在位置,第7个参数,通过分析 js 代码,貌似是一个数组的长度,是一个固定数值。

因为 js 代码可读性太差了,我也懒的再去找哪一段的js代码在哪里往内存写入了数据,并得到了地址以传给getSign函数。所以,我反复观察在哪一段地址的内存写入脏数据会影响sign值的输出,最终将范围缩小到了 65500 - 66500 ,看来这一段内存里面有影响到sign生成的参数,也包括了我不知道的参数1和参数6。

那么,在网页调用getSign函数之前,把 65500 - 66500 这一段内存的值全部取出来,在调用我自己实现的getSign函数前再将其写入到内存里面不就行了?最后实现的 getSign 函数如下

js
function getSign(skey, body, nonce, timestamp) {
    Init();
    var begin = wasmObject.exports.getSign(66264, writeStringToMemory(skey), writeStringToMemory(body), writeStringToMemory(nonce), writeStringToMemory(timestamp), 66368, 5);
    for (signArray = []; 0 !== memoryArray[begin];)
        signArray.push(memoryArray[begin]), begin += 1;
    sign = (new TextDecoder).decode(new Uint8Array(signArray));
    return sign;
}

经过测试,与网页上生成的结果一致。

像这种分析算法过程太复杂的还不如直接拿来用,直接拿来用才花了我一下午时间。而且这个 wasm 对 bom 和 dom 都没有检测,方便的很。


wasm相关教程: Nodejs调用扣取wasm某网站心跳包参数加密的wasm分析

Released under the MIT License.