37DATA

关于外部信息采集

前言

本文将从个人角度分享一些外部信息采集的方法以及技术理解,并提出一些个人认为合理的防范措施,希望能提供帮助。

本文主要分为四部分,其一为一些数据采集方法介绍,会从API、WEB、APP三种应用场景逐一分享;其二为一些数据采集工具的分享;其三为简略的采集服务调度模型分享;其四为数据采集防范的相关看法和经验分享。

一、数据采集方法

A ) 从API接口进行采集

描述

场景:API服务对接

特点:

  1. 可预见,变化少,开发门槛低

  2. 使用量有明文要求,错误有明显提示,维护成本低

  3. 数据可靠性高

使用方式:HTTP请求构造

示例

API文档截图

Image

请求指令

curl -H "Authorization: Bearer XXX" "https://api.appannie.com/v1.3/apps/google-play/app/XXXXXX/ratings?page_index=0"

结果

{"ratings": [{"country": "WW", "current_ratings": null, "all_ratings": {"star_1_count": 334019, "average": 3.88, "star_5_count": 1106111, "rating_count": 1921701, "star_2_count": 91834, "star_4_count": 238298, "star_3_count": 151439}}], "page_index": 0, "code": 200, "prev_page": null, "next_page": null, "page_num": 1, "product_name": "Paper.io 2"}

B ) 从WEB内容进行采集

其一:HTTP请求构造

描述

场景:目标数据为WEB内容(通常通过浏览器的开发者工具即可直接获得的数据)

特点:

  1. 难点在请求内容解析,构造相对简单

  2. 不可预见,变动情况不定,返回内容多样,高度定制化

  3. 使用量无明文要求,错误需要自行发现和判断,维护成本高

  4. 数据可靠性不定

使用方式:HTTP请求分析和构造

示例

假设我们要获取“天气”关键字的搜索联想词信息

Image

通过浏览器可以发现请求格式大致如下

curl 'https://www.baidu.com/sugrec?pre=1&p=3&ie=utf-8&json=1&prod=pc&from=pc_web&sugsid=34267,34099,34225,31660,34277,33848,34092,34111,26350&wd=%E5%A4%A9%E6%B0%94&req=2&bs=%E5%A4%A9%E6%B0%94&csor=2&cb=jQuery110206309653347430035_1626857712796&_=1626857712797' \  -H 'Connection: keep-alive' \  -H 'Pragma: no-cache' \  -H 'Cache-Control: no-cache' \  -H 'sec-ch-ua: " Not;A Brand";v="99", "Google Chrome";v="91", "Chromium";v="91"' \  -H 'Accept: text/javascript, application/javascript, application/ecmascript, application/x-ecmascript, */*; q=0.01' \  -H 'X-Requested-With: XMLHttpRequest' \  -H 'sec-ch-ua-mobile: ?0' \  -H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36' \  -H 'Sec-Fetch-Site: same-origin' \  -H 'Sec-Fetch-Mode: cors' \  -H 'Sec-Fetch-Dest: empty' \  -H 'Referer: https://www.baidu.com/s?wd=xxxxxx' \  -H 'Accept-Language: zh-CN,zh;q=0.9' \  -H 'Cookie: xxxxx' \  --compressed

获得结果

{"q":"天气","p":false,"g":[{"type":"sug","sa":"s_1","q":"天气预报"},{"type":"sug","sa":"s_2","q":"天气预报郑州"},{"type":"sug","sa":"s_3","q":"天气预报15天查询"},{"type":"sug","sa":"s_4","q":"天气郑州"},{"type":"sug","sa":"s_5","q":"天气预报河南"},{"type":"sug","sa":"s_6","q":"天气预报24小时详情"},{"type":"sug","sa":"s_7","q":"天气河南"},{"type":"sug","sa":"s_8","q":"天气预报下载2021最新版免费"},{"type":"sug","sa":"s_9","q":"天气热皮肤起小红疙瘩特别痒"},{"type":"sug","sa":"s_10","q":"天气太热怎么发朋友圈"}],"slid":"1872835131631399082","queryid":"0x98a5a127c2f0aa"}

局限
  1. 格式多样,可能需要做HTML、XML等内容解析

  2. 有状态的请求前后,需要维护cookie

  3. 难以实现页面加载和运行动作(例如处理验证码、获取JS计算结果等),有些页面还会通过接口返回JS内容,然后再通过浏览器执行JS来获取数据

  4. 页面上的媒体资源(例如图片)无法直接获取,需要先解析出链接地址,然后构造请求进行获取

其二:浏览器模拟操作

描述

场景:目标数据为WEB内容(无法通过构造请求直接获得的数据),通过模拟用户操作浏览器获取

特点:

  1. 需要借助三方工具,本质是模拟用户操作进行自动化测试

  2. 不需构造请求,但需要明确用户操作流程

  3. 仅能获取页面运行后的结果,如果需要获取过程中产生的具体请求内容,需要借助代理工具

使用方式:通过浏览器驱动操作浏览器以获取页面内容

示例

以python使用selenium + geckodriver + firefox进行数据采集为例

仅获取页面结果

封装一个selenium的firefox驱动的Firefox类

# -*- coding: utf-8 -*-import timefrom selenium import webdriverclass Firefox():    driver = None    def __init__(self):        # 浏览器设置        profile = webdriver.FirefoxProfile()        profile.set_preference("dom.webdriver.enabled", False)        profile.set_preference("accept_untrusted_certs", True)        self.profile = profile        # 启动参数设置        options = webdriver.FirefoxOptions()        options.add_argument("--disable-infobars")        options.add_argument("--disable-extensions")        options.add_argument('--no-sandbox')        # 无界面模式,可以加快运行效率,linux服务器无界面情况必选。开发时可以不选,这里为了演示先关闭        # options.add_argument('--headless')        # 忽略证书错误,以避免https安全警告        options.add_argument('--ignore-certificate-errors')        self.options = options    def set_proxy(self, proxy):        self.profile.set_proxy(proxy)    def start(self):        # 注意必需先安装好firefox浏览器        # 驱动地址        driver_path = "./tools/geckodriver.exe"        # 启动浏览器        self.driver = webdriver.Firefox(firefox_profile=self.profile, executable_path=driver_path,                                        firefox_options=self.options)    def stop(self):        self.driver.quit()

调用上方Firefox类请求咱们公司官网

if __name__ == '__main__':   # 启动浏览器驱动   firefox = Firefox()   firefox.start()   # 访问公司介绍页   firefox.driver.get("https://www.37wan.net/introduction.html#company")   # 简单处理,等待一下加载完成,实际使用时可以通过观察页面目标元素是否已加载出来来判断   time.sleep(10)   print(firefox.driver.page_source)   # 退出浏览器   firefox.stop()

浏览器驱动运行后,打开浏览器并访问了我们的目标网址

Image

代码运行的结果如下

Image

代理获取具体请求

继续沿用上面的Firefox类,新增继承browsermobproxy的Server类的ProxyServer类,用来加入我们的配置

from browsermobproxy import Serverclass ProxyServer(Server):    def __init__(self):        # 屏蔽父类构造函数        # super(ProxyServer, self).__init__()        self.process = None        self.proxy = None        self.selenium_proxy = None        # 配置browsermob-proxy        self.java_path = "java"        self.path = "./tools/browsermob-proxy-2.1.4/bin/browsermob-proxy"        self.proxy_dir = "/tools/browsermob-proxy-2.1.4"        self.proxy_jar_path = "./tools/browsermob-proxy-2.1.4/lib/browsermob-dist-2.1.4.jar"        self.host = "localhost"        self.port = 3737        self.command = []        self.command += [            self.java_path,            "-Dapp.name=browsermob-proxy",            "-Dbasedir=%s" % self.proxy_dir,            "-jar",            self.proxy_jar_path,            '--port=%s' % self.port       ]    def get_process_pid(self):        return self.process.pid    def start(self, options=None):        super(ProxyServer, self).start(options=options)        self.proxy = self.create_proxy()        self.selenium_proxy = self.proxy.selenium_proxy()    def new_har(self):        # 代理选项,截取http头、内容        options = {'captureHeaders': True, 'captureContent': True, 'captureBinaryContent': True}        self.proxy.new_har("har", options=options)

我们在浏览器驱动中设置好代理,然后来获取我们官网页面中的公司发展史接口数据

if __name__ == '__main__':    # 创建代理    local_proxy = ProxyServer()    local_proxy.start()    # 实例化浏览器驱动    firefox = Firefox()    # 给浏览器设置好代理    firefox.set_proxy(local_proxy.selenium_proxy)    # 启动浏览器    firefox.start()    driver = firefox.driver    # 重置请求记录    local_proxy.new_har()    # 加载页面    driver.get("https://www.37wan.net/introduction.html#company")    time.sleep(5)    # 此次页面加载执行的所有请求记录    entries = local_proxy.proxy.har['log']["entries"]    # 遍历所有请求记录,通过目标接口地址筛选出我们要的数据    for entry in entries:        if str(entry['request']['url']).find("https://www.37wan.net/api/web/about/history") != -1:            print(entry)            break    # 退出浏览器    firefox.stop()    # 退出代理    local_proxy.stop()

获取回来的请求记录数据格式如下

{ "pageref": "har", "startedDateTime": "2021-07-21T17:56:41.072+08:00", "request": {  "method": "GET",  "url": "https://www.37wan.net/api/web/about/history",  "httpVersion": "HTTP/1.1",  "cookies": [],  "headers": [           {"name": "Host","value": "www.37wan.net"},            {"name": "User-Agent","value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:84.0) Gecko/20100101 Firefox/84.0"},            {"name": "Accept","value": "*/*"},            {"name": "Accept-Language","value": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2"},            {"name": "Accept-Encoding","value": "gzip, deflate, br"},            {"name": "X-Requested-With","value": "XMLHttpRequest"},            {"name": "Connection","value": "keep-alive"},            {"name": "Referer","value": "https://www.37wan.net/introduction.html"}       ],  "queryString": [],  "headersSize": 389,  "bodySize": 0,  "comment": "" }, "response": {  "status": 200,  "statusText": "OK",  "httpVersion": "HTTP/1.1",  "cookies": [],  "headers": [           {"name": "Date","value": "Wed, 21 Jul 2021 09:56:35 GMT"},            {"name": "Content-Type","value": "application/json; charset=utf-8"},            {"name": "Transfer-Encoding","value": "chunked"},            {"name": "Connection","value": "keep-alive"},            {"name": "Server","value": "nginx"},            {"name": "Cache-Control","value": "no-cache"},            {"name": "Pragma","value": "no-cache"},            {"name": "Cache-Control","value": "no-cache"},            {"name": "Cache-Control","value": "max-age=0"},            {"name": "X-Via","value": "1.1 PSzjnbsxsy229:9 (Cdn Cache Server V2.0), 1.1 uzhoudianxin65:12 (Cdn Cache Server V2.0), 1.1 mdx76:13 (Cdn Cache Server V2.0)"},            {"name": "X-Ws-Request-Id","value": "60f7ef53_PS-000-01QTd185_18413-23653"}       ],  "content": {   "size": 17238,   "mimeType": "application/json; charset=utf-8",   "text": "《这里是具体请求的返回内容,太长这里就不显示了》",   "comment": ""  },  "redirectURL": "",  "headersSize": 458,  "bodySize": 17238,  "comment": "" }, "cache": {}, "timings": {"comment": "","connect": -1,"send": 0,"receive": 2,"blocked": -1,"wait": 164,"ssl": -1,"dns": -1}, "serverIPAddress": "113.96.140.117", "comment": "", "time": 166}
局限
  1. 单浏览器进行的操作是串行的,并行操作需要多开浏览器,并发处理效率不高

  2. 对三方工具依赖性很强,使用时必需做好异常捕获、报警、自我恢复,以及时发现和处理问题

  3. 服务器上运行时没有图形化界面,需要借助代码调用浏览器驱动进行截图来debug

C ) 从APP应用进行采集

其一:HTTP请求构造

描述

场景:目标数据为APP应用数据(可解析并构造请求的数据)

特点:

  1. 难点在请求内容解析,构造相对简单

  2. 需要通过代理截取请求进行解析,HTTPS请求需要想办法破解

  3. 使用量无明文要求、错误需要自行发现和判断,维护成本高

  4. 数据可靠性不定、请求和返回格式不可预见、变动情况大致与APP版本变动节奏一致

使用方式:通过代理截取(或补充破解https)分析APP请求内容,再进行请求构造

示例

这里以应用宝APP的预约列表为例

先通过Charles代理,直接获取代理请求内容进行分析,发现是乱码。

Image

在Charles设置解析目标域名下的内容

Image

再次访问发现请求失败

Image

接下来我们给模拟器加装Charles的证书

Image

重新访问,发现请求内容已经解析出来

Image

请求解析完成后,接下来只需要和之前一样进行请求构造获取数据即可,此处不继续演示。

局限
  1. HTTPS破解门槛较高,限制较多

  2. 安卓7.0后多数只信任系统自带证书,如果修改需要root权限,这就要求APP能在root环境下运行。安卓5.0虽无证书限制,但是要求APP兼容。即使满足前面两点,如果APP有证书加固,或者有内置加密方式,则依旧无法解出密文获取内容(除非破解源码)。

  3. 如果有通过APP操作设置请求状态的行为,则难以实现破解

其二:手机模拟操作

描述

场景:目标数据为APP内布局节点文字数据,通过模拟用户操作手机获取

特点:

  1. 重点在手机调度、APP操作及节点解析上

  2. 仅能通过节点解析获取文字内容,无法获取媒体资源(但可以操作截图)

  3. 需要使用硬件,或者使用云手机服务,维护成本较高

  4. 数据可靠性不定、变动情况大致与APP版本变动节奏一致

使用方式:本质是通过Airtest(基于adb调试)进行自动化测试,操作手机以获取数据

示例

这里使用华为云手机进行示例演示。我们先启动一个连接远程虚拟手机的ssh链接

Image

通过airtest查看设备

Image

示例代码

# -*- encoding=utf8 -*-from airtest.core.api import *if __name__ == '__main__':    target_link = "android:///127.0.0.1:3737"    connect_device(target_link)    from poco.drivers.android.uiautomation import AndroidUiautomationPoco    pc = AndroidUiautomationPoco(use_airtest_input=True, screenshot_each_action=False)    list_view = pc("android:id/content").child("android.widget.RelativeLayout").offspring(        "com.vivo.game:id/list_view").child("android.widget.LinearLayout")    for item in list_view:        try:            title = item.offspring("com.vivo.game:id/game_common_title").get_text()            time = item.offspring("com.vivo.game:id/game_publish_time").get_text()            number = item.offspring("com.vivo.game:id/game_appointment_number").get_text()        except:            continue        print(title, time, number)        break

运行结果

Image

二、一些工具分享

  • Selenium

    地址:https://www.selenium.dev/

    介绍:Selenium 是一系列工具和库的综合项目,这些工具和库支持 web 浏览器的自动化。

  • BrowserMobProxy

    地址:https://github.com/lightbody/browsermob-proxy/releases

    介绍:BrowserMobProxy会提供一个ProxyServer用于做转发代理拦截,这个server可以是单独部署,也可以编码进代码中。

  • airtest

    地址:https://airtest.netease.com/

    介绍:网易游戏出品的一个移动端自动化测试框架

  • Charles

    地址:https://www.charlesproxy.com/

    介绍:代理和抓包工具

三、简单的采集调度模型

结构设计

Image

此模型采用多线程方式进行工作,主要分为三层,有五个模块。

三层

第一层:数据源管理层
第二层:任务调度层
第三层:任务和请求处理层

五模块

模块1:数据源采集线程(第一层)
模块2:Generator(第二层)
模块3:Manager(第二层)
模块4:Worker(第三层)
模块5:Request(第三层)

运行说明

  1. 主线程依据需要,启动对应数量的【数据源采集线程】,维护这些线程的运行状态

  2. 【数据源采集线程】线程启动一个任务管理【Manager】线程,同时可以按需启动一个【Generator】任务生成线程来自动产生任务

  3. 【Manager】线程定时扫描任务记录,当有任务需要被启动时,启动该任务对应的【Worker】任务处理线程,开始处理任务

  4. 【Worker】线程为任务处理最小执行单位,一个【Worker】线程对应一个任务记录。【Worker】线程依据任务生成时指定的参数配置,组装并生成对应数量的请求记录,并负责调度这些请求,启动【Request】线程并交付其处理。【Worker】还将在在请求完成后进行过滤和回调。

  5. 【Request】请求处理线程为请求处理最小执行单位,一个【Request】线程对应一个请求记录。【Request】会依据自身参数配置(url、header、get、post)执行请求,过滤结果并写入ETL处理队列。

  6. 一个任务对应【Worker】下所有请求【Request】处理完成后,【Worker】会进入“任务结束”流程,处理回调逻辑,并更新任务状态。

  7. 至此【Worker】与【Request】生命周期往复循环,【数据源采集线程】【Generator】【Manager】常驻并提供服务

四、数据采集防范

  1. 防范请求伪造

  • 使用带验证信息的请求,同时加强验证信息设置的安全性

    比如请求必须验证登录态,同时登录必须做好验证,防止自动化操作。在目前图像文字提取的算法已经普及的情况下,需要提高攻击者的门槛,比如使用图片含义识别可以在校验方面提高门槛,而使用拖拽方式完成校验可以提高操作门槛。

  • 内容加密,同时用户端定时更换加密算法

    内容加密,可以防止攻击者直接通过解析http协议获取内容。此外,对于web端来说,虽然前端源码可能有混淆,但依旧有被找出加密算法的风险,所以定时更换加密密钥没有直接更换加密算法可靠。通过频繁更换算法,可以提高攻击者需要人为介入的频次,从而使其长期的采集失去意义。

  • 对于WEB应用,可以通过JS执行计算出页面内容

    请求构造只能拿到请求内容,如果页面不直接使用请求结果,而是通过JS计算出来,也可以提高攻击门槛,因为无论是解析JS算法或是手动执行JS代码都需要更繁琐的操作。

  • 防范模拟操作

    • 对于重要数据做访问验证,在验证时尽量使用自动化门槛更高的校验方式

      如果是模拟手机操作,除了常见的校验,还可以通过手机号校验或者其他硬件功能来进行校验,例如摇一摇、语音验证等,对于攻击者来说门槛更高。

    • 对于web应用,可以尝试在不影响页面呈现的情况下随机变换节点布局和名称

      对于攻击者,拿到返回内容后自然需要解析,而对于HTML内容解析,我们可以采取随机变换布局的方式进行反制。通常为了解析HTML,我们需要分析目标元素所在的节点,然后通过class、id或标签等元素进行相对或绝对路径定位。如果这些名称、标签或相对位置可以随机变换的话,对于想做精确解析的攻击者来说无疑是很棘手的。

    • 特征识别

      对于模拟操作,通常都会有一些特点存在,例如模拟手机操作的airtest是基于adb调试,那么进行手机模拟操作就必须打开调试模式;对于浏览器自动化操作的selenium,会有一些内置的JS属性(例如window.navigator.webdriver)。

      除此之外也可以考虑统计分析用户访问频次或者操作日志,然后划分风险等级进行管控。