HttpCanary实现对HTTP2协议的抓包和注入(原理篇)

今天发布了HttpCanary2.0版本,除了修复了部分bug以及优化性能外,最主要的是支持了HTTP2协议。html

HttpCanary是什么?Android平台第二强大的HTTP抓包和注入工具,不了解的同窗能够阅读下关于HttpCanary的介绍:juejin.im/post/5c1e37…java

HttpCanary2.0已经发布到GooglePlay,欢迎你们下载并给予评价建议,传送门:play.google.com/store/apps/…android

干货为主,废话很少说,下面开始本篇的正文。git

HTTP2.0和HTTP1.x的区别

先简单介绍一下HTTP2.0协议的概况,熟悉的同窗能够跳过。github

HTTP2.0协议是由SPDY协议进化而来,标准于2015年5月正式发布,算起来不到四年时间,属于比较新的技术。因此部分主流的抓包工具都不支持HTTP2,好比Fiddler,而Charles则是在4.0版本后开始支持。算法

HTTP2.0协议和HTTP/1.x协议在请求方法、状态码乃至URI和绝大多数HTTP头部字段等部分保持高度兼容性,即常说的请求行、请求头、请求体、响应行、响应头、响应体这些格式都具备一致性。服务器

可是,HTTP2.0协议在对头部数据的压缩、多路复用、服务器主动推送三个方面作了支持和优化。cookie

  • 头部数据压缩。对请求行、请求头、响应行、响应头这些头部数据进行压缩,采用Hpack算法。
  • 多路复用。每一个connection以stream的形式组织,数据包按照frame(数据帧)的形式通讯,同时增长了流量控制等功能。
  • 服务器主动推送。HTTP2.0协议支持双向通讯,以及half-close这种单向通讯。

HTTP2.0协议虽然没有明确要求加密,但目前的实现都是默认使用TLS加密,因此能够认为使用HTTP2.0则必须使用HTTPS。session

为了实现对HTTP1.x的兼容,HTTP2.0协议为此额外定义了应用层协商标准(Application-Layer Protocol Negotiation,简称ALPN),以便客户端和服务端可以从HTTP/1.0、HTTP/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协商的图解:

粗略一看很是简单,可是因为HTTP2.0协议强制使用TLS/SSL加密,因此只能使用中间人MITM方式进行解密抓包。而中间人MITM又分为中间人Client和中间人Server,因此ClientHello握手包的通讯流程是Client -> MITM Server -> MITM Client -> Server,而ServerHello握手包的通讯流程则是 Server -> MITM Client -> MITM Server -> Client,由原先的一来一回两步,变成了来回六步,复杂性上增长了许多。

增长了MITM层的ALPN协商的图解:

这里有个小技巧,最开始的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。

咱们依次遍历并找到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型),用图解表示为以下:

解析出来的protocol值,可能为HTTP/1.0、HTTP/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.一、h2这些协议组织成byte[]呢?

其实这个byte[]是按照protocols的length+protocol依次组织的,图解以下:

代码实现是:

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,两个表之间互不干扰,彻底独立。除此以外,为了尽可能压缩头部数据,仍是用了霍夫曼编码,编码后再存入动态表中。

静态表和动态表都是以二进制编码的方式组织的,编码状态机和规则以下图:

以上就是Hpack相关的知识点,下面来分析NetBare是如何进行Hpack编解码设计的。

2.2 NetBare的Hpack解码及重编码

NetBare库的VirtualGateway维护了四个Hpack表,MITM Client和MITM Server各两个,目的是先解码还原成咱们常见的HTTP协议格式,而后再从新编码,图解以下:

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的数据。这就是真正意义上的多路复用。

3.2 多路复用请求的拦截和注入

普通的HTTP请求是一个Connection一个请求响应,结束后销毁Connection,也就是常说的握手挥手,不留下一片云彩。虽然性能低,可是对请求和响应的拦截和注入就方便多了。因此NetBare对于HTTP1.x的拦截器设计是:

很明显,这种拦截器设计只能知足一个Connection一个请求响应的状况。若是是HTTP2.0协议那种frame单元传输并且交错的数据传输,Interceptors很难作逻辑处理。惟一的方案就是对各个stream的frame单元进行组包,还原成HTTP1.x格式的数据,交给Interceptors作拦截注入,最后再拆包成frame单元发给终端。另外,因为请求并发,同一个时间有多个stream的frame在传输,因此还须要对各个stream进行隔离。

因此,修改以后的拦截器设计以下:

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
相关文章
相关标签/搜索