iOS防DNS汙染方案調研—HTTPS(非SNI)業務場景

stevechen1010發表於2017-09-18

1. 背景說明

本文主要介紹 HTTPS(含SNI) 業務場景下在 iOS 端實現 “IP直連” 的通用解決方案。

1.1 HTTPS

傳送 HTTPS 請求首先要進行 SSL/TLS 握手,握手過程大致如下:

  1. 客戶端發起握手請求,攜帶隨機數、支援演算法列表等引數。
  2. 服務端收到請求,選擇合適的演算法,下發公鑰證照和隨機數。
  3. 客戶端對服務端證照進行校驗,併傳送隨機數資訊,該資訊使用公鑰加密。
  4. 服務端通過私鑰獲取隨機數資訊。
  5. 雙方根據以上互動的資訊生成session ticket,用作該連線後續資料傳輸的加密金鑰。

上述過程中,和“IP直連”有關的是第3步,客戶端需要驗證服務端下發的證照,驗證過程有以下兩個要點:

  1. 客戶端用本地儲存的根證照解開證照鏈,確認服務端下發的證照是由可信任的機構頒發的。
  2. 客戶端需要檢查證照的 domain 域和擴充套件域,看是否包含本次請求的 host。

如果上述兩點都校驗通過,就證明當前的服務端是可信任的,否則就是不可信任,應當中斷當前連線。

當客戶端使用“IP直連”解析域名時,請求URL中的host會被替換成解析出來的IP,所以在證照驗證的第2步,會出現domain不匹配的情況,導致SSL/TLS握手不成功。

1.2 SNI

SNI(Server Name Indication)是為了解決一個伺服器使用多個域名和證照的SSL/TLS擴充套件。它的工作原理如下:

  1. 在連線到伺服器建立SSL連結之前先傳送要訪問站點的域名(Hostname)。
  2. 伺服器根據這個域名返回一個合適的證照。

目前,大多數作業系統和瀏覽器都已經很好地支援SNI擴充套件,OpenSSL 0.9.8也已經內建這一功能。

上述過程中,當客戶端使用“IP直連”時,請求URL中的host會被替換成解析出來的IP,導致伺服器獲取到的域名為解析後的IP,無法找到匹配的證照,只能返回預設的證照或者不返回,所以會出現SSL/TLS握手不成功的錯誤。

比如當你需要通過 HTTPS 訪問 CDN 資源時,CDN 的站點往往服務了很多的域名,所以需要通過SNI指定具體的域名證照進行通訊。

2. HTTPS場景(非SNI)解決方案

針對“domain不匹配”問題,可以採用如下方案解決:hook 證照校驗過程中第2步,將IP直接替換成原來的域名,再執行證照驗證。該方案與使用“自定義證照”進行 HTTPS 請求的校驗方案一樣。

【注意】基於該方案發起網路請求,若報出SSL校驗錯誤,比如 iOS 系統報錯kCFStreamErrorDomainSSL, -9813; The certificate for this server is invalid,Android系統報錯System.err: javax.net.ssl.SSLHandshakeException: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.,請檢查應用場景是否為SNI(單IP多HTTPS域名)。

下面分別列出 iOS 平臺的示例程式碼。

iOS示例

此示例針對NSURLSession/NSURLConnection介面。

- (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);
}

/*
 * NSURLConnection
 */
- (void)connection:(NSURLConnection *)connection
willSendRequestForAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge
{
    if (!challenge) {
        return;
    }
    
    /*
     * URL裡面的host在使用“IP直連”的情況下被設定成了IP,此處從HTTP Header中獲取真實域名
     */
    NSString* host = [[self.request allHTTPHeaderFields] objectForKey:@"host"];
    if (!host) {
        host = self.request.URL.host;
    }
    
    /*
     * 判斷challenge的身份驗證方法是否是NSURLAuthenticationMethodServerTrust(HTTPS模式下會進行該身份驗證流程),
     * 在沒有配置身份驗證方法的情況下進行預設的網路請求流程。
     */
    if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust])
    {
        if ([self evaluateServerTrust:challenge.protectionSpace.serverTrust forDomain:host]) {
            /*
             * 驗證完以後,需要構造一個NSURLCredential傳送給發起方
             */
            NSURLCredential *credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
            [[challenge sender] useCredential:credential forAuthenticationChallenge:challenge];
        } else {
            /*
             * 驗證失敗,進入預設處理流程
             */
            [[challenge sender] continueWithoutCredentialForAuthenticationChallenge:challenge];
        }
    } else {
        /*
         * 對於其他驗證方法直接進行處理流程
         */
        [[challenge sender] continueWithoutCredentialForAuthenticationChallenge:challenge];
    }
}

////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////

/*
 * 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);
}

需要修改HOST的場景總結

那麼什麼時候需要修改Host?

答案是所有情況都需要設定 HOST:做網路請求時,採用 IP 直連的方案會遇到 HOST 欄位被改為 IP 的問題,所以都需要手動地配置 HOST 欄位。

場景 HTTP HTTPS(非SNI) HTTPS(SNI)
如何設定 改Host 改Host,在移動端我們自己校驗,直接返回YES 改HOST,而且需要做SNI適配。

雖然 IP 直接連的方案,導致的結果是 HOST 欄位被改為了IP,所以需要手動修改HOST。但是服務端唯一的根據是SNI欄位。下面就介紹下針對 SNI 場景的方案:

3. HTTPS(SNI)場景方案

3.1 iOS SNI場景

SNI(單IP多HTTPS證照)場景下,iOS上層網路庫NSURLConnection/NSURLSession沒有提供介面進行SNI欄位的配置,因此需要Socket層級的底層網路庫例如CFNetwork,來實現IP直連網路請求適配方案。而基於CFNetwork的解決方案需要開發者考慮資料的收發、重定向、解碼、快取等問題(CFNetwork是非常底層的網路實現),希望開發者合理評估該場景的使用風險。
可參考:

具體的實現方案可以參考: 《防 DNS 汙染方案調研—– SNI 場景》


相關文章