一、基础背景
1. 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解析就应运而生了。
2. 什么是DNS劫持
DNS劫持,是指在DNS解析过程中拦截域名解析的请求,然后做一些自己的处理,比如返回假的IP地址或者什么都不做使请求失去响应,其效果就是对特定的网络不能反应或访问的是假网址。根本原因就是以下两点:
- 1:恶意攻击,拦截运营商的解析过程,把自己的非法东西嵌入其中。
- 2:运营商为了利益或者一些其他的因素,允许一些第三方在自己的链接里打打广告之类的。
4. 防止DNS劫持
了解了DNS劫持的相关资料后我们就知道了,防止NDS劫持就要从第二步入手,因为DNS解析过程是运营商来操作的,我们不能去干涉他们,不然我们也就成了劫持者了,所以我们要做的就是在我们请求之前对我们的请求链接做一些修改,将我们原本的请求链接www.baidu.com 修改为180.149.132.47,然后请求出去,这样的话就运营商在拿到我们的请求后发现我们直接用的就是IP地址就会直接给我们放行,而不会去走他自己DNS解析了,也就是说我们把运营商要做的事情自己先做好了。不走他的DNS解析也就不会存在DNS被劫持的问题,从根本是解决了。
5. IP直连
它具有多方面的优势:
- 防劫持,可以绕过运营商 LocalDNS 解析过程,避免域名劫持,提高网络访问成功率。
- 降低延迟,DNS 解析是一个相对耗时的工作,跳过这个过程可以降低一定的延迟。
- 精准调度,运营商解析返回的节点不一定是最优的,自己获取 IP 可以基于自己的策略来获取最精准的、最优的节点。
5. 获取IP
对于获取 IP,有两种方案:
- HTTPDNS
HTTPDNS是客户端基于http协议向服务器A发送域名B解析请求(例如:www.baidu.com),服务器A直接返回域名B对应的ip地址(例如:119.75.217.109),客户端获取到的IP后就向直接往此IP发送业务协议请求。
这种方式替代了基于DNS协议向运营商LocalDNS发起解析请求,可以从根本上避免LocalDNS造成的域名劫持问题。
常规的DNS解析是通过UDP方式。
国内提供域名解析 API 接口的,有 DNSPod,示例如下:
http://119.29.29.29/d?dn=www.163.com&ttl=1
// 输出如下:
183.47.248.109;125.90.206.144;14.215.100.95;183.6.245.191,17
现在国内有很多厂商为 DNSPod 开发了 SDK,比如 阿里、七牛(开源)等。不想自己写的,不妨使用这些 SDK。
- 内置IP列表
可以在启动等阶段由服务端下发域名和 IP 的对应列表,客户端来进行缓存,发起网络请求的时候直接根据缓存 IP 来进行业务访问。
二、实际应用场景中的问题
实现 HTTP 协议下 IP 连接其实是很简单的,我们只需要通过 NSURLProtocol 来拦截网络请求,然后将符号条件的网络请求 URL 中的域名修改为 IP 就可以啦。
但是会有各种各样的问题:
1.http请求服务器无法判断请求访问的内容
原因:在我们修改http请求时,这时http的head中host字段会变成ip,因为一台服务器我们会有很多接口服务同时存在,服务器接收到请求后无法根据域名去判断我们访问的是哪个服务。
解决:由于服务器是根据host字段来判断请求的服务,所以在发起网络请求时,用带ip的URL生成request后,手动将request中的host字段改回域名。这样服务器可以正确识别,运营商也会根据域名中的ip为我们路由。
//原始URL
NSURL *originalUrl =[NSURL URLWithString:@"https://api.helijia.com/app-merchant"];
//根据原始URL获取 第三方解析出的ip
NSString *ip = [self getHostByUrlSyn:url];
//替换ip后的URL
NSURL *url = [ip replaceHostWithIp:ip];
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
//将request的host字段改为原始URL的域名
[request setValue:originalUrl.host forHTTPHeaderField:@"host"];
2. 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算了。。。你以为这么就结束了吗?并没有,后来查了大量的资料发现,既然post请求的httpbody没有苹果复制下来,那我们就不用httpbody,我们再往底层去看就会发现HTTPBodyStream这个东西我们可以通过他来获取请求的body体具体代吗如下
#pragma mark -
#pragma mark 处理POST请求相关POST 用HTTPBodyStream来处理BODY体
- (NSMutableURLRequest *)handlePostRequestBodyWithRequest:(NSMutableURLRequest *)request {
NSMutableURLRequest * req = [request mutableCopy];
if ([request.HTTPMethod isEqualToString:@"POST"]) {
if (!request.HTTPBody) {
uint8_t d[1024] = {0};
NSInputStream *stream = request.HTTPBodyStream;
NSMutableData *data = [[NSMutableData alloc] init];
[stream open];
while ([stream hasBytesAvailable]) {
NSInteger len = [stream read:d maxLength:1024];
if (len > 0 && stream.streamError == nil) {
[data appendBytes:(void *)d length:len];
}
}
req.HTTPBody = [data copy];
[stream close];
}
}
return req;
}
这样之后的req就是携带了body体的request啦,可以愉快地做post请求啦。
3.Https请求证书校验错误
分析:
发送HTTPS请求首先要进行SSL/TLS握手,握手过程大致如下:
- 客户端发起握手请求,携带随机数、支持算法列表等参数。
- 服务端收到请求,选择合适的算法,下发公钥证书和随机数。
- 客户端对服务端证书进行校验,并发送随机数信息,该信息使用公钥加密。
- 服务端通过私钥获取随机数信息。
- 双方根据以上交互的信息生成session ticket,用作该连接后续数据传输的加密密钥。
上述过程中,和HTTPDNS有关的是第3步,客户端需要验证服务端下发的证书,验证过程有以下两个要点:
- 客户端用本地保存的根证书解开证书链,确认服务端下发的证书是由可信任的机构颁发的。
- 客户端需要检查证书的domain域和扩展域,看是否包含本次请求的host。
如果上述两点都校验通过,就证明当前的服务端是可信任的,否则就是不可信任,应当中断当前连接。
当客户端使用HTTPDNS解析域名时,请求URL中的host会被替换成HTTPDNS解析出来的IP,所以在证书验证的第2步,会出现domain不匹配的情况,导致SSL/TLS握手不成功。
解决方案:只需在验证时,传入真实的 host 即可:
- (BOOL)evaluateServerTrust:(SecTrustRef)serverTrust
forDomain:(NSString *)domain
{
/*
* 创建证书校验策略
*/
NSMutableArray *policies = [NSMutableArray array];
if (domain) {
[policies addObject:(__bridge_transfer id)SecPolicyCreateSSL(true, (__bridge CFStringRef)domain)];
} else {
[policies addObject:(__bridge_transfer id)SecPolicyCreateBasicX509()];
}
/*
* 绑定校验策略到服务端的证书上
*/
SecTrustSetPolicies(serverTrust, (__bridge CFArrayRef)policies);
/*
* 评估当前serverTrust是否可信任,
* 官方建议在result = kSecTrustResultUnspecified 或 kSecTrustResultProceed
* 的情况下serverTrust可以被验证通过,https://developer.apple.com/library/ios/technotes/tn2232/_index.html
* 关于SecTrustResultType的详细信息请参考SecTrust.h
*/
SecTrustResultType result;
SecTrustEvaluate(serverTrust, &result);
return (result == kSecTrustResultUnspecified || result == kSecTrustResultProceed);
}
/*
* NSURLSession
*/
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge
completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential * __nullable credential))completionHandler
{
if (!challenge) {
return;
}
NSURLSessionAuthChallengeDisposition disposition = NSURLSessionAuthChallengePerformDefaultHandling;
NSURLCredential *credential = nil;
/*
* 获取原始域名信息。
*/
NSString* host = [[self.request allHTTPHeaderFields] objectForKey:@"host"];
if (!host) {
host = self.request.URL.host;
}
if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
if ([self evaluateServerTrust:challenge.protectionSpace.serverTrust forDomain:host]) {
disposition = NSURLSessionAuthChallengeUseCredential;
credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
} else {
disposition = NSURLSessionAuthChallengePerformDefaultHandling;
}
} else {
disposition = NSURLSessionAuthChallengePerformDefaultHandling;
}
// 对于其他的challenges直接使用默认的验证方案
completionHandler(disposition,credential);
}
4. webview中H5页面部分
HTTPDNS实施的主要难点与坑点都在H5页面上面,下面逐条记录下在实施webview的HTTPDNS时遇到的问题:由于web页面的请求并不是由客户端发起,我们无法在生成request的时候修改host。
解决:在这里我们使用NSURLProtocol来解决。
用一句话解释NSURLProtocol :NSURLProtocol就是一个苹果允许的中间人攻击。
NSURLProtocol可以劫持系统所有基于C socket的网络请求。
注意:WKWebView基于Webkit,并不走底层的C socket,所以NSURLProtocol拦截不了WKWebView中的请求。
具体步骤为:
注册NSURLProtocol子类 -> 使用NSURLProtocol子类拦截Webview请求 -> 使用NSURLSession重新发起请求 -> 将NSURLSession请求的响应内容返回给Webview
- NSURLProtocol子类的实现:
拦截哪些请求
- request的URL是ip的(ipv4、ipv6)
- 非白名单的请求
/**
* 是否拦截处理指定的请求
*
* @param request 指定的请求
*
* @return 返回YES表示要拦截处理,返回NO表示不拦截处理
*/
+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
//DNS开关控制功能开启关闭
if (![[HLJHttpDNS shareInstance] isDNSConfigWorking]) {
return NO;
}
/* 防止无限循环,因为一个请求在被拦截处理过程中,也会发起一个请求,这样又会走到这里,如果不进行处理,就会造成无限循环 */
if ([NSURLProtocol propertyForKey:protocolKey inRequest:request]) {
return NO;
}
// 防止无限循环, 第三方解析会发出ip域名的请求,这里筛选
// 判断请求URL的Host是否Ipv4
if ([WebViewURLProtocol checkHostIp:request.URL.host]) {
return NO;
}
NSString *url = [request.URL.host mutableCopy];
//去掉Ipv6的大括号
url = [url stringByReplacingOccurrencesOfString:@"[" withString:@""];
url = [url stringByReplacingOccurrencesOfString:@"]" withString:@""];
// 判断请求URL的Host是否Ipv6
if ([WebViewURLProtocol checkHostIpv6:url]) {
return NO;
}
NSMutableURLRequest *mutableReq = [request mutableCopy];
//假设原始的请求头部没有host信息,只有使用IP替换后的请求才有
NSString *host = [mutableReq valueForHTTPHeaderField:@"host"];
if (!mutableReq && host) {
return NO;
}
return YES;
}
在拦截的部分,我们需要注意一点,因为我们向第三方解析域名的请求也是ip的。这里我们需要在拦截时对域名的host位进行判断,如果是ipv4、ipv6的域名,就不对其进行拦截。不然程序就会循环拦截重新发起后的请求,导致程序卡死。
我们项目中图片服务是走CDN的服务器,还有其他统计等第三方的服务等等。我们将这类第三方的域名加入了白名单,在请求时会跳过对白名单内域名的拦截。
- 拦截住的请求怎么修改
- 替换域名为解析后的ip
- 修改request的host
- 修改证书校验中的host
拦截请求后,我们在重新发起的请求中对request进行修改:替换域名为解析后的ip、修改request的host
- (void)startLoading {
NSMutableURLRequest *request = [self.request mutableCopy];
// 表示该请求已经被处理,防止无限循环
[NSURLProtocol setProperty:@(YES) forKey:protocolKey inRequest:request];
NSMutableURLRequest *mutableReq = [request mutableCopy];
NSString *originalUrl = mutableReq.URL.absoluteString;
NSURL *url = [NSURL URLWithString:originalUrl];
// 同步接口获取IP地址
NSString *ip = [[HLJHttpDNS shareInstance] getHostByNameSyn:url.absoluteString];
if (ip) {
// 通过HTTPDNS获取IP成功,进行URL替换和HOST头设置
NSRange hostFirstRange = [originalUrl rangeOfString:url.host];
if (NSNotFound != hostFirstRange.location) {
mutableReq.URL = [NSURL URLWithString:ip];
// 添加原始URL的host
[mutableReq setValue:url.host forHTTPHeaderField:@"host"];
// 添加originalUrl保存原始URL
[mutableReq addValue:originalUrl forHTTPHeaderField:@"originalUrl"];
}
}
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
self.session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:[NSOperationQueue currentQueue]];
NSURLSessionTask *task = [_session dataTaskWithRequest:mutableReq];
[task resume];
}
在NSURLProtocol中拦截了请求后,在重新发起NSURLSession代理方法中,我们将证书校验的Host重新改回域名,这样就会通过证书校验过程。
#pragma NSURLSessionTaskDelegate
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential *_Nullable))completionHandler {
if (!challenge) {
return;
}
NSURLSessionAuthChallengeDisposition disposition = NSURLSessionAuthChallengePerformDefaultHandling;
NSURLCredential *credential = nil;
/*
* 获取原始域名信息。
*/
NSString *host = [[self.request allHTTPHeaderFields] objectForKey:@"host"];
if (!host) {
host = self.request.URL.host;
}
if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
if ([self evaluateServerTrust:challenge.protectionSpace.serverTrust forDomain:host]) {
disposition = NSURLSessionAuthChallengeUseCredential;
credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
} else {
disposition = NSURLSessionAuthChallengePerformDefaultHandling;
}
} else {
disposition = NSURLSessionAuthChallengePerformDefaultHandling;
}
// 对于其他的challenges直接使用默认的验证方案
completionHandler(disposition, credential);
}