iOS截取http/https流量


0x01.Why?

做移動測試的同學經常會在app和server中間架設一個代理(例如charles或者fiddler等),由經代理,app和server之間的交互及交互內容變得可視化,使得我們不再摸黑測試。事實上,能夠很好的掌握app和server端的交互不僅對於測試,對於開發,對於產品的整個質量提高都是有非常大益處的。但是,有些場景下,架設代理變得不易,或者難於滿足要求,舉幾個例子:

  • 想要找出正常用戶使用時候,哪些場景最耗流量(你不能讓用戶掛代理,如果有針對網絡流量的優化,掛代理也看不出問題來)。
  • 想要找出請求的各種接口中,哪些服務不穩定,如間歇出現4xx或者5xx錯誤,這需要統計大量的數據,單一客戶端掛代理是做不到的(當然服務端監控如果做得好也能實現)。
  • 想要找出某些特定條件下(如弱網,網絡切換等)客戶端自己產生的請求錯誤或者超時等等。
  • 想要查看一些特殊場景下接口是否會發生重復調用,錯誤調用序列。這些issue往往藏的很深,不易出現。這時候往往需要分析日志的pattern來把問題揪出來,這時候你就會發現,代理軟件做日志分析很麻煩,也要導出來專門分析,而且總掛着代理極為不方便(至少不能切換網絡,日志也要根據app做篩查,因為一般都是全流量截取)。

這時候需求就變成了:最好在app內部能夠截取所有的HTTP/HTTPS流量,以某種方式保存下來,並且能夠以某種方式傳遞給需要用這些數據的人。這其實是一種APM(Application Performance Monitoring)的概念,國外最早已經有人實現了這種功能,如 newrelic https://newrelic.com/ 國內也有一些類似的廠商了。

0x02. How?

先想一下我們每天都在使用的代理工具是如何實現的呢?代理工具會攔截所有的http的請求,記錄下我們需要的信息后替代客戶端重新發送相同的請求給服務端;攔截返回,記錄下想要的東西后返回給客戶端。如果JAVA寫的多,你可能看到過各種 interceptor 來截取流量。OKHttp的作者介紹這款被廣泛應用的http client的時候曾經說過:OKHttp只不過是請求和響應之間做了一堆interceptor而已。

具體落到iOS上。iOS的Foundation框架提供了 URL Loading System 這個庫(后面簡寫為ULS),所有基於URL(例如http://,https:// ,ftp://這些應用層的傳輸協議)的協議都可以通過ULS提供的基礎類和協議來實現,你甚至可以自定義自己的私有應用層通訊協議。

而ULS庫里提供了一個強有力的武器 NSURLProtocol。 繼承NSURLProtocol 的子類都可以實現截取行為,具體的方式就是:如果注冊了某個NSURLProtocol子類,ULS管理的流量都會先交由這個子類處理,這相當於實現了一個攔截器。由於現在處於統治地位的的http client庫 AFNetworking和 Alamofire 都是基於 URL Loading System實現的,所以他們倆和使用基礎URL Loading System API產生的流量理論上都可以被截取到。

注意一點,NSURLProtocol是一個抽象類,而不是一個協議(protocol)。

為了達到監控流量的目的,我們就先設計一個類來實現NSURLProtocol吧:


// MyHttpProtocol.h 
#import <Foundation/Foundation.h>
@interface MyHttpProtocol : NSURLProtocol
@end

//MyHttpProtocol.m

#import <Foundation/Foundation.h>
#import "MyHttpProtocol.h"

@implementation MyHttpProtocol

+(BOOL)canInitWithRequest:(NSURLRequest *)request{    
   NSString *scheme =[[request URL] scheme];
    if([[scheme lowercaseString] isEqualToString:@"http"]||
       [[scheme lowercaseString] isEqualToString:@"https"])
    {
        if([NSURLProtocol propertyForKey:@"processed" inRequest:request]){
            return NO;
        }
        return YES;
    }
    return NO;
}


+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request
{
    NSMutableURLRequest * duplicatedRequest;
    duplicatedRequest =  [request mutableCopy];
    [NSURLProtocol setProperty:@YES forKey:@"processed" inRequest:duplicatedRequest];
    NSLog(@"%@",request.HTTPBody);
    return (NSURLRequest *) duplicatedRequest;
}

上邊的MyHttpProtocol類繼承了NSURLProtocol,並實現了 NSURLProtocol的兩個方法。

+ (BOOL)canInitWithRequest:(NSURLRequest *)request

這個方法返回YES,MyHttpProtocol類就會處理一個 request,否則就按照原有方式處理。在上邊的代碼里,我先判斷了協議的類型是不是http/https,如果不是,則返回NO,如果是,則會做一個判斷:這個request是否帶有一個叫做 "processed"的標簽,如果是,則返回NO,不交給MyHttpProtocol處理;如果不是,則交給MyHttpProtocol處理。

重點說一下標簽“processed”:每當需要加載一個URL資源時,URL Loading System會詢問MyURLProtocol是否處理,如果返回YES,URL Loading System會創建一個MyURLProtocol實例,實例做完攔截工作后,會重新調用原有的方法,如session GET,URL Loading System會再一次被調用,如果在+canInitWithRequest:中總是返回YES,這樣URL Loading System又會創建一個MyURLProtocol實例。。。。這樣就導致了無限循環。為了避免這種問題,我們可以利用+setProperty:forKey:inRequest:來給被處理過的請求打標簽,然后在+canInitWithRequest:中查詢該request是否已經處理過了,如果是則返回NO。 上文中的“processed”就是打的一個標簽,標簽是一個字符串,可以任意取名。而這個打標簽的方法,通常會在

+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request 

中實現。

實現這個子類以后,在程序加載的地方,注冊這個類,這樣,理論上,請注意“理論上”這三個字,就可以截獲所有的http/https流量了。注冊的代碼如下

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    [NSURLProtocol registerClass:[MyHttpProtocol class]];
    return YES;
}

做完了上述工作,我們仍然無法實現我們所想:記錄下所有的請求和響應。這是因為:如果你攔截了請求,你就需要對你的攔截負責:比如重新發送攔截的請求,處理請求對應的返回等。這里就需要完成非常多的dirty work了。下面的玩具代碼只會處理最簡單的情況,如果真實使用,得處理很多細節問題。

為了便於理解,先介紹NSURLProtocol的幾個內置的屬性,包括:client,request,cachedResponse,類型如下

@property(readonly, retain) id<NSURLProtocolClient> client;
@property(readonly, copy) NSURLRequest *request;
@property(readonly, copy) NSCachedURLResponse *cachedResponse;

這三個概念稍微有點兒繞,先簡要說一下:request被用作接收ULS轉給NSURLProtocol的請求;client的實現了NSURLProtocolClient這個協議,這里邊有一堆callback函數,我們一會兒會用到didLoadData;cachesResponse,顧名思義,請求對應的相應會被緩存在這里。

我們還要實現NSURLProtocol的兩個方法。startLoading和stopLoading


- (void)startLoading{
    NSLog(@"Start loading -------");
    NSLog(@"request url is: %@",self.request.URL); //這里的self.request就是ULS傳過來的請求體,這里我們記錄下一些請求體的信息。
    NSLog(@"http method is:%@",self.request.HTTPMethod); //
    for (NSString *key in[self.request.allHTTPHeaderFields allKeys]){    //打印http請求的header
        NSLog(@"key:%@,value:%@",key,[self.request.allHTTPHeaderFields objectForKey:key]);
    }
    
    //重新轉發請求
    NSMutableURLRequest *newRequest = [self.request mutableCopy];
    NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
    NSURLSession * session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:nil];
    self.task = [session dataTaskWithRequest:newRequest];
    [self.task resume];
}

-(void) stopLoading{
    NSLog(@"Stop loading -------");   
    [self.task cancel];
}

通過上述代碼,我們成功的記錄下來了請求體的一些信息,但是如何記錄返回信息呢?由於ULS是異步框架,所以,響應會推給回調函數,我們必須在回調函數里進行截取。為了實現這一功能,我們需要實現 NSURLSessionDataDelegate 這個委托協議(NSURLSessionDataDelegate也有局限性,這里不展開說了)。

@interface MyHttpProtocol ()<NSURLSessionDataDelegate>
@property (nonatomic, strong) NSMutableData *data;
@property (nonatomic, strong) NSURLSessionDataTask *task;
@end


//當服務端返回信息時,這個回調函數會被ULS調用,在這里實現http返回信息的截取
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {
 
    [self.client URLProtocol:self didLoadData:data]; //返回給URL Loading System接收到的數據,這個很重要,不然光截取不返回,就瞎了。
    NSLog(@"--data received");

   //下面的代碼只打印json類型的http返回。
    NSError *error = nil;
    NSString *jsonObject = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
    if(error){
        NSLog(@"error occured!");
        return;
    }
    NSData *jsonData = [NSJSONSerialization dataWithJSONObject:jsonObject options:NSJSONWritingPrettyPrinted error:nil];
    NSString *jsonString = [[NSString alloc]initWithData:jsonData encoding:NSUTF8StringEncoding];
    NSLog(@"nsdata is %@",jsonString);   
}

好了,上邊這一坨代碼,理論上實現了我們想要的功能的最小集:攔截http/https請求和響應,並打印出來。為什么說理論上呢。如果你使用AFNETworking,你會發現,你的代碼根本沒有被調用。這是因為它根本不屌上邊的注冊,也就是下邊這句代碼:

 [NSURLProtocol registerClass:[MyHttpProtocol class]];

實際上 ULS允許加載多個NSURLProtocol,它們被存在一個數組里,默認情況下,AFNETWorking只會使用數組里的第一個protocol。這看起來是個悲劇,如果不改源碼,我想做的事兒不就止步於此了么?多虧Objective C是動態語言。我們可以用一項“尖端科技”,也就是object-c的動態方法替換來實現動態的修改源碼來達到目的。
實現一個類:MySessionConfiguration.m (這部分代碼基本照抄的一個叫做Netfox的開源項目,大家有興趣可以搜索)。

#import <Foundation/Foundation.h>
#import "MySessionConfiguration.h"
#import "MyHttpProtocol.h"
#import <objc/runtime.h>

@implementation MySessionConfiguration

//返回一個默認配置的單體
+ (MySessionConfiguration *) defaultConfiguration{
    static MySessionConfiguration *staticConfiguration;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        staticConfiguration =[[MySessionConfiguration alloc] init];
    });
    return staticConfiguration;
}


- (instancetype) init{
    self = [super init];
    if(self){
        self.isSwizzle=NO;
    }
    return self;
}

//load被調用的時候,其實吧session.configuration.protocolClasses 這個數組從原有配置換成了只有MyHttpProtocol
- (void)load{
    NSLog(@"----configuration load --");
    self.isSwizzle=YES;
    Class cls = NSClassFromString(@"__NSCFURLSessionConfiguration") ?:NSClassFromString(@"NSURLSessionConfiguration");
    [self swizzleSelector:@selector(protocolClasses) fromClass:cls toClass:[self class]];
    
}

- (void)unload {
    self.isSwizzle=NO;
     Class cls = NSClassFromString(@"__NSCFURLSessionConfiguration") ?:NSClassFromString(@"NSURLSessionConfiguration");
     [self swizzleSelector:@selector(protocolClasses) fromClass:cls toClass:[self class]];
}

- (void)swizzleSelector:(SEL)selector fromClass:(Class)original toClass:(Class)stub{
    Method originalMethod = class_getInstanceMethod(original, selector);
    Method stubMethod = class_getInstanceMethod(stub, selector);
    if(!originalMethod || !stubMethod){
        [NSException raise:NSInternalInconsistencyException format:@"Could't load NSURLSessionConfiguration "];
    }

   //真正的替換在這里
    method_exchangeImplementations(originalMethod, stubMethod);
}

 //返回MyHttpProtocol
- (NSArray *)protocolClasses{
    return @[[MyHttpProtocol class]];
}

@end

最后,簡單粗暴的,在程序啟動的時候加入這么一句:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
 
   //就是這一句
   [[[MySessionConfiguration alloc] init] load];
 
    return YES;
}

這樣,一個簡單的監控功能就實現了。實際上,想讓它能夠變得實用起來還有無數的坑要填,代碼量大概再增加20倍吧,這些坑包括:https的證書校驗,NSURLConnection和NSURLSession兼容,重定向,超時處理,返回值內容解析,各種異常處理(不能因為你崩了讓程序跟着崩了),開關,截獲的信息本地存儲策略,回傳服務端策略等。真正寫一個可用的工具不是那么簡單。所以,如果金錢允許,還是讓公司去采購吧。。。

0x03 BTW:

1.本人OC菜鳥,肯定有理解不當的地方,有高手請多加指正。
2.有小伙伴想一起做的話可以一同起個開源啊,一起利用一下碎片化的時間(除非專職的開發測試,否則幾乎沒有大把時間和機會寫產品形態的測試工具的)。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM