阅读 1967

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

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

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

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协商的图解:

粗略一看非常简单,但是由于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.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依次组织的,图解如下:

代码实现是:

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