HttpCanary實現對HTTP2協議的抓包和注入(原理篇)

MegatronKing發表於2019-01-14

今天釋出了HttpCanary2.0版本,除了修復了部分bug以及優化效能外,最主要的是支援了HTTP2協議。

HttpCanary是什麼?Android平臺第二強大的HTTP抓包和注入工具,不瞭解的同學可以閱讀下關於HttpCanary的介紹:juejin.im/post/5c1e37…

HttpCanary實現對HTTP2協議的抓包和注入(原理篇)

HttpCanary2.0已經發布到GooglePlay,歡迎大家下載並給予評價建議,傳送門:play.google.com/store/apps/…

乾貨為主,廢話不多說,下面開始本篇的正文。

HTTP2.0和HTTP1.x的區別

先簡單介紹一下HTTP2.0協議的概況,熟悉的同學可以跳過。

HTTP2.0協議是由SPDY協議進化而來,標準於2015年5月正式釋出,算起來不到四年時間,屬於比較新的技術。所以部分主流的抓包工具都不支援HTTP2,比如Fiddler,而Charles則是在4.0版本後開始支援。

HTTP2.0協議和HTTP/1.x協議在請求方法、狀態碼乃至URI和絕大多數HTTP頭部欄位等部分保持高度相容性,即常說的請求行、請求頭、請求體、響應行、響應頭、響應體這些格式都具有一致性。

但是,HTTP2.0協議在對頭部資料的壓縮、多路複用、伺服器主動推送三個方面做了支援和優化。

  • 頭部資料壓縮。對請求行、請求頭、響應行、響應頭這些頭部資料進行壓縮,採用Hpack演算法。
  • 多路複用。每個connection以stream的形式組織,資料包按照frame(資料幀)的形式通訊,同時增加了流量控制等功能。
  • 伺服器主動推送。HTTP2.0協議支援雙向通訊,以及half-close這種單向通訊。

HTTP2.0協議雖然沒有明確要求加密,但目前的實現都是預設使用TLS加密,所以可以認為使用HTTP2.0則必須使用HTTPS。

為了實現對HTTP1.x的相容,HTTP2.0協議為此額外定義了應用層協商標準(Application-Layer Protocol Negotiation,簡稱ALPN),以便客戶端和服務端能夠從HTTP/1.0、HTTP/1.1、HTTP/2乃至其他非HTTP協議中做出選擇。ALPN衍生於SPDY協議的NPN標準,都是基於TLS的擴充套件標準。

Android是從5.0開始支援ALPN,而Java是從OpenJDK 8和JDK 9開始支援,可以認為從這些時候開始才真正支援HTTP2.0協議。

HttpCanary的HTTP2之旅

我在釋出HttpCanary2.0的同時,已經將HTTP2.0協議的實現程式碼更新到了github,也就是HttpCanary的核心庫NetBare,對程式碼感興趣的可以對照著本文理解。

HTTP2.0的支援難點主要有三個:

  • 如何進行應用層協議協商,即ALPN協商。
  • 對請求和響應頭部進行Hpack解碼並重新編碼。
  • 將HTTP2.0的stream、frame並還原成HTTP1.x協議格式並重新生成stream、frame,以及多路複用的分離。

下面,講解NetBare是如何解決這四個難題,從而實現對HTTP2.0協議的抓包和注入的。

1. ALPN協商

Android從5.0開始支援ALPN協商,NetBare庫的最低支援版本也是5.0,所以在理論上是完全可以實現的。

1.1 ALPN協商圖解

簡單概括ALPN協商的過程:SSL握手的時候,Client將支援的協議版本列表發給Server,Server務端從列表中選擇一個協議版本併發給Client作為協商版本,SSL握手完成後,Client和Server都使用協商版本進行通訊。ALPN的協商是在Client發給Server的ClientHello握手包以及Server回給Client的ServerHello握手包兩步直接完成的。

下圖是ALPN協商的圖解:

HttpCanary實現對HTTP2協議的抓包和注入(原理篇)

粗略一看非常簡單,但是由於HTTP2.0協議強制使用TLS/SSL加密,所以只能使用中間人MITM方式進行解密抓包。而中間人MITM又分為中間人Client和中間人Server,所以ClientHello握手包的通訊流程是Client -> MITM Server -> MITM Client -> Server,而ServerHello握手包的通訊流程則是 Server -> MITM Client -> MITM Server -> Client,由原先的一來一回兩步,變成了來回六步,複雜性上增加了許多。

增加了MITM層的ALPN協商的圖解:

HttpCanary實現對HTTP2協議的抓包和注入(原理篇)

這裡有個小技巧,最開始的ClientHello報文並沒有直接交給MITM Server開始握手,而是通過一個Parser直接解析出list of protocols並交給MITM Client,讓MITM Client先和Server進行握手。獲取到selected protocol後,MITM Server在和Client開始握手。這樣的設計的目的主要是,降低兩組SSL握手之間的邏輯依賴。

接下來,按照這個圖解流程,實現新的ALPN協商過程。

1.2 解析ClientHello報文

第一個核心步驟,MITM Server需要解析出ClientHello握手包中的協議列表(list of protocols)。由於ALPN extension是基於TLS的extension標準,所以解析方式類似於SNI的解析方式。

TLS extensions資料區位於ClientHello包的Compression Method之後,TLS extensions(注意複數s)是支援多個extension擴充套件的,而SNI和APLN協商只是其中的一種。每個extension是按照type + length + data的格式依次組織的。其中SNI的type是0,而ALPN的type是16。

HttpCanary實現對HTTP2協議的抓包和注入(原理篇)

我們依次遍歷並找到type等於16的資料區域,並按照length讀取data資料區,這裡就是ALPN的list of protocols內容了。

下一步是繼續解析list of protocols中具體的協議,比如是HTTP1.0、HTTP1.1或者HTTP2.0。list of protocols的資料組織形式是count+(length+protocol)s,其中count表示協議列表中的協議個數,length表示其後的協議值長度(注意length所佔位元組數是1,也就是byte型),用圖解表示為如下:

HttpCanary實現對HTTP2協議的抓包和注入(原理篇)

解析出來的protocol值,可能為HTTP/1.0、HTTP/1.1、h2等,其中h2表示HTTP2.0協議。

1.2 MITM Client設定list of protocols

第二個核心步驟,MITM Client將解析出來的protocols加入到ClientHello包中發給真正的Server。由於Android並沒有公開相關的API,所以我們只能通過反射方式呼叫隱藏API。通過閱讀org.conscrypt.OpenSSLEngineImpl的原始碼,發現可以通過反射其成員變數sslParameters設定ClientHello的list of protocols。

sslParameters變數型別是SSLParametersImpl,我們來簡單看下其內部引數:

public class SSLParametersImpl implements Cloneable {
    ...
    byte[] npnProtocols;
    byte[] alpnProtocols;
    boolean useSessionTickets;
    boolean useSni;
    ...
}

複製程式碼

這裡除了ALPN外,還有NPN(SPDY協議的協商標準),所以反射ALPN設定list of protocols的程式碼是:

Field sslParametersField = mSSLEngine.getClass().getDeclaredField("sslParameters");
sslParametersField.setAccessible(true);
Object sslParameters = sslParametersField.get(mSSLEngine);
if (sslParameters == null) {
   throw new IllegalAccessException("sslParameters value is null");
}
Field alpnProtocolsField = sslParameters.getClass().getDeclaredField("alpnProtocols");
alpnProtocolsField.setAccessible(true);
alpnProtocolsField.set(sslParameters, listOfProtocols);
複製程式碼

必須注意這裡的alpnProtocols是byte[]型別的變數,那麼我們如何把HTTP/1.0、HTTP/1.1、h2這些協議組織成byte[]呢?

其實這個byte[]是按照protocols的length+protocol依次組織的,圖解如下:

HttpCanary實現對HTTP2協議的抓包和注入(原理篇)

程式碼實現是:

ByteArrayOutputStream os = new ByteArrayOutputStream();
for (HttpProtocol protocol : protocols) {
    String protocolStr = protocol.toString();
    os.write(protocolStr.length());
    os.write(protocolStr.getBytes(Charset.forName("UTF-8")), 0, protocolStr.length());
}
byte[] alpnProtocols = os.toByteArray();
複製程式碼

細心的同學,仔細一對比會發現,這個和上面解析的list of protocols資料相比就相差一個count,那為什麼還要費這麼大力氣來先解析出protocol值呢?

因為從Android P開始支援Java OpenJDK 8,以上通過反射OpenSSLEngineImpl的方式已經行不通了。由於OpenJDK 8已經支援直接通過SSLParameter類設定list of protocols,故Android對此作了相應的相容,具體的相容類是org.conscrypt.Java8EngineWrapper。閱讀其原始碼,可以找到setApplicationProtocols方法傳入list of protocols。

final class Java8EngineWrapper extends AbstractConscryptEngine {
    ...
    @Override
    void setApplicationProtocols(String[] protocols) {
    delegate.setApplicationProtocols(protocols);
    } 
    ...
}
複製程式碼

我們同樣需要通過反射呼叫此方法:

Method setApplicationProtocolsMethod = mSSLEngine.getClass().getDeclaredMethod("setApplicationProtocols", String[].class);
setApplicationProtocolsMethod.setAccessible(true);
setApplicationProtocolsMethod.invoke(mSSLEngine, new Object[]{protocols});
複製程式碼

這裡使用的是String[],這就是為什麼要解析出protocol值的緣故了。

1.3 解析ServerHello報文中的selected protocol

當真正的Server收到MITM Client發過去的ClientHello包後,需要回一個ServerHello包,同時將服務端選擇的協議版本加入其中。MITM Client收到ServerClient包後需要解析出selected protocol,這裡講解下是如何解析出selected protocol的。

從ServerHello包中解析selected protocol有兩種方式,一種是如同之前處理ClientHello一樣,強解析。因為selected protocol同list of protocols一樣,都是使用的TLS extensions標準。第二種方式,將ServerHello直接交給SSLEngine,開始正常的SSL握手流程,然後從SSLEngine中直接獲取解析後的selected protocol。兩種方法,都沒有任何問題,我這裡採用的是第二種。

這種方式需要反射SSLEngine,按照之前的經驗,要區分系統版本。

Android P以下,SSLEngine的實現類是org.conscrypt.OpenSSLEngineImpl,如何來反射selected protocol呢?仔細閱讀原始碼後,會發現OpenSSLEngineImpl類中並沒有相關ALPN selected protocol的程式碼,這個就非常捉急了。但是如果熟悉okhttp原始碼的同學,可能會知道okhttp對ALPN協商的支援使用過反射OpenSSLSocketImpl來完成的,所以再來看一下OpenSSLSocketImpl的程式碼,就找到ALPN selected protocol相關的程式碼了,如下:

private long sslNativePointer;
...
/**
* Returns the protocol agreed upon by client and server, or {@code null} if
* no protocol was agreed upon.
*/
public byte[] getAlpnSelectedProtocol() {
    return NativeCrypto.SSL_get0_alpn_selected(sslNativePointer);
}
複製程式碼

它是通過呼叫NativeCrypto的靜態方法SSL_get0_alpn_selected來獲取selectedProtocol的,如此一看,最關鍵的就是sslNativePointer這個引數了。sslNativePointer是個JNI層指標,同樣出現於OpenSSLEngineImpl類中,那麼是否是同一個呢?答案是肯定的,都是由SessionContext建立的,同一個Session下的sslNativePointer是相同的。

由此就找到了解決方案:先反射取到sslNativePointer,再反射NativeCrypto.SSL_get0_alpn_selected方法獲取ALPN selected protocol。

Class<?> nativeCryptoClass = Class.forName("com.android.org.conscrypt.NativeCrypto");
Method SSL_get0_alpn_selectedMethod = nativeCryptoClass.getDeclaredMethod("SSL_get0_alpn_selected", long.class);
SSL_get0_alpn_selectedMethod.setAccessible(true);

Field sslNativePointerField = mSSLEngine.getClass().getDeclaredField("sslNativePointer");
sslNativePointerField.setAccessible(true);
long sslNativePointer = (long) sslNativePointerField.get(mSSLEngine);
byte[] selectedProtocol = (byte[]) SSL_get0_alpn_selectedMethod.invoke(null, sslNativePointer);
複製程式碼

這裡的byte[]不需要再解析了,可以直接轉換成UTF-8字串。

對於Android P而言,獲取ALPN selected protocol就容易多了,Java8EngineWrapper中直接提供了相關方法,直接反射就可以了:

final class Java8EngineWrapper extends AbstractConscryptEngine {
    ...
    @Override
    public String getApplicationProtocol() {
        return delegate.getApplicationProtocol();
    }
    ...
}
複製程式碼

如此,就知曉了服務端選擇的協議型別了,也就是本次Connection通訊使用的協議型別了,如果是h2那就表示此次通訊使用的是HTTP2協議。

1.4 MITM Server設定selected protocol

ALPN協商的最後一步,就將selected protocol加入到ServerHello報文中,由MITM Server發給Client完成SSL握手。這一步同1.2 MITM Client設定list of protocols幾乎相同,唯一的區別是protocol列表變成了單個的selected protocol。

當SSL握手完成後,就開始進行請求和響應資料通訊了。

2. Hpack編解碼

Hpack是為了精簡要是HTTP頭部資料而設計的,HTTP2.0協議就使用了Hpack演算法,來提升效能。

2.1 Hpack演算法概念及原理

由於HTTP協議headers部分包含了大量相同的欄位,比如Content-Type,Cookie,Host等等,這些都是可以通過字典的方式進行編碼壓縮,比如Client和Server都約定1表示Content-Type,2表示cookie,如此資料就顯得非常小了。Hpack演算法的原理和作用就是類似這樣的。

Hpack只作用於HTTP頭部資訊,包括請求行、請求頭、響應行、響應頭這四個部分,而不僅僅是請求頭和響應頭。

首先,Hpack演算法定義了兩種Table,一種是靜態表(Static Table),一種是動態表(Dynamic Table)。

靜態表是由IETF統一制定的標準,定義了大部分常用的欄位:

Index Header Name Header Value
1 :authority
2 :method GET
3 :method POST
4 :path /
5 :path /index.html
6 :scheme http
7 :scheme https
8 :status 200
... ... ...
14 :status 500
15 accept-charset
16 accept-encoding gzip, deflate
... ... ...

靜態表一共定義了61個欄位,索引從1開始,完整的表可參考:http2.github.io/http2-spec/…

動態表,顧名思義就是針對不確定內容動態處理的表,它維護了一個索引和頭部值,比如訪問一個圖片,content-type為image/jpeg,image/jpeg這個字串資料就存放於動態索引表中。動態索引表的大小是可以動態增長的,而最大上限由SETTINGS幀的SETTINGS_HEADER_TABLE_SIZE來設定。

動態表由服務端和客戶端共同維護,每一條Connection讀資料和寫資料各有且僅有一個動態表,也就是說Client和Server各有兩個動態表,動態表作用於此Connection下的所有HTTP請求和響應。Client傳送請求,編碼使用動態表1,Server接收請求,解碼也使用動態表1;Server傳送響應,編碼使用動態表2,Client接收響應,解碼使用動態表2。此Connection下的所有HTTP請求和響應,都是使用的動態表1和動態表2,兩個表之間互不干擾,完全獨立。除此之外,為了儘量壓縮頭部資料,還是用了霍夫曼編碼,編碼後再存入動態表中。

靜態表和動態表都是以二進位制編碼的方式組織的,編碼狀態機和規則如下圖:

HttpCanary實現對HTTP2協議的抓包和注入(原理篇)

以上就是Hpack相關的知識點,下面來分析NetBare是如何進行Hpack編解碼設計的。

2.2 NetBare的Hpack解碼及重編碼

NetBare庫的VirtualGateway維護了四個Hpack表,MITM Client和MITM Server各兩個,目的是先解碼還原成我們常見的HTTP協議格式,然後再重新編碼,圖解如下:

HttpCanary實現對HTTP2協議的抓包和注入(原理篇)

Hpack演算法的實現,是基於OKHttp開源庫中的Hpack類並做了一些修改。值得注意的是,Hpack演算法有多種編碼規則,極有可能相同的資料先解碼再重新編碼後和原先不同。當時未注意到這一點,以為是bug,還給OkHttp提了issue,囧。

3. Stream+Frame的多路複用機制

3.1 HTTP2.0的多路複用設計

HTTP2.0協議最大的特性就是多路複用,降低HTTP延時提高效能。雖然HTTP1.1引入了管道機制(Pipelining)使用keep-alive也能夠實現多路複用,但是多個請求和響應必須依次排隊,未能將多路複用發揮到極致。

HTTP2.0協議的多路複用,同樣也是基於keep-alive,另外由於強制使用HTTPS,還需要開啟session ticket,其同樣是一個TLS extensions擴充套件。而開啟session ticket的方式,類似處理ALPN,都是通過反射完成的,不多贅述。

HTTP2.0協議多路複用最大的革新,是使用stream+frame的形式來組織HTTP請求和響應,來實現多個請求和響應可以併發而不用依次排隊。每一個stream代表一個請求+響應,一個Connection中可以同時存在多個stream,每個stream中的資料傳送和接收的最小處理單元就是frame。同一時間內可以有多個stream的各自的frame存在於管道中,每個frame中包含stream id,接收端用此區分frame是屬於哪個stream的資料。這就是真正意義上的多路複用。

HttpCanary實現對HTTP2協議的抓包和注入(原理篇)

3.2 多路複用請求的攔截和注入

普通的HTTP請求是一個Connection一個請求響應,結束後銷燬Connection,也就是常說的握手揮手,不留下一片雲彩。雖然效能低,但是對請求和響應的攔截和注入就方便多了。所以NetBare對於HTTP1.x的攔截器設計是:

HttpCanary實現對HTTP2協議的抓包和注入(原理篇)

很明顯,這種攔截器設計只能滿足一個Connection一個請求響應的情況。如果是HTTP2.0協議那種frame單元傳輸而且交錯的資料傳輸,Interceptors很難做邏輯處理。唯一的方案就是對各個stream的frame單元進行組包,還原成HTTP1.x格式的資料,交給Interceptors做攔截注入,最後再拆包成frame單元發給終端。另外,由於請求併發,同一個時間有多個stream的frame在傳輸,所以還需要對各個stream進行隔離。

所以,修改之後的攔截器設計如下:

HttpCanary實現對HTTP2協議的抓包和注入(原理篇)

HTTP2 Codec Interceptor分為Decode Interceptor和Encode Interceptor,分別用於Frame解碼和Frame編碼。而每個Stream的攔截使用各自完全獨立的攔截器例項,這樣就可以在自定義攔截器中對HTTP2的明文請求及響應進行注入等操作。

4. HTTP2.0的其它特性支援

HTTP2.0協議比HTTP1.x要複雜地多,除了以上一些特性外,還有服務端推送,Stream優先順序、Stream重置和資料流控制等特性。由於不影響正常的抓包和注入主功能,NetBare暫未做支援,有需求了後面會考慮。

關於NetBare和HttpCanary2.0

NetBare最新的程式碼已經開源到Github,有興趣的一起交流探討:github.com/MegatronKin…

HttpCanary2.0的下載推薦使用Google Play,或者百度雲:pan.baidu.com/s/147pSK2mP… 提取碼: 363b

下個版本的主要計劃:

  • 支援multipart/form-data資料格式解析
  • Websocket的抓包和注入。

最後,感謝各位的閱讀和支援!奉上10枚HttpCanary付費版本的兌換碼,可以在GooglePlay中進行兌換。

兌換碼
5Q5JYB4Z306WJQXJLQAXFPC
YTAYSLHGBEYZHMDU9A7H27J
TR1WFDAMGPBJ8KZM350LG8E
SJ6720KE369T5YPK8WRGEHA
MUBP7HE9NLJCVU7AVQJ8SG9
5XENFC9L1UGUT1KUZ9SMUZ2
EHL8BHRJFNYLS1SN818KW9P
YPBGFBML1APSSR4J9DPHLFT
6Q1L3EG4NSC8LFGG3VV0Y3Q
K1C761A389BWPMUYYVTXK2Y

相關文章