Tor 暗网爬虫的一些要点

最近为了做一些统计,需要到 Tor 这个暗网上采集大量信息。想起很久以前为了研究 Silk Road, EIC, Dr.D, Scream Bitch 和 PlayPen 写过几个爬虫,拿来用 scrapy 重构了一番,做了一个 Tor 暗网通用爬虫。

由于法律风险,此爬虫不开源,仅公开关键部分的代码供参考,此文主要阐述一些暗网爬虫和明网爬虫的区别,爬取注意事项以及开发、生产环境的设置。

所有代码都按 MIT License。

基础

首先,在 Tor 网络上爬东西和在明网上并没有多少不同,因为 Tor 方面只是在本地开了一个 socks5 代理,基本上让爬虫走 tor 的方法和设置程序走任何 socks5 代理的方法是一样的。在中国由于 shadowsocks 的盛行所以实际上没有太多挑战,设置方法都一样,指定 all_proxy 为 socks5://127.0.0.1:9150 即可。

因为 dns 查询默认是不走 tor 的 socks5 代理的,如果直接把爬虫的 all_proxy 指过去却不管 dns,所有 .onion 域名当然是 NXDOMAIN 了。。所以爬虫内设置代理一定要注意这些。

scrapy 有自己支持 HTTP 代理,但是我们可以不用这个代理,要走暗网的话当然是期望一丝不漏地把流量全都从暗网丢出去。。所以可以把请求都 hook ,走我们指定的代理。这里就可以用 proxychains 这个软件 (mac 上是 proxychains-ng)。考虑到需要 http 代理,然而 tor 提供的是 socks5,可以先用 polipo 或者 privoxy 转一下,方法也简单,还是本地开一个,设置好转发就可以了。

DNS的设置方法

scrapy 的 dns lookup 实现是在 scrapy/scrapy/resolver.py 文件中调用 twisted 模块的 twisted.internet.base.ThreadedResolver(object)
[相关文档](https://twistedmatrix.com/documents/16.6.0/api/twisted.internet.base.ThreadedResolver.html)

而 twisted 在解析域名的过程中实质上调用的是 python 标准库 socket 模块的方法 socket.gethostbyname(hostname)
[文档](https://docs.python.org/3/library/socket.html#socket.gethostbyname)

你可以从这些模块中间的任意位置实现一个装饰器把 dns lookup hook 到 tor 代理上。

如果不需要长期部署的话,也可以直接用 proxychains 等程序直接强制 python 走本地代理。比方说在本地设置好 tor 代理 (假设开的本地端口是9150) 后,在本地新建一个proxychains的配置文件如下

# proxychains.conf VER 4.x
 strict_chain
 proxy_dns
 remote_dns_subnet 224
 tcp_read_time_out 15000
 tcp_connect_time_out 8000
 [ProxyList]
 socks5 127.0.0.1:9150

然后以新的配置文件通过 proxychains 启动 scrapy 即可

爬虫策略

暗网和明网的一个超大的区别就在于暗网上没有 javascript 代码,所以爬取结果就只有一个静态文件直接处理,非常方便。

因为 tor 本身就是匿名代理,也不用再代理换 ip 了,省钱了。

唯一比较有问题的就是账号访问权限问题,因为暗网很多网站服务端是会记录用户访问的,最好多开几个账号(反正不用验证邮箱,验证码也基本上都是儿戏,机器批量注册几百个就行了),账号分给不同的 slave 去爬,但是最好注意 referer 的逻辑上的连贯性。。(不要从不可能的位置“点击”进某个页面去。。)

每个 slave 一定要新建一个 tor ciucuit,否则会大幅拖慢爬取的速度。

程序设计方面

  • 爬取策略

    暗网的论坛程序一般都比较简陋,很多程序 thread 竟然是用数字 id 的方法排序的,所以要全部爬的话最佳方法就是遍历 thread id。

  • 登录设计

登录方面都很容易设计,因为没有 javascript,服务端性能也一般,网站登录设计大多相同,直接构造数据 post 即可,有验证码的一般也多为固定题库或者简单的 php 生成的小图片,很容易程序识别。

比如说,这里是爬取 Magic Kingdom 的工程 demo 文件,可以看出只需要调用 scrapy 的 FormRequest.from_response 就可以方便地登录。。

# File: mks/mk/spiders/m.py
import scrapy
import logging
import re

from mk.items import MkItem

posts_number = re.compile(r'''(\d+) posts''')

class MkSpider(scrapy.Spider):
    name = 'MagicKingdom'
    start_urls = ['http://nj************og.onion/index.php']
    allowed_domains = ['nj************og.onion']

    def parse(self, response):
        yield scrapy.FormRequest.from_response(
            response,
            method='POST',
            # headers={'Content-Type': 'multipart/form-data'},
            url='http://nj************og.onion/ucp.php?mode=login',
            formdata={
                'username': 'BadUsErnAME',
                'password': 'N0TaGoOdPa$sw0Rd123',
                'login': 'Login',
                'redirect': '.%%2Findex.php%%3F',
            },
            callback=self.parse_index
        )

    def parse_index(self, response):
        for i in range(16700):
            yield scrapy.Request(
                'http://nj************og.onion/viewtopic.php?t={}'.format(i),
                callback=self.parse_thread,
                meta={'i':i}
            )

    def parse_thread(self, response):
        t = response.meta['i']

        t_title = response.xpath('//title/text()')[0].extract()

        if 'Information' in t_title:
            item = MkItem()
            item['url'] = response.url
            item['title'] = '404'
            item['thread_content'] = []
            yield item

        else:
            l = response.xpath('//div[@class="pagination"]/text()').extract()
            d = 0
            for i in l:
                if 'posts' in i:
                    d = int(posts_number.search(i).group(1))
                    break
            if d == 0:
                logging.log(logging.INFO, 'No pagination found for {}'.format(response.url))
                d = 1
            for i in range(0, d, 10):
                yield scrapy.Request(
                    'http://nj************og.onion/viewtopic.php?t={}&start={}'.format(t,i),
                    callback=self.parse_thread_page
                )

    def parse_thread_page(self, response):
        item = MkItem()
        item['url'] = response.url
        item['title'] = response.xpath('//title/text()')[0].extract()
        item['thread_content'] = response.xpath('//div[@class="content"]').extract()
        yield item
  • 多媒体处理

tor 暗网的另一个特色是它的图床和 Referer 检查,我们的爬虫爬到的 img_list 常常混杂很多 onion 上的图床,这些图床一方面只能通过 tor 访问,另一方面尝尝会检查 referer 从而确定访问来源,为了避免日后需要下载这些图片时麻烦,建议爬取的时候都把 url 记录下来备用。另外,silkroad (尤其是 reloaded 站) 上大量垃圾卖家会上传很多 duplicate 的图片,更多时候甚至是直接盗链,onion 图床一般又巨慢,先检查 url 去重再下载能节省不少时间。

  • 二进制文件和文件链接

    由于 tor 暗网在很多地区速度较慢,很难直接传输大文件,因此很多文件传递是通过 anonfiles 等 clear web service 实现的。由于很多论坛缺乏有效管理,很多情况下这些文件的下载地址在页面中的出现位置不定,file host 也常常变化。同时因为传递的文件通常非法,一般都经过严密的加密,并且常常会上传多份镜像,而解压密码也常常混杂在页面内容中难以分离。

    可以说,从这些位置爬取到的数据非常脏,数据清洗不是一件容易的事,如果要下载这些文件,需要付出相当的劳动。

    不过因为之前爬取了整个 sis 论坛和草榴论坛积累了大量类似数据,我在今年早些时候已经用机器学习做了一个简单的提取模型,通过 NLP 分词后做 classification,对中英文都有奇效。。

  1. 对每个文件,都在其来源处尝试提取 possible specific passwords。
  2. 通常帖子作者都会在原贴内标出密码,对帖子的内容分词后做 pattern match,is_password 的 possibility 大于临界值就归类为 possible specific passwords。
  3. 用 possible specific passwords 依次尝试解密文件,如果成功,该密码加入 all passwords dictionary,同时提升该 pattern 的权重。
  4. 大量数据输入(因为在学习的早期 model 不成熟,利用很低,数据有限不能浪费。。。所以我尝试了复用一次学习早期输入的数据,效果不错)
  5. 循环学习爆破,当几乎不再有 specific password 出现的时候,把仍然未能解密的文件标记出来,用之前成功过的密码(all passwords dictionary)爆破(原理是有很多发帖人在不同的帖子里会复用同一个密码,比如四散的尘埃、扶她奶茶之类)我用 sis 和 草榴 的数据训练了一个模型,然后去处理了 magic kingdom 的大约 10000 个thread,效果良好(何止是良好简直有奇效)。4152个文件解密后仅剩余44个文件不能解密,剩余的文件里手动查看之后发现多数都是来自同一家 file host,可能是该 file host 被恶意劫持,从它家下载的文件都不能正确解密,重新下载也没用,遂放弃。另一些则是密码提取有问题,不过这么多文件只需要手动解密几个,我已经很满足了。。。而处理 giftbox exchange 这样的论坛的时候因为论坛发帖格式非常严格,所以几乎所有文件都成功解密。这个策略由于早期和末期涉及遍历爆破,所以有效程度和 data size 关系密切。。如果数据太多会花费很长时间,不过因为 tor 暗网网站内容普遍不像其它暗网那么多,所以这个勉强算 effective solution 吧。。
  • 下载策略

    暗网上共享的文件常常上传到不同的 file host,图片也常常上传到多个 image host。方便地从这些地方下载文件的方法是将所有下载链接提取之后通过 xml-rpc 调度 aria2 统一下载。具体方法相当简单,可以写外部脚本,也可以内置到爬虫中。例如内置到 python 中:

import xmlrpc.client

# 先在远程服务器上以合适的配置文件启动 aria2, 并让它监听rpc port 6800,然后再执行以下代码
server_uri = 'http://username:[email protected]:6800/rpc' # 这里为了 demo 方便用了 http basic auth,在生产环境中为了安全请勿使用。请使用 token 授权。
proxy = xmlrpc.client.ServerProxy(server_uri, allow_none=True)

proxy.aria2.addUri(list_of_uris, dict(dir=directory_to_save))

11 thoughts on “Tor 暗网爬虫的一些要点”

    1. Sorry, 暂时不打算分发源码。但是主要的架构和坑都已经写出来了,去github上找些开源组件拼接一下就可以了

  1. 最近在做这块、有很大的迷茫点需要助希望可以跟您联系一下。回复一下我谢谢啦

  2. 您好 ,能讲下 privoxy 和 shadowsocks 在整个过程中的作用吗…
    我的理解是 privoxy 将 http流量转换成 sock5的流量 ,然后给tor,tor之后交给shadowsocks只是简单加密做个转发,然后到tor真正的网络中,是这样的吗…跪求解答下…

    1. 已经回复你的邮件了。
      实际上并不需要 Shadowsocks, 我只是用 shadowsocks 举个例子。
      这样就可以
      crawler -> (http) -> privoxy(127:0.0.1:8118) -> (socks5) -> Tor(127.0.0.1:9050) -> (socks5) -> Tor-network
      当然在中国访问 tor 很慢,可以用shadowsocks中转来加速,那样的话就是像你说的一样。

Leave a Reply to python小学生 Cancel reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.