找回密码
 立即注册

QQ登录

只需一步,快速开始

搜索
广告投放联系QQ68610888
查看: 11484|回复: 29

OpenWRT反DNS劫持总结

[复制链接]
本文是我正在写的《翻墙路由器的原理与实现》中的一部分,全文链接:https://docs.google.com/document ... SD1CZV4XY1OMG8/edit
目前写到了穿墙部分,基于OpenWRT路由器做了一些穿墙实验。第一部分关于DNS劫持的已经完成。摘录如下:

实验环境准备

        穿墙比翻墙要复杂得多,但也有意思得多。本章节以实验为主。实验的设备是家庭用的路由器,我用的是水星4530R。需要有公网IP。刷的操作系统是OpenWRT Attitude Adjustment 12.09 rc-1版本。使用的包有:
NetfilterQueue(https://github.com/fqrouter/fqrouter 中有)
bind-dig
shadow
dpkt (不是OpenWRT的包,是python的 http://dpkt.googlecode.com/files/dpkt-1.7.tar.gz
        本文并不打算详细讲解实验环境的设置。对于有OpenWRT编译和刷机经验的朋友可能可以按照我的叙述重建出实验环境来。整个实验的关键在于
公网上的ip地址
Linux
python
python访问netfilter queue的库
        如果你有一台公网上的Linux机器,安装了Python和Python的NetfilterQueue,也可以进行同样的实验。
        如果你使用的是路由器,需要验证你有公网ip。这个可以访问ifconfig.me来证实。其次要保证路由器是OpenWRT的并且有足够的空间安装python-mini。到这里基本上都和普通的OpenWRT刷机没有什么两样。重点在于:
安装Python的NetfilterQueue

        OpenWRT提供了NetfilterQueue的C的库。但是使用C来做实验太笨重了。所以我选择了Python。但是Python的NetfilterQueue的库没有在OpenWRT中。下载https://github.com/fqrouter/fqrouter 解压后可以得到一个名字叫fqrouter的目录。然后给feeds.conf添加一行src-link fqrouter /opt/fqrouter/package。把/opt/fqrouter替换为你解压的目录。然后scripts/feeds update -a,再执行scripts/feeds install python-netfilterqueue就添加好了。然后在make menuconfig中选择Languages=>Python=>python-netfilterqueue。
        有了这个库就赋予了我们使用Python任意抓包,修改包和发包的能力。在OpenWRT上,除了python没有第二种脚本语言可以如此简单地获得这些能力。
安装Python的dpkt

        能够抓取和发送IP包之后,第二个头疼的问题是如何解析和构造任意的IP包。Python有一个库叫dpkt可以帮我们很好地完成这项任务。这是我们选择Python做实验的第二个重要理由。
        在路由器上直接下载http://dpkt.googlecode.com/files/dpkt-1.7.tar.gz,然后解压缩,拷贝其中的dpkt目录到/usr/lib/python2.7/site-packages下。
DNS劫持观测

        我们要做的第一个实验是用python代码观测到DNS劫持的全过程。
应用层观测

        dig是DNS的客户端,可以很方便地构造出我们想要的DNS请求。dig @8.8.8.8 twitter.com。可以得到相应如下

; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 5494
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0

;; QUESTION SECTION:
;twitter.com.                        IN        A

;; ANSWER SECTION:
twitter.com.                4666        IN        A        59.24.3.173

;; Query time: 110 msec
;; SERVER: 8.8.8.8#53(8.8.8.8)
;; WHEN: Sun Jan 13 13:22:10 2013
;; MSG SIZE  rcvd: 45

        可以很清楚地看到我们得到的错误答案59.24.3.173。
抓包观测

        使用iptables我们可以让特定的IP包经过应用层的代码,从而使得我们用python观测DNS查询过程提供了可能。代码如下,保存文件名dns_hijacking_obversation.py(https://gist.github.com/4524294):

from netfilterqueue import NetfilterQueue
import subprocess
import signal
def observe_dns_hijacking(nfqueue_element):
   print('packet past through me')
   nfqueue_element.accept()
nfqueue = NetfilterQueue()
nfqueue.bind(0, observe_dns_hijacking)
def clean_up(*args):
   subprocess.call('iptables -D OUTPUT -p udp --dst 8.8.8.8 -j QUEUE', shell=True)
   subprocess.call('iptables -D INPUT -p udp --src 8.8.8.8 -j QUEUE', shell=True)
signal.signal(signal.SIGINT, clean_up)
try:
   subprocess.call('iptables -I INPUT -p udp --src 8.8.8.8 -j QUEUE', shell=True)
   subprocess.call('iptables -I OUTPUT -p udp --dst 8.8.8.8 -j QUEUE', shell=True)
   print('running..')
   nfqueue.run()
except KeyboardInterrupt:
   print('bye')

执行python dns_hijacking_observation.py,再使用dig @8.8.8.8 twitter.com应该可以看到package past through me。这就说明DNS的请求和答案都经过了python代码了。
        上一步主要是验证NetfilterQueue是不是工作正常。这一步则要靠dpkt的了。代码如下,文件名相同(https://gist.github.com/4524299):

from netfilterqueue import NetfilterQueue
import subprocess
import signal
import dpkt
import traceback
import socket

def observe_dns_hijacking(nfqueue_element):
   try:
       ip_packet = dpkt.ip.IP(nfqueue_element.get_payload())
       dns_packet = dpkt.dns.DNS(ip_packet.udp.data)
       print(repr(dns_packet))
       for answer in dns_packet.an:
           print(socket.inet_ntoa(answer['rdata']))
       nfqueue_element.accept()
   except:
       traceback.print_exc()
       nfqueue_element.accept()

nfqueue = NetfilterQueue()
nfqueue.bind(0, observe_dns_hijacking)

def clean_up(*args):
   subprocess.call('iptables -D OUTPUT -p udp --dst 8.8.8.8 -j QUEUE', shell=True)
   subprocess.call('iptables -D INPUT -p udp --src 8.8.8.8 -j QUEUE', shell=True)

signal.signal(signal.SIGINT, clean_up)

try:
   subprocess.call('iptables -I INPUT -p udp --src 8.8.8.8 -j QUEUE', shell=True)
   subprocess.call('iptables -I OUTPUT -p udp --dst 8.8.8.8 -j QUEUE', shell=True)
   print('running..')
   nfqueue.run()
except KeyboardInterrupt:
   print('bye')

执行python dns_hijacking_observation.py,再使用dig @8.8.8.8 twitter.com应该可以看到类似如下的输出:

DNS(ar=[RR(type=41, cls=4096)], qd=[Q(name='twitter.com')], id=8613, op=288)
DNS(an=[RR(name='twitter.com', rdata=';\x18\x03\xad', ttl=19150)], qd=[Q(name='twitter.com')], id=8613, op=33152)
59.24.3.173
DNS(an=[RR(name='twitter.com', rdata='\xc7;\x95\xe6', ttl=27), RR(name='twitter.com', rdata='\xc7;\x96\x07', ttl=27), RR(name='twitter.com', rdata="\xc7;\x96'", ttl=27)], ar=[RR(type=41, cls=512)], qd=[Q(name='twitter.com')], id=8613, op=33152)
199.59.149.230
199.59.150.7
199.59.150.39

可以看到我们发出去了一个包,收到了两个包。其中第一个收到的包是GFW发回来的错误答案,第二个包才是正确的答案。但是由于dig只取第一个返回的答案,所以我们实际看到的解析结果是错误的。

我的恩山、我的无线 The best wifi forum is right here.
 楼主| | 显示全部楼层
观测劫持发生的位置

        利用IP包的TTL特性,我们可以把TTL值从1开始递增,直到我们收到错误的应答为止。结合TTL EXECEEDED ICMP返回的IP地址,就可以知道DNS请求是在第几跳的路由器分光给GFW的。代码如下(https://gist.github.com/4524927):

from netfilterqueue import NetfilterQueue
import subprocess
import signal
import dpkt
import traceback
import socket
import sys

DNS_IP = '8.8.8.8'

# source http://zh.wikipedia.org/wiki/%E5 ... 8%E6%B1%A1%E6%9F%93
WRONG_ANSWERS = {
   '4.36.66.178',
   '8.7.198.45',
   '37.61.54.158',
   '46.82.174.68',
   '59.24.3.173',
   '64.33.88.161',
   '64.33.99.47',
   '64.66.163.251',
   '65.104.202.252',
   '65.160.219.113',
   '66.45.252.237',
   '72.14.205.99',
   '72.14.205.104',
   '78.16.49.15',
   '93.46.8.89',
   '128.121.126.139',
   '159.106.121.75',
   '169.132.13.103',
   '192.67.198.6',
   '202.106.1.2',
   '202.181.7.85',
   '203.161.230.171',
   '207.12.88.98',
   '208.56.31.43',
   '209.36.73.33',
   '209.145.54.50',
   '209.220.30.174',
   '211.94.66.147',
   '213.169.251.35',
   '216.221.188.182',
   '216.234.179.13'
}

current_ttl = 1

def locate_dns_hijacking(nfqueue_element):
   global current_ttl
   try:
       ip_packet = dpkt.ip.IP(nfqueue_element.get_payload())
       if dpkt.ip.IP_PROTO_ICMP == ip_packet['p']:
           print(socket.inet_ntoa(ip_packet.src))
       elif dpkt.ip.IP_PROTO_UDP == ip_packet['p']:
           if DNS_IP == socket.inet_ntoa(ip_packet.dst):
               ip_packet.ttl = current_ttl
               current_ttl += 1
               ip_packet.sum = 0
               nfqueue_element.set_payload(str(ip_packet))
           else:
               if contains_wrong_answer(dpkt.dns.DNS(ip_packet.udp.data)):
                   sys.stdout.write('* ')
                   sys.stdout.flush()
                   nfqueue_element.drop()
                   return
               else:
                   print('END')
       nfqueue_element.accept()
   except:
       traceback.print_exc()
       nfqueue_element.accept()


def contains_wrong_answer(dns_packet):
   for answer in dns_packet.an:
       if socket.inet_ntoa(answer['rdata']) in WRONG_ANSWERS:
           return True
   return False

nfqueue = NetfilterQueue()
nfqueue.bind(0, locate_dns_hijacking)

def clean_up(*args):
   subprocess.call('iptables -D OUTPUT -p udp --dst %s -j QUEUE' % DNS_IP, shell=True)
   subprocess.call('iptables -D INPUT -p udp --src %s -j QUEUE' % DNS_IP, shell=True)
   subprocess.call('iptables -D INPUT -p icmp -m icmp --icmp-type 11 -j QUEUE', shell=True)

signal.signal(signal.SIGINT, clean_up)

try:
   subprocess.call('iptables -I INPUT -p icmp -m icmp --icmp-type 11 -j QUEUE', shell=True)
   subprocess.call('iptables -I INPUT -p udp --src %s -j QUEUE' % DNS_IP, shell=True)
   subprocess.call('iptables -I OUTPUT -p udp --dst %s -j QUEUE' % DNS_IP, shell=True)
   print('running..')
   nfqueue.run()
except KeyboardInterrupt:
   print('bye')

执行 dig +tries=30 +time=1 @8.8.8.8 twitter.com 可以得到类似下面的输出:

=== 隐去 ===
=== 隐去 ===
=== 隐去 ===
219.158.100.166
219.158.11.150
* 219.158.97.30
* * 219.158.27.30
* 72.14.215.130
* 209.85.248.60
* 216.239.43.19
* * END

出现*号前面的那个IP就是挂了GFW的路由了。脚本只能执行一次,第二次需要重启。另外同一个DNS不能被同时查询,把8.8.8.8改成你没有在用的DNS。这个脚本的一个“副作用”就是dig返回的答案是正确的了,因为错误的答案被丢弃了。
反向观测

        前面我们已经知道从国内请求国外的DNS服务器大体是怎么一个被劫持的过程了。接下来我们在国内搭建一个服务器,从国外往国内发请求,看看是不是可以观测到被劫持的现象。
        把路由器的WAN口的防火墙打开。配置本地的dnsmasq为使用非标准端口代理查询从而保证本地做dig查询的时候可以拿到正确的结果。然后在国外的服务器上执行

dig @国内路由器ip twitter.com

        可以看到收到的答案是错误的。执行前面的路由跟踪代码,结果如下:

=== 隐去 ===
=== 隐去 ===
=== 隐去 ===
115.160.187.13
213.248.76.73
219.158.33.181
219.158.29.129
219.158.19.165
* 219.158.96.225
* * * 219.158.101.233
END

        可以看到不但有DNS劫持,而且DNS劫持发生在非常靠近国内路由器的位置。这也证实了论文中提出的观测结果。GFW并没有严格地部署在出国境前第一眺的位置,而是更加靠前。并且是双向的,至少DNS劫持是双向经过实验证实了。
       
我的恩山、我的无线 The best wifi forum is right here.
回复

使用道具 举报

 楼主| | 显示全部楼层

通过避免GFW重建请求反DNS劫持

使用非标准端口

        这个实验就非常简单了。使用53之外的端口查询DNS,观测是否有错误答案被返回。

dig @208.67.222.222 -p 5353 twitter.com
       
        使用的DNS服务器是OpenDNS,端口为5353端口。使用非标准端口的DNS服务器不多,并不是所有的DNS服务器都会提供非标准端口供查询。结果如下:

; <<>> DiG 9.9.1-P3 <<>> @208.67.222.222 -p 5353 twitter.com
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 5367
;; flags: qr rd ra; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 8192
;; QUESTION SECTION:
;twitter.com.                        IN        A

;; ANSWER SECTION:
twitter.com.                5        IN        A        199.59.150.39
twitter.com.                5        IN        A        199.59.148.82
twitter.com.                5        IN        A        199.59.148.10

;; Query time: 194 msec
;; SERVER: 208.67.222.222#5353(208.67.222.222)
;; WHEN: Mon Jan 14 11:47:46 2013
;; MSG SIZE  rcvd: 88
       
可见,非标准端口还是可以得到正确结果的。但是这种穿墙并不能被应用程序直接使用,因为几乎所有的应用程序都不支持使用非标准端口查询。有很多种办法把端口变成53端口能用。

使用本地DNS服务器转发(dnsmasq,pdnsd)
用NetfilterQueue改写IP包
用iptables改写IP包:iptables -t nat -I OUTPUT --dst 208.67.222.222 -p udp --dport 53  -j DNAT --to-destination 208.67.222.222:5353

使用TCP查询

        这个实验就更加简单了,也是一条命令:

dig +tcp @8.8.8.8 twitter.com

        GFW在日常是不屏蔽TCP的DNS查询的,所以可以得到正确的结果。但是和非标准端口一样,几乎所有的应用程序都不支持使用TCP查询。已知的TCP转UDP方式是使用pdnsd或者unbound转(http://otnth.blogspot.jp/2012/05/openwrt-dns.html?m=1)。
使用单向代理

        严格来说单向代理并不是穿墙,因为它仍然需要在国外有一个代理服务器。使用代理服务器把DNS查询发出去,但是DNS查询并不经由代理服务器而是直接发回客户端。这样的实现在目前有更好的反劫持的手段(比如非标准端口)的情况下并不是一个有实际意义的做法。但是对于观测GFW的封锁机制还是有帮助的。据报道在敏感时期,对DNS不仅仅是劫持,而是直接丢包。通过单向代理可以观测丢包是针对出境流量的还是入境流量的。
        客户端需要使用iptables把DNS请求转给NetfilterQueue,然后用python代码把DNS请求包装之后发给中转代理。对于应用程序来说,这个包装的过程是透明的,它仍然认为请求是直接发给DNS服务器的。
        客户端代码如下,名字叫smuggler.py(https://gist.github.com/4531012):

from netfilterqueue import NetfilterQueue
import subprocess
import signal
import traceback
import socket

IMPERSONATOR_IP = 'x.x.x.x'
IMPERSONATOR_PORT = 19840

udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)

def smuggle_packet(nfqueue_element):
   try:
       original_packet = nfqueue_element.get_payload()
       print('smuggled')
       udp_socket.sendto(original_packet, (IMPERSONATOR_IP, IMPERSONATOR_PORT))
       nfqueue_element.drop()
   except:
       traceback.print_exc()
       nfqueue_element.accept()

nfqueue = NetfilterQueue()
nfqueue.bind(0, smuggle_packet)

def clean_up(*args):
   subprocess.call('iptables -D OUTPUT -p udp --dst 8.8.8.8 --dport 53 -j QUEUE', shell=True)

signal.signal(signal.SIGINT, clean_up)

try:
   subprocess.call('iptables -I OUTPUT -p udp --dst 8.8.8.8 --dport 53 -j QUEUE', shell=True)
   print('running..')
   nfqueue.run()
except KeyboardInterrupt:
   print('bye')

        服务器端代码如下,名字叫impersonator.py:

import socket
import dpkt.ip

def main_loop(server_socket, raw_socket):
21while True:
                packet_bytes, from_ip = server_socket.recvfrom(4096)
                packet = dpkt.ip.IP(packet_bytes)
                dst = socket.inet_ntoa(packet.dst)
                print('%s:%s => %s:%s' % (socket.inet_ntoa(packet.src), packet.data.sport, dst, packet.data.dport))
                raw_socket.sendto(packet_bytes, (dst, 0))

server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
        server_socket.bind(('0.0.0.0', 19840))
        raw_socket = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_RAW)
        try:
                raw_socket.setsockopt(socket.SOL_IP, socket.IP_HDRINCL, 1)
                main_loop(server_socket, raw_socket)
        finally:
                raw_socket.close()
finally:
        server_socket.close()
       
在路由器上运行的时候要把WAN的防火墙规则改为接受INPUT,否则进入的UDP包会因为没有对应的出去的UDP包而被过滤掉。这是单向代理的一个缺陷,需要在墙上开洞。把防火墙整个打开是一种开洞的极端方式。后面专门讨论单向代理的时候会有更多关于防火墙凿洞的讨论。
第二个运行的条件是服务器所在的网络没有对IP SPROOFING做过滤。服务器实际上使用了和GFW发错误答案一样的技术,就是伪造SRC地址。通过把SRC地址填成客户端所在的IP地址,使得DNS查询的结果不需要经过代理服务器中装直接到达客户端。
我的恩山、我的无线 The best wifi forum is right here.
回复

使用道具 举报

 楼主| | 显示全部楼层

通过丢弃错误答案反DNS劫持

        前两种方式都是针对GFW的重建这一步。因为GFW没有在日常的时候监听所有UDP端口以及监听TCP流量,所以非标准端口或者TCP的DNS查询可以被放行。选择性丢包则针对的是GFW的应对措施。既然GFW发错误的答案回来,只要我们不认它给的答案,等正确的答案来就是了。有两篇相关文档
使用ipfilter过滤GFW滴DNS污染
AntiDNSPoisoning

        改写成python脚本是这样的(https://gist.github.com/4530465):

import sys
import subprocess

# source http://zh.wikipedia.org/wiki/%E5 ... 8%E6%B1%A1%E6%9F%93
WRONG_ANSWERS = {
   '4.36.66.178',
   '8.7.198.45',
   '37.61.54.158',
   '46.82.174.68',
   '59.24.3.173',
   '64.33.88.161',
   '64.33.99.47',
   '64.66.163.251',
   '65.104.202.252',
   '65.160.219.113',
   '66.45.252.237',
   '72.14.205.99',
   '72.14.205.104',
   '78.16.49.15',
   '93.46.8.89',
   '128.121.126.139',
   '159.106.121.75',
   '169.132.13.103',
   '192.67.198.6',
   '202.106.1.2',
   '202.181.7.85',
   '203.161.230.171',
   '207.12.88.98',
   '208.56.31.43',
   '209.36.73.33',
   '209.145.54.50',
   '209.220.30.174',
   '211.94.66.147',
   '213.169.251.35',
   '216.221.188.182',
   '216.234.179.13'
}

rules = ['-p udp --sport 53 -m u32 --u32 "4 & 0x1FFF = 0 && 0 >> 22 & 0x3C @ 8 & 0x8000 = 0x8000 && 0 >> 22 & 0x3C @ 14 = 0" -j DROP']
for wrong_answer in WRONG_ANSWERS:
   hex_ip = ' '.join(['%02x' % int(s) for s in wrong_answer.split('.')])
   rules.append('-p udp --sport 53 -m string --algo bm --hex-string "|%s|" --from 60 --to 180  -j DROP' % hex_ip)

try:
   for rule in rules:
       print(rule)
       subprocess.call('iptables -I INPUT %s' % rule, shell=True)
   print('running..')
   sys.stdin.readline()
except KeyboardInterrupt:
   print('bye')
finally:
   for rule in reversed(rules):
       subprocess.call('iptables -D INPUT %s' % rule, shell=True)

        本地有了这些iptables规则之后就可以丢弃掉GFW发回来的错误答案,从而得到正确的解析结果。
我的恩山、我的无线 The best wifi forum is right here.
回复

使用道具 举报

技术贴,分析得好,不得不赞下楼主!!!!!!!
我的恩山、我的无线 The best wifi forum is right here.
回复

使用道具 举报

高深啊……
我的恩山、我的无线 The best wifi forum is right here.
回复

使用道具 举报

好帖要顶!
我的恩山、我的无线 The best wifi forum is right here.
回复

使用道具 举报

技术贴,分析得好,不得不赞下楼主!!!!!!!
我的恩山、我的无线 The best wifi forum is right here.
回复

使用道具 举报

来自手机 | 显示全部楼层
规则的更新很重要,但还是有些隐藏得很深
我的恩山、我的无线 The best wifi forum is right here.
回复

使用道具 举报

来自手机 | 显示全部楼层
看了个大概明白,意思是说,即便设置了8.8.8.8作为dns服务器也无济于事咯?
我的恩山、我的无线 The best wifi forum is right here.
回复

使用道具 举报

楼主,我按你这样设了下,PING推特都PING不通了,原先是返回一个错误的地址93.46.8.89
我的恩山、我的无线 The best wifi forum is right here.
回复

使用道具 举报

来自手机 | 显示全部楼层
muziling 发表于 2013-1-16 21:56
楼主,我按你这样设了下,PING推特都PING不通了,原先是返回一个错误的地址93.46.8.89

推特正确的也是不能通的
我的恩山、我的无线 The best wifi forum is right here.
回复

使用道具 举报

iptables -I INPUT -p udp --sport 53 -m u32 --u32 "4 & 0x1FFF = 0 && 0 >> 22 & 0x3C @ 8 & 0x8000 = 0x8000 && 0 >> 22 &0x3C @ 14 = 0" -j DROP

这条语句一执行,PING百度都要反应半分钟,DNS解析明显变得很慢,哎
我的恩山、我的无线 The best wifi forum is right here.
回复

使用道具 举报

我的恩山、我的无线 The best wifi forum is right here.
回复

使用道具 举报

 楼主| | 显示全部楼层
muziling 发表于 2013-1-17 10:18
这条语句一执行,PING百度都要反应半分钟,DNS解析明显变得很慢,哎

是在路由器上执行的么?什么配置的?这条规则指定了sport是53,理论上不会影响ping的数据包的。
http://fqrouter.tumblr.com
我的恩山、我的无线 The best wifi forum is right here.
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

关闭

欢迎大家光临恩山无线论坛上一条 /1 下一条

有疑问请添加管理员QQ86788181|手机版|小黑屋|Archiver|恩山无线论坛(常州市恩山计算机开发有限公司版权所有) ( 苏ICP备05084872号 )

GMT+8, 2024-4-29 08:13

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

| 江苏省互联网有害信息举报中心 举报信箱:js12377 | @jischina.com.cn 举报电话:025-88802724 本站不良内容举报信箱:68610888@qq.com 举报电话:0519-86695797

快速回复 返回顶部 返回列表