网址:https://match2025.yuanrenxue.cn/match2025/topic/1

1、获取图片接口逆向分析


抓包分析,发现只有请求表单参数进行了加密

直接找到发起请求前的第一个栈打上断点

这里的a值已经生成了,本次我们采用补环境的方式来解决这道题,我们将js代码拉到本地进行调试分析

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
function myProxy(obj, name) {
return new Proxy(obj, {
//拦截对象属性的读取,比如proxy.foo和proxy['foo']。
get(target, propKey, receiver) {
let temp = Reflect.get(target, propKey, receiver)
console.log(`${name} -> get ${propKey} return -> ${temp}`)
if (typeof temp === 'object') {
temp = myProxy(temp, name + ' => ' + propKey)
}
return temp
},
//拦截对象属性的设置,比如proxy.foo = v或proxy['foo'] = v,返回一个布尔值。
set(target, propKey, value, receiver) {
const temp = Reflect.set(target, propKey, value, receiver)
console.log(`${name} -> set ${propKey} value -> ${value}`)
return temp
},
//拦截propKey in proxy的操作,返回一个布尔值。
has(target, propKey) {
const temp = Reflect.has(target, propKey)
console.log(`${name} -> has ${propKey}`)
return temp
},
//拦截delete proxy[propKey]的操作,返回一个布尔值。
deleteProperty(target, propKey) {
const temp = Reflect.defineProperty(target, propKey)
return temp
},
//拦截Object.getOwnPropertyNames(proxy)、Object.getOwnPropertySymbols(proxy)、Object.keys(proxy)、for...in循环,返回一个数组。该方法返回目标对象所有自身的属性的属性名,而Object.keys()的返回结果仅包括目标对象自身的可遍历属性。
ownKeys(target) {
const temp = Reflect.ownKeys(target)
return temp
},
//拦截Object.getOwnPropertyDescriptor(proxy, propKey),返回属性的描述对象。
getOwnPropertyDescriptor(target, propKey) {
const temp = Reflect.getOwnPropertyDescriptor(target, propKey)
return temp
},
//拦截Object.defineProperty(proxy, propKey, propDesc)、Object.defineProperties(proxy, propDescs),返回一个布尔值。
defineProperty(target, propKey, propDesc) {
const temp = Reflect.defineProperty(target, propKey, propDesc)
return temp
},
//拦截Object.preventExtensions(proxy),返回一个布尔值。
preventExtensions(target) {
const temp = Reflect.preventExtensions(target)
return temp
},
//拦截Object.getPrototypeOf(proxy),返回一个对象。
getPrototypeOf(target) {
const temp = Reflect.getPrototypeOf(target)
return temp
},
//拦截Object.isExtensible(proxy),返回一个布尔值。
isExtensible(target) {
const temp = Reflect.isExtensible(target)
return temp
},
//拦截Object.setPrototypeOf(proxy, proto),返回一个布尔值。如果目标对象是函数,那么还有两种额外操作可以拦截。
setPrototypeOf(target, proto) {
const temp = Reflect.setPrototypeOf(target, proto)
return temp
},
//拦截 Proxy 实例作为函数调用的操作,比如proxy(...args)、proxy.call(object, ...args)、proxy.apply(...)。
apply(target, object, args) {
const temp = Reflect.apply(target, object, args)
return temp
},
//拦截 Proxy 实例作为构造函数调用的操作,比如new proxy(...args)。
construct(target, args) {
const temp = Reflect.construct(target, args)
return temp
}
})
}

window = globalThis
myProxy(window, 'window')

location = {}
location = myProxy(location, 'location')

document = {}
document = myProxy(document, 'document')

navigator = {}
navigator = myProxy(navigator, navigator)

screen = {}
screen = myProxy(screen, 'screen')

我们先给浏览器常见的检测对象套上代理,然后执行代码开始补环境

出现报错,这里可以把window的原型链补一下,也可以直接把这里报错的代码直接改为true

这里提示$为定义,$在浏览器中是一个jquery库,我们通过在浏览器打断点的方式可以看到$下还有一个ajax的方法,我们直接在代码中给它定义一个空函数

1
2
3
4
5
6
7
$ = function() {
debugger
}

$.ajax = function() {
console.log(arguments)
}

定义完成后再次运行代码,出现打印信息

1
2
3
4
5
6
7
8
9
10
11
12
[Arguments] {
'0': {
url: '/match2025/topic/1_captcha_jpg',
dataType: 'json',
async: true,
type: 'POST',
data: {
a: 'CmCnJajVa4Wc9zEWAWbvNXvOXdyZdGadFacxDoSAeG1k2UBsWjPzgFLvhSPbY2exvsT8D2t2sC3/bzK5Z4k9hA=='
},
success: [Function (anonymous)]
}
}

我们直接导出这个a进行请求的话肯定是请求不通过的,因为在控制台中没有出现任何的浏览器对象的调用信息, 那么在代码中可能对浏览器对象进行了重新赋值,因为在浏览器中对浏览器对象进行重新赋值的话是无法生效的,但在我们的node环境中是可以生效的,那么我们就直接在代码中搜索浏览器对象的关键字

发现确实有重新赋值的地方,我们直接将这里的返回值修改成返回一个空对象即可,把所有对浏览器对象赋值的地方都修改一下
发现请求获取图片的接口还是不对,那么我们再次分析,发现每次请求接口图片前都会请求一个logo的接口,这个logo的接口响应值是一个时间戳参数,那么这个时间戳参数肯定和加密是有关系的,我们直接搜索Date相关的结果

直接页面上对应Date的位置打上断点

发现这里调用了Date.now,而且这个Date.now是被魔改过的,我们搜索一下Yr在哪里使用到了

发现在so方法中使用到了Yr,我们在页面对应的位置打上断点

a值就是在这里生成的,那么我们就需要在代码中修改一下Date.now方法,让Date.now方法返回我们请求logo时返回的时间戳,将代码封装成一个函数,将timestamp作为参数传递进来

1
2
3
4
5
6
function get_a(timestamp) {
Date.now = function() {
return timestamp
}
...
}

我们拿到a值请求获取图片的接口后发现可以请求成功,但是我们将接口返回的result值转为gif图片有时会出错,我们直接在页面上拿result的值和实际的图片base64的值进行比对发现实际渲染在页面的图片base64值和接口返回的result值不一致,说明在拿到result值之后还是会进行一次加密才的得到最后的图片值,那么我们如何快速定位到在哪进行的图片base64值加密的呢?

我们定位到图片的元素,在元素面板对图片元素进行右击,选择中断于子树修改和属性修改然后刷新页面

这里可以看到图片的base64值就已经生成了,我们直接分析堆栈

这里是在一个jquery中调用了一个方法然后进行了一系列的加密最终得到了加密值,我们直接跟进这个方法看看在哪

点击定位到了这里,看堆栈这个function是赋值给了Kf,那么我们将Kf存为一个全局函数然后进行调用就可以拿到我们的加密值了
在本地的代码中搜索到Kf的位置,然后保存到全局

我们先看一下调用Kf的函数需要传递哪些参数,把它赋值到我们的代码中来进行调用

这里传递了两个参数,其中t[1]是一个数组,我们将t[0]和t[1]都复制到本地然后都挂上代理来查看Kf函数都调用了参数中的哪些属性

1
window.Kf.apply(t0, t1)

调用后发现出现了一些新的环境检测,把需要的环境补上就好,通过代理打印的日志后发现只有t1参数中下标为0的参数被取值了,t1参数下标为0的元素就是请求图片接口返回的值

我们可以将请求图片接口的返回值作为参数传递进来进行加密

继续运行出现报错,这时我们回到浏览器

这里是往$对象中传递了一个#captchaImg然后返回了一个对象,对象中有一个attr函数,我们直接在本地进行模拟

1
2
3
4
5
6
7
8
9
10
$ = function (name) {
if (name == "#captchaImg") {
return {
attr: function() {
console.log(arguments)
}
}
}
debugger
}

这时就已经把图片加密的值打印出来了

2、验证码图片识别

我们识别gif图片的话需要将gif图片的帧进行分析,将停留时间最长帧的图片保存为png图片,然后通过ddddocr进行识别即可
至于如何将停留时间最长帧的图片保存可以直接使用ai帮助编写

3、验证接口逆向分析


对于验证接口的话也是只对请求表单参数进行了加密,那么我们在全扣代码的情况下要触发text加密就得观察页面上是怎样触发验证接口的

我们定位到输入框元素,发现这里有一个text_oninput(this)函数,那么这里肯定就是发起请求的入口了,但是我们并不知道text_oninput(this)函数在哪定义的我们该怎么做
我们直接通过油猴脚本来hook一下,看看它赋值的位置在哪

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ==UserScript==
// @name text_oninput
// @namespace http://tampermonkey.net/
// @version 2025-06-11
// @description try to take over the world!
// @author theone
// @match https://match2025.yuanrenxue.cn/match2025/topic/1
// @icon https://www.google.com/s2/favicons?sz=64&domain=bilibili.com
// @grant none
// @run-at document-start
// ==/UserScript==

(function() {
'use strict';
Object.defineProperty(window, 'text_oninput', {
set: function () {
debugger
}
})
})();


hook完成后发现我们在执行Kf函数的时候就已经将text_oninput函数赋值给了window对象,那么我们在执行完Kf函数的时候text_oninput函数就已经保存在全局了,我们需要关注的是text_oninput函数传参是怎么样的
我们直接对验证接口打上xhr断点看看

触发验证接口后直接来到最下面的堆栈查看,发现text_oninput函数传入的是一个input标签对象,其实input标签对象也可以看成是一个js对象,我们直接调用查看

1
text_oninput(myProxy({}, 'text_oninput'))

我们给text_oninput函数的参数套上代理,发现这里取了对象中的value值,那么我们来到页面上看看这个value值是什么

这里我们可以发现value值就是我们输入的验证码,我们将value值作为参数传递给text_oninput函数,然后就可以得到我们的加密值了
这个value的值就是我们输入框输入的内容,那么这个value值也可以通过传递参数的方式来进行接收,最后我们可以在$方法中把最后的值进行导出即可

最后还需要注意的就是在请求验证接口的时候对请求头的顺序进行了检测

1、抓包分析


抓包分析后发现接口请求的params参数和响应信息都是加密的,那么我们需要通过frida对加密参数进行分析

2、frida hook加密位置

我们首先通过hook HashMap的方式来看看是否能得到有用的信息

1
2
3
4
5
6
7
function hookMap() {
var hashMap = Java.use('java.util.HashMap')
hashMap.put.implementation = function (key, value) {
console.log(`key: ${key}, value: ${value}`)
return this.put(key, value)
}
}

hook完成之后发现并没有获取到有用的信息,那么我们接着尝试hook json的方式

1
2
3
4
5
6
7
8
9
10
11
12
function hookJson() {
var JSONObject = Java.use('org.json.JSONObject');
// Hook JSONObject.put() 方法
JSONObject.put.overload('java.lang.String', 'java.lang.Object').implementation = function (key, value) {
console.log('Hooked JSONObject.put()');
console.log('Key: ' + key.toString());
console.log('Value: ' + value.toString());
// 可在此处对参数进行修改或记录
// 调用原始的put()方法
return this.put(key, value);
};
}

通过hook json的这种方式也并没有获取到有用的信息,那么我们接着尝试hook StringBuilder的方式

1
2
3
4
5
6
7
8
function hookStrBuilder() {
var stringBuilderClass = Java.use("java.lang.StringBuilder");
stringBuilderClass.toString.implementation = function () {
var res = this.toString.apply(this, arguments)
console.log('tostring is called ', res)
return res
}
}

通过hook StringBuilder的方式发现了有用的信息

1
tostring is called  /v3/article/lists.json?zqkd_param=wXU6PBNNsHKc=Rse1ImYBpFW8PwGnvfcNJa5Yg4UIJOenwoUuu7mUCt0kEXDj5ACzfcISZdrlvya9h9ijWacMRP8N7qvjYgQNzL45veAD9SZlcNH7QJiW-AyOu-HKB7OjeSRZjvPBBbhhbTTyw1eCHnMU1uJ5rY0g4DsUz7lEmml-PRkpa-F1BmjpS2U6j9kmZC3j04W40ZV6Btru9WfGSEtyo55F50BGtVWw4gDRYBrqDVK3R4yTIlg4WcMjD_UWWS_LqaLpL-LgBIdx8PpPfoAqmkM6cOo-DdD-Sb5IZNA_-aRQJVT_RoscdZPNjM8u5Si_x_MSUwe16vJMr7rCd3_D5i9lNAQDlqAGfEkcgPQkWll25ietvQOJqZgTdv9L0Ko85-u7yPOwliw2rStygqCQXqOZ4j0m2UBpRoMQNJyGNO1tuK6zl-iqsCg-G7ChY15A6rAZhJzxBQ-6UTv_BLRrpRs-bQ2oV_eQif_2zDq4BvFsJ36xpfTrmu0eoclncG-Qmx8kyakYqk2Q_9TKO517I3G3P5hIfK4pPb0GDzgZH2fQ9VtkaWWMdHyCqcxlKRBd0lvQBqE8Gs7LivC1bVg1iCZNXQhiDUzjEUOjopcpoWullznXXRsje7qK87rQpKFKP85n24ppAbmBrT5kgnGkd8s9AvBM5yK-yMq3sDyhU-kJIgDDnKFIZM6dK9tFZYNuDH3SUw0CZ607P0CUz1X-CPmPD3AyHfFJyjUe_wDCwxmbsweN_TnUeP57IA4g-HHwOnEcBwS3LBOthXVepwDCjn5rFk4Jy8-XUAou-gRO7B6o-Tpb3kZcXH7T3ur0C150l-swLgYq1Wliep2WZLLrNAXSMPxzUflN5g5ATLTXCcAPYEmaO6f43GHGActJlY967F23PWQh7bG510CfNtnpHHa1UgfYQgeTy4el3pGuxB4YpN2YCFl074efcPCQU3LzwiMsbAQHOS3jwhfumesz-JGJrsfyxgLPr31HBmsswFZpDtjoVpUWFM8J7wHcdO1zdvlnACoRdeIYUTNibXUpRf80ONyt1PXalttPuHJpFgcR5EIXdSNVhAj4yNZEZTa0ot2TE7cxWsbNlP0U7UNRhxMAifcMbENf85nUtxrFdGDaMeQg8V_KN61_FELDhiI0Q_N8LhcWyc18Eyr-r153Gs6JF3uA02HUkKKRM5nzMJrywO5syHBvnSb80Wg2tjIiKJvwYE0ZY7bOHiv6Gbr6fX-hgWMTJ4777mClRdCJ5p0Q4eAvRskgkNwmavuw6lw1QwQwpVepPGl4GpbmZMTm6QXBrGMqcdUMrDuzKCgMoAZV7yhL8gk=

那么我们可以调整一下hook脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function showStacks() {
Java.perform(function () {
console.log(Java.use("android.util.Log").getStackTraceString(
Java.use("java.lang.Throwable").$new()
));
})
}

function hookStrBuilder() {
// 获取 StringBuilder 类并定义需要 Hook 的方法名
var stringBuilderClass = Java.use("java.lang.StringBuilder");
stringBuilderClass.toString.implementation = function () {
var res = this.toString.apply(this, arguments)
if (res.includes('zqkd_param')) {
showStacks()
console.log('tostring is called ', res)
}
// console.log('tostring is called ', res)
return res
}
}

如果字符串中包含zqkd_param关键字则打印堆栈信息,我们再次触发接口获取到对应的日志信息如下:

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
tostring is called  https://kandian.wkandian.com/v3/article/lists.json?zqkd_param=wXU6PBNNsHKc=Rse1ImYBpFW8PwGnvfcNJa5Yg4UIJOenwoUuu7mUCt0kEXDj5ACzfcISZdrlvya9h9ijWacMRP8N7qvjYgQNzL45veAD9SZlcNH7QJiW-AyOu-HKB7OjeSRZjvPBBbhhbTTyw1eCHnMU1uJ5rY0g4DsUz7lEmml-PRkpa-F1BmjpS2U6j9kmZC3j04W40ZV6Btru9WfGSEtyo55F50BGtVWw4gDRYBrqDVK3R4yTIlg4WcMjD_UWWS_LqaLpL-LgBIdx8PpPfoAqmkM6cOo-DdD-Sb5IZNA_-aRQJVT_RoscdZPNjM8u5Si_x_MSUwe16vJMr7rCd3_D5i9lNAQDlqAGfEkcgPQkWll25ietvQOJqZgTdv9L0Ko85-u7yPOwliw2rStygqCQXqOZ4j0m2UBpRoMQNJyGNO1tuK6zl-iqsCg-G7ChY15A6rAZhJzxBQ-6UTv_BLRrpRs-bQ2oV_eQif_2zDq4BvFsJ36xpfTrmu0eoclncG-Qmx8kyakYqk2Q_9TKO517I3G3P5hIfK4pPb0GDzgZH2fQ9VtkaWWMdHyCqcxlKRBd0lvQBqE8Gs7LivC1bVg1iCZNXQhiDUzjEUOjopcpoWullznXXRsje7qK87rQpKFKP85n24ppAbmBrT5kgnGkd8s9AvBM5yK-yMq3sDyhU-kJIgDDnKFIZM6dK9tFZYNuDH3SUw0CZ607P0CUz1X-CPmPD3AyHfFJyjUe_wDCwxmbsweN_TnUeP57IA4g-HHwOnEcBwS3LBOthXVepwDCjn5rFk4Jy8-XUAou-gRO7B6o-Tpb3kZcXH7T3ur0C150l-swLgYq1Wliep2WZLLrNAXSMPxzUflN5g5ATLTXCcAPYEmaO6f43GHGActJlY967F23PWQh7bG510CfNtnpHHa1UgfYQgeTy4el3pGuxB4YpN2YCFl074efcPCQU3LzwiMsbAQHOS3jwhfumesz-JGJrsfyxgLPr31HBmsswFZpDtjoVpUWFM8J7wHcdO1zdvlnACoRdeIYUTNibXUpRf80ONyt1PXalttPuHJpFgcR5EIXdSNVhAj4yNZEZTa0ot2TE7cxWsbNlP0U7UNRhxMAifcMbENf85nUtxrFdGDaMeQg8V_KN61_FELDhiI0Q_N8LhcWyc18Eyr-r153Gs6JF3uA02HUkKKRM5nzMJrywO5syHBvnSb80Wg2tjIiKJvwYE0ZY7bOHiv6Gbr6fX-hgWMTJ4777mClRdCJ5p0Q4eAvRskgkNwmavuw6lw1QwQwpVepPGl4GpbmZMTm6QXBrGMqcdUMrDuzKCgMoAZV7yhL8gk=
java.lang.Throwable
at java.lang.StringBuilder.toString(Native Method)
at okhttp3.internal.http.RequestLine.requestPath(RequestLine.java:62)
at okhttp3.internal.http.RequestLine.get(RequestLine.java:40)
at okhttp3.internal.http1.Http1Codec.writeRequestHeaders(Http1Codec.java:128)
at okhttp3.internal.http.CallServerInterceptor.intercept(CallServerInterceptor.java:50)
at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.java:147)
at okhttp3.internal.connection.ConnectInterceptor.intercept(ConnectInterceptor.java:45)
at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.java:147)
at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.java:121)
at okhttp3.internal.cache.CacheInterceptor.intercept(CacheInterceptor.java:93)
at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.java:147)
at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.java:121)
at okhttp3.internal.http.BridgeInterceptor.intercept(BridgeInterceptor.java:93)
at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.java:147)
at okhttp3.internal.http.RetryAndFollowUpInterceptor.intercept(RetryAndFollowUpInterceptor.java:126)
at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.java:147)
at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.java:121)
at cn.youth.news.network.api.YouthNetworkInterceptor.youthIntercept(YouthNetworkInterceptor.kt:246)
at cn.youth.news.network.api.YouthNetworkInterceptor.intercept(YouthNetworkInterceptor.kt:62)
at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.java:147)
at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.java:121)
at cn.youth.news.network.api.LogInterceptor.intercept(LogInterceptor.java:219)
at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.java:147)
at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.java:121)
at okhttp3.RealCall.getResponseWithInterceptorChain(RealCall.java:250)
at okhttp3.RealCall.execute(RealCall.java:93)
at retrofit2.l.a(OkHttpCall.java:186)
at retrofit2.a.a.c.b(CallExecuteObservable.java:45)
at io.reactivex.m.a(Observable.java:12267)
at retrofit2.a.a.a.b(BodyObservable.java:34)
at io.reactivex.m.a(Observable.java:12267)
at io.reactivex.internal.e.e.an$b.run(ObservableSubscribeOn.java:96)
at io.reactivex.internal.g.l.call(ScheduledDirectTask.java:38)
at io.reactivex.internal.g.l.call(ScheduledDirectTask.java:26)
at java.util.concurrent.FutureTask.run(FutureTask.java:266)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
at java.lang.Thread.run(Thread.java:764)
at com.blankj.utilcode.util.z$e$1.run(ThreadUtils.java:1150)

根据日志中的堆栈信息我们可以跟进cn.youth.news.network.api.YouthNetworkInterceptor.youthIntercept这个方法中去看看对应的逻辑

3、jadx分析定位加密位置

搜索进入到youthIntercept方法

getExtraParams方法是一个比较可疑的方法,我们步入进去查看

getExtraParams方法应该是将加密参数添加进TreeMap中去,我们可以hook一下getExtraParams方法最终返回的TreeMap的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function hookGetExtraParams() {
let YouthNetworkInterceptor = Java.use("cn.youth.news.network.api.YouthNetworkInterceptor");
YouthNetworkInterceptor["getExtraParams"].implementation = function (i) {
console.log(`YouthNetworkInterceptor.getExtraParams is called: i=${i}`);
let result = this["getExtraParams"](i);
console.log(`YouthNetworkInterceptor.getExtraParams result=${result}`);
var key = result.keySet() // 这一行获取了Map对象的键集合
var it = key.iterator();
var count = 0
while (it.hasNext()) {
var keystr = it.next()
var valuestr = result.get(keystr)
console.log(`${count + 1}. ${keystr}: ${valuestr}`)
count++
}
return result;
};
}

日志输出:

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
1. ab_client:
2. ab_feature:
3. ab_version:
4. access: WIFI
5. app-version: 4.0.0
6. app_name: zqkd_app
7. app_version: 4.0.0
8. carrier: CHINA MOBILE
9. channel: c4015
10. device_brand: realme
11. device_id: 83790068
12. device_model: RMX3843
13. device_platform: android
14. device_type: android
15. dpi: 640
16. inner_version: 202203070952
17. jssdk_version:
18. language: zh-CN
19. memory: 3
20. mi: 0
21. mobile_type: 1
22. network_type: WIFI
23. oaid:
24. openudid: c71abbf59d5f8b13
25. os_api: 28
26. os_version: PQ3A.190705.05091555 release-keys
27. request_time: 1748938801
28. resolution: 1440x2560
29. rom_version: PQ3A.190705.05091555 release-keys
30. s_ad: tXU6PBNNsHKc=RhyCH9tvHyWred1oAcZFwPDUT1utyFcH
31. s_im: lXU6PBNNsHKc=kBxJn_kMptmu0oSq2gTyPg==DR
32. sim: 1
33. sm_device_id: 2025052611502897c113ea1f79fa4b89bd6ae043101a0d01ab161637167f19
34. storage: 32.73
35. uid: 88523216
36. version_code: 74
37. zqkey: MDAwMDAwMDAwMJCMpN-w09Wtg5-Bb36eh6CPqHualq2jmrCarWayp4myhLJ-3q_OqmqXr6NthJl7mI-shMmXeqDau4StacS3o7GFsoaars-uq4J5fWmEY2Ft
38. zqkey_id: 55b8c6713f25a80e4afb735753d87e14

继续往下看,找到构建请求前的加密结果

步入到encrypt方法中

这里可以看到初始化了一个StringBuilder对象,然后对arrayList中的数据进行循环遍历取值然后进行拼接,拼接成key1=value1&key2=value2这样的字符串,然后将拼接后的字符串传入SecurityHelper.encryptDES方法中进行加密

4、加密算法分析

我们直接hook对应的DES传入的key和data值看看传入加密的字符串是什么

1
2
3
4
5
6
7
8
9
function hookEncryptDes() {
let SecurityHelper = Java.use("cn.youth.news.utils.SecurityHelper");
SecurityHelper["encryptDES"].implementation = function (str, str2) {
console.log(`SecurityHelper.encryptDES is called: str=${str}, str2=${str2}`);
let result = this["encryptDES"](str, str2);
console.log(`SecurityHelper.encryptDES result=${result}`);
return result;
};
}

日志输出:

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
str=6HPjSZFH8RLv4dBj0YlmC2wFUySX, str2=access=WIFI&app-version=4.0.0&app_name=zqkd_app&app_version=4.0.0&carrier=CHINA+MOBILE&channel=c4015&device_brand=realme&device_id=83790068&device_model=RMX3843&device_platform=android&device_type=android&dpi=640&inner_version=202203070952&language=zh-CN&memory=3&mi=0&mobile_type=1&network_type=WIFI&openudid=c71abbf59d5f8b13&os_api=28&os_version=PQ3A.190705.05091555+release-keys&phone_sim=1&request_time=1749032877&resolution=1440x2560&rom_version=PQ3A.190705.05091555+release-keys&s_ad=sYdVi_XPUOzA%3D9T1NsxwRsf6yGN2cfL26dQVFZJ4e2aZlhS&s_im=7YdVi_XPUOzA%3DStkU0MGsTl9hM-LhDCDUCQ%3D%3Dez&sim=1&sm_device_id=2025052611502897c113ea1f79fa4b89bd6ae043101a0d01ab161637167f19&storage=32.73&uid=88523216&version_code=74&zqkey=MDAwMDAwMDAwMJCMpN-w09Wtg5-Bb36eh6CPqHualq2jmrCarWayp4myhLJ-3q_OqmqXr6NthJl7mI-shMmXeqDau4StacS3o7GFsoaars-uq4J5fWmEY2Ft&zqkey_id=55b8c6713f25a80e4afb735753d87e14&catid=20030&op=0&behot_time=1749032741251&oid=0&video_catid=1453&sign=0154d6b73a7fee7de3940294328c9648
SecurityHelper.encryptDES result=YdVi_XPUOzA=cmEU2wd-Oc7Ne4apw1WrWujGkpodE-ali3LriI_3CUsbRE_85-fWvvjL_7kT0JI-WFkZakMEukUwuk2cEW6MTxtBn6RNxUkWkuivVHFuLcWk5y2djViVJIpDbO1pftSiZpXK3c5QnACRXz0Htki2PZ6ik6QJfBHyRPO2VmYVIhUfiNM-xuP53uTn7Dg8hMwJP3xacZYA_E6EiJE7U92ufPHECTjD1v9uipzFWSGWrZFWuuZc22xTvQkd6CbaJD8MxmfrYNGujvlwDHJkarxjdH9AT0CffaQD461Jd3elrLBsAKfnj5nut7GX4k5LeCDr3jIVU9hc7avkfhFMALzkBjLtYhTxPwaFOmL56OnnpryMnDdyoYOhN_Gi0RZlmx1RzWoybElXKR9uLxuAkOGnRVKz3Yftg8MxlFLwVCYy5CcQR0F6JiPFbxy_XLIUJhJAGGPFCnohVYlMb3aO8tDEiJqW-1EJnQOw8BsKOJ3BEgS44nHd3ATWFNR7hQ4fFakTJgG4gut2ZtkuSZ5lV6Krjwo18bEMH3O7gNhCUtvpemHLErweMACCbBOemQNOBMEPaRasRQUJlYxXjLyw_HBfRLPdFkR5tEEJWAtd2ff1ikuo2gh8ss1Lf6VsOVTawVrlDjeqFRBTicMw5SEMGaSHuuopLN29kbX5XkBAVNP5A55UhLb2g9tKJV682ZzW5_OO3zb2VzQghMGBlHWA3nZxRbozJbDadVrucusn1l9gbjy7qqc5_jELYOmg97iJR3zqH_7A5Oc9VF-UukNyq7Kvj08xOCN7uxe--FZCTIsK05i_n6ARRexRoHc3wHtmOuzw79tieiYI84osPnus0_Zsf0Huhe8KTNUUq5931ehla338BjIjOci2YJ2Y0NAMzclB_bcsFXXjq1L6X3MbXf2f8o1ABmQiCmq1hsefbFl1nEn5AABOTIdvVcQqr9os2XlMQ5_rsihn4xCr51wG68nHXgYoBZD4tgCwSq63_POFT0DRSDmYfODcE2OkO_lo1_GHvjagNHKCk_xC35OdsNV9zAPfSWrw43-vuZ4HId43nZ6lLsZTK6YhDp53lh4Vm9IDebMgDOtnjVxMyKUMFOctA3tQCbAMHLHojuNOsHxS0eloVzrFMwHSDM435qumcpKR9vDeSZHllicFEnI5hMD8p7UP0sEcG4Elc4Kthu5NgEolIGfPOeWzDELV0svS0cwNGkf4HRrrnEHjIKU3AinWvJzc4PnOMDLdPqEfcbinbqGJztNp8NzMb785zxpuuAK68DS9danKjSgGb6SZi-lmGR65hDQy7y8MoeRspFFqs_0=

将加密字符串转换为python字典
params = {
"access": "WIFI",
"app-version": "4.0.0",
"app_name": "zqkd_app",
"app_version": "4.0.0",
"carrier": "CHINA MOBILE",
"channel": "c4015",
"device_brand": "realme",
"device_id": "83790068",
"device_model": "RMX3843",
"device_platform": "android",
"device_type": "android",
"dpi": "640",
"inner_version": "202203070952",
"language": "zh-CN",
"memory": "3",
"mi": "0",
"mobile_type": "1",
"network_type": "WIFI",
"openudid": "c71abbf59d5f8b13",
"os_api": "28",
"os_version": "PQ3A.190705.05091555 release-keys",
"phone_sim": "1",
"request_time": "1749032877",
"resolution": "1440x2560",
"rom_version": "PQ3A.190705.05091555 release-keys",
"s_ad": "sYdVi_XPUOzA=9T1NsxwRsf6yGN2cfL26dQVFZJ4e2aZlhS",
"s_im": "7YdVi_XPUOzA=StkU0MGsTl9hM-LhDCDUCQ==ez",
"sim": "1",
"sm_device_id": "2025052611502897c113ea1f79fa4b89bd6ae043101a0d01ab161637167f19",
"storage": "32.73",
"uid": "88523216",
"version_code": "74",
"zqkey": "MDAwMDAwMDAwMJCMpN-w09Wtg5-Bb36eh6CPqHualq2jmrCarWayp4myhLJ-3q_OqmqXr6NthJl7mI-shMmXeqDau4StacS3o7GFsoaars-uq4J5fWmEY2Ft",
"zqkey_id": "55b8c6713f25a80e4afb735753d87e14",
"catid": "20030",
"op": "0",
"behot_time": "1749032741251",
"oid": "0",
"video_catid": "1453",
"sign": "0154d6b73a7fee7de3940294328c9648"
}

那么我们就已经确定了需要的参数,其中s_ad、s_im和sign参数是需要我们进一步分析的,request_time是时间戳参数,其他的参数都是固定不变的,接下来直接对算法进行分析

这里对key进行了一个MD5加密,然后对拼接的字符串进行了DES加密,然后将两个字符串拼接得到最终的字符串

RandomValidateCode.getDisturbString方法的逻辑是:

  • 将一个字符添加在字符串前面。
  • 根据该字符的 ASCII 值决定要在末尾添加几个随机字符(0~2 个)

经过以上的操作后就得到了最终的zqkd_param的值

5、加密参数分析

5.1 sign参数分析


回到最开始的位置,发现这里signature方法加密后将signature方法返回的Pair属性的first属性作为Map的键,signature方法返回的Pair属性的second属性作为Map的值
我们直接跟进signature方法

这里我们看到了两处sign的值,这里对i做了判断,根据i的值来决定sign在哪进行返回,,我们直接hook signature方法看看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function hookSignature() {
let YouthNetworkInterceptor = Java.use("cn.youth.news.network.api.YouthNetworkInterceptor");
YouthNetworkInterceptor["signature"].implementation = function (map, i) {
console.log(`YouthNetworkInterceptor.signature is called: map=${map}, i=${i}`);
var key = map.keySet() // 这一行获取了Map对象的键集合
var it = key.iterator();
var count = 0
while (it.hasNext()) {
var keystr = it.next()
var valuestr = map.get(keystr)
console.log(`${count + 1}. ${keystr}: ${valuestr}`)
count++
}
let result = this["signature"](map, i);
console.log(`YouthNetworkInterceptor.signature result=${result}`);
return result;
};
}

日志输出:

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
YouthNetworkInterceptor.signature is called: map=[object Object], i=3
1. ab_client:
2. ab_feature:
3. ab_version:
4. access: WIFI
5. app-version: 4.0.0
6. app_name: zqkd_app
7. app_version: 4.0.0
8. carrier: CHINA MOBILE
9. channel: c4015
10. device_brand: realme
11. device_id: 83790068
12. device_model: RMX3843
13. device_platform: android
14. device_type: android
15. dpi: 640
16. inner_version: 202203070952
17. jssdk_version:
18. language: zh-CN
19. memory: 3
20. mi: 0
21. mobile_type: 1
22. network_type: WIFI
23. oaid:
24. openudid: c71abbf59d5f8b13
25. os_api: 28
26. os_version: PQ3A.190705.05091555 release-keys
27. phone_sim: 1
28. request_time: 1749032385
29. resolution: 1440x2560
30. rom_version: PQ3A.190705.05091555 release-keys
31. s_ad: hYdVi_XPUOzA=9T1NsxwRsf6yGN2cfL26dQVFZJ4e2aZlL
32. s_im: ZYdVi_XPUOzA=StkU0MGsTl9hM-LhDCDUCQ==
33. sim: 1
34. sm_device_id: 2025052611502897c113ea1f79fa4b89bd6ae043101a0d01ab161637167f19
35. storage: 32.73
36. uid: 88523216
37. version_code: 74
38. zqkey: MDAwMDAwMDAwMJCMpN-w09Wtg5-Bb36eh6CPqHualq2jmrCarWayp4myhLJ-3q_OqmqXr6NthJl7mI-shMmXeqDau4StacS3o7GFsoaars-uq4J5fWmEY2Ft
39. zqkey_id: 55b8c6713f25a80e4afb735753d87e14
40. catid: 20030
41. op: 0
42. behot_time: 1749031397776
43. oid: 0
44. video_catid: 1453
YouthNetworkInterceptor.signature result=Pair{sign 9a40f9786b729d500a7b4076a6e6bc9b}

这里可以看到i=3,那么返回sign的位置应该是 return new Pair<>("sign", EncryptUtils.getMD5(sb.toString() + ZQNetUtils.KEY));

那么我们现在需要关注sb参数是怎么样的,我们这里直接对md5算法进行hook,看看传入的值是什么

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
function bytesToString(arr) {
if (typeof arr === 'string') {
return arr;
}
var str = '',
_arr = arr;
for (var i = 0; i < _arr.length; i++) {
var one = _arr[i].toString(2),
v = one.match(/^1+?(?=0)/);
if (v && one.length == 8) {
var bytesLength = v[0].length;
var store = _arr[i].toString(2).slice(7 - bytesLength);
for (var st = 1; st < bytesLength; st++) {
store += _arr[st + i].toString(2).slice(2);
}
str += String.fromCharCode(parseInt(store, 2));
i += bytesLength - 1;
} else {
str += String.fromCharCode(_arr[i]);
}
}
return str;
}

function hookMd5() {
let EncryptUtils = Java.use("cn.youth.news.utils.EncryptUtils");
EncryptUtils["getMD5"].overload('[B').implementation = function (bArr) {
console.log(`EncryptUtils.getMD5 is called: bArr=${bytesToString(bArr)}`);
let result = this["getMD5"](bArr);
console.log(`EncryptUtils.getMD5 result=${result}`);
return result;
};
}

日志输出:

1
2
3
4
EncryptUtils.getMD5 is called: bArr=access=WIFIapp-version=4.0.0app_name=zqkd_appapp_version=4.0.0behot_time=1749032328595carrier=CHINA MOBILEcatid=20030channel=c4015device_brand=realmedevice_id=83790068device_model=RMX3843device_platform=androiddevice_type=androiddpi=640inner_version=202203070952language=z
h-CNmemory=3mi=0mobile_type=1network_type=WIFIoid=0op=0openudid=c71abbf59d5f8b13os_api=28os_version=PQ3A.190705.05091555 release-keysphone_sim=1request_time=1749032631resolution=1440x2560rom_version=PQ3A.190705.05091555 release-keyss_ad=6YdVi_XPUOzA=9T1NsxwRsf6yGN2cfL26dQVFZJ4e2aZl7s_im=kYdVi_XPUOzA=StkU0MG
sTl9hM-LhDCDUCQ==Dsim=1sm_device_id=2025052611502897c113ea1f79fa4b89bd6ae043101a0d01ab161637167f19storage=32.73uid=88523216version_code=74video_catid=1453zqkey=MDAwMDAwMDAwMJCMpN-w09Wtg5-Bb36eh6CPqHualq2jmrCarWayp4myhLJ-3q_OqmqXr6NthJl7mI-shMmXeqDau4StacS3o7GFsoaars-uq4J5fWmEY2Ftzqkey_id=55b8c6713f25a80e4afb735753d87e14jdvylqchJZrfw0o2DgAbsmCGUapF1YChc
EncryptUtils.getMD5 result=6ae715eb3e9d8dac48e7449f19982101

这里可以看出是将请求参数先根据键名进行排序,排序完成后通过key=value的方式一次拼接,最后再和字符串jdvylqchJZrfw0o2DgAbsmCGUapF1YChc拼接得到最终的字符串,将最终的字符串进行md5加密就得到了最后的sign值

5.2 s_ad和s_im参数分析

我们找到刚开始看到的getExtraParams方法,步入进去查看s_ad和s_im的值是怎么来的

这两个值看起来是获取到一些设备的信息然后通过getSecurityParamsByDES方法进行加密,那么暂时先将这两个参数写死

6、整体加密逻辑梳理

  • 先将请求参数按照key排序拼接为key=value的形式,再和字符串jdvylqchJZrfw0o2DgAbsmCGUapF1YChc进行拼接,将最终的字符串进行md5加密得到sign的值
  • 将请求参数及加密后的sign参数拼接成字符串(key=value&key2=value2的形式),把拼接的字符串进行DES加密
  • 最后通过getDisturbString方法在DES算法加密的值前后随机添加字符串

逆向目标: 登录接口请求参数Encrypt逆向分析

1、抓包分析


点击登录通过charles抓包,看到登录接口的请求参数中存在一个加密参数Encrypt

2、通过jadx定位加密位置

jadx打开apk文件,直接通过搜索关键字的方式进行定位

这里搜索出来很多结果,优先去看跟apk包名相关的位置,这里发现了两处可疑的位置,点击进入到源码位置

3、通过frida hook的方式确定是否是加密位置

以上的Encrypt参数是通过encrypt进行赋值的,encrypt在上方进行的赋值

1
String encrypt = RequestUtil.encodeDesMap(code, this.desKey, this.desIV);

我们可以通过hook RequestUtil类下的encodeDesMap方法来得到encrypt的值

hook代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function hook() {
let RequestUtil = Java.use("com.dodonew.online.http.RequestUtil");
RequestUtil["encodeDesMap"].overload('java.lang.String', 'java.lang.String', 'java.lang.String').implementation = function (data, desKey, desIV) {
console.log(`RequestUtil.encodeDesMap is called: data=${data}, desKey=${desKey}, desIV=${desIV}`);
let result = this["encodeDesMap"](data, desKey, desIV);
console.log(`RequestUtil.encodeDesMap result=${result}`);
return result;
};
}

function main() {
Java.perform(function () {
hook()
})
}

main()

hook结果如下:

1
2
3
4
5
6
RequestUtil.encodeDesMap is called: data={"equtype":"ANDROID","loginImei":"Android010139023152113","sign":"6390CC1EE2406073DB9FEF4E9CB80A29","timeStamp":"1747969001281","userPwd":"123456","username":"13456798976"}, desKey=65102933, desIV=32028092

RequestUtil.encodeDesMap result=NIszaqFPos1vd0pFqKlB42Np5itPxaNH//FDsRnlBfgL4lcVxjXiiyqIScii6OFy//cZnTrYzQcr
ozbW4axiV+SeLV71aIQ4AeSd5UD0yf5ogdESrlwVkyfuDkmwz3FX/lKWbALMS5qEp0jbw2vtbEni
E8tVSrSd3H0yGIPI8M+3YsMqBRVm+Sy6XFGe+QmHprBkLA+cSz1iZP/tKcwFi/zeVAfchRVIJq7m
1A/eQyI=

我们对比charles的抓包结果可以发现,这里就是加密的位置,那么我们接下来就需要去重点分析RequestUtil类下的encodeDesMap方法

4、逆向分析

通过frida hook RequestUtil.encodeDesMap方法的hook结果分析入参

1
RequestUtil.encodeDesMap is called: data={"equtype":"ANDROID","loginImei":"Android010139023152113","sign":"6390CC1EE2406073DB9FEF4E9CB80A29","timeStamp":"1747969001281","userPwd":"123456","username":"13456798976"}, desKey=65102933, desIV=32028092

我们多调用几次登录接口可以发现equtype和loginImei参数是固定的,timeStamp参数是时间戳,userPwd和username分别是用户输入的密码和用户名,那么现在需要找到sign参数加密的位置

在encrypt的上方可以看到sign,我们直接跟进去RequestUtil.paraMap方法看一下,跟进去后看到该方法的源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static String paraMap(Map<String, String> addMap, String append, String sign) {
try {
Set<String> keyset = addMap.keySet();
StringBuilder builder = new StringBuilder();
List<String> list = new ArrayList<>();
for (String keyName : keyset) {
list.add(keyName + "=" + addMap.get(keyName));
}
Collections.sort(list);
for (int i = 0; i < list.size(); i++) {
builder.append(list.get(i));
builder.append("&");
}
builder.append("key=" + append);
String checkCode = Utils.md5(builder.toString()).toUpperCase();
addMap.put("sign", checkCode);
String result = new Gson().toJson(sortMapByKey(addMap));
Log.w(AppConfig.DEBUG_TAG, result + " result");
return result;
} catch (Exception e) {
e.printStackTrace();
return "";
}
}

很明显sign的值是checkCode的值,checkCode的值通过Utils.md5加密得到的,那么我们先编写frida hook脚本hook一下Utils.md5接收的参数是什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function hook3() {
let Utils = Java.use("com.dodonew.online.util.Utils");
Utils["md5"].implementation = function (string) {
console.log(`Utils.md5 is called: string=${string}`);
let result = this["md5"](string);
console.log(`Utils.md5 result=${result}`);
return result;
};
}

function main() {
Java.perform(function () {
hook3()
})
}

main()

hook的结果为

1
2
Utils.md5 is called: string=equtype=ANDROID&loginImei=Android010139023152113&timeStamp=1747981884623&userPwd=123456&username=13456798976&key=sdlkjsdljf0j2fsjk
Utils.md5 result=00cfb423f3e7c8894f25f5069f4fbd7d

根据上面的代码我们可以发现sign的值就是将Map中的键值对循环遍历出来然后通过key=value的形式中间拼接一个&符号得到最终的字符串,然后将字符串进行md5加密转为大写,只不过在进行字符串拼接前往Map中添加了一个key键,值为sdlkjsdljf0j2fsjk,那么我们就需要确认一下Utils.md5加密是否是一个标准的md5加密,我们可以通过frida主动调用的方式来确定

1
2
3
4
5
6
7
8
9
10
function main() {
Java.perform(function () {
let Utils = Java.use("com.dodonew.online.util.Utils");
console.log(Utils["md5"]('1'));
})
}

main()

// 打印结果:c4ca4238a0b923820dcc509a6f75849b

那么可以确定Utils.md5就是一个标准的md5加密

接下来hook一下paraMap方法,看看paraMap方法最终返回的结果是什么

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
function hook2() {
let RequestUtil = Java.use("com.dodonew.online.http.RequestUtil");
RequestUtil["paraMap"].overload('java.util.Map', 'java.lang.String', 'java.lang.String').implementation = function (addMap, append, sign) {
console.log(`RequestUtil.paraMap is called: append=${append}, sign=${sign}`);

// 打印 Map 内容
console.log("addMap content:");
let entrySet = addMap.keySet();
let iterator = entrySet.iterator();
while (iterator.hasNext()) {
var keyStr = iterator.next()
var valueStr = addMap.get(keyStr)
console.log(` ${keyStr} => ${valueStr}`);
}
let result = this["paraMap"](addMap, append, sign);
console.log(`RequestUtil.paraMap result=${result}`);
return result;
};
}

function main() {
Java.perform(function () {
hook2()
// hook3()
// hook()
})
}

main()

hook结果:

1
2
3
4
5
6
7
8
RequestUtil.paraMap is called: append=sdlkjsdljf0j2fsjk, sign=sign
addMap content:
timeStamp => 1747982496340
loginImei => Android010139023152113
equtype => ANDROID
userPwd => 123456
username => 13456798976
RequestUtil.paraMap result={"equtype":"ANDROID","loginImei":"Android010139023152113","sign":"2205707C5A4F0944792BD57E8257C68F","timeStamp":"1747982496340","userPwd":"123456","username":"13456798976"}

通过hook paraMap方法可以看到最终方法返回的结果是一个字符串,该字符串是将加密后的sign参数push到addMap中,然后将addMap参数转为对应的字符串

最终回到RequestUtil.encodeDesMap方法,点击进入到encodeDesMap方法中

跟进DesSecurity类中看看

发现这是一个des加密方式,只是key又被经过了一次md5的加密,在刚才hook RequestUtil.encodeDesMap的时候就已经知道了key的值为65102933,iv的值为32028092,那么我们只需要进行算法还原就行了

python算法还原

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
48
49
50
51
52
53
54
55
56
57
58
59
import base64
from urllib.parse import urlencode
import hashlib
import json
from Crypto.Cipher import DES
from Crypto.Util.Padding import pad, unpad
from Crypto.Hash import MD5


class DESCipher:
def __init__(self, sec_key: bytes, sec_iv: bytes):
# Java 中:md = MessageDigest.getInstance("MD5")
# md.update(secKey)
# key = new DESKeySpec(md.digest()[0:8])
md5 = MD5.new()
md5.update(sec_key)
des_key = md5.digest()[:8] # DES 需要 8 字节密钥

# Java 中的 Cipher.getInstance("DES/CBC/PKCS5Padding")
self.en_cipher = DES.new(des_key, DES.MODE_CBC, sec_iv)
self.de_cipher = DES.new(des_key, DES.MODE_CBC, sec_iv)

def encrypt64(self, data: bytes) -> str:
from Crypto.Util.Padding import pad
encrypted = self.en_cipher.encrypt(pad(data, DES.block_size))
return base64.b64encode(encrypted).decode('utf-8')

def decrypt(self, ciphertext: bytes) -> bytes:
decrypted = self.de_cipher.decrypt(ciphertext)
return unpad(decrypted, DES.block_size)


data = {"equtype": "ANDROID", "loginImei": "Android010139023152113",
"timeStamp": "1747973461904", "userPwd": "123456", "username": "13456798976"}

query_string = urlencode(data)
# 添加 key 参数
final_string = query_string + "&key=sdlkjsdljf0j2fsjk"

print(final_string)

sign = hashlib.md5(final_string.encode('utf-8')).hexdigest().upper()
print(sign)

data['sign'] = sign
print(data)

# 按 key 自动排序
sorted_dict = dict(sorted(data.items()))

code = json.dumps(sorted_dict).replace(" ", "")
print(code)

sec_key = b"65102933" # 相当于 Java 中的 secKey
sec_iv = b"32028092" # 必须是 8 字节,Java 中的 secIv

cipher = DESCipher(sec_key, sec_iv)
encrypted_b64 = cipher.encrypt64(code.encode())
print("Base64 加密结果:", encrypted_b64)

最终只需要将入参保持一致,然后把最终的结果和frida hook的结果对比一下,结果一致的话就说明算法还原的没问题

逆向参数: 请求头部参数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参数的加密位置:
  1. 可以通过hook json的方式进行定位
    1
    2
    3
    4
    5
    6
    7
    8
    hook代码: 
    var _stringify = JSON.stringify;
    JSON.stringify = function () {
    if(arguments[0] && arguments[0]['payload']){
    debugger;
    }
    return _stringify(this,arguments);
    };
  2. 可以通过搜索关键字的方式进行定位

    直接搜索关键字X-s,区分大小写后搜索出来的结果不多,可以很轻松的找到加密的入口

定位到加密入口后进入到加密函数,将代码整体拷贝到本地编辑器进行分析

这里可以看到类ob混淆的特征,L是一个解密函数,F是一个数组函数,然后有一个自执行函数将F函数传入对数组进行移位,最后的自执行函数中有一个b函数,我们可以先通过AST进行反混淆,然后再进行分析

AST反混淆

反混淆思路:

  1. 先处理进制数据
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    const 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);
  2. 将?.?还原成字面量
    我们需要将所有依赖的对象复制到AST文件中,然后再编写插件进行字面量还原
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    const 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)
    3.方法调用还原成字面量
    这里有很多地方对解密函数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
    48
    const 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let url = "/api/sns/web/v1/homefeed"
let data = {
"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
}

let xs = window._webmsxyw(url, data)
console.log(xs)


点击运行代码后我们可以从控制台看到X-s的值已经打印出来了

插桩分析

一般对于VMP代码的插桩我们可以在apply的位置进行插桩
搜索apply关键字,一共可以搜索到三个为位置:

  1. 第一个位置是在数组中
  2. 第二位置
    1
    i["SEoiV"](V, new (Function["prototype"]["bind"]["apply"](F4, F2))(), E, E, 0);
    这里有一个Function,应该也不是
  3. 第三个位置
    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)的初始值
后续数值可能的作用是:

  1. 输入分组的长度信息(如消息填充后的长度)
  2. 算法轮次中的中间变量(如循环左移位数或非线性函数的输出)
  3. 具体需结合上下文代码判断,但 MD5 的每一轮计算会涉及类似的位操作和常量

我们直接使用在线工具对以上字符串进行MD5加密,看看加密后的结果是什么,然后在打印的日志记录中搜索,发现搜索不到对应的字符串,我们接着往下翻日志,发现一下一段日志:

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
函数名称:  ƒ charAt() { [native code] } this:  0123456789abcdef 参数:  [7]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [11]0: 11length: 1[[Prototype]]: Array(0)
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this: 3 参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [3]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [3]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this: Æ 参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [12]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [6]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this: p 参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [7]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [0]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this: ß 参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [13]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [15]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this: Ú 参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [13]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [10]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this: ¥ 参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [10]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [5]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this: ê 参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [14]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [10]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this: W 参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [5]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [7]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this:  参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [1]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [10]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this: Ú 参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [13]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [10]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this: T 参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [5]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [4]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this: › 参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [9]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [11]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this:  参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [1]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [12]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this: Ž 参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [8]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [14]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this: Ý 参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [13]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [13]

这里对字符串0123456789abcdef调用了32次的charAt方法,我们将这32个字符串拼接起来就是得到的MD5加密后的结果

观察第二个关键点:
看到了下一个明文字符串4uzjr7mbsibcaldp,但是暂时不知道这个字符串对后续的加密有什么用处,先记录一下继续往下翻日志

观察第三个关键点:
继续往下翻日志可以看到往数组中push一个base64的字符串

接下来的日志就是对数组中的每一个字母进行charCodeAt操作,将charCodeAt后的结果再次push到一个新的数组中,我们直接往下翻找到最后的数组

这里可以看到是一个208位的数组,我们对数组fromCharCode操作一下,得到的结果就是一个base64的字符串

1
2
3
4
5
6
7
8
9
10
11
12
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, …])

输出的结果为:
'eDE9N2IzM2M2NzBkZmRhYTVlYTU3MWFkYTU0OWIxYzhlZGQ7eDI9MHwwfDB8MXwwfDB8MXwwfDB8MHwxfDB8MHwwfDB8MXwwfDB8MDt4Mz0xOTZjY2UyYTQ2MzdleXRhdzltem16aWY3cXVvNWZ6NGswM3U0OXh6MjUwMDAwMjA5MDE0O3g0PTE3NDA3MTA5MDc5NTg7\b\b\b\b\b\b\b\b'

将以上base64的字符串复制下来进行atob操作,得到的结果为:
'x1=7b33c670dfdaa5ea571ada549b1c8edd;x2=0|0|0|1|0|0|1|0|0|0|1|0|0|0|0|1|0|0|0;x3=196cce2a4637eytaw9mzmzif7quo5fz4k03u49xz250000209014;x4=1740710907958;'

x1=7b33c670dfdaa5ea571ada549b1c8edd; 加密后的md5结果
x2=0|0|0|1|0|0|1|0|0|0|1|0|0|0|0|1|0|0|0; 这里应该是对浏览器的环境检测
x3=196cce2a4637eytaw9mzmzif7quo5fz4k03u49xz250000209014; cookie中a1的值
x4=1740710907958; 时间戳

在上面解码的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
2
3
4
5
'cAlyE': function(C, f) {
const result = C(f)
window.log_result += result + '\r\n'
return result;
}

我们最后使用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
2
3
4
101 ^ 52 = 81
68 ^ 117 = 49
69 ^ 122 = 63
57 ^ 106 = 83

所以可以确定数组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
2
3
4
5
6
7
8
9
10
11
12
13
14
String.fromCharCode(...[81, 49, 63, 83, 60, 5, 36, 24, 62, 91, 47, 81, 47, 22, 38, 27])
输出结果为:
Q1?S<$>[/Q/&

通过js代码还原一下
const CryptoJS = require('crypto-js')
const key = CryptoJS.enc.Utf8.parse('Q1?S<$>[/Q/&')
console.log(key)

输出结果为:
{
words: [1362181971, 1006969880, 1046163281, 789980699],
sigBytes: 16
}

我们直接搜索1362181971,1006969880,1046163281,789980699,发现可以搜索到内容

搜索到之后往下查看日志发现有对数组中的4个元素进行异或操作

1
2
1362181971 ^ 929260340 = 1362181971
1006969880 ^ 1633971297 = 1566656633

那么key的值为 929260340,1633971297,895580464,925905270

总结
  1. 将url和请求载荷进行拼接成字符串,将字符串进行MD5加密得到的结果
  2. 将字符串’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加密
  3. 将加密后的base64字符串进行AES加密
  4. 将字符串’{“signSvn”: “56”, “signType”: “x2”, “appId”: “ugc”, “signVersion”: “1”, “payload”: <aes加密后的字符串>}’进行base64加密
  5. 最终的base64值与字符串XYW_进行拼接

网址: aHR0cHM6Ly93d3cubWFzaGFuZ3BhLmNvbS9wcm9ibGVtLWRldGFpbC8xNC8=

1、抓包找到对应接口,点击查看启动器,点击启动器进入分析


这里可以看到是对params参数进行了加密

直接找到启动器中的loadPage进入分析

2、点击$.ajax()函数

在$.ajax的位置发现发现加密参数,那么应该是对ajax进行了重写,我们直接打上断点步入函数进行调试

3、断点到send发包的位置


这里一直跟到了send发包的位置还是没有发现加密参数

我们将鼠标悬停在send函数上发现这个send函数是可以点击进入调试的,我们可以随便找一个用ajax发送请求的网站来对比一下就可以发现如果是原生的ajax请求send是一个bultin函数,而这里的send是一个function,说明这里对ajax进行了重写,我们直接点击send函数进入调试

4、分析send函数


这里可以看到open函数并非一个bultin函数,而是一个function,我们直接跟进open函数进入调试

进入open函数之后很快就找到了加密的位置,接下来就是着重分析这个x函数

5、分析x函数

观察整体代码可以发现是一个类似于ob的混淆,我们可以通过AST进行反混淆

6、反混淆思路
  1. 先处理十六进制数据
  2. 处理完十六进制数据之后可以发现大量的计算操作,直接进行常量折叠
  3. 字面量还原。其实这里一个类ob混淆,解密函数和自执行函数,我们可以先把解密函数__sk_Q及解密函数依赖的函数__sk_z和自执行函数先扣取到本地进行字面量还原。混淆代码对解密函数进行了多次的重复赋值,我们可以先将解密函数名__sk_Q存进数组中,通过绑定的方式找到赋值语句,判断赋值语句右节点的变量名称是否在数组中,如果在的话通过左边节点的变量名称的引用位置的父节点找到需要还原的代码(如a(398)),我们已经将解密的代码拿到了本地,我们获取到参数后直接调用__sk_Q(398)得到实际字面量之后替换节点即可,最后再将左节点的变量名称添加到数组中,这样就可以完美解决解密函数重复赋值的问题。

网址: aHR0cHM6Ly93d3cubWFzaGFuZ3BhLmNvbS9wcm9ibGVtLWRldGFpbC8xMi8=

1、抓包找到对应接口,点击查看启动器,点击启动器进入分析

2、打上断点进行翻页


在$.ajax()打上断点,做过图灵题目的都知道这里的$.ajax是关键的加密逻辑,这里的$.ajax并非是来自于jquery,而是对ajax进行了重写

3、点击$.ajax()函数

进入到$.ajax()函数中发现里面的变量名以及函数名经过了混淆,我们观察整体代码,可以看到有明显的jsvmp混淆特征

可以参考网站:https://www.jsjiami.com/

4、全扣补环境

对于jsvmp混淆的代码,这里直接采用全扣补环境的方式,将代码全部拿下来之后在本地先运行,出现如下报错:

1
2
3
OOO00OO['push'](Q0OQO0O[OQOQQ0Q]);

TypeError: Cannot read properties of undefined (reading 'ajax')

定位到报错的行号打印对应的Q0OQO0O[OQOQQ0Q]

1
2
console.log(Q0OQO0O[OQOQQ0Q])
console.log(OQOQQ0Q)

这里发现Q0OQO0O[OQOQQ0Q]是一个undefined,OQOQQ0Q的值为$,这里我们就需要在页面上对应的位置打上条件断点,去观察看看浏览器中当OQOQQ0Q==$时Q0OQO0O[OQOQQ0Q]的值是什么

发现当OQOQQ0Q==$时Q0OQO0O[OQOQQ0Q]是一个function,那我们在这里改造一下代码,当OQOQQ0Q==$时给Q0OQO0O[OQOQQ0Q]赋值为一个function

1
2
3
4
5
6
7
$e = function() {

}
if (OQOQQ0Q === '$') {
Q0OQO0O[OQOQQ0Q] = $e
}
OOO00OO['push'](Q0OQO0O[OQOQQ0Q]);

继续运行会发现接下来就是补一些浏览器的环境对象了,我们直接上代理器进行补环境,补完环境之后发现不报错了,但是也不出结果,那是因为我们没有调用对应的启动函数,我们直接调用loadPage函数传入参数(参数为对应的页码),在调用loadPage函数的时候还是会出现一些报错,这里直接修改一下loadPage函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function loadPage(page) {
$.ajax({
url: `/api/problem-detail/12/data/?page=${page}`,
method: 'GET',
dataType: 'json',
success: function (data) {
if (!data) {
return
}
updatePageContent(data);
},
error: function (jqXHR, textStatus, errorThrown) {
console.error('Error fetching problem details:', textStatus, errorThrown);
}
});
}

改造完loadPage函数之后还会出现报错$不存在,根据刚才在浏览器条件断点的时候,window.$是一个function,我们将window.$复制给了$e,那么我们需要把$e的值复制给window.$

1
2
3
4
$e = function() {

}
window.$ = $e

再次运行还是出现报错,我们将pycharm调试模式下的出现异常断点模式打开,右击进行调试模式,等到出现异常时断住,我们可以看到其实完整的请求url已经来了,在QQO0OQ0参数中

出来之后我们往上看代码,发现QQO0OQ0最开始赋值的是一个空数组然后经过for循环进行元素的push,那么我们改造一下,当数组的长度为4时将数组中的url进行全局导出然后将代码进行return,这样就可以得到最终的结果了

1
2
3
4
5
6
7
QQO0OQ0 = [];
for (Q0OQO0Q = 0x0; QQ0OQOQ[OQQQ00Q(0x258, 'TUGE')](Q0OQO0Q, OQOQQ0Q); Q0OQO0Q++)
QQO0OQ0[OQQQ00Q(0x31e, 'ck!W')](OOO00OO[OQQQ00Q(0x295, 'SEv2')]());
if (QQO0OQ0.length === 4) {
window.url = QQO0OQ0[2]['url']
return
}

我们在调用完loadPage函数之后,将window.url的值打印出来,发现已经得到了最终的结果了,将调用过程封装成一个函数传参调用

1
2
3
4
5
6
function get_url(page) {
loadPage(page)
console.log(window.url)
}

get_url(process.argv[2])
5、python调用
1
2
3
4
5
6
7
8
base_url = "https://www.mashangpa.com"

for page in range(1, 21):
url = subprocess.run(['node', 'encrypt.js', str(page)], capture_output=True, text=True)
new_url = url.stdout.strip()
finally_url = base_url + new_url

response = requests.get(finally_url, headers=headers, cookies=cookies)

random-user-agent介绍

random-user-agent 是一个用于随机生成用户代理字符串的 Python 库。它可以帮助你在进行网络爬虫或发送 HTTP 请求时随机化用户代理,从而降低被网站检测的风险。

1
pip install random-user-agent

random-user-agent的使用

1
2
3
4
5
6
7
8
9
10
11
12
13
from random_user_agent.user_agent import UserAgent
from random_user_agent.params import SoftwareName, OperatingSystem

def random_user_agent():
# 生成随机的user-agent
software_names = [SoftwareName.CHROME.value, SoftwareName.FIREFOX.value, SoftwareName.EDGE.value,
SoftwareName.SAFARI] # 指定浏览器
operating_systems = [OperatingSystem.WINDOWS.value] # 指定操作系统

user_agent_rotator = UserAgent(software_names=software_names, operating_systems=operating_systems, limit=100)

random_user_agent = user_agent_rotator.get_random_user_agent()
return random_user_agent

在上述代码是使用random_user_agent的示例,random_user_agent可以生成多种浏览器的及操作系统的user_agent,使用起来非常的方便,定制性非常强,用户可以根据自己的需要去定制ua生成的范围,比如某些网站只支持web浏览器的请求头,那么operating_systems参数就可以指定对应的ua生成范围为web浏览器。

software_names参数用来指定浏览器,接收的是一个列表参数,下面是software_names参数支持的浏览器类型,支持的浏览器非常的多,除了主流的浏览器之外支持其他的浏览器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ALERTSITE_MONITORING_BOT = 'alertsite-monitoring-bot'
ANDROID = 'android-browser'
AWESOMIUM = 'awesomium'
BLACKBERRY = 'blackberry-browser'
CATCHPOINT_ANALYSER = 'catchpoint-analyser'
CHROME = 'chrome'
CHROMIUM = 'chromium'
COSMOS_CRAWLER = 'cosmos-crawler'
DOTCOM_MONITOR_BOT = 'dotcom-monitor-bot'
EDGE = 'edge'
FIREFOX = 'firefox'
FIREFOX_FOCUS = 'firefox-focus'
GOOGLE_APP_ENGINE = 'google-app-engine-software'
......

operating_systems参数用来指定操作系统,接收的也是一个列表参数,下面是operating_systems参数支持的操作系统类型,支持的操作系统也是非常的多

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
UNIX = 'a-unix-based-os'
ANDROID = 'android'
BADA = 'bada'
BEOS = 'beos'
BLACKBERRY = 'blackberry-os'
CHROMEOS = 'chromeos'
FIRE_OS = 'fire-os'
FREEBSD = 'freebsd'
HAIKU = 'haiku'
IOS = 'ios'
LINUX = 'linux'
MAC = 'mac'
MAC_OS_X = 'mac-os-x'
MACOS = 'macos'
OPENBSD = 'openbsd'
SUNOS = 'sunos'
SYMBIAN = 'symbian'
WEBOS = 'webos'
WINDOWS = 'windows'
WINDOWS_MOBILE = 'windows-mobile'
WINDOWS_PHONE = 'windows-phone'
HP_WEBOS = 'hp-webos'
DARWIN = 'darwin'
IRIX = 'irix'
RIM_TABLET_OS = 'rim-tablet-os'
LIVEAREA = 'livearea'
PALMOS = 'palmos'

limit参数用来指定生成ua的个数,最终从生成的ua中随机选择一个ua返回。

random-user-agent库的优势

1.随机化用户代理:通过随机生成用户代理字符串,可以有效降低被网站检测到的风险,避免被封锁或限制访问。
2.易于使用:库的 API 设计简单,方便快速集成到现有项目中,无需复杂配置。
3.多样化用户代理:提供了多种常见的用户代理字符串,可以模拟不同浏览器和设备,增加请求的多样性。
4.缓存支持:支持缓存功能,可以提升获取用户代理的速度,减少请求延迟。
5.可定制:用户可以传入自定义的用户代理列表,满足特定需求,提供灵活性。
6.跨平台:作为一个 Python 库,支持在多种操作系统上使用,适合各种环境。

深入理解JavaScript原型链

JavaScript 是一种基于原型的语言。理解原型和原型链对于掌握 JavaScript 的继承机制和对象模型至关重要。在本文中,我们将深入探讨 JavaScript 原型链的概念及其在实际编程中的应用。

1.什么是原型

在 JavaScript 中,每个对象都有一个与之关联的原型对象。这个原型对象也是一个对象,它可以拥有自己的原型,以此类推,形成一个链条,这就是所谓的原型链。

2.构造函数和原型
1
2
3
4
5
6
7
8
9
10
11
12
13
function Person(name) {
this.name = name;
}

Person.prototype.sayHello = function() {
console.log('Hello, my name is ' + this.name);
};

let person1 = new Person('Alice');
let person2 = new Person('Bob');

person1.sayHello(); // Hello, my name is Alice
person2.sayHello(); // Hello, my name is Bob

在上述的实例中Person是一个构造函数,Person.prototype是Person构造函数的原型对象,sayHello方法被添加到Person.prototype上,这意味着所有 Person 的实例都可以访问这个方法。

3.property和__proto__属性

在JavaScript中,对象属性分为两类:自身属性和原型链属性。
自身属性是直接定义在对象自身上的属性,原型属性是通过原型链继承的属性。

1
2
3
4
5
6
7
8
const person = {
name: "Alice",
age: 30
};

console.log(person.hasOwnProperty('name')); // true
console.log(person.hasOwnProperty('age')); // true
console.log(person.hasOwnProperty('toString')); // false,因为 toString 是从 Object.prototype 继承来的

__proto__属性是每一个对象都有的一个内部属性,它指向对象的原型(即构造函数的 prototype 属性)。

4.原型链

当我们访问对象的属性或方法时,JavaScript 引擎会首先在对象自身的属性中查找。如果找不到,则会沿着原型链向上查找,直到找到属性或到达原型链的顶端(Object.prototype),其 proto 为 null。

1
2
3
console.log(person1.__proto__ === Person.prototype); // true
console.log(Person.prototype.__proto__ === Object.prototype); // true
console.log(Object.prototype.__proto__ === null); // true

原型链检测

在我们扣代码时通常会遇到一些环境检测,环境检测通过才会返回正确的结果,但我们在补环境时通常不会按照实际浏览器的原型链去补,下面是常用的补环境方式:

1
2
navightor = {}
navightor.userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/127.0.0.0'

当js代码中存在原型链检测,这种补环境的方式是无法通过的,比如:

1
2
3
4
5
6
7
8
navightor = {}
navightor.userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/127.0.0.0'
if (Object.hasOwnProperty(navigator, 'userAgent')) {
return '错误的结果'
} else {
return '正确的结果'
}
// hasOwnProperty方法是JavaScript中所有对象从 Object.prototype 继承而来的一个方法。它用于检查一个对象是否拥有指定的属性(该属性必须是对象自身的属性,而不是从原型链继承来的属性)。

上面这种常规补环境的方式如果遇到原型链的检测那无法得到正确的结果,我们必须符合浏览器的环境,所以我们要将对应的属性补在原型链上,如下是补环境的方式符合浏览器的真实环境:

1
2
3
4
5
6
7
Navigator = function () {
}
Navigator.prototype = {
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/127.0.0.0'
}
navigator = new Navigator()
// 以上将userAgent属性补在了原型对象上面,符合真实的浏览器环境

描述符检测

Object.getOwnPropertyDescriptor() 方法返回指定对象上一个自有属性对应的属性描述符。(自有属性指的是直接赋予该对象的属性,不需要从原型链上进行查找的属性)

1
2
3
4
5
6
7
8
9
// 浏览器里面执行
Object.getOwnPropertyDescriptor(navigator,'userAgent') // ---> undefined

// node环境执行
var navigator = {
userAgent:"aaaaa"
}
Object.getOwnPropertyDescriptor(navigator,'userAgent')
// 输出{value: 'aaaaa',writable: true,enumerable: true,configurable: true}

value:与属性关联的值(仅限数据描述符)。
writable:当且仅当与属性关联的值可以更改时,为 true(仅限数据描述符)
configurable:当且仅当此属性描述符的类型可以更改且该属性可以从相应对象中删除时,为 true
enumerable:当且仅当此属性在相应对象的属性枚举中出现时,为 true

以下是检测描述符检测的正确补法

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
// 第1种方式,直接通过navigator来补他的隐式原型
navigator = {}
navigator.__proto__.userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36'

console.log(navigator.userAgent);
console.log(Object.getOwnPropertyDescriptor(navigator, 'userAgent'));

// 第2种方式, 通过navigator创建的类来补显式原型
var Navigator = function() {};
Navigator.prototype = {"userAgent": "123123123"};
navigator = new Navigator();
console.log(navigator.userAgent)
console.log(Object.getOwnPropertyDescriptor(navigator, 'userAgent'));

// 补方法
Document = function Document(){}
// Object.defineProperty 直接在一个对象上定义一个新属性,或修改其现有属性,并返回此对象。
Object.defineProperty(Document.prototype,'createElement',{
configurable: true,
enumerable: true,
value: function createElement(){},
writable: true,
})
HTMLDocument = function HTMLDocument(){}
//可以将一个指定对象的原型(即内部的隐式原型属性)设置为另一个对象
Object.setPrototypeOf(HTMLDocument.prototype,Document.prototype)
document = new HTMLDocument()

console.log(Object.getOwnPropertyDescriptor(document.__proto__.__proto__, 'createElement'));

toString检测

在 JavaScript 中,toString() 是一个内置函数,用于将一个值转换为字符串。
toString() 方法可以应用于大部分 JavaScript 值类型,包括数字、布尔值、对象、数组等。它的返回值是表示该值的字符串。
如果js代码中检测toSting方法,那么即使我们按照原型链的方式去补环境也无法通过检测

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
Document = function Document(){}
Object.defineProperty(Document.prototype,'createElement',{
configurable: true,
enumerable: true,
value: function createElement(){},
writable: true,
})
HTMLDocument = function HTMLDocument(){}
Object.setPrototypeOf(HTMLDocument.prototype,Document.prototype)
document = new HTMLDocument()

console.log(document.toString()); --> 输出function createElement(){}

// 浏览器控制台输出 --> 'function createElement() { [native code] }'
// 这个时候如果检测了toString即使补原型链也无法通过
// 我们可以重写toString方法来达到目的
Document = function Document(){}
Object.defineProperty(Document.prototype,'createElement',{
configurable: true,
enumerable: true,
value: function createElement(){},
writable: true,
})

HTMLDocument = function HTMLDocument(){}
Object.setPrototypeOf(HTMLDocument.prototype,Document.prototype)
document = new HTMLDocument()
document.createElement.toString = function(){return "function createElement() { [native code] }"}
console.log(document.createElement.toString());

什么是jsdom

jsdom是一个纯粹由 javascript 实现的一系列 web标准,特别是 WHATWG 组织制定的DOMHTML 标准,用于在nodejs中使用。大体上来说,该项目的目标是模拟足够的Web浏览器子集,以便用于测试和挖掘真实世界的Web应用程序

jsdom的基本使用

1
2
3
4
5
const jsdom = require("jsdom");
const { JSDOM } = jsdom;
const dom = new JSDOM(`<div>Hello Wrold!</div>`);
title = dom.window.document.querySelector("div").textContent
console.log(title)

很多人都喜欢使用jsdom来补环境,但是jsdom设置userAgent时会有jsdom的一些标识,如果生成的数据中检测了userAgent就会导致数据错误从而请求失败

以下是常见的userAgent的设置方式

1
2
3
4
5
6
7
8
9
10
const jsdom = require("jsdom");
const { JSDOM } = jsdom;
const userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
const dom = new JSDOM(``, {
url: "https://example.org/",
referrer: "https://example.com/",
userAgent: userAgent,
});
window = dom.window
console.log(window.navigator.userAgent)

以上代码输出的结果为: Mozilla/5.0 (darwin) AppleWebKit/537.36 (KHTML, like Gecko) jsdom/24.0.0
在userAgent中有很明显的jsdom特征

再次尝试使用如下代码:

1
2
3
4
5
6
7
const jsdom = require("jsdom");
const { JSDOM } = jsdom;
const dom = new JSDOM();
window = dom.window
navigator = window.navigator
navigator.userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
console.log(window.navigator.userAgent)

以上代码输出的结果仍然为: Mozilla/5.0 (darwin) AppleWebKit/537.36 (KHTML, like Gecko) jsdom/24.0.0

jsdom正确设置userAgent

1
2
3
4
5
6
7
8
9
10
11
12
13
const jsdom = require("jsdom");
const { JSDOM } = jsdom;
const userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
const resourceLoader = new jsdom.ResourceLoader({
userAgent: userAgent
});
const dom = new JSDOM(``, {
url: "https://example.org/",
referrer: "https://example.com/",
resources: resourceLoader,
});
window = dom.window
console.log(window.navigator.userAgent)

以上代码输出的结果为: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36
通过以上方式则可以正常设置userAgent

有些网站一打开控制台页面就会发生跳转会关闭当前页面,防止我们对网页进行抓包分析

常见的检测用户打开开发者工具的js代码

1.通过profiles记录里面有没有数据来判断用户是否打开了控制台

1
2
3
4
5
6
7
8
9
10
function ck() { 
console.profile();
console.profileEnd(); //我们判断一下profiles里面有没有东西,如果有,肯定有人按F12了,没错!!
if(console.clear) {
console.clear()
};
if (typeof console.profiles =="object"){
return console.profiles.length > 0;
}
}

2.通过判断窗口的高度来检测用户是否打开了控制台

1
2
3
4
5
6
7
8
9
function fuckyou(){ 
window.close(); //关闭当前窗口(防抽)
window.location="about:blank"; //将当前窗口跳转置空白页
}

window.onresize = function(){
if((window.outerHeight-window.innerHeight)>200) //判断当前窗口内页高度和窗口高度,如果差值大于200,则关闭当前窗口跳转至空白页
fuckyou();
}

如何进行调试

1.分析打开网站后网站的表现(如:跳转至指定页面、关闭当前窗口、控制输出”已清除控制台”等)

2.提前打开控制台,在源代码页面中找到事件侦听器断点,勾选脚本选项,再次进入指定网站

3.页面处于断点时搜索文件中相关的js代码进行定位,找到对应的位置

如搜索window.close/console.clear/window.location.href/window.history.back

4.将对应的js代码进行注释,并替换文件

0%