前言
DNS劫持指在劫持的網(wǎng)絡范圍內(nèi)攔截域名解析的請求,分析請求的域名,把審查范圍以外的請求放行,否則返回假的IP地址或者什么都不做使請求失去響應。

DNS劫持的主要表現(xiàn)為看視頻,點擊之后莫名其妙的跳到了某些廣告網(wǎng)站。正常情況下,當我們點擊某個鏈接的時候,會向一個稱作DNS服務器的東西發(fā)出請求,把鏈接轉換成機器能夠識別的ip地址,其過程如下:
域名->
ip地址的過程被稱作DNS解析。在這個過程中,由于DNS請求報文是明文狀態(tài),可能會在請求過程中被監(jiān)測,然后攻擊者偽裝DNS服務器向主機發(fā)送帶有假ip地址的響應報文,從而使得主機訪問到假的服務器。
NSURLProtocol
NSURLProtocol是蘋果提供給開發(fā)者的黑魔法之一,大部分的網(wǎng)絡請求都能被它攔截并且篡改,以此來改變URL的加載行為。這使得我們不必改動網(wǎng)絡請求的業(yè)務代碼,也能在需要的時候改變請求的細節(jié)。作為一個抽象類,我們必須繼承自NSURLProtocol才能實現(xiàn)中間攻擊的功能。
是否要處理對應的請求。由于網(wǎng)頁存在動態(tài)鏈接的可能性,簡單的返回
YES可能會創(chuàng)建大量的NSURLProtocol對象,因此我們需要保證每個請求能且僅能被返回一次YES
+ (BOOL)canInitWithRequest: (NSURLRequest *)request;
+ (BOOL)canInitWithTask: (NSURLSessionTask *)task;是否要對請求進行重定向,或者修改請求頭、域名等關鍵信息。返回一個新的
NSURLRequest對象來定制業(yè)務
+ (NSURLRequest *)canonicalRequestForRequest: (NSURLRequest *)request;如果處理請求返回了
YES,那么下面兩個回調(diào)對應請求開始和結束階段。在這里可以標記請求對象已經(jīng)被處理過
- (void)startLoading;
- (void)stopLoading;
當發(fā)起網(wǎng)絡請求的時候,系統(tǒng)會像注冊過的NSURLProtocol發(fā)起詢問,判斷是否需要處理修改該請求,通過一下代碼來注冊你的子類
[NSURLProtocol registerClass: [CustomURLProtocol class]];
DNS解析
一般情況下,考慮DNS劫持大多發(fā)生在使用webView的時候。相較于使用網(wǎng)頁,正常的網(wǎng)絡請求即便被劫持了無非是返回錯誤的數(shù)據(jù)、或者干脆404,而且對付劫持,普通請求還有其他方案選擇,所以本文討論的是如何處理網(wǎng)頁加載的劫持。
LocalDNS
LocalDNS是一種常見的防劫持方案。簡單來說,在網(wǎng)頁發(fā)起請求的時候獲取請求域名,然后在本地進行解析得到ip,返回一個直接訪問網(wǎng)頁ip地址的請求。結構體struct hostent用來表示地址信息:
struct hostent {
char *h_name; // official name of host
char **h_aliases; // alias list
int h_addrtype; // host address type——AF_INET || AF_INET6
int h_length; // length of address
char **h_addr_list; // list of addresses
};
C函數(shù)gethostbyname使用遞歸查詢的方式將傳入的域名轉換成struct hostent結構體,但是這個函數(shù)存在一個缺陷:由于采用遞歸方式查詢域名,常常會發(fā)生超時。但是gethostbyname本身不支持超時處理,所以這個函數(shù)調(diào)用的時候放到操作隊列中執(zhí)行,并且采用信號量等待1.5秒查詢:
+ (struct hostent *)getHostByName: (const char *)hostName {
__block struct hostent * phost = NULL;
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
NSOperationQueue * queue = [NSOperationQueue new];
queue.maxConcurrentOperationCount = 1;
[queue addOperationWithBlock: ^{
phost = gethostbyname(hostName);
dispatch_semaphore_signal(semaphore);
}];
dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 1.5 * NSEC_PER_SEC));
[queue cancelAllOperations];
return phost;
}
然后通過函數(shù)inet_ntop把結構體中的地址信息符號化,獲得C字符串類型的地址信息。提供getIpAddressFromHostName方法隱藏對ipv4和ipv6地址的處理細節(jié):
+ (NSString *)getIpv4AddressFromHost: (NSString *)host {
const char * hostName = host.UTF8String;
struct hostent * phost = [self getHostByName: hostName];
if ( phost == NULL ) { return nil; }
struct in_addr ip_addr;
memcpy(&ip_addr, phost->h_addr_list[0], 4);
char ip[20] = { 0 };
inet_ntop(AF_INET, &ip_addr, ip, sizeof(ip));
return [NSString stringWithUTF8String: ip];
}
+ (NSString *)getIpv6AddressFromHost: (NSString *)host {
const char * hostName = host.UTF8String;
struct hostent * phost = [self getHostByName: hostName];
if ( phost == NULL ) { return nil; }
char ip[32] = { 0 };
char ** aliases;
switch (phost->h_addrtype) {
case AF_INET:
case AF_INET6: {
for (aliases = phost->h_addr_list; *aliases != NULL; aliases++) {
NSString * ipAddress = [NSString stringWithUTF8String: inet_ntop(phost->h_addrtype, *aliases, ip, sizeof(ip))];
if (ipAddress) { return ipAddress; }
}
} break;
default:
break;
}
return nil;
}
+ (NSString *)getIpAddressFromHostName: (NSString *)host {
NSString * ipAddress = [self getIpv4AddressFromHost: host];
if (ipAddress == nil) {
ipAddress = [self getIpv6AddressFromHost: host];
}
return ipAddress;
}
適配IPv6
蘋果明確現(xiàn)在的的應用要支持IPv6地址,對于開發(fā)者來說,并沒有太大的改動,無非是將gethostbyname改成另外一個函數(shù):
phost = gethostbyname2(host, AF_INET6);
另外就是解析域名過程中優(yōu)先獲取IPv6的地址而不是IPv4:
+ (NSString *)getIpAddressFromHostName: (NSString *)host {
NSString * ipAddress = [self getIpv6AddressFromHost: host];
if (ipAddress == nil) {
ipAddress = [self getIpv4AddressFromHost: host];
}
return ipAddress;
}
擴展
localDNS直接進行解析獲取的ip地址可能不是最優(yōu)選擇,另一種做法是讓應用每次啟動后從服務器下發(fā)對應的DNS解析列表,直接從列表中獲取ip地址訪問。這種做法對比遞歸式的查詢,無疑效率要更高一些,需要注意的是在下發(fā)請求過程中如何避免解析列表被中間人篡改。
因為請求地址可能無效,需要以ip映射host的映射表來保證在訪問無效的地址之后能重新使用原來的域名發(fā)起請求。另外確定ip無效后應該維護一個無效地址表,用來域名解析后判斷是否繼續(xù)使用地址訪問。整個域名解析過程大概如下:

此外,如果你的應用還沒有服務器下發(fā)DNS解析列表這一業(yè)務,那么直接使用Local DNS解析可能會遇到解析出來的ip無效問題。目前上面代碼的處理是如果ip無效,發(fā)起回調(diào)讓webView重新加載。除此之外有另外一種解決方案。應用本地存儲一張需要訪問到的域名表,然后在程序啟動之后異步執(zhí)行域名解析過程,參照DNS解析失敗的處理 (支持IPv6)一文,提前做好無效解析的處理。
WebKit
WKWebView是蘋果推出的UIWebView的替代方案,但前者還不夠優(yōu)秀以至于使用后者開發(fā)的大有人在。另外使用NSURLProtocol實現(xiàn)防DNS劫持功能的時候,在調(diào)起canInitWithRequest:后就再無下文。通過查閱資料發(fā)現(xiàn)想實現(xiàn)WebKit的請求攔截需要調(diào)用一些私有方法,讓 WKWebView 支持 NSURLProtocol文章已經(jīng)做了很好的處理,在文中的基礎上,筆者對注冊協(xié)議的過程多加了一層處理(畢竟蘋果爸爸坑起我們來絕不手軟):
static inline NSString * lxd_scheme_selector_suffix() {
return @"SchemeForCustomProtocol:";
}
static inline SEL lxd_register_scheme_selector() {
const NSString * const registerPrefix = @"register";
return NSSelectorFromString([registerPrefix stringByAppendingString: lxd_scheme_selector_suffix()]);
}
static inline SEL lxd_unregister_scheme_selector() {
const NSString * const unregisterPrefix = @"unregister";
return NSSelectorFromString([unregisterPrefix stringByAppendingString: lxd_scheme_selector_suffix()]);
}
NSURLSession
在AFNetworking替換成NSURLSession實現(xiàn)之后,常規(guī)的NSURLProtocol已經(jīng)不能攔截請求了。為了能繼續(xù)實現(xiàn)攔截功能,需要在NSURLSessionConfiguration中設置對攔截類的支持:
NSURLSessionConfiguration * configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
configuration.protocolClasses = @[LXDDNSInterceptor class];
由于AFNetworking跟SDWebImage都是采用默認的defaultSessionConfiguration初始化請求會話對象的,因此直接hook掉這個默認方法可以實現(xiàn)攔截適配:
+ (NSURLSessionConfiguration *)lxd_defaultSessionConfiguration {
NSURLSessionConfiguration * configuration = [self lxd_defaultSessionConfiguration];
configuration.protocolClasses = @[LXDDNSInterceptor class];
return configuration;
}
但是為了避免省字數(shù)出現(xiàn)[NSURLSessionConfiguration new]的創(chuàng)建方式,hook上面的方法并不能保證能夠攔截到請求。于是我把hook的目標放到了NSURLSession上,發(fā)現(xiàn)存在一個類方法構造器生成實例:
+ (NSURLSession *)sessionWithConfiguration: (NSURLSessionConfiguration *)configuration delegate: (id<NSURLSessionDelegate>)delegate delegateQueue: (NSOperationQueue *)queue;
最開始是想hook這個類方法,然而在class_getClassMethod獲取所有的方法列表輸出之后發(fā)現(xiàn)竟然不存在這個類方法,取而代之的是一個init構造器:

不知道這是不是蘋果有意為之來誤導開發(fā)者(蘋果:我是爸爸,規(guī)則我來定)。但是通過代碼聯(lián)想又無法直接輸出這個函數(shù),于是通過
category的方式暴露這個方法名,并且hook掉:
/// h文件
@interface NSURLSession (LXDIntercept)
- (instancetype)initWithConfiguration: (NSURLSessionConfiguration *)configuration delegate: (id<NSURLSessionDelegate>)delegate delegateQueue: (NSOperationQueue *)queue;
@end
/// m文件
@implementation NSURLSession (LXDIntercept)
+ (void)load {
Method origin = class_getClassMethod([NSURLSession class], @selector(initWithConfiguration:delegate:delegateQueue:));
Method custom = class_getClassMethod([NSURLSession class], @selector(lxd_initWithConfiguration:delegate:delegateQueue:));
method_exchangeImplementations(origin, custom);
}
- (NSURLSession *)lxd_initWithConfiguration: (NSURLSessionConfiguration *)configuration delegate: (id<NSURLSessionDelegate>)delegate delegateQueue: (NSOperationQueue *)queue {
if (lxd_url_session_configure) {
lxd_url_session_configure(configuration);
}
return [self lxd_initWithConfiguration: configuration delegate: delegate delegateQueue: queue];
}
@end
于是,又能愉快的在項目里面玩耍網(wǎng)絡攔截啦。
本文demo:LXDAppMonitor
參考資料
NSURLProtocol
iOS網(wǎng)絡請求優(yōu)化之DNS映射
iOS應用支持IPV6,就那點事兒
讓 WKWebView 支持 NSURLProtocol