数据抓取实践:对加密参数及压缩混淆 JS 的逆向分析

2018-05-17 09:55:15 +08:00
 lxy

本文也发布在我的博客

之前在 V 站上看到一个帖子,内容是楼主想抓取数据的网站做了参数加密,不知如何解密。刚好最近有点空闲时间,可以尝试解密一下网页参数,而且很久没练手了。

文中会介绍几种分析技巧,需要一点前端知识(总感觉在前端做防爬没什么意义,因为源码都是公开的)。文末附上爬虫 Demo 验证,虽然对于这个案例来说使用 Selenium 可能才是合适的解决方法,但<del>暴力破解才是男人的浪漫!</del> ...嗯本文的重点只是在于分析解密的过程。有些图片因代码过长未包含在内,意会即可。

1. 一夫当关 - XHR Breakpoints

网站是七麦数据。我们要抓取的内容是页面上的 App Store 排行榜数据。

通过分析网络请求我们可以发现,榜单数据是通过 Ajax 请求来获取的。返回的数据格式是明文 Json。

请求参数如下:

analysis: dDB4Fi8wUEF...(太长,略)
brand: all
country: cn
device: iphone
genre: 36
date: 2018-05-14
page: 1

各个参数所代表的含义都较为易懂,除了 analysis。猜测是一个经过 Base64 编码后的加密参数,事实上的确如此,隔一段时间再利用相同的 analysis 提交请求时会被拒绝。

要解密参数,只能去看 JS 的加密代码。我们需要查看是哪部分的 JS 代码发起了请求,一般的方法是点击请求列表的 Initiator 跳转到代码部分。

不过我更推荐另一种方法:添加 XHR Breakpoints 拦截请求。这样有两个好处,一是可以直接观察代码上下文的变量在发出请求前的数值,二是方便直接调试。此处填入 URL 包含的关键词 indexPlus

我们也可以在 Watch 处添加变量 h 进行观察,得知 h 是一个 XHR 对象。

然后再来看代码……写得乱七八糟的是什么鬼!

2. 穿针引线 - Module Require

为了应对 Web 应用越来越复杂的趋势,前端趋向模块化开发,各种自动化构建工具成为必不可少的开发利器。

跳蚤大神说得好呀,若想真正掌握爬虫技术,就要了解一个网站是怎样构建起来的。在此意义上,学习爬虫所需的前置知识还挺多的。

来观察这堆 JS 中的一段代码。

"7O1s": function(t, e, n) {
    var r = n("DIVP")
        , i = n("XSOZ")
        , o = n("kkCw")("species");
    t.exports = function(t, e) {
        var n, a = r(t).constructor;
        return void 0 === a || void 0 == (n = r(a)[o]) ? e : i(n)
    }
},
"7UMu": function(t, e, n) {
    var r = n("R9M2");
    t.exports = Array.isArray || function(t) {
        return "Array" == r(t)
    }
},
"7gX0": function(t, e) {
    var n = t.exports = {
        version: "2.5.5"
    };
    "number" == typeof __e && (__e = n)
},

虽然代码经过了混淆,但是依然可以看出类似 7O1s 之类的随机字符串是模块名称,而 var r = n("DIVP") 即引入模块,正常的写法可能是 import a from 'b' 或者 const a = require('b')

这里发起 Ajax 请求的函数很可能只是一个被封装了的模块供整个项目调用,粗略看一下函数代码也没有发现计算加密的部分。

针对这种模块化开发,一个逆向的思路是,只要查看该模块被引用的情况,不断向上追溯,总能找到最初发起请求和加密的函数。

将网站所有 JS 文件拷贝到本地,检索断点所在的模块名 7GwW

得知其由模块 KCLY 引入,接着检索 KCLY

得知其有三个模块引入,不怕,我们先选第一个XmWM继续检索,如果没找到再回来

得知其由模块 tIFN 引入,继续检索,由mtWM引入,继续检索

得出最终结果,是由模块 gXmS 组装的请求参数。

只要找到组装请求的代码,分析过程就算完成了一半。

3. 顺藤摸瓜 - Call Stack

可能有人要抱怨了,感觉这样查找好麻烦呀,有没有更简便的方法?

当然有。在理解了第二点分析的模块化组织代码的原理后,我们可以使用更简便的方法—— Call Stack。

如图,通过从上至下依次查看调用栈上的代码,检查一下其所在的模块是否是要查找的目标。

get 方法即是模块 gXmS 中的函数。需要注意的是,此方法在模块的末尾,如果不细心可能会错过。

4. 偷天换日 - Hijack The Response

先在目标模块 gXmS 下个断点吧

可以看到虽然变量 f 被很机智地用 Base64 重新编码了(不完全是,还有个解密函数,注意这里的 p.g 和 p.a ),但是在调试器下其解码值 analysis 很容易暴露。

不过重点还是在 l.a.interceptors.request.use 的那行,里面是完整的参数组装过程,附这一段代码:

l.a.interceptors.request.use(function(a) {
    try {
        var n = +new Date - (h.difftime ? h.difftime : 0) - 1515125653845
            , e = ""
            , t = [];
        return void 0 === a.params && (a.params = {}),
        m()(a.params).forEach(function(n) {
            if (n == f)
                return !1;
            a.params.hasOwnProperty(n) && t.push(a.params[n])
        }),
        t = t.sort().join(""),
        t = Object(p.d)(t),
        t += "@#" + a.url.replace(a.baseURL, ""),
        t += "@#" + n,
        t += "@#1",
        e = Object(p.d)(Object(p.g)(t)),
        -1 == a.url.indexOf(f) && (a.url += "?" + f + "=" + encodeURIComponent(e)),
        a
    } catch (a) {}
}, function(a) {
    return i.a.reject(a)
}),

代码被用逗号运算符精简了。由于 Chrome 调试器的单步执行是以表达式为单位,因此这里我们无法对重要的变量 t 的每一步转换进行观察调试。

我们需要对代码进行修改,然后让浏览器运行修改后的代码。方法是劫持 JS 文件。

具体手段有很多种,比如 Nginx 反代,Fiddler 等。在此我选择 ReRes 插件,用法很简单就不具体介绍了。

劫持后,我们可以随意更改代码以方便输出获取我们想要的信息,如下图我加了一个 v1 中间变量。

通过单步调试后,得出组装的过程,大致步骤如下:

  1. 设置一个时间差变量
  2. 提取查询参数值(除了 analysis )
  3. 排序拼接参数值字符串并 Base64 编码
  4. 拼接自定义字符串
  5. 自定义加密后再 Base64 编码
  6. 拼接 URL

那么如何得知自定义加密函数和 Base64 编码函数?

通过这一行 var v1 = Object(p.d)(t); 我们可得知 p.d 是 Base64 编码函数,因为单步调试时可观察到变量 t 经过这行代码后被编码。

然后看这一行 e = Object(p.d)(Object(p.g)(t));,我们已知 p.d 是 Base64 编码函数,那么 p.g 就是一个未知函数。需要找到 p.g 的函数本体。

在 Watch 一栏添加 p.g,点击 FunctionLocation 的值,即可跳转到该函数。其实 p.g 同时也是一个解密函数,异或运算经常被用来加解密。

5. 东搜西罗 - Search

这里再多说一点技巧。由于模块化开发,可能会引用到第三方库的模块,因此我们可以利用模块中的一些附带字符串在网上搜索也许会得到额外的信息。

搜索相关错误信息后得知网页用了 Node.js 的 Buffer 模块。

进一步分析还发现引入 Buffer 模块的目的之一就是为了方便 Base64 编码。

6. 一锤定音 - Crawler

最后写一个 50 行的简单爬虫来验证分析,抓取 iPhone 免费榜单。

#!/usr/bin/env python3

import time
import json
import base64
import requests
from urllib.parse import urlencode


headers = {
    "Accept": "application/json, text/plain, */*",
    "Referer": "https://www.qimai.cn/rank",
    "User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:57.0) Gecko/20100101 Firefox/59.0"
}

params = {
    "brand": "all",
    "country": "cn",
    "date": "2018-05-14",
    "device": "iphone",
    "genre": "36",
    "page": 1
}


# 自定义加密函数
def encrypt(a, n="9d1abd758c043319aee5ee1c0e3f26c6"):
    s = list(a)
    n = list(n)
    sl = len(s)
    nl = len(n)
    for i in range(0, sl):
        s[i] = chr(ord(s[i]) ^ ord(n[i % nl]))
    return "".join(s)


def main():
    # iPhone 免费榜单
    # 提取查询参数值并排序
    s = "".join(sorted([str(v) for v in params.values()]))
    # Base64 Encode
    s = base64.b64encode(bytes(s, encoding="ascii"))
    # 时间差
    t = str(int((time.time() * 1000 - 1515125653845)))
    # 拼接自定义字符串
    s = "@#".join([s.decode(), "/rank/indexPlus/brand_id/1", t, "1"])
    # 自定义加密 & Base64 Encode
    s = base64.b64encode(bytes(encrypt(s), encoding="ascii"))
    # 拼接 URL
    params["analysis"] = s.decode()
    url = "https://api.qimai.cn/rank/indexPlus/brand_id/1?{}".format(urlencode(params))
    # 发起请求
    res = requests.get(url, headers=headers)
    j = json.loads(res.text)
    print(j)


if __name__ == '__main__':
    main()

EOF

6338 次点击
所在节点    分享创造
21 条回复
dobelee
2018-05-17 10:00:55 +08:00
人生在于折腾。
lxy
2018-05-17 10:07:39 +08:00
悲剧,后面图片顺序乱了……还是到我博客看吧 http://blowingdust.com/
LeungJZ
2018-05-17 10:15:14 +08:00
赞👍。
glacer
2018-05-17 10:32:12 +08:00
2 年前破解过过去哪儿网网页的酒店订单接口,深感这才是做爬虫的真正乐趣!
qqpkat2
2018-05-17 10:47:41 +08:00
自从用了一个 XX 控件就能直接把 js 执行后的 html 代码给返回,就再也懒得去分析 js 加密了
soulmine
2018-05-17 10:50:17 +08:00
为嘛不直接用 chromeless 呢 有解密混淆的功夫爬虫都写完了 emmm
qsnow6
2018-05-17 11:37:40 +08:00
干货!分享一个调试 cookies 的东西,只要监控到 cookies 被改写,就自动跳断点
https://github.com/paulirish/break-on-access
yamedie
2018-05-17 11:43:15 +08:00
@soulmine 是 chrome headless😀
wdd2007
2018-05-17 16:58:07 +08:00
厉害了。
lh900519
2018-05-17 17:37:19 +08:00
学习了
soulmine
2018-05-17 19:04:27 +08:00
@yamedie 不 我说的是 js 包
codehz
2018-05-17 19:12:43 +08:00
chrome 有本地 override 功能的,不需要外部辅助
xuanyuanaosheng
2018-05-18 17:07:11 +08:00
学习,mark
frankyxu
2018-05-19 09:22:15 +08:00
666666,学习了,向大佬致敬
sergiojune
2018-07-28 19:13:21 +08:00
@lxy 您好,请问可以转载到日常学 python 公众号吗?会标明原创地址
lxy
2018-07-28 20:59:12 +08:00
@sergiojune 可以的
mrcomer
2018-08-05 17:11:29 +08:00
@qsnow6 您分享的工具里的 snippets 的导入的链接失效了,能讲解一下这个工具如何导入 chrome 开发者工具使用吗,望回复,谢谢!!!
mrcomer
2018-08-05 17:19:59 +08:00
@qsnow6 专门为您注册了 V2EX 的账号,然后等了一天回来给您发的提问,对 V2EX 提问规则,我昨天差点把电脑给砸了。。。。
lxy
2018-08-06 11:02:40 +08:00
@mrcomer 安装扩展后直接添加规则就可以了,映射到本地文件。Chrome 扩展需要勾选允许访问文件网址。

https://i.loli.net/2018/08/06/5b67b97cc80c8.png
mrcomer
2018-08-07 10:17:49 +08:00
@lxy 感谢解答!!!

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/455492

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX