某红书请求头x-s参数纯算
逆向参数: 请求头部参数x-s
这里的x-s参数分为两个部分,第一部分是固定的XYW,第二部分是一个base64的字符串,我们可以在浏览器控制台通过atob对这个base64字符串进行解码,解码后的结果为:
1 | '{"signSvn":"56","signType":"x2","appId":"xhs-pc-web","signVersion":"1","payload":"c72b7da9c62fd16a0dca20ecfe2e6487d037b22fc2aef47c8019048b76c9e9a96a33d6d8123094f01c69fdc732fbebd9a6aced375af518943b2363d86817644fed7fa17f77fa6580f911c4603d0ebe24f74a1bc47a39b4ee6fd7941d37614923e0c8fc14e4a908b43a0e410571a7316e8c7ce5ee0c25647b38e61501251b03a52f67f0449126b38768236d95078a3606354ff0e08dec829eac4716bdc41ee7d034e397d3d56759abf9ebb2d0a3eb73f8016c8d96c6219ce0f37e961d680f9c03bbf75904fc8b8b8cfa15ecf391a4daf6"}' |
可以看到解码后的字符串是一个json格式的字符串,所以接下来主要解决payload的参数加密问题
定位x-s参数的加密位置:
- 可以通过hook json的方式进行定位
1
2
3
4
5
6
7
8hook代码:
var _stringify = JSON.stringify;
JSON.stringify = function () {
if(arguments[0] && arguments[0]['payload']){
debugger;
}
return _stringify(this,arguments);
}; - 可以通过搜索关键字的方式进行定位
直接搜索关键字X-s,区分大小写后搜索出来的结果不多,可以很轻松的找到加密的入口
定位到加密入口后进入到加密函数,将代码整体拷贝到本地编辑器进行分析
这里可以看到类ob混淆的特征,L是一个解密函数,F是一个数组函数,然后有一个自执行函数将F函数传入对数组进行移位,最后的自执行函数中有一个b函数,我们可以先通过AST进行反混淆,然后再进行分析
AST反混淆
反混淆思路:
- 先处理进制数据
1
2
3
4
5
6
7
8
9
10
11
12
13const simplifyLiteral = {
NumericLiteral({node}) {
if (node.extra && /^0[obx]/i.test(node.extra.raw)) {
node.extra = undefined;
}
},
StringLiteral({node}) {
if (node.extra && /\\[ux]/gi.test(node.extra.raw)) {
node.extra = undefined;
}
},
}
traverse(ast, simplifyLiteral); - 将?.?还原成字面量
我们需要将所有依赖的对象复制到AST文件中,然后再编写插件进行字面量还原3.方法调用还原成字面量1
2
3
4
5
6
7
8
9
10
11const visitor = {
MemberExpression(path) {
let {node} = path
if (!(node.object.name && node.computed == false && node.property.name)) {
return
}
path.replaceWith(types.valueToNode(eval(`${node.object.name}[node.property.name]`)))
}
}
traverse(ast, visitor)
这里有很多地方对解密函数L进行了重复赋值,我们需要将解密函数以及解密函数依赖的数组函数和数组移位自执行函数都复制到AST文件中,然后再编写插件进行字面量还原1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48const funcList = ['L']
const callToString = {
VariableDeclarator(path) {
let {node, scope} = path
let {id, init} = node
if (!types.isIdentifier(id) || !types.isIdentifier(init)) {
return
}
if (!funcList.includes(init.name)) return
let binding = scope.getBinding(id.name)
if (!binding) return
let {constantViolations, referencePaths} = binding
if (constantViolations.length !== 0) return
let flag = false
for (const referPath of referencePaths) {
let parentPath = referPath.parentPath
if (!parentPath.isCallExpression({"callee": referPath.node})) continue
let {callee, arguments} = parentPath.node
if (!types.isIdentifier(callee)) continue
if (arguments.length !== 1 || !types.isNumericLiteral(arguments[0])) {
continue
}
let value = L(arguments[0].value)
console.log(parentPath.toString(), '-->', value)
parentPath.replaceWith(types.valueToNode(value))
flag = true
}
funcList.push(id.name)
flag && path.remove()
}
}
traverse(ast, callToString)
AST解混淆之后可以将解混淆之后的代码在浏览器上进行替换测试一下是否正常,正常说明解混淆没问题的
调试VMP代码
我们在浏览器中新建一个代码片段去调试我们解混淆之后的VMP代码,先将解混淆之后的代码粘贴到代码段中,我们从页面上拿一下请求参数下来进行测试
1 | let url = "/api/sns/web/v1/homefeed" |
点击运行代码后我们可以从控制台看到X-s的值已经打印出来了
插桩分析
一般对于VMP代码的插桩我们可以在apply的位置进行插桩
搜索apply关键字,一共可以搜索到三个为位置:
- 第一个位置是在数组中
- 第二位置这里有一个Function,应该也不是
1
i["SEoiV"](V, new (Function["prototype"]["bind"]["apply"](F4, F2))(), E, E, 0);
- 第三个位置这里应该就是我们需要插桩的位置了,可以在这个位置打上一个日志点
1
V(F3["apply"](typeof F2['_sabo_c724'] == C["IgyrF"] ? R : F2["_sabo_c724"], F1), E, E, 0);
1
"函数名称: ", F3, "this: ", typeof F2['_sabo_c724'] == C["IgyrF"] ? R : F2["_sabo_c724"], "参数: ", F1
日志分析
观察第一个关键点:
1 | url=/api/sns/web/v1/homefeed{"cursor_score":"","num":39,"refresh_type":1,"note_index":35,"unread_begin_note_id":"","unread_end_note_id":"","unread_note_count":0,"category":"homefeed_recommend","search_key":"","need_num":14,"image_formats":["jpg","webp","avif"],"need_filter_image":false} |
再往下翻日志可以看到一个数组[1732584193, -271733879, -1732584194, 271733878, 1030517365, 7, -680876936]
直接搜索一下发现数组的前四位1732584193, -271733879, -1732584194, 271733878是 MD5 算法的初始幻数(Initial Magic Numbers),对应 4 个 32 位寄存器(A、B、C、D)的初始值
后续数值可能的作用是:
- 输入分组的长度信息(如消息填充后的长度)
- 算法轮次中的中间变量(如循环左移位数或非线性函数的输出)
- 具体需结合上下文代码判断,但 MD5 的每一轮计算会涉及类似的位操作和常量
我们直接使用在线工具对以上字符串进行MD5加密,看看加密后的结果是什么,然后在打印的日志记录中搜索,发现搜索不到对应的字符串,我们接着往下翻日志,发现一下一段日志:
1 | 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [7] |
这里对字符串0123456789abcdef调用了32次的charAt方法,我们将这32个字符串拼接起来就是得到的MD5加密后的结果
观察第二个关键点:
看到了下一个明文字符串4uzjr7mbsibcaldp
,但是暂时不知道这个字符串对后续的加密有什么用处,先记录一下继续往下翻日志
观察第三个关键点:
继续往下翻日志可以看到往数组中push一个base64的字符串
接下来的日志就是对数组中的每一个字母进行charCodeAt操作,将charCodeAt后的结果再次push到一个新的数组中,我们直接往下翻找到最后的数组
这里可以看到是一个208位的数组,我们对数组fromCharCode操作一下,得到的结果就是一个base64的字符串
1 | String.fromCharCode(...[101, 68, 69, 57, 78, 50, 73, 122, 77, 50, 77, 50, 78, 122, 66, 107, 90, 109, 82, 104, 89, 84, 86, 108, 89, 84, 85, 51, 77, 87, 70, 107, 89, 84, 85, 48, 79, 87, 73, 120, 89, 122, 104, 108, 90, 71, 81, 55, 101, 68, 73, 57, 77, 72, 119, 119, 102, 68, 66, 56, 77, 88, 119, 119, 102, 68, 66, 56, 77, 88, 119, 119, 102, 68, 66, 56, 77, 72, 119, 120, 102, 68, 66, 56, 77, 72, 119, 119, 102, 68, 66, 56, 77, 88, 119, 119, 102, 68, 66, 56, …]) |
在上面解码的base64字符串中后面出现了很多的\b\b\b\b\b\b\b\b,查看日志发现是因为最后往数组的末尾push了8个8
这里push完8个8之后数组的长度变成了208位,208位的数组长度在密码学中可能是魔改的AES加密算法
观察第四个关键点:
往下查看日志后可以看到一个已经加密好的数组,将数组进行拼接其实就是我们需要的payload参数,那么现在最主要的就是解决如何进行加密得到最后的payload参数
核心加密逻辑分析
我们根据现有的日志没有办法再获取到更多的信息,我们继续观察插桩的位置,发现都是调用F3,而F3 = C[‘ckdoI’](j, F2),我们进入到ckdoI方法中,进入到ckdoI方法中发现调用了cAlyE方法,我们继续跟进去cAlyE方法,对cAlyE方法进行插桩
1 | 'cAlyE': function(C, f) { |
我们最后使用copy(window.log_result)的方式将日志拷贝下来到本地进行分析,我们先搜索一下字符串4uzjr7mbsibcaldp
,然后往下查看日志,发现进行了多次的charCodeAt和push操作,最后出现了一个数组52,117,122,106,114,55,109,98,115,105,98,99,97,108,100,112,其实这个数组就是将字符串4uzjr7mbsibcaldp
的每一个字符进行了charCodeAt操作然后得到的,长度是16位很有可能是做了异或操作
我们搜索52,117,122,106,114,55,109,98,115,105,98,99,97,108,100,112,定位到最后一次出现的位置继续往下翻可以看到一串base64的字符串eDE9N2IzM2M2NzBkZmRhYTVlYTU3MWFkYTU0OWIxYzhlZGQ7eDI9MHwwfDB8MXwwfDB8MXwwfDB8MHwxfDB8MHwwfDB8MXwwfDB8MDt4Mz0xOTZjY2UyYTQ2MzdleXRhdzltem16aWY3cXVvNWZ6NGswM3U0OXh6MjUwMDAwMjA5MDE0O3g0PTE3NDA3MTA5MDc5NTg7
,这个就是需要加密的base64字符串,我们继续往下翻就看到了熟悉的208位数组,那么接下来就要开始进行加密了
接下来取出了数组的前16位101,68,69,57,78,50,73,122,77,50,77,50,78,122,66,107,紧接着出现了另外一个16位的数组81,49,63,83,60,5,36,24,62,91,47,81,47,22,38,27,结合刚才我们对字符串4uzjr7mbsibcaldp
字符串charCodeAt操作得到的数组52,117,122,106,114,55,109,98,115,105,98,99,97,108,100,112可以发现如下特点:
1 | 101 ^ 52 = 81 |
所以可以确定数组52,117,122,106,114,55,109,98,115,105,98,99,97,108,100,112是一个iv,iv找到之后接下来就差key了
往下翻,81,49,63,83,60,5,36,24,62,91,47,81,47,22,38,27之后出现了很多的4位数,怀疑这里是将81,49,63,83,60,5,36,24,62,91,47,81,47,22,38,27进行了转换,我们对这个数组进行fromCharCode一下试试看
1 | String.fromCharCode(...[81, 49, 63, 83, 60, 5, 36, 24, 62, 91, 47, 81, 47, 22, 38, 27]) |
我们直接搜索1362181971,1006969880,1046163281,789980699,发现可以搜索到内容
搜索到之后往下查看日志发现有对数组中的4个元素进行异或操作
1 | 1362181971 ^ 929260340 = 1362181971 |
那么key的值为 929260340,1633971297,895580464,925905270
总结
- 将url和请求载荷进行拼接成字符串,将字符串进行MD5加密得到的结果
- 将字符串’x1=<md5值>;x2=0|0|0|1|0|0|1|0|0|0|1|0|0|0|0|1|0|0|0;x3=<cookie中a1的值>;x4=<时间戳>;’进行base64加密
- 将加密后的base64字符串进行AES加密
- 将字符串’{“signSvn”: “56”, “signType”: “x2”, “appId”: “ugc”, “signVersion”: “1”, “payload”: <aes加密后的字符串>}’进行base64加密
- 最终的base64值与字符串XYW_进行拼接