美文网首页
利用Network Extension 改Host

利用Network Extension 改Host

作者: felix9 | 来源:发表于2018-03-28 15:59 被阅读0次

    在日常移动开发中,我们经常会遇到改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的版本迭代,一些接口也会有变化,网上其它人分享的知识就稍显过时。这篇文章希望能帮到你们。

    相关文章

      网友评论

          本文标题:利用Network Extension 改Host

          本文链接:https://www.haomeiwen.com/subject/izjcbxtx.html