0x01 Intro

第一次接触js的逆向是从17年的hctf中,之后,也时常会遇到过需要解开Webpack/混淆js的情景。近几日遇到了一个批量刷票的需求,目标是投票网http://wenjuan.com/

目标

  1. 逆向投票网的防刷票机制,实现自动化投票。
  2. 总结鄙人常用的chrome debug操作,望能提供有需求逆向js同学帮助。

结构

本文结构从实例开始,当中穿插Chrome Debug逆向使用操作、分析、脚本编写等,最后进行总结。

0x02 从需求开始

目标:https://www.wenjuan.in/s/6N3iy2u

HTTP分析

走了一遍流程,发现是可以重复投票的,只要你点击在此参与即可,抓了一下数据包,发现流程如下:

  1. GET请求目标网址,得到静态页面
  2. 填写完相关选项后,POST至相关网址
  3. 如果成功的话则返回json,不成功则只返回{“status”: “200”}
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
//如成功,则返回的json response
{"status": "200", "seq": 14529796, "adc_time": "1623371158", "member_type": -1, "vote_result_vcode": 2229, "rid": "60c2ad96caa0b19b270aca74", "invalide_rspd": false, "is_weixin_browser": false, "finished_time": "2021-06-11 08:25:58", "err_msg": "", "redirect_uri": null, "redirect_url": null, "adc_sign": "b1327e469f67bf15e463cb9b2e659dd7"}

// Request Body如下
POST /s/6N3iy2u HTTP/1.1
Host: www.wenjuan.in
Connection: close
Content-Length: 1150
sec-ch-ua: "Chromium";v="92", " Not A;Brand";v="99", "Google Chrome";v="92"
Accept: application/json, text/javascript, */*; q=0.01
DNT: 1
X-Requested-With: XMLHttpRequest
sec-ch-ua-mobile: ?0
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4475.0 Safari/537.36
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Origin: https://www.wenjuan.in
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: https://www.wenjuan.in/s/6N3iy2u
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7,da;q=0.6
Cookie: xxxxxxxxxxxxxxx

total_answers_str=%7B%2260b838b580ad916cfc3eb0ac%22%3A%5B%2260b838b580ad916cfc3eb0aa%22%5D%2C%2260b839867f3f556548c056ec%22%3A%5B%2260b839867f3f556548c056ea%22%5D%2C%2260b839aafd631e66e352c732%22%3A%5B%2260b839aafd631e66e352c730%22%5D%2C%2260b839bf660ced68a8eff345%22%3A%5B%2260b839bf660ced68a8eff343%22%5D%2C%2260b839d4fd631e66e0613aac%22%3A%5B%2260b839d4fd631e66e0613aab%22%5D%2C%2260b839e9fd631e66ddccb7f4%22%3A%5B%2260b839e9fd631e66ddccb7f2%22%5D%2C%2260b839fefd631e66dcf91d8f%22%3A%5B%2260b839fefd631e66dcf91d8e%22%5D%2C%2260b83a194400f2a00f964931%22%3A%5B%2260b83a194400f2a00f964930%22%5D%2C%2260b83a3a7f3f55654231a61b%22%3A%5B%2260b83a3a7f3f55654231a619%22%5D%2C%2260b83a4c7f3f55654231a621%22%3A%5B%2260b83a4c7f3f55654231a61f%22%5D%7D&pconvert_data=%7B%7D&finish_status=1&timestr=2021-06-11+08%3A25%3A31&idy_uuid=1aae7ef0f1c16fde2d75e6650f454360&svc=424377ce2dbdea77c70f2ea23d4ad16e&project_version=3&s_code=158&s_func_id=94&vvv=e27b5613ca6acd8a6aaa3f97ec29b4b4&rand_int=77&question_captcha_map_str=%7B%7D&question_ids_skipped_by_time=null&actual_start_timestr=2021-06-11+08%3A25%3A37&actual_timestr=7626&_xsrf=eb4b7e6a3b6a4d4c926c3f11c6b441ac

Request POST参数如下:

total_answers_str
pconvert_data
finish_status
timestr
idy_uuid
svc
project_version
s_code
s_func_id
vvv
rand_int
question_captcha_map_str
question_ids_skipped_by_time
actual_start_timestr
actual_timestr
_xsrf

从参数中目测有可能是伪随机数的几个参数:idy_uuid,svc,vvv

Chrome Debug

目前,已知POST的url是/s/6N3iy2u,与POST的结构体,由于6N3iy2u是一个变值,在js中可能通过location.href等方式进行获取,故搜索某个具有特征的post参数,比如:question_ids_skipped_by_time。为什么选择这个参数?因为这个参数比较长,且在webpack的过程中,对象中的字段名不会被替换。由此可定位到https://s1.wenjuan.com/static/build/js/survey_main.js 的saveTotalAnswer函数。

从其中的data赋值逻辑中可以看出当前变量的来源都是来自window.variable_name。

这里鄙人认为有前端逆向的一个重要基础:不论采取何种加密方式,在前端对抗中传入加密的方法中的参数、密钥和加密方法都是在客户端实现的,如果可以模拟加密方法,我们即可伪造任意加密后的数据包。

故上述参数中必然存在某些参数以某种方式传入前端,故在burpsuite中搜索post body中的某一潜在随机值,如uuid。

在此可发现_xsrf、timestr、idy_uuid、version、s_func_id、vvv、rand_int、from_form值均为GET请求获取。

再从data赋值中,发现,svc使用window.et函数,传入uuid、timestr计算得出。

svc: [window.et](http://window.et/)([projectId, window.uuid, window.timestr], window.ev),

在data赋值过程进行断点:

再次发包,触发断点,在console中键入window.et即可得到相关函数:

中间function这一坨在console中执行即可发现真正函数定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function slice_str(a, b) {
var d, e, c = "";
for (d = 0; d < a.length / b; d++) e = a.charAt(d * b),
c += e;
return c
}
function et(sa, v) {
var f = null;
return eval("f = ef" + v),
f(sa)
}
function ef1(a) {
var c, b = "";
for (c = 0; c < a.length; c++) b += slice_str(a[c], 2);
return hex_md5(b)
}

断点步入可知通过et([projectId, window.uuid, window.timestr],ev),其中ev为1,数组传入ef1后,进行slice_str,后进行hexmd5。

从github上搜一个hexmd5 js的实现,然后复制上述函数至nodejs,即可计算svc的值。

此处可以通过原有的逻辑打断点,跑一下看看对不对即可。

定位saveTotalAnswer函数的另一方法可通过XHR/fetch Breakpoints,设置断点后,触发xhr请求,断住后再回溯调用栈即可。

随机函数

在算出了svc的值后,发包发现仍然无法成功调用,应该还存在一随机数,从GET请求中,发现s_code并未给出值,但post包中确存在,故该值也为本地计算中得出。

debug发现,s_code为已随机函数计算出,每次函数名和计算过程不相同。

在GET请求中,可知func_name和rand_int。

在不断的搜索和debug,均无法定位到相关逻辑,故分析,既然函数名和计算过程均为随机,那么这里的计算过程必需要服务端传入。刚开始认为是func_id指定了响应的计算函数,其后发现该函数内容完全随机。结合func_name无法所有到,故推断应该是做了某种转换,比如函数名的拆分、数字字符的转换等。

仔细观察GET页面发现script标签中执行了这么一段内容:


目测像String.fromCharCode,故将s1替换成String.fromCharCode,执行发现即为函数定义:

至此,所有的随机值均已能够计算出。

0x03 脚本编写

针对js的模拟,nodejs具有天然优势,除去nodejs的http client非常不好用之外,可以直接复用webpack或混淆代码,非常便捷,节省了造轮子的时间。

HTTP Request Clinet

在测试了http/https 、axios、request等httpclient后,我最终选择了superagent,superaget不会有axios非常奇怪的问题,比如axios发起get请求且进行代理时,请求的URL居然是http://host/url,类似下图这种请求,至今我仍不知道问题在哪里。

在脚本中使用superagent-proxy,对代理进行支持,需要配置NODE_TLS_REJECT_UNAUTHORIZED已获得对自签名证书的信任。

1
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0

HTML Parser

这里使用cheerio做HTML的解析,基本语法类似于jquery,比较亲民。

实现

代码实现上传到github了,比较草率。

nodejs是纯异步的,对于异步的支持比较好,故高并发对于nodejs从来不是什么太大的问题,具体实现网上资料很多,就不再赘述了。

时间原因,鄙人在此仅简单实现了一个能用的版本,对于IP的限制可以通过云上的NAT或者云函数绕过,简单的说就是通过一个或数个CVM或者容器组成一个vpc,vpc可以搞一个nat在前置,每个NAT可以挂十个公网的ip。

具体实现的代码按需自取:

https://raw.githubusercontent.com/whip1ash/Scripts/main/nodejs/crack_js/main.js

0x04 阿里云验证码

昨日夜间搞到凌晨,今天早上起来发现上了阿里云验证码,心里一万个句MMP。

上都上了,就研究一下这玩意儿,官方doc:https://help.aliyun.com/document_detail/193141.html?spm=a2c4g.11186623.2.13.73c37b94JPZQa5

测试了一下验证码,由这么几个包组成,忽略ynuf.aliapp.org ,目测是tracker。

原理

在第一个GET请求中,接入阿里云验证码,接入代码如下:

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
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
var requestInfo = {

type: 'GET', // 'GET' 和 'POST'
url: 'https://www.wenjuan.in/s/6N3iy2u', // 'https://www.taobao.com/detail'
args: '',
data: '', //a=1&b=2&c=3...
token: 'cb7029ee-3744-4f6c-bdb3-b5fdaf653d39',
refer: 'sIBicL8DGWmz60LR5sJkeRDS+2g=',
headers: {},
};

function insertScripts(){
var script = document.createElement('script');
var time = new Date();
var head = document.head || document.getElementsByTagName('head')[0];
if (_waf_is_mobile){
script.src = '//g.alicdn.com/sd/nch5/index.js?t=' + (time.getFullYear()+(time.getMonth()+1)+time.getDate()+time.getHours());
}else{
script.src = '//g.alicdn.com/sd/ncpc/nc.js?t=' + (time.getFullYear()+(time.getMonth()+1)+time.getDate()+time.getHours());
}
if ("onload" in script) {
script.onload = function(){
initNC();
}
} else {
script.onreadystatechange = function() {
if (/loaded|complete/.test(script.readyState)) {
initNC();
}
};
}
head.appendChild(script);
}

insertScripts();

function parseURL(url) {

var search_index = url.indexOf('?'),
hash_index = url.indexOf('#');

var base, search, hash;

try{
if (search_index < 0 || (hash_index > -1 && search_index > hash_index)){
if (hash_index < 0){
base = url;
search = '';
hash = '';
}else{
base = url.slice(0, hash_index);
search = '';
hash = url.slice(hash_index, url.length);
}

}else{
if (hash_index < 0){
base = url.slice(0, search_index);
search = url.slice(search_index, url.length);
hash = '';
}else{
base = url.slice(0, search_index);
search = url.slice(search_index, hash_index);
hash = url.slice(hash_index, url.length);
}
}
}catch(e){
base = url;
search = '';
hash = '';
}

return {
base: base,
search: search,
hash: hash,
original: url
}

}

function parseQuery(qstr) {
if (qstr.charAt(0) != '?') {
return {};
}
var query = {};
var a = qstr.substr(1).split('&');
for (var i = 0; i < a.length; i++) {
var b = a[i].split('=');
console.log(decodeURIComponent(b[0]))
if (decodeURIComponent(b[0]) !== 'u_asec'){
query[decodeURIComponent(b[0])] = decodeURIComponent(b[1] || '');
}

}
return query;
}

function addQuery(query, data) {
var qdata = parseQuery(query);
var rt = '?';
for (var i in data) {
qdata[i] = data[i];
}
for (var i in qdata) {
rt += encodeURIComponent(i) + '=' + encodeURIComponent(qdata[i]) + '&';
}
rt = rt.substr(0 , rt.length - 1);
return rt;
}

function combineUrl(parsedUrl) {
return parsedUrl.base + parsedUrl.search + parsedUrl.hash;

}

function parseFormQuery(qstr) {
if (qstr.length === 0 || qstr.indexOf('=') < 0){
return [];
}

var formItems = [];
var a = qstr.split('&');
for (var i = 0; i < a.length; i++) {
var b = a[i].split('=');
var str = '<input type="hidden" name="' + b[0] + '" value="' + b[1] + '" />'
formItems.push(str);
}
return formItems;
}

function reform(data) {
var form = document.createElement('form');
var parsedUrl = parseURL(requestInfo.url);
parsedUrl.search = addQuery(parsedUrl.search,data)
var newUrl = combineUrl(parsedUrl);
form.action = newUrl;
form.method = "POST";
form.innerHTML = parseFormQuery(requestInfo.data).join('');
document.body.appendChild(form);
form.submit();
// document.body.appendChild(form);
}

var NC_Opt = {
renderTo: "nocaptcha",//渲染到DOM ID
cssUrl: "//g.alicdn.com/sd/ncpc/nc.css",
appkey: "CF_APP_WAF", // 应用标识
trans: {"key1": "code100", "user": "default"},
token: requestInfo.token,//umid token
elementID: ["usernameID", "passwordID"],//用户名和密码 DOM ID
is_Opt: "",//是否自己配置collina
language: "cn",//语言包,默认中文
isEnabled: true,
times: 3,
callback: function (data) {
if (requestInfo.type === 'GET'){
var d = {
u_atoken: data.token,
u_asession: data.csessionid,
u_asig: data.sig,
u_aref: requestInfo.refer
};
// location.href = requestInfo.url + addQuery(requestInfo.data, d);
var parsedUrl = parseURL(requestInfo.url);
parsedUrl.search = addQuery(parsedUrl.search,d)
// location.href = combineUrl(parsedUrl);
location.replace(combineUrl(parsedUrl));
}else{
var d = {
u_atoken: data.token,
u_asession: data.csessionid,
u_asig: data.sig,
u_aref: requestInfo.refer
};
reform(d);
}
},
error: function (s) {
window.console && console.log("error");
window.console && console.log(s);
},
umidServer: "r"//r 日常 , h 杭州 , m 美国 。 默认/不填 r
};

var NC_h5_Opt = {
renderTo: "#h5_nocaptcha",
appkey: "CF_APP_WAF",
token: requestInfo.token,
trans: {
"key1": "code200",
"user": "default"
},
inline: true,
times: 3,
scene: "register_h5",
is_Opt: "", //是否自己配置collina
language: "cn", //语言包,默认中文
callback: function(data) {
if (data.token === undefined) data.token = requestInfo.token;
if (requestInfo.type === 'GET'){
var d = {
u_atoken: data.token,
u_asession: data.csessionid,
u_asig: data.sig,
u_aref: requestInfo.refer
};
// location.href = requestInfo.url + addQuery(requestInfo.data, d);
var parsedUrl = parseURL(requestInfo.url);
parsedUrl.search = addQuery(parsedUrl.search,d)
// location.href = combineUrl(parsedUrl);
location.replace(combineUrl(parsedUrl));
}else{
var d = {
u_atoken: data.token,
u_asession: data.csessionid,
u_asig: data.sig,
u_aref: requestInfo.refer
};
reform(d);
}
},
error: function(s) {
window.console && console.log("error");
window.console && console.log(s);
},
umidServer: "r" //r 日常 , h 杭州 , m 美国 。 默认/不填 r
};
function initNC() {
if (window._waf_is_mobile){
document.getElementById('H5').style.display = 'block';
NoCaptcha.init(NC_h5_Opt);
NoCaptcha.setEnabled(true);
}else{
document.getElementById('PC').style.display = 'block';
var nc = new noCaptcha(NC_Opt);
}
}

其中,ReqestInfo 中token和refer应均为随机生成,

其后,拼这几个参数到/nocaptcha/initialize.jsonp这个接口,得到一个jsonp。滑动后将数据(参数n)发送到/nocaptcha/analyze.jsonp接口,若成功则获得csessionid等数据,使用数据可正常访问页面。

经过测试,每一次滑动的数据应该是可以复用的,但不知道能复用几次,写个脚本测试一下。

https://raw.githubusercontent.com/whip1ash/Scripts/main/python/Captcha_count.py

绕过尝试

写了个脚本测试了一下,脚本的思路很简单,就是重放手势数据和token,基本脚本见链接,刚开始有成功的,后面逐渐又没有了,鉴于投入和收益,这里绕过尝试就到此为止。鄙人想了一下,改进和测试大概有这么几个思路:

  1. tracker可能是辅助验证的数据,原因是参数p(我这里直接忽略了p参数,理由是跟tracker相关的意义应该不大,可能是统计数据的)中umidToken参数来自于https://ynuf.aliapp.org/service/um.json接口的tn参数。
  2. 仔细观察后发现,当/nocaptcha/analyze.jsonp第一包发送没有成功的时候,随后还有发两个包进行尝试,并且添加了一个目测是时间相关的t参数。

如果有后续的话测试思路除了上述改进的几点以外关于验证码场景还存在以下的思路:

appkey

appkey目前看来是公开标识,不过我可以直接复用该网站的appkey。这东西有什么用呢?鄙人大概想了一下,可以是这么个场景。

我自己在前端复制一套这个验证码,验证后得到csessionid数据,使用这个数据后端去访问限制访问的网站。这里主要测试点在于只要csessionid、value和token数据是正确的,即可通过服务端的验签过程,而无法区分不同应用。

这里为什么说无法区分呢,我极度怀疑appkey: “CF_APP_WAF”和scene:”register_h5”是一个公共验证的池子,而不是以用户层面去做鉴权。

对比一下生成的appkey的长度。

这里就有一个问题了,说了这么多有他妈什么用?不还是需要人拖动验证码?

鄙人认为这里还是有一点用的,在原有的模式下,解法好像只有通过外部模拟或对浏览器行为进行hook来操作这个验证码。而在这种模式下,你可以通过调用原生的package中的js函数和接口,这里模拟或者逆向的难度低一些,灵活性也会大一些。

触发的话可以使用浏览器内核不断去渲染我们构造的这个页面。

这个方式并未做技术可行性验证,纯属拍脑门思路,里面有多少坑跟他妈鄙人无关。

0x05 总结

怎么说呢,浪费了两天,做出来了,但没完全做出来。啥也不是,散会!

⬆︎TOP