利用Network Extension 改Host

2018-03-28  本文已影响0人  felix9

在日常移动开发中,我们经常会遇到改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的头文件,我们会发现两个关键的读写流量的方法readDatagramswriteDatagrams。我们需要通过这两个方法实现对流量的读取和写入。至于写入的数据,则需要我们另外通过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流量的流程

  1. flow.open 打开Flow,并获取本地ip地址
  2. updFlow.readDatagrams 读取Flow数据,并获取DNS远程服务器地址
  3. DNSProxyProvider.createUDPSession 创建session
  4. 基于session创建socket
  5. socket.write 发送DNS请求报文
  6. 通过socket 获取DNS请求响应报文
  7. 回写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的版本迭代,一些接口也会有变化,网上其它人分享的知识就稍显过时。这篇文章希望能帮到你们。

上一篇下一篇

猜你喜欢

热点阅读