声明:目的是用于记录逆向思路,而非提供结果。仅供学习参考,请勿滥用爬虫
文章目录
前言
学习js逆向,破解广东省公共资源交易平台,详细记录个人探索、踩坑全过程。
网站地址: aHR0cHM6Ly95Z3AuZ2R6d2Z3Lmdvdi5jbi8jLzQ0L2p5Z2c=
一、前戏
首先开始,我个人处理这种文本数据采集基本流程都是这样:
- 检查页面、清除cookie和请求记录、重新刷新页面
- 定位需要的数据请求接口
- 复制接口curl, 转成python请求尝试能否正常使用request/等方式请求
- 关键步骤:
- 看响应内容是否需要解密->先处理解密
- 看是否需要特殊cookie、headers、param参数加密生成
- 编写完整代码
这里我们打开链接、重新刷新后,直接用关键文本去搜(比如标题、链接),这里很显然直接就能搜索出结果:
于是我们就能容易定位到/v2/items 接口,响应数据没有加密,包含了我们想要的内容。
复制curl,通过https://spidertools.cn/#/curl2Request转成python-request请求
执行后能看到正常请求成功(如果403 就是已经过了一会, 已经失效了,重新拿一个就可以)
这里大致看一下请求,很显然包含了cookie、headers中也有些特殊的东西,表单参数没什么特别的
二、cookie生成
尝试将cookie 删除后会直接导致403,所以我们先看下cookie的生成逻辑
- cookie中包含两个值,_horizon_sid和_horizon_uid,全局搜索一下。搜出来只有一个结果,但是可以看到应该是做了映射,那就换成cookie_key_sid、cookie_key_uid重新搜
- 重新搜索,在结果中能够比较明显看出,cookie_key_sid生成的关键逻辑就是genUUID()方法
- 同样的cookie_key_uid
- 直接搜或者打断点再跳转到该方法,比较明显能看出来其实就是用来生成了一些特定格式的随机字符串
- 那么理论上来说 固定cookie也是可以的、或者自己保持格式随便改些数,也没问题。当然这里如果追求严谨,就按它的逻辑抠下来,也完全没问题(只能确定客户端生成逻辑,但在不知道后端校验逻辑的情况下,可以选择保守的方式。只是通常情况下都不需要考虑这么多)。看个人选择
三、headers生成关键函数定位
首先观察headers, 可以比较明显看出来,其中包含几个特别的参数
第一步还是先尝试删除,看是否是必需的。实际尝试后发现这几个都是必需的。
其中X-Dgi-Req-App明显是固定的,X-Dgi-Req-Nonce看起来可能是随机的,X-Dgi-Req-Signature不知道是什么,X-Dgi-Req-Timestamp时间戳就不用说了。所以我们重点确定Nonce和Signature的生成逻辑。
- 先尝试直接全局搜Signature,啥都找不到。搜其他的也是
- 可以先尝试搜索/items定位,确实可以找到一处。但是打上断点调试,调用栈太多了很难看明白,还是尝试换个办法
(以下是header生成核心方法的定位步骤,比较琐碎可以跳过)
-
尝试打上XHR断点,刷新后断在send处
-
然后通过调用堆栈往前找,可以看到这里前几个都是Promise相关的执行逻辑(这里要简单了解Promise即可)。前两个都可以看到header已经生成好了。再往前看到异步调用栈,打个断点看看。
-
这里如果简单理解下代码,可以看出是实现了一个串行异步操作,通过then调用可以看出是依次执行h中的函数
简单从python角度理解就是类似于(并不准确):- 定义了一个函数列表 h : 包含[strip, split, sort ];定义一个c, 初始是"abcdefg";然后就是执行"abcdefg".strip().split().sort()。只是这里是异步的
这里核心在于,我们可以看出c初始是一个Promise对象,通过Promise.resolve(n)定义,而这里n就是一个请求对象的东西
并且从中我们看到这里的headers中还尚未包含我们想要的Signature什么的。
当然这里也会发现请求地址「url」并不是我们想要的,因为我们刷新了页面,其他接口请求前也走到了这里,并不是请求/item时断住的。
但是可以敏锐的察觉到这里或许就是一些请求发起前的预处理,并且在执行h中的某个函数后,就向请求headers中添加了一些东西。
当然我们就需要验证一下, 验证方式就是:- 这里先打个条件断点,让它只有在请求接口是/item时才断住,看下这时headers中有没有Signature
- 如果没有,再然后从这里执行下去,看执行后,headers中会不会有Signature
取消原来的断点,右键点击在原位置改为打上条件断点。
for循环后也打上个断点,可以是return的位置。方便直接跳到最后
这里因为c是Promise链并不能直接看到c的结果,可以逐步向下走,到send处,才能确定。
或者也可以在h列表中的每个函数都打上断点,这样执行到这几个函数时,就可以看到每个函数执行的内容,是否有关于headers的操作。(事实上h的第一个函数执行u(o)就是)
这里过程琐碎,不再细致说明 -
除了通过打上XHR断点,其实还有其他方式。比如我们发现headers中其实包含时间戳Timestamp,那么可以通过搜索new Date、或者Date.now()。比如这里搜索Date.now()
- 可以看到有一堆结果,实际上定位起来也比较麻烦,就是分析代码、打断点,也比较费时间
- 通过以上方法,或者其他方式,最终我们就可以定位到下面这个函数u(),接下来就是抠这个函数了
四、headers生成关键函数逆向
关掉其他断点,只在关键函数u(o)中打个断点,重新刷新页面,断住后,可以看到传入的o是个请求对象,但是url并不是/item。
所以同样,这里我们将普通断点改为条件断点o.url.includes(‘/items’) ,重新刷新
向下走几步,可以看出前面之所以没办法直接搜索出X-Dgi-Req-App是因为是通过qu([56, 62, 52, 11, 23, 62, 39, 18, 16, 62, 54, 25, 25])这种方式实现的,
向下继续走到最后,可以大致分析出以下内容:
- X-Dgi-Req-App 固定
- X-Dgi-Req-Nonce 通过hne(16)方法生成
- 对应/item接口是post请求,只会走到else执行
- X-Dgi-Req-Signature 通过t1方法生成
- 传入四个参数:p是将字典参数转成固定url参数格式,其余就是其他的App、Nonce和固定的’k8tUyS$m’
- 但t1此处结果并不是字符串而是个init对象,包含个words数组
先按整体逻辑将js代码写下来, 整理一下, 删除明显不需要的东西, 补上明显可以看出的东西
function u() {
// _o.inc();
const a = Date.now()
, l = hne(16)
, c = 'k8tUyS$m'
, d = {
'X-Dgi-Req-App': 'ggzy-portal',
'X-Dgi-Req-Nonce': l,
'X-Dgi-Req-Timestamp': a
};
const p = t1({
p: 'type=trading-type&openConvert=false&keyword=&siteCode=44&secondType=A&tradingProcess=&thirdType=%5B%5D&projectType=&publishStartTime=&publishEndTime=&pageNo=1&pageSize=10',
t: a,
n: l,
k: c
});
d['X-Dgi-Req-Signature'] = p
return d
}
console.log(u())
关键就是其中的hne()和t1()方法。重新刷新,开始补
hne()方法没什么特殊的,跳转进入后把对应的缺失的函数和变量补上。会发现其实就是个生成固定长度的随机英文+数字字符串(所以其实固定写死也可以)
function dne(e, t) {
switch (arguments.length) {
case 1:
return parseInt(Math.random() * e + 1, 10);
case 2:
return parseInt(Math.random() * (t - e + 1) + e, 10);
default:
return 0
}
}
const lF = "zxcvbnmlkjhgfdsaqwertyuiop0987654321QWERTYUIOPLKJHGFDSAZXCVBNM"
, fne = lF + "-@#$%^&*+!";
function hne(e) {
return [...Array(e)].map(()=>lF[dne(0, 61)]).join("")
}
t1()函数逆向
t1()函数内部处理相对复杂,这里有好几种方式处理,更简单的和更原始的,这里列举两个例子
- 直接发现是SHA256加密,用SHA256算法替换原来的逻辑
跳转到t1,发现uK函数,其参数就是将已知内容拼接成字符串。pne()方法没什么特别的,直接补就行。
进入uK发现实际调用是_createHelper,搜一下,找到_createHelper,发现是赋值给SHA256方法
这里不管具体逻辑怎么嵌套,已经可以猜测,此处uK实际上就是sha256加密,加密参数也很清晰。那就直接写一个sha256加密试试,对比结果看看
比如用python写一个
import hashlib
def calculate_sha256(message):
sha256_hash = hashlib.sha256()
sha256_hash.update(message.encode('utf-8'))
return sha256_hash.hexdigest()
# 这个message就是u + o + decodeURIComponent(r) + n拼接的
message = 'HKO6UceFvo8xZALuk8tUyS$mkeyword=&openConvert=false&pageNo=1&pageSize=10&projectType=&publishEndTime=&publishStartTime=&secondType=A&siteCode=44&thirdType=[]&tradingProcess=&type=trading-type1712116609325'
sha256_digest = calculate_sha256(message)
print("SHA-256摘要:", sha256_digest)
# 输出:SHA-256摘要: 12d9f16fef71a00c4dd87d50580de92917f2bc8382fd173d281e724c2a791942
浏览器上直接执行到最后,对比下最终请求包含的Signature,显然就是一样的
这里还有个要注意的点是,如果调试时间太长,发起请求是网页会报错什么时间不一致,是因为请求对时间戳有校验超过一定时间差就不行了,重新刷新即可。并且重新刷新前,我们要仅保留最开始的条件断点,使其只有在构造/item接口请求时才断住,不然其他请求走到这也断住了,会产生干扰。条件断点断住后再打开其他的即可
到这里,t1()方法就很清晰了,那就没什么难点的,用js代码实现或者python代码实现都行。
这里就不贴了。
-
通用方式,不管是什么加密,一步步补上
上述使用sha256加密替换的方式,并不适用于所有网站,或者说即使是我们也不一定都能发现。如果是实际开发,当然是怎么简单怎么来,但这里如果我们目的是学习逆向处理思路,还可以参考以下过程。
还是先走到uK方法内,从这里开始处理
这里可以看到创建了一个v.init实例化对象,E是undefined。然后调用finalize。
这里也有两种方式,可以抠出完整的v对象,也可以单纯顺着finalize方法抠。这里就单纯从finalize方法开始抠。
不管v.init(E)。直接看finalize,进入finalize方法,打上断点,执行进去
可以看到这里实际执行了_append方法。进入_append方法。
同样的,接下来进入m.parse,再从m.parse进入h.parse,其中就是对v的处理,然后又创建一个d.init对象,d.init创建了一个包含v、y值的对象,对应word和sigBytes的对象。把这个方法单独写出来
到这里先把代码写一下,目前是梳理到_append()方法
function init(v, y) {
v = this.words = v || [],
y != undefined ? this.sigBytes = y : this.sigBytes = v.length * 4
}
function h_parse(v) {
for (var y = v.length, E = [], B = 0; B < y; B++)
E[B >>> 2] |= (v.charCodeAt(B) & 255) << 24 - B % 4 * 8;
return new init(E,y)
}
function m_parse(v) {
return h_parse(unescape(encodeURIComponent(v)))
}
var _data = new init
var _nDataBytes = 0
function _append(v) {
typeof v == "string" && (v = m_parse(v)),
_data = v,// this._data也是new d.init,创建的内容值为空,这里没有拿到concat, 直接赋值跟concat效果一样
_nDataBytes += v.sigBytes
}
function finalize(v) {
v && _append(v);
var y = _doFinalize();
return y
}
function uK(y, E) {
return finalize(y)
}
function pne(e) {
let t = "";
return typeof e == "object" ? t = Object.keys(e).map(n=>`${n}=${e[n]}`).sort().join("&") : typeof e == "string" && (t = e.split("&").sort().join("&")),
t
}
function t1(e={}) {
const {p: t, t: n, n: u, k: o} = e
, r = pne(t);
return uK(u + o + decodeURIComponent(r) + n)
}
这里关键就是_data、_nDataBytes、init方法的定义
然后就是_doFinalize(), 依次补齐_process()、以及其中_doProcessBlock等等方法,还有_hash什么的。太过琐碎,具体就不再仔细列举,缺什么就补上即可。
u()方法补完,执行后得到的结果以下这种格式,其中Signature需要进一步转换
{
'X-Dgi-Req-App': 'ggzy-portal',
'X-Dgi-Req-Nonce': '7wEVLIF6qYU4QwVV',
'X-Dgi-Req-Timestamp': 1712125314880,
'X-Dgi-Req-Signature': init {
words: [
806825146, 842258491,
2032025445, 274988583,
-1296157461, 752774242,
-1482948191, 591972000
],
sigBytes: 32
}
}
往后u()方法后继续执行,定位Signature转换逻辑,从后续Qn.from(e.headers)进入、最后会找到一个Da(): return e === !1 || e == null ? e : Y.isArray(e) ? e.map(Da) : String(e)就是核心方法了
(这里偷懒不再详细写调试过程了,太累了)
五、完整代码
js代码
const CryptoJS = require('crypto-js');
const lF = "zxcvbnmlkjhgfdsaqwertyuiop0987654321QWERTYUIOPLKJHGFDSAZXCVBNM"
, fne = lF + "-@#$%^&*+!";
function qu(e=[]) {
return e.map(t=>fne[t]).join("")
}
function dne(e, t) {
switch (arguments.length) {
case 1:
return parseInt(Math.random() * e + 1, 10);
case 2:
return parseInt(Math.random() * (t - e + 1) + e, 10);
default:
return 0
}
}
function hne(e) {
return [...Array(e)].map(()=>lF[dne(0, 61)]).join("")
}
function pne(e) {
let t = "";
return typeof e == "object" ? t = Object.keys(e).map(n=>`${n}=${e[n]}`).sort().join("&") : typeof e == "string" && (t = e.split("&").sort().join("&")),
t
}
function init(v, y) {
v = this.words = v || [],
y != undefined ? this.sigBytes = y : this.sigBytes = v.length * 4
}
var d = []
function _doProcessBlock(p, h) {
for (var m = _hash.words, C = m[0], g = m[1], v = m[2], y = m[3], E = m[4], B = m[5], A = m[6], _ = m[7], O = 0; O < 64; O++) {
if (O < 16)
d[O] = p[h + O] | 0;
else {
var P = d[O - 15]
, L = (P << 25 | P >>> 7) ^ (P << 14 | P >>> 18) ^ P >>> 3
, ue = d[O - 2]
, N = (ue << 15 | ue >>> 17) ^ (ue << 13 | ue >>> 19) ^ ue >>> 10;
d[O] = L + d[O - 7] + N + d[O - 16]
}
var H = E & B ^ ~E & A
, Q = C & g ^ C & v ^ g & v
, W = (C << 30 | C >>> 2) ^ (C << 19 | C >>> 13) ^ (C << 10 | C >>> 22)
, R = (E << 26 | E >>> 6) ^ (E << 21 | E >>> 11) ^ (E << 7 | E >>> 25)
, I = _ + R + H + c[O] + d[O]
, te = W + Q;
_ = A,
A = B,
B = E,
E = y + I | 0,
y = v,
v = g,
g = C,
C = I + te | 0
}
m[0] = m[0] + C | 0,
m[1] = m[1] + g | 0,
m[2] = m[2] + v | 0,
m[3] = m[3] + y | 0,
m[4] = m[4] + E | 0,
m[5] = m[5] + B | 0,
m[6] = m[6] + A | 0,
m[7] = m[7] + _ | 0
}
function _process(v) {
var y, E = _data, B = E.words, A = E.sigBytes, _ = 16, O = _ * 4, P = A / O;
v ? P = Math.ceil(P) : P = Math.max((P | 0) - 0, 0);
var L = P * _
, ue = Math.min(L * 4, A);
if (L) {
for (var N = 0; N < L; N += _)
_doProcessBlock(B, N);
y = B.splice(0, L),
E.sigBytes -= ue
}
return new init(y,ue)
}
var l = [],c = [];
(function() {
function p(g) {
for (var v = Math.sqrt(g), y = 2; y <= v; y++)
if (!(g % y))
return !1;
return !0
}
function h(g) {
return (g - (g | 0)) * 4294967296 | 0
}
for (var m = 2, C = 0; C < 64; )
p(m) && (C < 8 && (l[C] = h(Math.pow(m, 1 / 2))),
c[C] = h(Math.pow(m, 1 / 3)),
C++),
m++
}
)();
var _hash = new init(l.slice(0))
function _doFinalize() {
var p = _data
, h = p.words
, m = _nDataBytes * 8
, C = p.sigBytes * 8;
return h[C >>> 5] |= 128 << 24 - C % 32,
h[(C + 64 >>> 9 << 4) + 14] = Math.floor(m / 4294967296),
h[(C + 64 >>> 9 << 4) + 15] = m,
p.sigBytes = h.length * 4,
_process(),
_hash
}
function finalize(v) {
v && _append(v);
var y = _doFinalize();
return y
}
function h_parse(v) {
for (var y = v.length, E = [], B = 0; B < y; B++)
E[B >>> 2] |= (v.charCodeAt(B) & 255) << 24 - B % 4 * 8;
return new init(E,y)
}
function m_parse(v) {
return h_parse(unescape(encodeURIComponent(v)))
}
function clamp() {
var v = []
, y = 0
v[y >>> 2] &= 4294967295 << 32 - y % 4 * 8,
v.length = Math.ceil(y / 4)
}
var _data = new init
var _nDataBytes = 0
function _append(v) {
typeof v == "string" && (v = m_parse(v)),
_data = v,
_nDataBytes += v.sigBytes
}
function uK(y, E) {
return finalize(y)
}
function t1(e={}) {
const {p: t, t: n, n: u, k: o} = e
, r = pne(t);
return uK(u + o + decodeURIComponent(r) + n)
}
function u(param) {
// _o.inc();
const a = Date.now()
, l = hne(16)
, c = 'k8tUyS$m'
, d = {
[qu([56, 62, 52, 11, 23, 62, 39, 18, 16, 62, 54, 25, 25])]: qu([11, 11, 0, 21, 62, 25, 24, 19, 20, 15, 7]),
[qu([56, 62, 52, 11, 23, 62, 39, 18, 16, 62, 60, 24, 5, 2, 18])]: l,
[qu([56, 62, 52, 11, 23, 62, 39, 18, 16, 62, 40, 23, 6, 18, 14, 20, 15, 6, 25])]: a
};
const p = t1({
// p: 'type=trading-type&openConvert=false&keyword=&siteCode=44&secondType=A&tradingProcess=&thirdType=%5B%5D&projectType=&publishStartTime=&publishEndTime=&pageNo=1&pageSize=10',
p: param,
t: a,
n: l,
k: c
});
d[[qu([56, 62, 52, 11, 23, 62, 39, 18, 16, 62, 53, 23, 11, 5, 15, 20, 22, 19, 18])]] = p
return d
}
function deal_param(data) {
rst = ""
for (k in data) {
rst += k + "=" + data[k] + "&"
}
return rst.slice(0, -1)
}
function setHead(param) {
if (typeof param == 'string'){
param = JSON.parse(param)}
rst1 = u(deal_param(param))
const wordArray = CryptoJS.lib.WordArray.create(rst1['X-Dgi-Req-Signature']['words']);
const hexString = CryptoJS.enc.Hex.stringify(wordArray);
rst1['X-Dgi-Req-Signature'] = hexString;
return rst1
}
//
// param = {
// "type": "trading-type",
// "openConvert":false,
// "keyword": "",
// "siteCode": "44",
// "secondType": "A",
// "tradingProcess": "",
// "thirdType": "[]",
// "projectType": "",
// "publishStartTime": "",
// "publishEndTime": "",
// "pageNo": 1,
// "pageSize": 10
// }
// console.log(setHead(param))
这里还有个点需要注意的是,如果在js代码中调试时使用console.log(setHead(param))执行,用完必须注释掉。因为其中有对全局变量执行了_nDataBytes += v.sigBytes,在使用python代码调用时,编译时先执行一次,调用后再次执行,会导致_nDataBytes值会被加两次,就不对了
python代码
import execjs
import requests
import json
from proxy.get_proxy import get_proxy
headers = {
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Content-Type": "application/json",
"Origin": "https://ygp.gdzwfw.gov.cn",
"Pragma": "no-cache",
"Referer": "https://ygp.gdzwfw.gov.cn/",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
"X-Dgi-Req-App": "ggzy-portal",
"sec-ch-ua": "\"Chromium\";v=\"122\", \"Not(A:Brand\";v=\"24\", \"Google Chrome\";v=\"122\"",
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": "\"macOS\""
}
cookies = {
"_horizon_uid": "095b34f0-a40b-40a0-89d9-11dcefe38afd",
"_horizon_sid": "8946bb7e-79f8-40c8-bd0f-c356a7903607"
}
url = "https://ygp.gdzwfw.gov.cn/ggzy-portal/search/v2/items"
data = {
"type": "trading-type",
"openConvert": False,
"keyword": "",
"siteCode": "44",
"secondType": "A",
"tradingProcess": "",
"thirdType": "[]",
"projectType": "",
"publishStartTime": "",
"publishEndTime": "",
"pageNo": 1,
"pageSize": 10
}
with open('test.js', 'r') as f:
js_ = f.read()
js_c = execjs.compile(js_)
header_rst = js_c.call('setHead', json.dumps(data))
headers['X-Dgi-Req-App'] = header_rst['X-Dgi-Req-App']
headers['X-Dgi-Req-Signature'] = header_rst['X-Dgi-Req-Signature']
headers['X-Dgi-Req-Nonce'] = header_rst['X-Dgi-Req-Nonce']
headers['X-Dgi-Req-Timestamp'] = str(header_rst['X-Dgi-Req-Timestamp'])
data = json.dumps(data, separators=(',', ':'))
response = requests.post(url, headers=headers, cookies=cookies, data=data, proxies=get_proxy())
print(response.text)
print(response)
总结
仅供学习参考,如有疑问欢迎交流,一起学习进步