爬虫代码已复制

Python爬虫实战C08 JS逆向实战练习案例解析

爬虫实战练习C08案例解析

打开Python爬虫实战练习C08页面 爬虫实战练习C08,页面上的是金融数据,涵盖股票(科技、金融、能源)、ETF(指数、行业)、加密货币,字段包括代码、名称、价格、涨跌幅等。先点击“立即验证”看看要求计算什么数据,发现是要求计算 Price 列的平均值。

初步分析

想要打开开发者工具,发现快捷键跟右键都失效了,只能从浏览器菜单打开了。

打开浏览器开发者工具之后刷新一下页面,看看都加载了什么。发现了一个 API 数据接口,有三个参数,返回的响应内容看似是加密数据。

在开发者工具 -> 网络,点击 API 的请求,右边切换到启动器,直接点击请求调用堆栈(c08.min.js:1),就自动跳转到发起这个请求的 JavaScript 代码行了,看到一个 fetch 关键字,先在这一行代码下个断点。

根据正常的逻辑,请求参数肯定是在发起请求之前生成的,所以以这一行代码为中心,往上翻一下代码,就能够看到 JS 变量定义的代码:

const timestamp = Math[_0x5e4801(-0x1dd, -0x1ec, -0x1fa, -0x214)](Date[_0x5e4801(-0x200, -0x1fe, -0x1f0, -0x225)]() / (0xf4e + -0x2427 + 0x18c1))
  , salt = btoa(performance[_0x5e4801(-0x1d3, -0x1fe, -0x1d2, -0x200)]()[_0x49b309(0x320, 0x2f1, 0x334, 0x2ff)]())
  , hash = CryptoJS[_0x5e4801(-0x1dc, -0x1d5, -0x1e6, -0x1d3)]('' + salt + timestamp, window[_0x5e4801(-0x1d5, -0x202, -0x1ed, -0x21a)][_0x49b309(0x32b, 0x304, 0x30d, 0x33a)]);
let signature = CryptoJS[_0x49b309(0x33d, 0x33a, 0x318, 0x327)][_0x5e4801(-0x1cc, -0x1cd, -0x1a2, -0x1d3)][_0x5e4801(-0x244, -0x21e, -0x238, -0x219)](hash)
  , matches = [];

这样看这段代码,也能猜到生成了一个时间戳、一个盐值、一个哈希值、一个由 CryptoJS 签名的值,哈希值 hash 由盐值、时间戳参与了运算。更详细的逻辑就要调试一下这段代码了。

JS逆向动态调试

在上面这段代码 const 这一行下个断点,刷新页面,执行完 salt = ... 这一行后,在右侧作用域可以看到 salt 的值已经变成了类似 NDUwMzY3LjM5OTk5OTk3NjE2 这样的24位的字符串。timestamp 变量的值也出现了,可以确定就是时间戳。而且 salt 的赋值有 salt = btoa(performance...这样的关键字,把它里面的参数逐一打印出来就能知道完整的代码。

接下来就要搞清楚 hash 变量的计算逻辑。单步调试的时候注意观察右边作用域,发现 _0x4281a0 变量出现了 HmacSHA256 这样的关键字,这是一个签名算法,先记下来。至于 hash 后面那一段我们可以试试直接在控制台打印出来。

console.log(_0x5e4801(-0x1d3, -0x1fe, -0x1d2, -0x200));//salt = btoa(performance...
console.log(_0x49b309(0x320, 0x2f1, 0x334, 0x2ff));//salt = btoa(performance...
console.log(window[_0x5e4801(-0x1d5, -0x202, -0x1ed, -0x21a)][_0x49b309(0x32b, 0x304, 0x30d, 0x33a)]);

发现打印出来乱码, 以上三行代码的结果都是乱码的可能性不大,而且我们在动态调试的过程中右边是一直有一行乱码的,还有 originalLog 这样的关键字,JS 代码里面也发现了 console['log'] = function(..._0x185951) 这样的代码,所以应该想到 console.log 是被下钩子了,JS 函数在浏览器里是可以 Hook 的。所以先验证一下是不是 console.log 的问题。

在控制台执行:

console.log('test');

发现输出同样的乱码,百分百肯定是 console.log 被 Hook 了,试试 originalLog :

originalLog('ok');

这样就正常了,说明是把 console.log 指向了 originalLog 这个变量,那我们就直接使用 originalLog 就好了。

回到刚才要打印的内容,在控制台执行:

originalLog(_0x5e4801(-0x1d3, -0x1fe, -0x1d2, -0x200));//salt = btoa(performance...
originalLog(_0x49b309(0x320, 0x2f1, 0x334, 0x2ff));//salt = btoa(performance...
originalLog(window[_0x5e4801(-0x1d5, -0x202, -0x1ed, -0x21a)][_0x49b309(0x32b, 0x304, 0x30d, 0x33a)]);
//Output: https://spiderbuf.cn/web-scraping-practice/scraper-practice-c08

根据输出的内容,把盐值的取值还原成了 btoa(performance.now().toString()) 这样的代码。上面也输出了本次爬虫练习的完整网址,也就是说哈希值 hash 的计算逻辑是把盐值跟时间戳以字符串的形式相连,然后使用本练习的网址作为 Key,使用 HamcSHA256 计算出来的,因为 HamcSHA256 函数加密出来默认是十六进制的,但我们看到的请求参数是类似 Base64 的字符串,所以可以想到 signature 就是把变量 hash 的值转换成 Base64 。

继续单步调试,等 signature 的值也在作用域里出现的时候,找一个 HamcSHA256 的在线工具,把 salt、timestamp 的值拼接成

NDUwMzY3LjM5OTk5OTk3NjE21758904121

输出格式选择 Base64,就会发现在线工具计算出来的值与作用域中的 signature 变量的值一致。

这里要注意一下:计算逻辑取了当前的网址,所以推测后台校验的话要么写死了,要么就是从 HTTP Header 的 Referer 中取,为了保险起见,要在爬虫代码里面加入这个 Header 。

到此就解决了请求参数的计算逻辑,先编写爬虫代码尝试一下看能不能正常请求:

timestamp = int(time.time())
timestamp_str = str(timestamp)

salt_raw = '{:.6f}'.format(time.perf_counter() * 1000)  # 保留小数,类似浏览器行为
salt = base64.b64encode(salt_raw.encode()).decode()    # 标准 Base64(btoa)

message = (salt + timestamp_str).encode()

digest = hmac.new(base_url.encode(), message, hashlib.sha256).digest()
signature_b64 = base64.b64encode(digest).decode()

# 5a) 方式 A:让 requests 帮你做 URL 编码(通常足够)
params = {'t': timestamp_str, 's': salt, 'sig': signature_b64}
resp = requests.get(base_url + '/api', params=params, headers=myheaders)
print('请求 URL(requests 自动编码):', resp.url)
print('状态码:', resp.status_code)
print('响应 body:', resp.text)

解密响应数据

以上的爬虫代码已经可以顺利拿到响应数据,接下来就是研究如何解密数据了。在 fetch 之后的代码就是处理数据 API接口的逻辑了,通过观察代码发现了 CryptoJS、WordArray、iv、padding 这样的关键字,很容易就联想到 AES 算法了。所以就要看 AES 解密用的密文、Key、IV 是什么逻辑。

在 fetch 函数里,第211-212行代码发现了 const、CryptoJS、signature 关键字,所以这里就有可能是跟解密的参数有关的。

const _0xbb591e = _0x406a3f
  , _0x2bd26d = CryptoJS[_0x1c1752(0x249, 0x219, 0x252, 0x23a)][_0x1c1752(0x254, 0x224, 0x252, 0x244)][_0x1da990(-0x8b, -0x67, -0x84, -0xa5)](signature[_0x1da990(-0xc0, -0xcd, -0x9e, -0xe5)](-0x44 * -0x2f + 0x16a5 + -0x2321, 0xd * -0x11f + 0x2 * 0xf32 + -0x25 * 0x6d));

如果有足够的耐心,可以把 _0x1c1752(0x249, 0x219, 0x252, 0x23a) 这样的代码在控制台一个一个打印出来,这些函数通常都是由混淆时生成的混淆代码,返回的都是字符串,然后交给 eval 函数执行。所以一个个打印出来之后,就可以拼凑出完整的代码,再结合浏览器开发者工具的替换功能,逐步把整个代码文件变成可读性强的代码,这样分析起来就清晰了。

还原后的 JavaScript 代码:

const key = CryptoJS.enc.Utf8.parse(signature.slice(0, 16)); // Key 长度需为 16 字节(128-bit)
// 先 base64 解码
const cipherData = CryptoJS.enc.Base64.parse(data.d);
const cipherBytes = CryptoJS.lib.WordArray.create(cipherData.words, cipherData.sigBytes);

const ciphertext = CryptoJS.lib.WordArray.create(cipherBytes.words.slice(4), cipherBytes.sigBytes - 16);

// 解密
const decrypted = CryptoJS.AES.decrypt({ ciphertext }, key, {
  // 提取 IV(前 16 字节)与密文(剩下的)
  iv: CryptoJS.lib.WordArray.create(cipherBytes.words.slice(0, 4), 16),// 4 * 4 bytes = 16 bytes
  mode: CryptoJS.mode.CBC,
  padding: CryptoJS.pad.Pkcs7
});

const raw = decrypted.toString(CryptoJS.enc.Utf8);
const items = JSON.parse(raw);

转换成 Python 爬虫代码:

result = json.loads(resp.text)
key = signature_b64[:16].encode("utf-8")

cipher_data = base64.b64decode(result['d'])

# 前 16 字节是 IV,后面才是密文
iv = cipher_data[:16]
ciphertext = cipher_data[16:]

# AES-CBC 解密
cipher = AES.new(key, AES.MODE_CBC, iv)
decrypted = cipher.decrypt(ciphertext)

# 去掉 PKCS7 填充
pad_len = decrypted[-1]
decrypted = decrypted[:-pad_len]

# 转成 UTF-8 字符串并解析 JSON
raw = decrypted.decode("utf-8")
items = json.loads(raw)

print(items)

为什么不直接使用 Selenium

因为在静态分析 JS 代码里就已经发现了 navigator['webdriver'] 这样的代码,这些代码明显是在检测 Selenium 之类的 webdriver 的,所以使用 Selenium 是无法爬虫到数据的,有兴趣的朋友可以动手验证一下。

完整的爬虫案例代码:爬虫案例代码