网络劫持一般有两种情况,一种是DNS劫持
,另一种是HTTP劫持
。
从表现上区分这两种劫持非常简单。
如果是DNS劫持
,你输入的网址是google.com,然后出来的页面是百度。
如果是HTTP劫持
,你打开了google.com,可是右下角弹出了百度推广的不孕不育广告。
URL域名解析成ip地址的过程被称作 DNS 解析
。在这个过程中,由于 DNS 请求报文是明文状态,可能会在请求过程中被监测,然后攻击者伪装DNS服务器向主机发送带有假ip地址的响应报文,从而使得主机访问到假的服务器。这个就是DNS劫持的根本原理。
而另一种就是HTTP劫持
。在运营商的路由器节点上,设置协议检测,一旦发现是HTTP请求,而且是html类型请求,则拦截处理。后续做法往往分为2种,1种是类似DNS劫持
返回302让用户浏览器跳转到另外的地址,还有1种是在服务器返回的 HTML 数据中插入 js 或 dom 节点,从而使网页中出现自己的广告等等垃圾信息。
一般来说,针对各种网络劫持,大部分工作都是由前端来完成,针对这一方面的研究,也大多都是前端开发方向。但是其实客户端也可以通过一些方法来防劫持。
作为客户端开发,我们应该先了解我们的URL Loading System。
虽然 URL 加载系统包含的内容众多,但代码的设计上却非常良好,没有把复杂的操作暴露出来,开发者只需要在用到的时候进行设置。(苹果官方文档About the URL Loading System,是每个 iOS 开发者都应该认真研究的。)
DNS劫持
DNS劫持
的问题,就可以基于 NSURLProtocol
实现 LocalDNS
防劫持方案。
关于LocalDNS
防劫持方案,可以参考一篇大神文章DNS防劫持。
简单来说,在网页发起请求的时候获取请求域名,然后在本地进行解析得到ip
,返回一个直接访问网页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函数gethostbyname
,使用递归查询的方式将传入的域名转换成struct hostent
结构体,在本地将URL
解析成123.123.25.53
这种ip
地址。具体实现参考文章中的代码。
另外还可以用从服务器下发对应的DNS
解析列表来代替递归查询这种比较低效的方式,文章中也有介绍。
而无论是那种方式,NSURLProtocol
都是处理的核心部分。
NSURLProtocol
NSURLProtocol
或许是 URL 加载系统中最功能强大但同时也是最晦涩的部分了。它是一个抽象类,你可以通过子类化来定义新的或已经存在的 URL 加载行为。
用了它,你不必改动应用在网络调用上的其他部分,就可以改变URL加载行为的全部细节。 NSURLProtocol
就是一个苹果允许的中间人攻击。
下面这么多需求,都可以通过 NSURLProtocol
,在不改动其他代码的情况下,比较简单地就能实现:
拦截图片加载请求,转为从本地文件加载
为了测试对 HTTP 返回内容进行mock和stub
对发出请求的header进行格式化
对发出的媒体请求进行签名
创建本地代理服务,用于数据变化时对URL请求的更改
故意制造畸形或非法返回数据来测试程序的鲁棒性
过滤请求和返回中的敏感信息
在既有协议基础上完成对 NSURLConnection 的实现且与原逻辑不产生矛盾
官方文档对 NSURLProtocol
的描述是这样的:
An NSURLProtocol object handles the loading of protocol-specific URL data. The NSURLProtocol class itself is an abstract class that provides the infrastructure for processing URLs with a specific URL scheme. You create subclasses for any custom protocols or URL schemes that your app supports.
在每一个 HTTP 请求开始时,URL 加载系统创建一个合适的 NSURLProtocol
对象处理对应的 URL 请求,而我们需要做的就是写一个继承自 NSURLProtocol
的类,并通过 - registerClass:
方法注册我们的协议类,然后 URL 加载系统就会在请求发出时使用我们创建的协议对象对该请求进行处理。
如何使用 NSURLProtocol 拦截 HTTP 请求?有这个么几个问题需要去解决:
如何决定哪些请求需要当前协议对象处理?
对当前的请求对象需要进行哪些处理?
NSURLProtocol
如何实例化?如何发出 HTTP 请求并且将响应传递给调用者?
这几个问题其实都可以通过 NSURLProtocol
为我们提供的 API 来解决,决定请求是否需要当前协议对象处理的方法是:+ canInitWithRequest
,每一次请求都会有一个 NSURLRequest
实例,上述方法会拿到所有的请求对象,我们就可以根据对应的请求选择是否处理该对象。
那么究竟是否要处理对应的请求。由于网页存在动态链接的可能性,简单的返回YES
可能会创建大量的NSURLProtocol
对象,因此我们需要保证每个请求能且仅能被返回一次YES
。
请求经过 + canInitWithRequest:
方法过滤之后,我们得到了所有要处理的请求,接下来需要对请求进行一定的操作,而这都会在 + canonicalRequestForRequest:
中进行,虽然它与 + canInitWithRequest:
方法传入的 request 对象都是一个,但是最好不要在 + canInitWithRequest:
中操作对象,可能会有语义上的问题。
所以,我们需要覆写 + canonicalRequestForRequest:
方法提供一个标准的请求对象:
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request {
return request;
}
这里就可以对request做一些修改,比如加个header什么的,只需要最后能返回一个NSURLRequest即可。
如果处理请求返回了YES
,那么下面两个回调对应请求开始和结束阶段。在这里可以标记请求对象已经被处理过。
- (void)startLoading;
- (void)stopLoading;
在之前的 iOS 客户端基于 WebP 图片格式的流量优化 这篇文章中,就是利用- (void)startLoading;
来替换图片请求的。当时替换 WebP 图片的核心功能,实际上就是在NSURLProtocol
中完成的。
HTTP劫持
实际上,这种劫持对于大部分客户端来说,是无能为力的,需要前端来处理。不过,有些特殊情况下,客户端也可以有一些针对 HTTP劫持
的办法。
比如像媒体新闻类客户端的文章中,防止js注入
。
在具体的实现方式之前,需要有一些准备工作,就是关于URL Loading System
中的NSURLCache
。
NSURLCache
NSURLCache
为您的应用的 URL 请求提供了内存中以及磁盘上的综合缓存机制。 作为基础类库 URL Loading System 的一部分,任何通过 NSURLConnection
加载的请求都将被 NSURLCache
处理。
当一个请求完成得到来自服务器的Response
,在本地保存作为cache。下一次同一个请求再发起时,本地保存的Response
就会马上返回,不需要连接服务器。NSURLCache
会 自动 且 透明 地返回回应。
在NSURLConnection加载系统中,缓存被设计为request对象的一个属性,由NSURLRequest对象的cachePolicy属性指定。而在NSURLSession加载系统中,缓存被设计为 NSURLSessionConfiguration对像的一个属性,该属性所指定的策略被该session的所有request所共享。
作为一个Cache
,它头文件中提供的方法并不复杂,就是基本的增删查改,(其中增和改可以算是一个功能,没有就增,改就是覆盖)。主要方法仅六个:
// 初始化方法
- (instancetype)initWithMemoryCapacity:(NSUInteger)memoryCapacity diskCapacity:(NSUInteger)diskCapacity diskPath:(nullable NSString *)path;
// 查询方法
- (nullable NSCachedURLResponse *)cachedResponseForRequest:(NSURLRequest *)request;
// 存储方法
- (void)storeCachedResponse:(NSCachedURLResponse *)cachedResponse forRequest:(NSURLRequest *)request;
// 删除方法
- (void)removeCachedResponseForRequest:(NSURLRequest *)request;
- (void)removeAllCachedResponses;
- (void)removeCachedResponsesSinceDate:(NSDate *)date NS_AVAILABLE(10_10, 8_0);
非常简洁的类,功能高度封装,用起来很简单。但是它也开辟了一个新世界,就是你可以实现一个子类,来接管系统的URLCache
功能。只需要一个简单的步骤:
- (BOOL)application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
STURLCache *URLCache = [[STURLCache alloc] initWithMemoryCapacity:4 * 1024 * 1024
diskCapacity:20 * 1024 * 1024
diskPath:nil];
[NSURLCache setSharedURLCache:URLCache];
}
这样,STURLCache
就可以接管缓存的管理了。当系统调用URLCache
的增删改查方法时,都可以由子类来接管。
NSURLCache
的缓存策略,以及和HTTP header
之间的关系,可以参考NSHipster NSURLCache文章,不再深入。
这样,配合NSURLProtocol
,就可以对缓存做精准控制了。
js签名防注入
这个方法,适用于新闻类app,因为新闻类app的web页都是自己写的,所以,js和css都是可知的。
防注入的方式就是这样:
发版前,在 bundle 中存一份最新的前端js文件
后台在返回 js 文件 URL 的时候,对 js 文件内容进行
SHA-256
,得到的 hash 值拼接到 js 的文件名中请求 js 资源文件时,在
NSURLCache
中的- (NSCachedURLResponse *)cachedResponseForRequest:(NSURLRequest *)request
方法中拦截请求拦截请求之后,判断本地是否有缓存,如果有,则直接返回缓存文件包装成 response
下载 js 资源时,走
NSURLProtocol
代理方法- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data
,对 data 进行SHA-256
签名比对,如果签名一致,将 data 通过;如果签名不一致,代表 js 被污染,直接丢弃,从bundle取出本地预存的 js 文件返回回来。
代码本身不会有什么难处,所以就只写出基本逻辑。
在自定义的URLCache实现文件中:
- (NSCachedURLResponse *)cachedResponseForRequest:(NSURLRequest *)request {
NSString* ua = [request valueForHTTPHeaderField:@"User-Agent"];
if (!EmptyString(ua) && [ua lf_containsSubString:@"AppleWebKit"]) {
if ([CacheManager shouldVerifyHashCode:request]) { //包含64位hashcode的js css文件
// 取本地JS缓存
NSData *resultData = [CacheManager getJSCache];
if (resultData) {
NSURLResponse *response = [[NSURLResponse alloc] initWithURL:request.URL MIMEType:nil expectedContentLength:resultData.length textEncodingName:nil];
return [[NSCachedURLResponse alloc] initWithResponse:response data:resultData];
} else {
return nil;
}
}
return [super cachedResponseForRequest:request];
}
- (void)storeCachedResponse:(NSCachedURLResponse *)cachedResponse forRequest:(NSURLRequest *)request {
NSString* ua = [request valueForHTTPHeaderField:@"User-Agent"];
if ([CacheManager shouldVerifyHashCode:request] && [ua lf_containsSubString:@"AppleWebKit"]) {
// 将请求回来的,并且通过验证的新js放到缓存中
[[CacheManager defaultManager] storeCachedResponse:cachedResponse forRequest:request])
return;
}
[super storeCachedResponse:cachedResponse forRequest:request];
}
而在自定义的NSURLProtocol
子类中
+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
NSString *ua = [request valueForHTTPHeaderField:@"User-Agent"];
if ([CacheManager shouldVerifyHashCode:request] && [ua lf_containsSubString:@"AppleWebKit"]) {
// 拦截js请求
return YES;
}
return NO;
}
// 收到请求返回data的代理方法
- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data {
if([data verifySHA256Success]) {
[self.client URLProtocol:self didLoadData:data];
} else {
localData = [CacheManager bundleCacheFromUrl:url];
[self.client URLProtocol:self didLoadData:localData];
}
}
比较抽象的一点就是,这个方案是不是不能再更新 js 了?
当然不会,因为当后台的 js 文件有更新时,新 js 文件的签名就会发生变化,js 文件的URL也就自然变化,于是本地请求的时候,缓存是无法命中的,所以,也就会直接走下载 js 的那个路径。
这种方案的缺点就是:
在发生 js 劫持的时候,只能使用本地 js,可能会比最新版本 js 落后
js 文件必须是由自己的服务端提供,并控制,才好对 js 进行签名,所以适用范围略窄
作为这个方案的扩充,可以考虑再次利用NSURLProtocol
,当发现 js 被污染,重定向URL,此URL由服务端返回一个加密的 js 文件,对称加密,密钥插入在 js 的密文中,本地解密 js 文件,就可以保证得到最新的,安全的 js 文件了。
不过话说回来,既然都这么费劲的话,为啥不让前端来帮忙做呢,或者直接上HTTPS
,才是真正的防劫持之道。
总结
关于运营商网络劫持,我本人毕竟不是前端开发,所以有很多问题可以理解也不是很准确,肯定也不是太专业。只是提供一个比较少看到的功能实现。其实防劫持最终的解决办法就是HTTPS,不过我们还是可以通过这方面的思索,来尝试一些更深入,更好玩的东西。而且可以更深入地去探索苹果框架下的URL Loading System。
References
NSHipster NSURLProtocol
NSHipster NSURLCache
DNS劫持
iOS 开发中使用 NSURLProtocol 拦截 HTTP 请求
About the URL Loading System
更多其他文章欢迎访问我的博客 http://suntao.me