可能是最全的iOS端HttpDns整合方案 | 掘金技術徵文

茉莉兒發表於2017-04-25

科普片

1、DNS劫持的危害

  不知道大家有沒有發現這樣一個現象,在開啟一些網頁的時候會彈出一些與所瀏覽網頁不相關的內容比如這樣奇(se)怪(qing)的東西

可能是最全的iOS端HttpDns整合方案 | 掘金技術徵文
圖一
或者這樣
可能是最全的iOS端HttpDns整合方案 | 掘金技術徵文
圖二
,其實造成這樣的原因就是DNS劫持,在我們正常瀏覽的網頁連結裡面被惡意插入一些奇怪的東西。不止是這些,DNS劫持還會對我們的個人資訊保安造成很大的傷害,釣魚網站之類的,也許我們所訪問的網站根本不是我們需要的網站,或者根本打不開網頁,有時還會消耗我們過多的流量。

2、什麼是DNS解析

  現在假如我們訪問一個網站www.baidu.com從按下回車到百度頁面顯示到我們的電腦上會經歷如下幾個步驟

  • 1:計算機會向我們的運營商(移動、電信、聯通等)發出開啟www.baidu.com的請求。
  • 2:運營商收到請求後會到自己的DNS伺服器中找www.baidu.com這個域名所對應的伺服器的IP地址(也就是百度的伺服器的IP地址),這裡比如是180.149.132.47。
  • 3:運營商用第二步得到的IP地址去找到百度的伺服器請求得到資料後返回給我們。

其中第二步就是我們所說的DNS解析過程,域名和IP地址的關係其實就是我們的身份證號和姓名的關係,都是來標記一個人或者是一個網站的,只是IP地址\身份證號只是一串沒有意義的數字,辨識度低,又不好記,所以就會在IP上加上一個域名以便區分,或是做的更加個性化,但是如果真的要來準確的區分還是要靠身份證號碼或者是IP的,所以DNS解析就應運而生了。

3:什麼是DNS劫持

  根本原因就是以下兩點:

  • 1:惡意攻擊,攔截運營商的解析過程,把自己的非法東西嵌入其中。
  • 2:運營商為了利益或者一些其他的因素,允許一些第三方在自己的連結裡打打廣告之類的。

4:防止DNS劫持

  瞭解了DNS劫持的相關資料後我們就知道了,防止NDS劫持就要從第二步入手,因為DNS解析過程是運營商來操作的,我們不能去幹涉他們,不然我們也就成了劫持者了,所以我們要做的就是在我們請求之前對我們的請求連結做一些修改,將我們原本的請求連結www.baidu.com 修改為180.149.132.47,然後請求出去,這樣的話就運營商在拿到我們的請求後發現我們直接用的就是IP地址就會直接給我們放行,而不會去走他自己DNS解析了,也就是說我們把運營商要做的事情自己先做好了。不走他的DNS解析也就不會存在DNS被劫持的問題,從根本是解決了。

技術篇

5:專案中的實際操作

5.1:DNSPOD相關

  我們知道要要把專案中請求的介面替換成成IP其實很簡單,URL是字串,域名替換IP,無非就是一個字串替換而已,的確這塊其實沒有什麼技術含量,而且現在像阿里雲(沒開源),七牛雲(開源),等一些比較大的平臺在這方面也都有了比較成熟的解決方案,一個SDK,傳個普通的URL進去就會返回一個域名被替換成IP的URL出來,也比較好用,這裡要說一下IP地址的來源,如何拿到一個域名所對應的IP呢?這裡就是需要用到另一個服務——HTTPDNS,國內比較有名的就是DNSPOD,包括阿里,七牛等也是使用他們的DNS服務來解析,就是這個

可能是最全的iOS端HttpDns整合方案 | 掘金技術徵文
DNSPOD logo

可能是最全的iOS端HttpDns整合方案 | 掘金技術徵文
簡介
他會給我們提供一個介面,我們使用HTTP請求的方式去請求這個介面,引數帶上我們的域名,他們就會把域名對應的IP列表返回回來。類似這樣:

///這個請求URL的結構是固定的119.29.29.29是DNSPOD固定的伺服器地址,ttl引數的意思是返回結果是否帶ttl是個BOOL,dn就是我們需要解析的域名,id就是我們在dnspod上註冊時候他給我們的一個KEY
NSString *url = [NSString stringWithFormat:@"http://119.29.29.29/d?ttl=1&dn=www.baidu.com&id=KEY"];
NSMutableURLRequest * request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url] cachePolicy:NSURLRequestUseProtocolCachePolicy timeoutInterval:10];
NSData * data = [NSURLConnection sendSynchronousRequest:request returningResponse:nil error:&networkError];複製程式碼

這裡使用同步還是非同步都是可以的,具體根據你們業務需求。

5.2:專案中的使用

  其實dnspod最難的部分是接入的部分,因為不同的APP不同的網路環境會導致各種各樣的問題,如果你是一個新的專案那麼接入難度會大大降低,因為你完全可以自己封裝一套網路請求,把DNS解析相關的邏輯都封裝到自己的網路請求中,這樣你就可以得到APP所有的網路層的控制權,想幹什麼就幹什麼,但是如果是在一個已經比較完善的APP中加入DNS防劫持的話那就是比較困難,因為你不能拿到所有網路請求的控制權這篇文章中我主要使用是NSURLProtocol + Runtime hook方式來處理這些東西的,NSURLProtocol屬於iOS黑魔法的一種可以攔截任何從APP的 URL Loading System系統中發出的請求,其中包括如下

  • File Transfer Protocol (ftp://)
  • Hypertext Transfer Protocol (http://)
  • Hypertext Transfer Protocol with encryption (https://)
  • Local file URLs (file:///)
  • Data URLs (data://)

如果你的請求不在以上列表中就不能進行攔截了,比如WKWebview,AVPlayer(比較特殊,雖然請求也是http/https但是就是不走這套系統,蘋果爸爸就是這樣~)等,其實對於正常來說光用已經NSURLProtocol足夠了。
  NSURLProtocol這個類我們不能直接使用,我們需要自己建立一個他的子類然後在我們的子類中操作他們像這樣

 // 註冊自定義protocol
[NSURLProtocol registerClass:[CustomURLProtocol class]];
 NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
configuration.protocolClasses = @[[CustomURLProtocol class]];複製程式碼

在這個類中我們可以攔截到請求,然後進行處理。這個類中有四個非常重要的方法

+ (BOOL)canInitWithRequest:(NSURLRequest *)request;
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request;
- (void)startLoading;
//對於攔截的請求,NSURLProtocol物件在停止載入時呼叫該方法
- (void)stopLoading;複製程式碼
+ (BOOL)canInitWithRequest:(NSURLRequest *)request;

通過返回值來告訴NSUrlProtocol對進來的請求是否攔截,比如我只攔截HTTP的,或者是某個域名的請求之類

+ (NSURLRequest )canonicalRequestForRequest:(NSURLRequest )request;

如果上面的方法返回YES那麼request會傳到這裡,這個地方通常不做處理 直接返回request

- (void)startLoading;

這個地方就是對我們攔截的請求做一些處理,我們文中所做的IP對域名的替換就在這裡進行,處理完之後將請求轉發出去,比如這樣

- (void)startLoading {
///其中customRequest是處理過的請求(域名替換後的)
    NSURLSession *session = [NSURLSession sessionWithConfiguration:[[NSURLSessionConfiguration alloc] init] delegate:self delegateQueue:nil];
    NSURLSessionDataTask *task = [session dataTaskWithRequest:customRequest];
    [task resume];
}複製程式碼

你可以在 - startLoading 中使用任何方法來對協議物件持有的 request 進行轉發,包括 NSURLSession、 NSURLConnection 甚至使用 AFNetworking 等網路庫,只要你能在回撥方法中把資料傳回 client,幫助其正確渲染就可以,比如這樣:

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler {
    [[self client] URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageAllowed];

    completionHandler(NSURLSessionResponseAllow);
}

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {
    [[self client] URLProtocol:self didLoadData:data];
}複製程式碼

client在後面會有講解。

- (void)stopLoading;

請求完畢後呼叫
大概的執行流程是這樣

可能是最全的iOS端HttpDns整合方案 | 掘金技術徵文
流程

在NSURLProtocol中有一個貫穿始終的變數

/*! 
    @method client
    @abstract Returns the NSURLProtocolClient of the receiver. 
    @result The NSURLProtocolClient of the receiver.  
*/
@property (nullable, readonly, retain) id <NSURLProtocolClient> client;複製程式碼

你可以認為是這個是請求的傳送者,打個比方,A想給B傳送一個訊息,由於距離遙遠於是A去了郵局,A把訊息內容告訴了郵局,並且A在郵局登記了自己名字方便B有反饋的時候郵局來通知A查收。這個例子中郵局就是NSURLProtocol,A在郵局登記的名字就是client。所有的 client 都實現了 NSURLProtocolClient 協議,協議的作用就是在 HTTP 請求發出以及接受響應時向其它物件傳輸資料:

@protocol NSURLProtocolClient <NSObject>
...
- (void)URLProtocol:(NSURLProtocol *)protocol didReceiveResponse:(NSURLResponse *)response cacheStoragePolicy:(NSURLCacheStoragePolicy)policy;

- (void)URLProtocol:(NSURLProtocol *)protocol didLoadData:(NSData *)data;

- (void)URLProtocolDidFinishLoading:(NSURLProtocol *)protocol;
...
@end複製程式碼

當然這個協議中還有很多其他的方法,比如 HTTPS 驗證、重定向以及響應快取相關的方法,你需要在合適的時候呼叫這些代理方法,對資訊進行傳遞。
到此正常情況下的DNS的解析過程已經結束,如果你發現按照如上操作之後並沒有達到預期效果那麼請往下看,(通常情況下完成以上操作 原有的URL的就會變成http://123.456.789.123/XXX/XXX/XXX的格式。如果發現請求不成功就往下看吧)

6:遇到的坑點

  6.1:我們知道運營商本來是根據域名來確定一個URL的,我們將域名改為IP之後雖然不用運營商幫我們解析了,但是運營商在收到一串數字的時候也是懵逼狀態,我們還是需要將域名傳給他們,但是不能用正常的方式傳,我們需要把原來的域名加到http請求的Header中的host欄位下,根據Http協議的規定,如果在URL中無法找到域名的話就會去Header中找,這樣一來我們既把域名告訴了運營商同時也直接制定了IP地址,這個是必須配置的,不然的話是請求不成功的。
[mutableRequest setValue:self.request.URL.host forHTTPHeaderField:@"HOST"];複製程式碼
[mutableRequest setValue:YOUR Cookie forHTTPHeaderField:@"Cookie"];複製程式碼
  6.2:關於AfNetworking的問題,現在大部分網路請求是基於Afnetworking的,這裡有一個坑,我們知道我們註冊CustomProtocol的時候是這樣
 // 註冊自定義protocol
[NSURLProtocol registerClass:[CustomURLProtocol class]];
 NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
configuration.protocolClasses = @[[CustomURLProtocol class]];複製程式碼
在系統的configuration加入我們的CustomProtocol,protocolClasses是一個陣列裡面可以放很多各種不同的CustomProtocol,我們看一下afnetworking的初始化方法。
AFHTTPSessionManager * sessionManager = [AFHTTPSessionManager manager];複製程式碼
我相信大家通常都會這麼來建立,但是這裡我要說下manager並不是一個單利,最後都會調到一個方法
- (instancetype)initWithSessionConfiguration:(NSURLSessionConfiguration *)configuration {
    self = [super init];
    if (!self) {
        return nil;
    }

    if (!configuration) {
        configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
    }

    self.sessionConfiguration = configuration;
    self.operationQueue = [[NSOperationQueue alloc] init];
    self.operationQueue.maxConcurrentOperationCount = 1;

    self.session = [NSURLSession sessionWithConfiguration:self.sessionConfiguration delegate:self delegateQueue:self.operationQueue];
    .
    .
    .
}複製程式碼
大家注意第二個判斷,如果沒有傳入configuration的話他會建立一個預設的,這樣以至於我們之前在configuration的protocolClasses中註冊類全部被這個新的configuration替換掉了,所以無法解析。這裡我採取的辦法就是runtime hook,因為hook第三方的程式碼並不是一個很好的辦法,所以我直接hook NSURLSession的sessionWithConfiguration方法,因為通過觀察Afnetworking的原始碼最終都是走到這裡的。Hook之後把自己的configuration換進去,像這樣
+ (NSURLSession *)swizzle_sessionWithConfiguration:(NSURLSessionConfiguration *)configuration {

    NSURLSessionConfiguration *newConfiguration = configuration;
    // 在現有的Configuration中插入我們自定義的protocol
    if (configuration) {
        NSMutableArray *protocolArray = [NSMutableArray arrayWithArray:configuration.protocolClasses];
        [protocolArray insertObject:[CustomProtocol class] atIndex:0];
        newConfiguration.protocolClasses = protocolArray;
    }
    else {
        newConfiguration = [NSURLSessionConfiguration defaultSessionConfiguration];
        NSMutableArray *protocolArray = [NSMutableArray arrayWithArray:configuration.protocolClasses];
        [protocolArray insertObject:[CustomProtocol class] atIndex:0];
        newConfiguration.protocolClasses = protocolArray;
    }

    return [self swizzle_sessionWithConfiguration:newConfiguration];
}複製程式碼
然後就完美解決了。不過要注意下系統的是有兩個方法的
/*
 * Customization of NSURLSession occurs during creation of a new session.
 * If you only need to use the convenience routines with custom
 * configuration options it is not necessary to specify a delegate.
 * If you do specify a delegate, the delegate will be retained until after
 * the delegate has been sent the URLSession:didBecomeInvalidWithError: message.
 */
+ (NSURLSession *)sessionWithConfiguration:(NSURLSessionConfiguration *)configuration;
+ (NSURLSession *)sessionWithConfiguration:(NSURLSessionConfiguration *)configuration delegate:(nullable id <NSURLSessionDelegate>)delegate delegateQueue:(nullable NSOperationQueue *)queue;複製程式碼
這兩個方法不能確定最終會走那個,所以為了保險起見都hook下,hook的方式是一樣的
  6.3:AVPlayer請求,AVPlayer是我們iOS系統中系統自帶的播放視訊的框架,用到地方也很多,但是這個是比較坑的,因為AVPlayer雖然也有http/https/file……請求這個概念,但是AVPlayer所有的請求都不會走URL Loading System,也就是說所有由AVPlayer發出的請求都不能被我們的CustomProtocol攔截,這時候大家也許會問,不對呀,我們正常除錯的時候可以被攔截到的啊。其實蘋果官方上是說AVPlayer在真機除錯和模擬器除錯時候走的完全不是一套策略,也就是說在模擬器執行時候是完全正常的,可以被攔截到也可以被解析,但是在真機上面就恰恰相反了,因為我們最後還是以真機為準,所以我們採取的辦法還是hook,因為我們需要在媒體URL傳給AVPlayer前就要將相關東西配置好,域名替換啊,加host啊之類的,所以我們要找AVPlayer的入口,先看初始化方法,我發現專案中使用一個AVURLAsset來初始化AVPlayer,那麼AVURLAsset又是什麼呢?繼續查到AVURLAsset的初始化方法,可以發現這個方法:
/*!
  @method        initWithURL:options:
  @abstract        Initializes an instance of AVURLAsset for inspection of a media resource.
  @param        URL
                An instance of NSURL that references a media resource.
  @param        options
                An instance of NSDictionary that contains keys for specifying options for the initialization of the AVURLAsset. See AVURLAssetPreferPreciseDurationAndTimingKey and AVURLAssetReferenceRestrictionsKey above.
  @result        An instance of AVURLAsset.
*/
- (instancetype)initWithURL:(NSURL *)URL options:(nullable NSDictionary<NSString *, id> *)options NS_DESIGNATED_INITIALIZER;複製程式碼
AVF_EXPORT NSString *const AVURLAssetPreferPreciseDurationAndTimingKey NS_AVAILABLE(10_7, 4_0);
AVF_EXPORT NSString *const AVURLAssetReferenceRestrictionsKey NS_AVAILABLE(10_7, 5_0);
AVF_EXPORT NSString *const AVURLAssetHTTPCookiesKey NS_AVAILABLE_IOS(8_0);
AVF_EXPORT NSString *const AVURLAssetAllowsCellularAccessKey NS_AVAILABLE_IOS(10_0);複製程式碼
但是並沒有發現和Host相關的Key,其實這個key是有的就是AVURLAssetHTTPHeaderFieldsKey只是因為這個Key沒暴露出來。這個地方不太確定是不是蘋果的私有API,網上查了大量的資料也沒有個說法,甚至我親自去蘋果開發者去問,蘋果也沒有給任何答覆,各種說法都有,具體使用的話就是
[self swizzle_initWithURL:videoURL options:@{AVURLAssetHTTPHeaderFieldsKey : @{@"Host":host}}]複製程式碼
這樣使用是沒有任何問題的,但是畢竟是沒有暴露出來的方法,我們不能這樣明目張膽的使用,其實對於字串來說還是比較好規避的,只要不要明文出現這個KEY就可以,我在這裡使用了一個加密,吧key變成密文然後這個地方通過解密獲取,就像這樣:
//加密後的KEY
const NSString * headerKey = @"35905FF45AFA4C579B7DE2403C7CA0CCB59AA83D660E60C9D444AFE13323618F";
.
.
.
//getRequestHeaderKey方法為解密方法
return [self swizzle_initWithURL:videoURL options:@{[self getRequestHeaderKey] : @{@"Host":host}}];複製程式碼
這樣之後就大功告成了,AVPlayer可以在DNS被劫持的情況下播放了,
  6.4:POST請求這塊也算是一個大坑,我們知道http的post請求會包含一個body體,裡面包含我們需要上傳的引數等一些資料,對於POST請求我們的NSURLProtocol是可以正常攔截的,但是我們攔截之後發現無論怎麼樣我們獲得的body體都為nil!後來查了一些資料發下又是蘋果爸爸在做手腳。NSURLProtocol在攔截NSURLSession的POST請求時不能獲取到Request中的HTTPBody,這個貌似早就國外的論壇上傳開了,但國內好像還鮮有人知,據蘋果官方的解釋是Body是NSData型別,即可能為二進位制內容,而且還沒有大小限制,所以可能會很大,為了效能考慮,索性就攔截時就不拷貝了(內流滿面臉)。為了解決這個問題,我們可以通過把Body資料放到Header中,不過Header的大小好像是有限制的,我試過2M是沒有問題,不過超過10M就直接Request timeout了。。。而且當Body資料為二進位制資料時這招也沒轍了,因為Header裡都是文字資料,另一種方案就是用一個NSDictionary或NSCache儲存沒有請求的Body資料,用URL為key,最後方法就是別用NSURLSession,老老實實用古老的NSURLConnection算了。。。
  6.5:WKWebview是新出的瀏覽器控制元件,這裡就不多說了,WKWebview不走URL Loading System,所以也不會被攔截,不過也是有辦法的,但是因為這次專案中沒有用到,所以沒有過多的去研究,後續我會寫一篇關於這個部落格,不是很難,依舊是runtime大法。
  6.6:SNI環境,這個可是坑了我好久好久的東西,所以我會放在最後去說,SNI環境因為涉及到證照驗證所以是在https的基礎上來說的,SNI(Server Name Indication)是為了解決一個伺服器使用多個域名和證照的擴充套件。一句話簡述它的工作原理就是,在連線到伺服器建立SSL連結之前先傳送要訪問站點的域名(Hostname),這樣伺服器根據這個域名返回一個合適的證照。其實關於SNI環境在這裡就不過多解釋,阿里雲文件有很明白的解釋,同時他也有安卓和iOS在SNI環境下的處理文件,我們發現安卓部分寫的很詳細,可是已到了iOS這邊就這樣了:

可能是最全的iOS端HttpDns整合方案 | 掘金技術徵文
阿里雲文件截圖

######三行文字加三個連結就完事了。其實在遇到這個坑的時候我也查過很多相關資料,無非就是這三行話加這三個連結複製來複制去,沒有實質性的進展,大部分公司或者是專案沒有這麼重的Httpdns需求,所以也就不會有這個環境,即使遇到了也就直接關閉httpdns了,後來只能自己去用CFNetwork一點點實現。具體程式碼就不跟大家貼上了因為涉及到一些公司內部的程式碼,不過我會把我主要的參考資料發給大家。這裡有個小技巧,因為都在說CFNetwork是比較底層的網路實現,好多東西需要開發者自行處理比如一些變數的釋放之類的,所以我們能少用盡量少用,因為Cfnetwork是為SNI(https)環境服務,所以我們在攔截判斷的時候可以區分是用上層的網路請求轉發還是用底層的cfnetwork來轉發,

 if ([self.request.URL.scheme isEqualToString:@"https"] ) {
//使用CFnetwork
        curRequest = req;
        self.task = [[CustomCFNetworkRequestTask alloc] initWithURLRequest:originalRequest swizzleRequest:curRequest delegate:self];
        if (self.task) {
            [self.task startLoading];
        }
    } else {
//使用普通網路請求
        NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
        self.session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:[NSOperationQueue mainQueue]];
        NSURLSessionTask *task = [self.session dataTaskWithRequest:req];
        [task resume];
    }複製程式碼
我是這麼做的。

7:總結

  完成了以上的步驟之後你回發現在DNS壞掉的情況下手機裡面除了微信QQ(他們也做了DNS解析)之外其他應用都不能上網了但是你的App依然可以正常瀏覽網路資料。這就是我最近在做的時候遇到的一些問題,有什麼問題及時與我交流吧。
  juejin.im/post/58d8e9…

相關文章