发现一个小程序有个签到,抓包对应网址发现其中有个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
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)非常不方便,可以在相应位置打上断点,然后直接在控制台输入,看返回值是什么。
o(459)
> 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'
把函数还原后,问了下 Chatgpt 这是什么算法,因为 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' 格式看起来好像和 UUID 有关系。
果然,这段代码的作用是生成一个随机的 UUID 值,既然是随机的那就好说了。
x-sign
sign 的值是40位字符串,盲猜是将时间戳,UUID 什么的进行SHA-1算出来的结果。
定位到相关代码,并还原参数。
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 分析
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。
$ wat2wasm test.wat -o test.wasm
接着把wasm文件读入。
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 去试试,直接运行发现没有报错,可以正常读到内存。
console.log(wasmMemory)
> Memory [WebAssembly.Memory] {}
导出函数
之后可以试着调用 getSign 函数了,随便输入点内存地址,可以正常输出40位字符串。
console.log(wasmObject.exports.getSign(66264, 66264, 66264, 66264, 66264, 66368, 5));
> 05685ab5d3b988de577bdee6689fbbc43fa9be62
接下来就是要想办法实现前面的 m 函数,即传入字符串生成对应ASCII数组并返回数组所在位置。
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 函数如下
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分析