利用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的头文件,我们会发现两个关键的读写流量的方法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的版本迭代,一些接口也会有变化,网上其它人分享的知识就稍显过时。这篇文章希望能帮到你们。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容