【爬虫实战】使用Python和JS逆向观鸟网Search接口

前言

中国观鸟记录中心,这个网站有点特殊,不同于平时常见的网站,header的部分字段和响应数据都是加密的。最重要的是加密方式是在Ajax中处理的。综上所述,记录一下这类网站的逆向过程。

一、目的整理

首先看一下目标数据:

可以看到header里有个Sign的加密字段。

提交的数据也是加密后的:

1
IlK5l/qQzp2wezTIFutF0AhbjY1YFnaupfdqZDsQEGPRmJuseYDk0aC72qTdeUMExSFPav6CkGqUNvQ/K2HawAQ3dO5naN23qx/I9QZboOdfxwJ5KJ2Rq++s5qRv0DiC8YaC+n3+dokGwzQGXIzvtkSOgeOOzEsuo47NXcInmQU: 

响应数据也是一样加密的:

1
2
3
4
5
6
7
8
{
"code": 0,
"count": 365105,
"data": "tOlWskGAcviXt0rdOEToBWsBg0Ch1EkiAcpX7208UVAFBKwJtL5sDR6h/VV2f7YeJfKbF2Yvav2nsH1P8h1zCXv/b4ygt4uxYOfNU+8n...后续省略",
"timestamp": 1700450808,
"sign": "1387B4CC7227D0DD5C5CD3459AF60197",
"requestId": "ED797483-CCB0-4CC6-8362-D0253922BA7F"
}

所以我们逆向的顺序就是先破解Sign,然后破解提交的data,最后就是响应数据。

二、逻辑分析

1. 函数定位

首先要分析Sign的生成方式定位。如果按照往常全局搜索的方式的话,大概率是拿不到想要结果的,因为这个网站的相关功能真的搜不到。其实无论是Sign关键字还是搜索接口地址都是一样的。这种情况就需要使用另外一种方式了,使用启动器逐级查找。毕竟隐藏的再深的接口或者函数总之要在启动器这里留下痕迹的。

一级一级去查:

需要注意URL得是正确的

可以看到这时候已经有返回值了,所以要往上一层找。

接下来就找到了这里,可以看到rk分别携带了加密的Sign和提交的data。

既然已经生成了明文,所以就需要继续往前找。这样就找到了t.ajax

里面有一个明显的参数dataheaders,但是这个header里是未定义的,data里的数据也不是密文。所以再继续请求看一下。

这时候可以看到请求成功后返回的数据就是咱们之前看到的接口响应数据。

到了这里就可以说明一些问题,既然在发送请求前dataheader都是明文的,加密处理就只能出现在发送请求这一段代码里,也就是t.ajax。接下来再次定位一下这段代码验证一下。

现在就跟踪到了jquery.min.js,说明有可能这里的代码被改写过。这个函数代码有很多,浏览一下看看哪里有嫌疑的地方打上断点。由图可知,到了这里,dataheader还都是明文的。对于后面的代码不太好直接判断哪些代码是相关的,只能根据一些经验进行猜测,比如相关的就是dataheader,所以在后面打上断点。

可以看到k.data的值已经变化了,之前的是一个对象,现在变成了字符串。但是header还是空白的,所以还得继续完后走。

2. 加密逻辑

继续走到后面发现l的data和k的data已经生成密文了,所以加密函数一定在下面的代码里:

1
2
if (k.beforeSend && (k.beforeSend.call(l, v, k) === !1 || 2 === t))
return v.abort();

这时候答案就呼之欲出了,加密逻辑在这里:

  • c是日期函数生成的
  • d 是uuid
  • e是之前的字符串JSON处理
  • f 就是前面三个参数进行MD5处理。

可以看到使用的是encrypt这个加密方式对sign做了处理。其实这个库在node.js 中对应的是node-jsencrypt,使用的时候要提前安装。

但是在复制JS代码后执行的时候会发现没有encryptUnicodeLong这个方法,因为这个是自定义的。因此需要修改源码来实现,点击encryptUnicodeLong可以看到下面的代码

可以看到有若干的中文注释,所以又很好的佐证了这是自定义的方法。所以我们完全可以仿照这种写法将这三段放入我们的源码中。找到node-jsencrypt 的相关文件加进去就可以了:

全部代码如下:

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
//任意长度RSA Key分段加密解密长字符串

//获取RSA key 长度
JSEncrypt.prototype.getkeylength = function () {
return ((this.key.n.bitLength() + 7) >> 3);
};

// 分段解密,支持中文
JSEncrypt.prototype.decryptUnicodeLong = function (string) {
var k = this.getKey();
//解密长度=key size.hex2b64结果是每字节每两字符,所以直接*2
var maxLength = ((k.n.bitLength() + 7) >> 3) * 2;
try {
var hexString = b64tohex(string);
var decryptedString = "";
var rexStr = ".{1," + maxLength + "}";
var rex = new RegExp(rexStr, 'g');
var subStrArray = hexString.match(rex);
if (subStrArray) {
subStrArray.forEach(function (entry) {
decryptedString += k.decrypt(entry);
});
return decryptedString;
}
} catch (ex) {
return false;
}
};

// 分段加密,支持中文
JSEncrypt.prototype.encryptUnicodeLong = function (string) {
var k = this.getKey();
//根据key所能编码的最大长度来定分段长度。key size - 11:11字节随机padding使每次加密结果都不同。
var maxLength = ((k.n.bitLength() + 7) >> 3) - 11;
try {
var subStr = "", encryptedString = "";
var subStart = 0, subEnd = 0;
var bitLen = 0, tmpPoint = 0;
for (var i = 0, len = string.length; i < len; i++) {
//js 是使用 Unicode 编码的,每个字符所占用的字节数不同
var charCode = string.charCodeAt(i);
if (charCode <= 0x007f) {
bitLen += 1;
} else if (charCode <= 0x07ff) {
bitLen += 2;
} else if (charCode <= 0xffff) {
bitLen += 3;
} else {
bitLen += 4;
}
//字节数到达上限,获取子字符串加密并追加到总字符串后。更新下一个字符串起始位置及字节计算。
if (bitLen > maxLength) {
subStr = string.substring(subStart, subEnd)
encryptedString += k.encrypt(subStr);
subStart = subEnd;
bitLen = bitLen - tmpPoint;
} else {
subEnd = i;
tmpPoint = bitLen;
}
}
subStr = string.substring(subStart, len)
encryptedString += k.encrypt(subStr);
return hex2b64(encryptedString);
} catch (ex) {
return false;
}
};
//添加的函数与方法结束

三、代码实现

1. 发出请求获取数据

需要注意提前安装:

1
npm install node-jsencrypt            

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
var JSEncrypt = require("node-jsencrypt");
var crypto = require("crypto");


function getUuid() {
var s = [];
var a = "0123456789abcdef";
for (var i = 0; i < 32; i++) {
s[i] = a.substr(Math.floor(Math.random() * 0x10), 1)
}
s[14] = "4";
s[19] = a.substr((s[19] & 0x3) | 0x8, 1);
s[8] = s[13] = s[18] = s[23];
var b = s.join("");
return b
}

function sort_ASCII(a) {
var b = new Array();
var c = 0;
for (var i in a) {
b[c] = i;
c++
}
var d = b.sort();
var e = {};
for (var i in d) {
e[d[i]] = a[d[i]]
}
return e
}

function dataTojson(a) {
var b = [];
var c = {};
b = a.split('&');
for (var i = 0; i < b.length; i++) {
if (b[i].indexOf('=') != -1) {
var d = b[i].split('=');
if (d.length == 2) {
c[d[0]] = d[1]
} else {
c[d[0]] = ""
}
} else {
c[b[i]] = ''
}
}
return c
}

var paramPublicKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCvxXa98E1uWXnBzXkS2yHUfnBM6n3PCwLdfIox03T91joBvjtoDqiQ5x3tTOfpHs3LtiqMMEafls6b0YWtgB1dse1W5m+FpeusVkCOkQxB4SZDH6tuerIknnmB/Hsq5wgEkIvO5Pff9biig6AyoAkdWpSek/1/B7zYIepYY0lxKQIDAQAB";
var encrypt = new JSEncrypt();
encrypt.setPublicKey(paramPublicKey);

function get_headers(b) {
var c = Date.parse(new Date());
var d = getUuid();
var e = JSON.stringify(sort_ASCII(dataTojson(b || '{}')));
b = encrypt.encryptUnicodeLong(e);
var f = crypto.createHash("md5").update(e + d + c).digest('hex');
return {
"headers": {
timestamp: c + "", requestId: d, sign: f
}, "data": b
}
}

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
import execjs
import requests
from urllib.parse import urlencode


def start():
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36",
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
}
with open('观鸟网.js') as f:
js_code = f.read()
js = execjs.compile(js_code)
data = {
"limit": "20",
"page": "1",
}
encoded = urlencode(data)
print('编码后的数据', encoded)
sign_data = js.call("get_headers", encoded)
print("sign_data:::", sign_data['data'])
headers.update(sign_data['headers'])
print("headers:::", headers)
url = 'https://api.birdreport.cn/front/activity/search'
res = requests.post(url, headers=headers, data=sign_data['data'])
data = res.json()
print(data)


if __name__ == '__main__':
print(start())

输出结果:

2. 解密请求数据

其实解密部分就在最开始的ajax发请求的那里

可以看到a.parseData(t)就是解析的方法。

加下来是具体的decode

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var BIRDREPORT_APIJS = {
"url": "www.birdreport.cn", "key": "3583ec0257e2f4c8195eec7410ff1619", "iv": "d93c0d5ec6352f20"
}

function decode(a) {
var b = CryptoJS.enc.Utf8.parse(BIRDREPORT_APIJS.key);
var c = CryptoJS.enc.Utf8.parse(BIRDREPORT_APIJS.iv);
var d = CryptoJS.AES.decrypt(a, b, {
iv: c, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7
});
return d.toString(CryptoJS.enc.Utf8)
}

function parseData(res) {
var decode_str = decode(res.data);
var results = JSON.parse(decode_str);
return {
"code": res.code, "count": res.count, "data": results
};
}

python部分:

1
2
3
4
res = requests.post(url, headers=headers, data=sign_data['data'])
data = res.json()
decode_data = js.call('parseData',data)
print(decode_data)

直接把密文传给解密函数即可。

输出:

1
2
{'code': 0, 'count': 365555, 'data': [{'timeend': '2022-10-24 11:52', 'point_id': 54428, 'domain_type': 0, 'address': '湖南省长沙市天心区中南林业科技大学(东区)', 'outside_count': 0, 'point_name': '中南林业科技大学(东区)', 'userid': 36405, 'timebegin': '2022-10-24 10:52', 'province_name': '湖南省', 'visits_count': 0, 'district_name': '天心区', 'city_name': '长沙市', 'serial_id': '2023112100136', 'taxoncount': 1, 'name': '2023112100136', 'ctime': '2023-11-21 10:55:35', 'location': '112.99929,28.13190', 'id': 554130, 'state': 2, 'username': '中南林野保文鸟', 'statistics': 1}.....省略]}

四、另外一种定位方式—hook函数

1. 初步理解

在编程中,”hook” 函数通常指的是一个被设计成允许其他函数插入其执行过程的函数。这种机制通常用于自定义或扩展某个功能,允许用户在特定的事件或操作发生时插入自己的代码。

那么在这个网站上如何使用呢?其实可以这么理解,hook只是一种思路,其目的就是伪造一个相似的函数名使相关请求进来,但是我们不做处理,然后再跳出去。

当在控制台插入写的伪造函数,就会取代原有的目标函数,原有逻辑就会调用新的伪造函数。

首先编写一个同样名字的函数,原函数为foo()

1
2
3
4
5
6
var _foo=foo
function foo() {
console.log("foo 截获开始")
debugger;
console.log("foo 截获结束")
}

然后打断点

将刚才的伪造函数在控制台执行。

之后再点击继续执行脚本的箭头,此时会停留到伪造函数中的debugger上。

点击调用堆栈,双击匿名函数就到了目标函数的位置。

2. 简单使用

在这个网站上我们看到有加密的Sign以及data,但是基本可以猜测一下是否用到了JSON.stringfy,比如在处理data数据,具体有没有可以试一下。首先在目标接口的启动器上找到最初的调用的那一级,然后打断点。

刷新后在控制台输入代码:

1
2
3
4
5
6
7
8
(function() {
var stringfy = JSON.stringify
JSON.stringify = function (params){
console.log("Hook JSON.stringfy:::",params)
debugger;
return stringfy(params)
}
})();

这时候就捕获到了第一次使用JSON.stringfy,但是我们要留意当前调用的上一级,也就是截图中的beforeSend,是不是就是咱们之前看到那个加密的位置!

假设是第一次使用,不知道具体位置,但是现在可以看一下beforeSend代码是否和加解密有关,打个断点试试。

加密

以上只是一个初步使用,hook函数还有很多种使用方式。

五、总结

其实在这个网站上卡壳时间最长在以下几点:

  1. 首先就是加密算法的自定义,之前没想到过,以为是版本问题
  2. 其次就是在加密函数那里,首次调试的时候把变量写死了,导致后续调用的时候总是验证失败

经过这个网站的分析,增长了不少经验,也获取了更高效的技巧。

参考