本文是接续《利用Frida手动绕过Android APP证书校验 》和《泄露的网站证书和私钥?来做些有趣的实验吧! 》这两篇文章的后续,将这两篇文章中提到的技术进行了简化和实现和拓展,提供一种新的测试思路。
故事的开始
书接上文。在使用Apache配置了反向代理,利用Proxifier转发流量并使用Burpsuite抓包后,发现这一流程非常麻烦,且难以在其他领域复现。比如测试目标是IoT设备,无法接入设备修改Hosts文件,则较难引导流量。
虽然说ARP攻击也是可以完成的,但是很容易在局域网内引起网络拥堵、丢包等情况:总是会觉得设备响应变慢,用Wireshark抓包的时候看到一大片重传包(不知道是不是哪里设置的不对,如果有大佬知道还烦请指点一二)。
因此想到使用DNS欺骗的方法来引导流量,并搭建反向代理,在出口上指定Proxy到Burpsuite,这样就可以在一个程序里完成全部操作,省时省力。
DNS欺骗
这里的DNS欺骗其实是有歧义的。一般意义上说DNS欺骗,大体就是两种:篡改Hosts和本地DNS劫持。
篡改Hosts:这种方法并不通用,很多设备都无法修改Hosts文件,如IoT设备、未Root的安卓/iOS设备等。
本地DNS劫持:这种方式使用ARP引导流量,过滤出其中的DNS请求,将要篡改的域名进行响应。一般用Ettercap工具去做(如果一定要劫持推荐试试Bettercap),但是仍然没跳出ARP的范畴。
因此这里选用了一个门槛略高但方式更加温和的思路:伪造DNS服务器。
门槛高是因为这种方式需要接触设备/路由器,将其中的首选DNS服务器地址设置成本机的IP,而温和是因为这种方式并不会对局域网流量造成任何影响。使用ARP攻击,局域网内会有大量的假ARP请求,且目标机器的流量全部会经过本机,而我们感兴趣的可能只是其中很小很小很小的一部分。
开始动手
既然确定了思路,下面就是动手环节了,先搭建一个DNS服务器。
写一个简易DNS服务器
为什么要自己写一个?不用现成的?
这两个疑问可能是很多人(包括我自己)都会提出的,毕竟重复造轮子是一件很没有意义和效率的事情。但是基于我找到的DNS服务器搭建都是很复杂的方案,比如Bind9
,Windows Server
等等,尝试过后发现复杂且难用(压根就没配置成功过),于是一气之下就打算自己写一个。
原理很简单,在本地监听53端口,当流量到达的时候按照DNS的协议解析,将要查询的域名提取出来,根据本地的规则匹配后返回IP,并封装成DNS应答发送出去。
在查询DNS解析协议的时候,发现网上竟然有类似的Python代码,虽然说性能肯定不行,但是毕竟承载的设备数量不多,自己测试用肯定没问题。
于是拿来主义,Copy过来源码,简单调试一下,成功!
在此声明一下,这段源码在很多不同的博客或社区都有转载,但是无一例外都没注明作者。在进一步搜索之后,某一个博客页面上转载的代码里有一句注释,表明这个代码的作者是@author: RobinTang
,不知道我这样使用是否侵权~原始代码如下:
'''
Created on 2012-10-15
@author: RobinTang
'''
import socketserver
import struct
# DNS Query
class SinDNSQuery:
def __init__(self, data):
i = 1
self.name = ''
while True:
d = data[i]
if d == 0:
break;
if d < 32:
self.name = self.name + '.'
else:
self.name = self.name + chr(d)
i = i + 1
self.querybytes = data[0:i + 1]
(self.type, self.classify) = struct.unpack('>HH', data[i + 1:i + 5])
self.len = i + 5
def getbytes(self):
return self.querybytes + struct.pack('>HH', self.type, self.classify)
# DNS Answer RRS
# this class is also can be use as Authority RRS or Additional RRS
class SinDNSAnswer:
def __init__(self, ip):
self.name = 49164
self.type = 1
self.classify = 1
self.timetolive = 190
self.datalength = 4
self.ip = ip
def getbytes(self):
res = struct.pack('>HHHLH', self.name, self.type, self.classify, self.timetolive, self.datalength)
s = self.ip.split('.')
res = res + struct.pack('BBBB', int(s[0]), int(s[1]), int(s[2]), int(s[3]))
return res
# DNS frame
# must initialized by a DNS query frame
class SinDNSFrame:
def __init__(self, data):
(self.id, self.flags, self.quests, self.answers, self.author, self.addition) = struct.unpack('>HHHHHH', data[0:12])
self.query = SinDNSQuery(data[12:])
def getname(self):
return self.query.name
def setip(self, ip):
self.answer = SinDNSAnswer(ip)
self.answers = 1
self.flags = 33152
def getbytes(self):
res = struct.pack('>HHHHHH', self.id, self.flags, self.quests, self.answers, self.author, self.addition)
res = res + self.query.getbytes()
if self.answers != 0:
res = res + self.answer.getbytes()
return res
# A UDPHandler to handle DNS query
class SinDNSUDPHandler(socketserver.BaseRequestHandler):
def handle(self):
data = self.request[0].strip()
dns = SinDNSFrame(data)
socket = self.request[1]
namemap = SinDNSServer.namemap
if(dns.query.type==1):
# If this is query a A record, then response it
name = dns.getname();
if namemap.__contains__(name):
# If have record, response it
dns.setip(namemap[name])
socket.sendto(dns.getbytes(), self.client_address)
elif namemap.__contains__('*'):
# Response default address
dns.setip(namemap['*'])
socket.sendto(dns.getbytes(), self.client_address)
else:
# ignore it
socket.sendto(data, self.client_address)
else:
# If this is not query a A record, ignore it
socket.sendto(data, self.client_address)
# DNS Server
# It only support A record query
# user it, U can create a simple DNS server
class SinDNSServer:
def __init__(self, port=53):
SinDNSServer.namemap = {}
self.port = port
def addname(self, name, ip):
SinDNSServer.namemap[name] = ip
def start(self):
HOST, PORT = "0.0.0.0", self.port
server = socketserver.UDPServer((HOST, PORT), SinDNSUDPHandler)
server.serve_forever()
# Now, test it
if __name__ == "__main__":
sev = SinDNSServer()
sev.addname('www.aa.com', '192.168.0.1') # add a A record
sev.addname('www.bb.com', '192.168.0.2') # add a A record
sev.addname('*', '0.0.0.0') # default address
sev.start() # start DNS server
# Now, U can use "nslookup" command to test it
# Such as "nslookup www.aa.com"
这段代码在Win Python 3.7环境下是没问题,可以正常响应,但是有两个问题:
只能对域名进行精准匹配,无法使用
*
通配符;对于没有指定的域名,若设置了
default address
,则会一律返回该地址,否则就不回复
在使用过程中,我需要让这个服务器对于我没指定的地址回复真实地址,这样可以保证被欺骗设备的其他业务是正常的(业务间可能存在关联性,某一业务无法访问可能导致其他业务停止),同时通配符可以减少统计域名的麻烦,也不会漏掉流量。
针对以上两点,对源代码进行了部分的修改(修改详情可参考文末的连接)。
写一个反向代理服务器
虽然使用Apache + Proxifier可以转流量,但是用到的工具多不易排错,且当需要修改配置时,Apache配置文件的查询、新增都相对麻烦。
反向代理转发的思路也很简单:因为DNS欺骗,客户端会把流量发到本机,因此
本机监听端口,将收到的HTTP(S)流量解析;
取出Host字段中的目标域名、端口,重组新的请求发出;
获取服务端响应
发送给客户端
既然要求便捷且不考虑性能,直接用Python的http.server
模块构建一个HTTP服务器接收请求即可。
重写http.server.BaseHTTPRequestHandler
模块,对其中的HTTP方法处理进行重写即可:
class MyHandler(http.server.BaseHTTPRequestHandler):
def req(self):
try:
if isinstance(self.request, ssl.SSLSocket):
scheme = "https://"
else:
scheme = "http://"
# 根据Host信息重组URL
self.url = scheme + self.headers["host"].strip("\n") + self.path
# 判断是否有HTTP Body
if self.headers.__contains__('Content-Length'):
data = self.rfile.read(int(self.headers['Content-Length']))
else:
data = ""
req = requests.Request(method=self.command, url=self.url, headers=self.headers, data=data)
s = requests.Session()
prepped = req.prepare()
# 将请求通过代理发送出去
r = s.send(prepped, verify=False,proxies=proxies, allow_redirects=False, stream=True)
# 设置对客户端的响应头
self.send_response(r.status_code)
for key in r.headers:
self.send_header(key, r.headers[key])
self.end_headers()
# 写入Response Body,写完后会自动发出这个请求
self.wfile.write(r.content)
except IOError as e:
print(e)
self.send_error(404, 'file not found: %s' % self.path)
except Exception as e:
print(e)
def do_GET(self):
self.req()
def do_POST(self):
self.req()
def do_HEAD(self):
self.req()
def do_OPTIONS(self):
self.req()
def do_PUT(self):
self.req()
def do_DELETE(self):
self.req()
def do_MOVE(self):
self.req()
def do_TRACE(self):
self.req()
http.server.BaseHTTPRequestHandler
模块会读取当前请求的HTTP Method
,并调用do_xxx
函数来进行处理。在这里我们并不需要对不同Method进行差异化处理,我们只想安安静静把他们转发出去,因此下面的do_xxx
全部都调用req()
统一进行处理。
完整代码已上传Github:https://github.com/mactavishmeng/mitmserver
Github上的版本是将DNS服务器和反代服务器集成在一起,通过配置文件mitmserver.json
来进行配置:
{
"proxies" : {"http": "http://127.0.0.1:8080",
"https":"http://127.0.0.1:8080"},
"dns_list" : [
{"host": "www.baidu.cn", "address": "192.168.1.3"},
{"host": "*.baidu.cn", "address": "192.168.1.3"},
{"host": "www.google.com", "address": "192.168.1.3"}
],
"dns_query_enable" : true,
"http_list" : [
{"address":"0.0.0.0", "port":80, "ishttps":false},
{"address":"0.0.0.0", "port":443, "ishttps":true, "certfile":"./certificate.crt", "keyfile":"./private_key.key"}
]
}
在这个配置文件中可以轻易的对各个部分进行方便的调整,如配置的代理,本地监听的HTTP(S)端口,证书,DNS列表等。
实战验证
实际使用中,整个流程的核心有两点:DNS如何欺骗,HTTPS证书是否是真的。
配置DNS
虽然上面说了那么多,还写了一个DNS服务器,但是如果你的路由器支持插件,可以自定义hosts,实际上是不需要这么麻烦的。比如我借到的这台极路由:
直接用插件强行更改设备的DNS查询结果,不过有些路由器不支持修改Hosts,并不通用。
手机、PC等可直接修改网络配置的设备
对于手机APP来说,可以直接在“无线局域网”中将IP设置修改为“静态”,在DNS服务器部分配置IP为本机即可。以安卓为例:
IoT设备等无法直接修改配置的设备
而IoT设备因为无法在设备上操作,需要在路由器上设置,如果能获得控制权的话 ; - )
将路由器的DNS设置为本机IP即可(不同路由器界面可能不同,但原理是一样的):
至此,设备端DNS欺骗的前置步骤完成。
配置反向代理
打开mitmserver.json
,修改其中的监听端口的配置。
如这里监听的APP通信,它与三个域名进行HTTPS通信,其中两个域名访问443,一个域名访问9988。此时配置列表里需打开两个监听端口(443、9988),并将"ishttps"
的值设置为true
,表示这个端口监听HTTPS。后面的certfile
和keyfile
部分填入证书和私钥文件的路径。
如果你没有这两个文件,可以从Burpsuite等抓包工具中导出,或使用openssl工具生成自签名证书。
开始抓包
在本机上配置好要监听的端口、要劫持的域名、填入Burpsuite的地址后,即可开始抓包。
因为手头上没有合适的能公开的IoT设备,因此以安卓APP为例,其原理是一致的。
APP已经做了证书校验的绕过,所以这里的HTTPS证书是随意找了一个来配置的(毕竟已经绕过校验)。配置文件如下:
配置DNS。这里偷懒没有去路由器上配置,直接在手机上配了,因此在运行日志里看到的请求源是手机的IP。如果在路由器上配置,则日志中的请求IP会是网关的IP。
运行起来:
对应在Burpsuite中拦截到的请求:
这样,仅需一个py文件就完成了全部的流程,简单方便,且增减配置只需要修改JSON的配置即可,比起在Apache的conf文件中改来改去方便的多。