注:此文章是基于 2024年6月12号的某红薯网页版进行分析的。此前没发现可以使用MarkDown编写,使用的默认的,所以代码有点混乱,将就的看吧
1. 前言
· 其实刚开始我也不知道jsvmp是什么,之前也没接触过jsvmp之类的东西,某红薯算是我第一个接触的项目。
· 我以前是做Windows逆向的,接触的是俄罗斯佬弄的VMProtect,像PC上的微信、企业微信等一些软件都经过VMProtect的加密,我的认知都是来源于这些,在Windows上的VMProtect有几种模式:
- 代码膨胀:一条汇编,用N多多条汇编代替实现,中间塞入很多毫无意义的汇编代码
- 虚拟化(真正的vmp):把Intel处理器能识别运行的机器码转成另一种字节码,然后用一个专有的虚拟机读取这些字节码进行解析执行
· 搜索了一些相关由于文章,已经有人逆向出源码了,所以直接上手逆向源码把,省略扣代码,补环境的过程(ps:我怎么可能告诉你,我不擅长扣代码呢)
2. 前期分析
由于网上版本都是不写时间的,所以以防万一,还是从头开始看协议是否有变化。刚开始的时候,我是登录以后,在发文章那里开始逆的,后来发现,其实停留在登录二维码扫码页面也会不断地被调用,所以下面就停留在扫码页面吧。
打开某红薯首页,点开登录页面如下确认:
如上图,可以正常看到X-S请求参数,那么我们继续,去找关键代码,赋值X-S的地方,搜索:["X-S"]。
接着双击搜索到的X-S赋值地方,如果有多个,可以一个个的分析,是否是属于算法出来以后赋值的地方,复杂的可能需要下断点从Call Stack往回找,庆幸某红薯直接算法出来就赋值了
找到如上代码以后,已经离成功很近了,接着在 var c = getRealUrl(r, n, o) 开始的三行都下个断点把,等断下来,
好了,现在代码已经断在外面下的第一行断点上了,此时按 F10(跳过函数细节单步),执行到下一条代码,此时代码为
, l = (a && void 0 !== window._webmsxyw ? window._webmsxyw : encrypt_sign)(c, i) || {};
看代码,其实我们并不清楚解析去是执行 window._webmsxyw 还是 encrypt_sign (我们Network都看到X-S有值了,所以不可能直接返回最后的 {} 的)。此时,可以鼠标移上 window._webmsxyw 看他是否有值,或者在Console输入 window._webmsxyw 看是否为null,
可以看到,不管是 window._webmsxyw 还是 encrypt_sign 都是一个函数,根据上面的代码如果 window._webmsxyw 不为空,那么就执行
l = window._webmsxyw(c, i);
而,window._webmsxyw 指向的函数为(双击一下Console 输出就可以调到对应函数,如果不清楚双击哪里,那么按 F11 (步入函数的单步)) _ace_1ae3c(function() { ...
此时,我们就在x-s算法核心部分了。
3. 分析jsvmp中X-S生成算法
此时,这整个js由于是jsvmp处理的,就不要尝试单步去跟踪了,除非你想逆jsvmp的vm实现。
解析去,可以稍微看下,整个文件的代码结构:
然后,再上下翻一翻vm的代码,可以看到很有规律的各种运算占大部分:
粗略看完,可以知道 _ace_34d1 是取操作数,_ace_1ae3c 是执行操作,我们知道各种算法代码无非是对数据进行 加减乘除、与或非抑或、左移右移、表替换 这些运算,那么我们把所有的这些操作都打印出来不就好了 (#^.^#),不知道是哪个小天才想出来的好方法。
接下去在当前文件中搜索 _ace_1ae3c(_ace_34d1(p0, p1), 在相关的算法上面进行下日志断点把,方法如下:
我下断点的几个方法(我并没有对==, && ,!=,>=,<=,instanceof,in 等做日志处理):
_ace_1ae3c(_ace_34d1(p0, p1) / _ace_34d1(p2, p3), _ace_be07c, _ace_be07c, 0);
_ace_1ae3c(_ace_34d1(p0, p1) & _ace_34d1(p2, p3), _ace_be07c, _ace_be07c, 0);
_ace_1ae3c(_ace_34d1(p0, p1) >>> _ace_34d1(p2, p3), _ace_be07c, _ace_be07c, 0);
_ace_1ae3c(_ace_34d1(p0, p1) % _ace_34d1(p2, p3), _ace_be07c, _ace_be07c, 0);
_ace_1ae3c(_ace_34d1(p0, p1) + _ace_34d1(p2, p3), _ace_be07c, _ace_be07c, 0);
_ace_1ae3c(_ace_34d1(p0, p1) * _ace_34d1(p2, p3), _ace_be07c, _ace_be07c, 0);
_ace_1ae3c(_ace_34d1(p0, p1) << _ace_34d1(p2, p3), _ace_be07c, _ace_be07c, 0);
_ace_1ae3c(_ace_34d1(p0, p1) - _ace_34d1(p2, p3), _ace_be07c, _ace_be07c, 0);
_ace_1ae3c(_ace_34d1(p0, p1) | _ace_34d1(p2, p3), _ace_be07c, _ace_be07c, 0);
_ace_1ae3c(_ace_34d1(p0, p1) >> _ace_34d1(p2, p3), _ace_be07c, _ace_be07c, 0);
_ace_1ae3c(_ace_34d1(p0, p1) ^ _ace_34d1(p2, p3), _ace_be07c, _ace_be07c, 0);
日志(把里面的?替换成对应的运算符即可):
"?运算:参数1:", _ace_34d1(p0, p1), "? 参数2:", _ace_34d1(p2, p3), " => ", _ace_34d1(p0, p1) ? _ace_34d1(p2, p3)
下完所有断点以后,先清空下Console,然后点击运行,等待运行结束,经过漫长的等待,浏览器会断到 e.headers["X-t"] = l["X-t"], 这一行
等待断在 e.headers["X-t"] = l["X-t"] ,可以把Console的输出日志保存一份,过程有些卡顿,耐心等一下即可
用notepad++或者VSCode打开,或你任意喜欢的编辑器都可以,我们拉到末尾看下:
是的,你没看错,日志有19万行,接下去就是苦逼的分析并还原工作了,不过这有些技巧,比如看到 eyJzaWduU3Z............... 先不要急着分析,这种一看就是Base64,这个转码工具或者网站进行转码,这里可以使用这个 Base64在线编码解码 。
看到这里,是不是很开心啊,直接用 {"signSvn 到日志里面搜索就可以了:
可以看到json是一个个字符拼接起来的(说明是一个个解码出来,大概率是前面就已经存在的),其中 signSvn、signType、
appId、signVersion都很好理解,但 payload 一大串十六进制,是哪里来的呢,直接拿出来搜索一下,发现是一个个byte转出来凑上去的,
再往上翻一点,有一片混乱编码,目前还不知道是什么,然后前面就是各种算法了
先别急着分析,我们猜测下,这个是什么,会不会是这个乱码转成的上面的十六进制字符串呢(其实就是),还记得我们的 payload 最后的字节码吗?,不记得翻上去看看:.....cf233254dee6443698d326051c0c8e3b760f3359
有没有发现,就是这里生成的呀?因为这里显示的是十进制,所以你看不出来,我标记下你就知道了
再看生成基本是右移8、16、24得来的,是不是32位取字节算法?我们直接拿个 1980707673 转成 十六进制看看,0x760f3359,是不是就是我们找到payload末尾呀?既然都是从32位出来的,那么我们拿最开始的32位出来找:0xf0170120,记得转成十进制搜索,4028039456,又因为日志里面有符号数,(取反+1: ( 0xFFFFFFFF ^ 0xf0170120 ) + 1 ),那么应该是搜索 -266927840
好了,记住这里的行数 146251,从这里往上找 ,看到charCodeAt 就是一个循环的算法了
等等,先别急着去逆算法,再看看这个参数是哪里来的(为了方便看,把中间charCodeAt的拼接算法删除了):
由于这些值都是出于一个32位数,所以先把前面的 0x65、0x44、0x45、0x39 组合起来看看,0x65444539,放入二进制文件看看?
是不是又有新的惊喜啦,拿这个字符串去搜索一下吧,"eDE9YWFi"
看着是不是还是像base64呀,所以,再解码看看
啊,这,大部分参数不就来了么,x3是内部版本号( 是Cookie里面的a1的值,可以使用 js_cookie.A.get(LOCAL_ID_KEY) 获取),x2固定值,x4时间搓,x1 这个是url的md5。
那,那,现在只需要逆向 145641 ~ 146239 的代码不就可以了吗 (#^.^#),
保姆级教程到此结束,剩下这部分逆向就交给各位啦,提示逆向可参考 js实现DES算法,这篇文章我验证过,算法大部分相似,稍微改动即可,各位爬虫er,干巴爹~~
4. 参考的文章:
5. 关于X-S-Common的算法逆向
这个X-S-Common应该是今年新加的,相对于上面的那个啥jsvmp,这个X-S-Common就弄起来很简单了。
首先,起步就是搜索 ["X-S-Common"],可以发现只有一个哦
找到关键代码:
e.headers["X-S-Common"] = encrypt_b64Encode(encrypt_encodeUtf8(stringify_default()(h)))
可以在Console里面先运行一下,各个函数的功能,比如查看 h 值,虽然上面就是 h 的赋值过程
可以看到 stringify_default、encrypt_encodeUtf8 都不需要分析,我们看下 encrypt_b64Encode 出来的是不是base64编码呀
很遗憾,对比发现,并不是base64,那么怎么办呢? 先进入 encrypt_b64Encode 函数看看,发现正常函数,并没有jsvmp加密
function encrypt_b64Encode(t) {
var e = 664
, r = 634
, n = 448
, o = 599
, i = 315
, a = 416
, u = 512
, s = 361
, c = 406
, l = 487
, f = 496
, p = 333
, h = 630
, d = 639
, v = 548
, g = 582
, m = 447
, y = 468
, w = 375
, b = 331
, _ = 149
, E = 382
, x = 265
, k = 625
, T = 570
, S = 551
, A = 582
, L = 581
, R = 638
, I = 618
, O = 606
, C = 429
, N = 651
, P = 667
, B = 817
, M = 333
, j = 567
, F = 747
, D = 561
, q = 570
, U = 676
, G = 840
, H = 240
, V = {
udFrB: function(t, e) {
return t % e
},
cCZFe: function(t, e) {
return t === e
},
jevwl: function(t, e) {
return t - e
},
aqlTy: function(t, e) {
return t + e
},
rceYY: function(t, e) {
return t >> e
},
OwjMq: function(t, e) {
return t & e
},
kSGXO: function(t, e) {
return t << e
},
veNiI: function(t, e) {
return t === e
},
QLthP: function(t, e) {
return t + e
},
wDtJz: function(t, e) {
return t + e
},
nYqUQ: function(t, e) {
return t & e
},
TCArD: function(t, e) {
return t << e
},
RHteb: function(t, e) {
return t - e
},
mZPJZ: function(t, e) {
return t < e
},
zDETq: function(t, e, r, n) {
return t(e, r, n)
},
YlZGp: function(t, e) {
return t > e
}
};
function W(t, e) {
return a0_0x10f4ac(e, t - -H)
}
for (var X = (W(-413, -442) + W(-e, -r) + "7")[W(-n, -o)]("|"), z = 0; ; ) {
switch (X[z++]) {
case "0":
var Y;
continue;
case "1":
var K = [];
continue;
case "2":
var J = V[W(-i, -a)]($, 3);
continue;
case "3":
var $ = t[W(-350, -u)];
continue;
case "4":
V[W(-s, -c)](J, 1) ? (Y = t[V[W(-l, -f)]($, 1)],
K[W(-p, -346)](V[W(-h, -d)](encrypt_lookup[V[W(-503, -v)](Y, 2)] + encrypt_lookup[V[W(-g, -741)](V[W(-331, -m)](Y, 4), 63)], "=="))) : V[W(-y, -w)](J, 2) && (Y = V[W(-b, -_)](t[$ - 2], 8) + t[V[W(-l, -E)]($, 1)],
K[W(-333, -x)](V[W(-k, -505)](V[W(-T, -S)](encrypt_lookup[Y >> 10], encrypt_lookup[V[W(-A, -L)](Y >> 4, 63)]) + encrypt_lookup[V[W(-R, -I)](V[W(-O, -C)](Y, 2), 63)], "=")));
continue;
case "5":
var Q = 16383;
continue;
case "6":
for (var Z = 0, tt = V[W(-509, -N)]($, J); V[W(-P, -B)](Z, tt); Z += Q)
K[W(-M, -153)](V[W(-j, -F)](encrypt_encodeChunk, t, Z, V[W(-D, -413)](Z + Q, tt) ? tt : V[W(-q, -501)](Z, Q)));
continue;
case "7":
return K[W(-U, -G)]("")
}
break
}
}
看着没有加密,要不,扣代码试试?但是扣完发现有个 esm_typeof.A 报错,我解决不了,有知道这个怎么解决的可以告诉我吗?这里,还是继续分析逆向吧,
从上面分析可以知道,W(-s, -c) 结果是 'cCZFe' ,对应的是上面的
cCZFe: function(t, e) {
return t === e
},
所以,这个代码可以精简成 t === e,在Console已经验证是一致的。所以可以把for循环整个解密替换以后
for (var X = '0|3|2|1|5|6|4|7'.split("|"), z = 0; ; ) {
switch (X[z++]) {
case "0":
var Y;
continue;
case "1":
var K = [];
continue;
case "2":
var J = $%3;
continue;
case "3":
var $ = t.length;
continue;
case "4":
(J === 1) ? (Y = t[$ - 1], K.push((encrypt_lookup[(Y >> 2)] + encrypt_lookup[((Y << 4) & 63)]) + "==")) :
(J === 2) &&
(Y = (t[$ - 2] << 8) + t[($ - 1)], K.push((encrypt_lookup[Y >> 10] + encrypt_lookup[((Y >> 4) & 63)]) + encrypt_lookup[((Y << 2) & 63)] + "="));
continue;
case "5":
var Q = 16383;
continue;
case "6":
for (var Z = 0, tt = ($ - J); (Z < tt); Z += Q)
K.push(encrypt_encodeChunk(t, Z, ((Z + Q) > tt) ? tt : (Z + Q)));
continue;
case "7":
return K.join("")
}
break
}
代码细细的看,可以发现主要功能是 encrypt_encodeChunk(t, Z, ((Z + Q) > tt) ? tt : (Z + Q)) ,以 0x3fff (16383)块大小进行加密。接下去同理分析 encrypt_encodeChunk 方法,精简如下:
function encrypt_encodeChunk(t, e, r) {
for (var _ = e; _ < r; _ += 3)
n = ((t[_] << 16) & 16711680) + ((t[_ + 1]<< 8) & 65280) + (t[(_ + 2)] & 255),
w.push(encrypt_tripletToBase64(n));
return w[b(-v, -g)]("")
}
这里代码看一下,把输入进来的字节码,三个三个转成一个数值以后传入 encrypt_tripletToBase64 加密再组合起来,验证这些可以在Console里面调用看看,是不是分析的一致。
接着打开 encrypt_tripletToBase64 分析(需要自己精简一下)如下:
function encrypt_tripletToBase64(t) {
var y = g;
return y[m(s, c)](encrypt_lookup[63 & y[m(l, -75)](t, 18)], encrypt_lookup[y[m(r, f)](y[m(p, h)](t, 12), 63)]) + encrypt_lookup[t >> 6 & 63] + encrypt_lookup[y[m(-a, -d)](t, 63)]
}
看到这里,豁朗开朗,终于到头了,这里只用到了 encrypt_lookup 表替换,我们看看这个表是什么把,直接Console输入
咿,好像是跟Base64算法一致,只是把表替换了,那么把我们猜测的用工具运行一下(可惜我的base64加密解密.exe不在身边,只能自己手动处理啦),这里就使用 "123456789' 吧
还记得前面的0x3fff限制吗,把整个数据拉进来验证下,以防有变
可以确定,最终 encrypt_b64Encode 算法只是替换了私有表的base64。再来看下参数来源吧
这里只有 x9 来源是需要经过 encrypt_mcr() 函数计算的,去逆向一下吧。
根据前面的经验,这里很容易就还原出了代码
// 生成crc32表
for (var H, V, W = U, X = 3988292384, z = 256, Y = []; z--; Y[z] = H)
for (V = 8, H = z; V--; )
H = (H & 1) ? (H >> 1) ^ X : (H >> 1);
// 计算hash
return function(t) {
function e(t, e) {
return G(e - 1181, t)
}
// 只需要看这部分
if ((typedef(t) == "string")) {
for (var r = 0, n = -1; r < t.length; ++r)
n = (Y[(n & 255) ^ t.charCodeAt(r)]) ^ (n >>> 8);
return (n ^ -1) ^ X
}
for (r = 0,
n = -1; (r < t[e(O, C)]); ++r)
n = (Y[((n & 255) ^ t[r])]) ^ (n >>> 8);
return ((n ^ -1) ^ X)
}
用 C语言写一个还原代码
unsigned int crc32_table[256];
void make_crc32_table()
{
unsigned int c;
int i = 0;
int bit = 0;
for(i = 0; i < 256; i++)
{
c = (unsigned int)i;
for(bit = 0; bit < 8; bit++)
{
if(c&1)
{
c = (c >> 1)^(0xEDB88320);
}
else
{
c = c >> 1;
}
}
crc32_table[i] = c;
}
}
unsigned int make_crc32(unsigned char *str, unsigned int size)
{
static bool binit = false;
if (!binit)
{
make_crc32_table();
binit = true;
}
unsigned int crc = 0xFFFFFFFF;
while(size--)
crc = crc32_table[(crc&0xFF) ^ *str++] ^ (crc >> 8);
return (crc^-1)^0xEDB88320;
}
验证一下:
好了,X-S-Common 逆向完毕
写好,测试下数据接口吧