利用Network Extension 改Host
在日常移动开发中,我们经常会遇到改host,抓包等需求。如果是在模拟器上还可以通过抓包工具实现,在真机上就麻烦多了。由于公司网络限制,想要通过抓包工具变相控制真机的网络流量非常麻烦,要先申请权限等。如果想要在app内内置改host的能力,苹果却没有相关的api,改host文件只能是越狱的情况。在iOS 9以后苹果提供了一个新的组件——Network Extension,并且在iOS 11中这个组件新增了控制DNS流量的能力。这让我们有了修改host的可能性。
概览
Network Extension一共提供了HotSpot,Personal VPN,Filter Data,Tunnel Packet,App Proxy,DNS Proxy几种能力。很多早期关于Network Extensiond的分享文章中都说了要使用Network Extension需要向苹果申请Entitlements,但实际上后来苹果调整了政策,只有使用HotSpot这个能力的时候才需要向苹果申请Entitlements。
HotSpot用于获取和Wifi相关的能力,比如说搜索到附近有几个wifi热点等信息。Personal VPN则用于向系统提供个人定制的VPN服务。Filter Data用于过滤网络请求,大部分应于拦截广告的场景。Tunnel Packet可以用于做系统流量代理,常见的场景比如翻墙以及Http代理。App Proxy和DNS Proxy是Tunnel Packket的子集。App Proxy在其基础上提供了更多针对app的规则设置,DNS Proxy则是专注于对系统DNS流量的控制。本文下面讲的大部分内容便是基于DNS Proxy。
创建工程
首先我们建立一个普通的iOS工程,这里我们使用Swift,因为后面会用到一个Swift的开源库。创建好工程,我们在新建一个基于Network Extension的target。注意Provider Type要改为DNS Proxy。
image.png
因为我们要使用Network Extension的能力,所以还要在Capabilities中设置开通Network Extension。
image.png
需要注意的时候,主工程和target都需要设置Capabilities。同时还要检查AppGroup的设置,主工程和target都需要设置同一个group id,这样两者才能共享数据。
image.png
最后的工程结构如图所示,注意两个Entitlements文件,很多奇怪的错误都是有由于这两个文件没有正确配置导致的。
image.png
创建NEDNSProxyManager
工程的准备工作就绪,那么接下来就是如何创建一个NEDNSProxyManager。简单来说就是读取配置,更新配置,保存配置三个步奏。
func createDns(){
let manager:NEDNSProxyManager = NEDNSProxyManager.shared();
manager.loadFromPreferences { (error) in
if ((error) != nil){
print(error!);
return;
}
var conf: NEDNSProxyProviderProtocol? = manager.providerProtocol
if conf == nil {
conf = NEDNSProxyProviderProtocol()
}
conf!.disconnectOnSleep = false;
manager.providerProtocol = conf!;
manager.localizedDescription = "改host不求人";
self.dnsSwitch.isOn = manager.isEnabled;
self.dnsProxy = manager
manager.saveToPreferences { (error) in
if error != nil {
print("done: \(error.debugDescription)")
print(error!);
}
}
}
}
NEDNSProxyManager的配置是通过NEDNSProxyProviderProtocol来实现。执行这段代码后,manager并未生效,还需要在合适的位置设置
manager.isEnabled = true
执行代码,我们就会看到申请添加VPN的权限授予对话框(必须是真机)。需要注意的是,添加DNS代理并不像VPN、Tunnle Packet那样会在系统配置以及状态栏上有显示,但实际上已经生效。利用Xcode的功能Debug->Attach to Process
会看到有一个进程是DNSProxy。选中它,我们就可以对其进行断点调试。
有时候我们会发现在Process列表里面找不到DNSProxy,那是因为DNSProxy没能正常运行,或者crash了。但是XCode不会有任何提示。这时候会让我们很抓狂。我总结了以下经验,或许能对你有些帮助。
- 最低系统要求是否相符,比如工程配置最低11.1,但设备是11.0.1的系统
- Capabilities设置正确,没有红色的错误提示(证书设置需要配置成automatic,否则需要去苹果证书后台配置)
- 检查Entitlements文件配置的id是否正确
- 以DNSProxy为target重新启动app
- 删除app,重新链接真机调试
- 尝试对manager.isEnable来回切换
- 如果修改了工程其它配置,比如配置运行脚本,设置链接framework或framework查找路径等,建议还原配置尝试是否可以运行起DNSProxy。这种情况下,建议通过版本管理保存每一步修改,方便revert
- 检查代码是否有可能导致DNSProxy的target一启动就crash的bug
- 如果以上的方法都无效,只能建议重建一个工程或Network Extension的target
控制DNS流量
细心的朋友可能发现,在DNS Proxy启动后,设备的网络请求都失效了。这是由于DNS Proxy接管了设备的所有DNS流量,而我们还没有处理这些DNS流量的代码,所以网络请求都因为无法获取域名的ip地址而请求失败。
先来看看DNSProxyProvider的代码:
class DNSProxyProvider: NEDNSProxyProvider {
override func startProxy(options:[String: Any]? = nil, completionHandler: @escaping (Error?) -> Void) {
// Add code here to start the DNS proxy.
completionHandler(nil)
}
override func stopProxy(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
// Add code here to stop the DNS proxy.
completionHandler()
}
override func sleep(completionHandler: @escaping () -> Void) {
// Add code here to get ready to sleep.
completionHandler()
}
override func wake() {
// Add code here to wake up.
}
override func handleNewFlow(_ flow: NEAppProxyFlow) -> Bool {
// Add code here to handle the incoming flow.
return false
}
}
这些函数都很容易能从函数名猜出作用。在startProxy
我们处理代理初始化的工作,而我们处理DNS流量的核心代码则是在handleNewFlow
。
和很多人想象的不同,我们并不能想改host文件一样,识别出要处理的域名然后返回一个ip字符串就完事。实际处理过程要复杂得多。handleNewFlow
的参数NEAppProxyFlow
包含了DNS请求的UDP数据,其实质上是NEAppProxyUDPFlow
。
通过NEAppProxyUDPFlow
的头文件,我们会发现两个关键的读写流量的方法readDatagrams
和writeDatagrams
。我们需要通过这两个方法实现对流量的读取和写入。至于写入的数据,则需要我们另外通过upd 请求从网络获取。在网络请求这里,为了减少工作量和重复造轮子,我使用了NEKit这个开源库。这个开源库非常强大,实现了ShadowSocks和VPN等协议,只是想吐槽一下,国内开源库的一个通病就是不愿意写文档写注释,短短的几句demo说不清楚使用方式而且还过时了。还好通过学习源代码我们多少可以了解到一些使用方法。不过我们只是想对做一下dns请求,只用到了DNSReslover部分的代码(注意,由于NEKit各部分有些耦合,为了只使用DNS部分,我做了一些简单的修改)。我使用Cartfile来集成的NEKit,具体方法限于篇幅请自行百度谷歌,但需要注意的是,framework在主工程和DNS的target中都需要引入。
回到handleNewFlow
这个方法。这个方法有一个返回值,其意义是Proxy是否处理这个DNS流量。如果返回为false,则这个DNS请求直接算是失败,如果返回为true,则意味着Proxy需要处理这个flow。
override func handleNewFlow(_ flow: NEAppProxyFlow) -> Bool {
//1. 打开Flow
flow.open(withLocalEndpoint: nil) { (error) in
if error == nil {
let updFlow = flow as! NEAppProxyUDPFlow
//2.读取Flow数据
updFlow.readDatagrams(completionHandler: { (datagrams, remoteEndPoints, readError) in
self.endPoints = remoteEndPoints
var udpsession = self.session
guard (remoteEndPoints?.count)! > 0 else{
return
}
if udpsession == nil{
//3.创建session
udpsession = self.createUDPSession(to: (remoteEndPoints?.first!)!, from: updFlow.localEndpoint! as? NWHostEndpoint)
}
guard udpsession != nil else{
return
}
//4.创建socket
let socket: NWUDPSocket = NWUDPSocket(udpsession: udpsession!)!
socket.delegate = self;
for index in 0...(datagrams?.count)!-1{
let data:Data = (datagrams![index]);
let id:Int = (data.subdata(in: 0...1).intValue())
self.flows![id] = ["socket":socket,"flow":updFlow]
//5.发送socket
socket.write(data: data)
}
print(socket)
})
}
}
return true
}
func didReceive(data: Data, from: NWUDPSocket) {
guard let message = DNSMessage(payload: data) else {
print("Failed to parse response from remote DNS server.")
return
}
//6.获取socket的数据,即dns请求的相应报文
var resultData = Data(data)
let tID:Int = Int(message.transactionID)
if let flow = self.flows?[tID]?["flow"] {
if let udpflow = flow as? NEAppProxyUDPFlow {
//7.将socket数据回写到Flow
udpflow.writeDatagrams([resultData], sentBy: self.endPoints!, completionHandler: { (error) in
if let aError = error {
let host = message.queries.first?.name
print(host)
print(aError)
udpflow.closeWriteWithError(error)
}
self.flows?.removeValue(forKey: tID)
})
}
}
print(message)
}
通过这段代码,我们可以总结出以下处理DNS流量的流程
- flow.open 打开Flow,并获取本地ip地址
- updFlow.readDatagrams 读取Flow数据,并获取DNS远程服务器地址
- DNSProxyProvider.createUDPSession 创建session
- 基于session创建socket
- socket.write 发送DNS请求报文
- 通过socket 获取DNS请求响应报文
- 回写socket数据到Flow
代码中涉及到部分DNS报文解析的内容,请自行百度,限于篇幅不做赘述。
这样,一个简单的DNS代理就搭建完毕。这时候,打开safari,随便请求一个网页,dns请求会被app拦截处理。
修改Host
好了,啰嗦了那么久,终于到了最关键的一部分。由于前面的工作已经准备充分,我们剩下的工作就很简单了,只需要把dns请求的相应报文中ip字段篡改为我们想要的ip地址就可以了。
var resultData = Data(data)
if let host = message.queries.first?.name {
if host == "host.you.want" {
for answer in message.answers{
if answer.data.count == 4 {
let range = answer.rDataRange
let ipData = IPAddress(fromString: "192.168.0.1")?.dataInNetworkOrder
if let aIpData = ipData {
resultData.replaceSubrange(range, with: aIpData)
break
}
}
}
}
}
运行程序,搞定!
补充
鉴于很多人问我关于NWUDPSocket的初始化问题,这里我解释一下。NEKit本身只支持通过host、port来初始化,但是你们仔细看看源码会发现,这个方法的第一步是生成一个udpsession,所以很简单的,只要把这步修改剥离出去,就能提供一个根据udpsession来初始化的方法了。
修改过的NWUDPSocket代码如下,注意,这段代码可能已经过时,请自行参考修改。
public class NWUDPSocket: NSObject {
private let session: NWUDPSession
private var pendingWriteData: [Data] = []
private var writing = false
private let queue: DispatchQueue = QueueFactory.getQueue()
private let timer: DispatchSourceTimer
private let timeout: Int
/// The delegate instance.
public weak var delegate: NWUDPSocketDelegate?
/// The time when the last activity happens.
///
/// Since UDP do not have a "close" semantic, this can be an indicator of timeout.
public var lastActive: Date = Date()
/**
Create a new UDP socket connecting to remote.
- parameter host: The host.
- parameter port: The port.
*/
public convenience init?(host: String, port: Int, timeout: Int = Opt.UDPSocketActiveTimeout) {
guard let udpsession = RawSocketFactory.TunnelProvider?.createUDPSession(to: NWHostEndpoint(hostname: host, port: "\(port)"), from: nil) else {
return nil
}
self.init(udpsession: udpsession)
}
/**
Create a new UDP socket connecting to remote.
- parameter host: The host.
- parameter port: The port.
*/
public init?(udpsession:NWUDPSession,timeout:Int = Opt.UDPSocketActiveTimeout) {
session = udpsession
self.timeout = timeout
timer = DispatchSource.makeTimerSource(queue: queue)
super.init()
timer.schedule(deadline: DispatchTime.now(), repeating: DispatchTimeInterval.seconds(Opt.UDPSocketActiveCheckInterval), leeway: DispatchTimeInterval.seconds(Opt.UDPSocketActiveCheckInterval))
timer.setEventHandler { [weak self] in
self?.queueCall {
self?.checkStatus()
}
}
timer.resume()
session.addObserver(self, forKeyPath: #keyPath(NWUDPSession.state), options: [.new], context: nil)
session.setReadHandler({ [ weak self ] dataArray, error in
self?.queueCall {
guard let sSelf = self else {
return
}
sSelf.updateActivityTimer()
guard error == nil, let dataArray = dataArray else {
DDLogError("Error when reading from remote server. \(error?.localizedDescription ?? "Connection reset")")
return
}
for data in dataArray {
sSelf.delegate?.didReceive(data: data, from: sSelf)
}
}
}, maxDatagrams: 32)
}
/**
Send data to remote.
- parameter data: The data to send.
*/
public func write(data: Data) {
pendingWriteData.append(data)
checkWrite()
}
public func disconnect() {
session.cancel()
timer.cancel()
}
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
guard keyPath == "state" else {
return
}
switch session.state {
case .cancelled:
queueCall {
self.delegate?.didCancel(socket: self)
}
case .ready:
checkWrite()
default:
break
}
}
private func checkWrite() {
updateActivityTimer()
guard session.state == .ready else {
return
}
guard !writing else {
return
}
guard pendingWriteData.count > 0 else {
return
}
writing = true
session.writeMultipleDatagrams(self.pendingWriteData) {_ in
self.queueCall {
self.writing = false
self.checkWrite()
}
}
self.pendingWriteData.removeAll(keepingCapacity: true)
}
private func updateActivityTimer() {
lastActive = Date()
}
private func checkStatus() {
if timeout > 0 && Date().timeIntervalSince(lastActive) > TimeInterval(timeout) {
disconnect()
}
}
private func queueCall(block: @escaping () -> Void) {
queue.async {
block()
}
}
deinit {
session.removeObserver(self, forKeyPath: #keyPath(NWUDPSession.state))
}
}
结尾
Network Extension的出现,让我们控制系统网络流量成为可能。但是官方文档很少,并且稍显混乱,导致我们在实际开发中会出现很多的问题。并且随着iOS的版本迭代,一些接口也会有变化,网上其它人分享的知识就稍显过时。这篇文章希望能帮到你们。