网络劫持一般有两种情况,一种是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都是可知的。

防注入的方式就是这样:

  1. 发版前,在 bundle 中存一份最新的前端js文件

  2. 后台在返回 js 文件 URL 的时候,对 js 文件内容进行 SHA-256 ,得到的 hash 值拼接到 js 的文件名中

  3. 请求 js 资源文件时,在NSURLCache中的

    - (NSCachedURLResponse *)cachedResponseForRequest:(NSURLRequest *)request

    方法中拦截请求

  4. 拦截请求之后,判断本地是否有缓存,如果有,则直接返回缓存文件包装成 response

  5. 下载 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 的那个路径。

这种方案的缺点就是:

  1. 在发生 js 劫持的时候,只能使用本地 js,可能会比最新版本 js 落后

  2. js 文件必须是由自己的服务端提供,并控制,才好对 js 进行签名,所以适用范围略窄

作为这个方案的扩充,可以考虑再次利用NSURLProtocol,当发现 js 被污染,重定向URL,此URL由服务端返回一个加密的 js 文件,对称加密,密钥插入在 js 的密文中,本地解密 js 文件,就可以保证得到最新的,安全的 js 文件了。

不过话说回来,既然都这么费劲的话,为啥不让前端来帮忙做呢,或者直接上HTTPS,才是真正的防劫持之道。

总结

关于运营商网络劫持,我本人毕竟不是前端开发,所以有很多问题可以理解也不是很准确,只是提供一个比较少看到的功能实现。其实防劫持最终的解决办法就是HTTPS,不过我们还是可以通过这方面的思索,来尝试一些更深入,更好玩的东西。

References

NSHipster NSURLProtocol

NSHipster NSURLCache

DNS劫持

iOS 开发中使用 NSURLProtocol 拦截 HTTP 请求