- 手机环境:安卓8.1.0
- frida_version:12.8.0
本文重点
本篇文章主要介绍一下r0capture项目抓包部分的一个Hook点是如何发现的。大家可以去r0capture项目地址下载体验:https://github.com/r0ysue/r0capture
r0capture简介
- 仅限安卓平台,测试安卓7、8、9、10、11 可用 ;
- 无视所有证书校验或绑定,不用考虑任何证书的事情;
- 通杀TCP/IP四层模型中的应用层中的全部协议;
- 通杀协议包括:Http,WebSocket,Ftp,Xmpp,Imap,Smtp,Protobuf等等、以及它们的SSL版本;
- 通杀所有应用层框架,包括HttpUrlConnection、Okhttp1/3/4、Retrofit/Volley等等;
- 无视加固,不管是整体壳还是二代壳或VMP,不用考虑加固的事情;
我们可以看到r0capture有很多优点,当我们在使用其他抓包软件的时候,容易被证书折磨,还有各种抓包的检测手段,这时候可以使用r0capture进行hook抓包一波,小爽一下,无视各种证书问题。那今天我们就来看看r0capture是如何写出来的。
使用Socket进行Http访问
为什么要讲使用Socket进行Http访问呢?要想做到通杀
这两个字是不简单的,因为平台版本都会更新迭代,而且很多的APP都会选择使用第三方的框架来做网络请求,版本各异,尽不相同。所以我们要找一个通用的点就需要从更底层来找。而Http又是建立在Tcp协议之上的一种应用,所以我们要从Tcp出发,无论是多么优秀的框架,都离不开系统提供的网络接口进行收发数据。
下面我们给出一个使用Java进行Socket进行Http访问的例子:
//http://www.dtasecurity.cn:18080/demo01/getNotice
private void request() {
try {
final String host = "www.dtasecurity.cn";
final int port = 18080;
final String path = "/demo01/getNotice";
Socket socket = new Socket(host,port);
StringBuilder sb = new StringBuilder();
sb.append("GET "+path+" HTTP/1.1\r\n");
sb.append("user-Agent: test\r\n");
sb.append("Host: "+host+"\r\n");
sb.append("\r\n");
Log.d("DTA===>", sb.toString());
OutputStream outputStream = socket.getOutputStream();
outputStream.write(sb.toString().getBytes());
InputStream inputStream = socket.getInputStream();
byte[] buffer = new byte[1024];
int len;
while( ( len = inputStream.read(buffer,0,buffer.length) ) != -1 ){
Log.d("DTA===>", new String(Arrays.copyOf(buffer,len)));
}
}catch (Exception e){
e.printStackTrace();
}
}
首先创建了一个Socket对象,通过host跟port指定我们要访问的Server,然后根据Http协议约定的格式构造了一个HTTP请求的报文,后面将构造好的数据通过Socket管道进行发送,然后读取服务器返回的数据并打印Log。
程序比较简单,但是可以反映出一个Http请求要做的最基本的工作,就是创建Socket对象,然后构造Http报文发送,那么我们接下来就通过这段代码,来分析我们的数据报文流到了哪里,并且哪里可以做一个更好的Hook点。
Http请求关键点的跟踪
先来明确一下我们的目标,要跟着目标来分析。我们要做的是一个Hook抓包对吧,所以我们就是想截获到APP进行HTTP(S)请求的明文数据,这个就是Hook抓包的一个核心点,因为我们需要对数据在任何明文状态下进行DUMP。那只需要跟着我们构造出来的数据包,看看它流向了哪里
OutputStream outputStream = socket.getOutputStream();
outputStream.write(sb.toString().getBytes());
调用了OutputStream类的write方法,把数据报文传了进去,但是这里的OutputStream是一个抽象类,所以我们要看outputStream对象的具体类是哪个,我们可以在这一行打个断点,能够直接看到当前对象的类型
我们可以看到,具体的实现类为SocketOutputStream类,那么我们就直接去看该类的write方法的一个实现
public void write(byte b[]) throws IOException {
socketWrite(b, 0, b.length);
}
又调用了socketWrite方法
private void socketWrite(byte b[], int off, int len) throws IOException {
if (len <= 0 || off < 0 || len > b.length - off) {
if (len == 0) {
return;
}
throw new ArrayIndexOutOfBoundsException("len == " + len
+ " off == " + off + " buffer length == " + b.length);
}
FileDescriptor fd = impl.acquireFD();
try {
// Android-added: Check BlockGuard policy in socketWrite.
BlockGuard.getThreadPolicy().onNetwork();
socketWrite0(fd, b, off, len);
} catch (SocketException se) {
if (se instanceof sun.net.ConnectionResetException) {
impl.setConnectionResetPending();
se = new SocketException("Connection reset");
}
if (impl.isClosedOrPending()) {
throw new SocketException("Socket closed");
} else {
throw se;
}
} finally {
impl.releaseFD();
}
}
先对数据进行了一个越界和长度校验,如果长度为0直接return,如果访问越界就直接抛出异常。关键点为socketWrite0方法,在阅读源码的过程中,一定要掌握一个方法,带着目的去阅读源码,比如这里我们想观察数据的流向,所以我们需要关心的就是我们的数据在哪些方法之中进行了传递。来看socketWrite0的一个实现
private native void socketWrite0(FileDescriptor fd, byte[] b, int off,int len) throws IOException;
这里是一个native原生方法,我们就不继续往下跟了,因为在Java层的一个Socket数据最终会经过这里传向native层,所以我们Hook这个方法就能够实现对Java层Socket数据的DUMP,这里也是r0capture的第一个Hook点,我们来写一个代码测试一下
Frida Hook socketWrite0
首先我们要打印这个byte[]数据,在frida中如何对byte数组进行打印呢?可以借助framework层的一个工具类来帮助我们进行打印
function hexdump(bytearry,offset,length){
// bytearray => [B
// offset => I
// length => I
var HexDump = Java.use("com.android.internal.util.HexDump")
console.log(HexDump.dumpHexString(bytearry,offset,length))
}
有了上面的hexdump方法,我们来继续hook socketWrite0方法
Java.use("java.net.SocketOutputStream").socketWrite0.implementation = function(fd,bytes,off,len){
hexdump(bytes,off,len)
this.socketWrite0(fd,bytes,off,len)
}
可以正常Hook到Http请求的报文并进行DUMP,也实现来我们的目标
Frida Hook socketRead0
有了上面的例子,我们能够抓到Http请求了,现在我们来看一下Http的返回数据。
我们可以按照上面找到socketWrite0方法同样的方式,找到socketRead0方法,它们是一对,我们猜也能够猜的到,所以直接就进行Hook一把试试
Java.use("java.net.SocketInputStream").socketRead0.implementation = function(fd,bytes,off,len,timeout){
var result = this.socketRead0(fd,bytes,off,len,timeout)
hexdump(bytes,off,len)
return result
}
也是没有问题的,能够Hook到。但是这里我们要处理一个问题,读取的数据如果超出buffer的长度,需要多次读取,也就会造成数据不连续。但是没有关系,因为我们多需要关注的是文本型数据,像一个图片之类的数据一般才会超过buffer的长度。而且此处的len永远是buffer的长度,所以这里我们需要特殊处理一下,result是一个int型数据,它表示的是当前读取的大小。
Java.use("java.net.SocketInputStream").socketRead0.implementation = function(fd,bytes,off,len,timeout){
var result = this.socketRead0(fd,bytes,off,len,timeout)
hexdump(bytes,off,result)
return result
}
这样我们的返回数据也就能够DUMP出来了,Http的数据部分就结束了。但是我们的r0capture还有两个重要的信息:
- 打印调用栈
调用栈的打印非常简单,我们直接来看实现,在需要的地方直接加上这个方法就可以打印当前方法的调用栈
function showStacks() {
console.log(Java.use("android.util.Log").getStackTraceString(Java.use("java.lang.Exception").$new()));
}
- 打印本机的地址和Server的地址
这里我们可以通过查看SocketOutputStream类和SocketInputStream两个类,都有一个成员变量socket,保存的就是我们当前的socket连接,我们就可以直接通过socket这个变量来获取到Socket连接的两端地址
function printAddress(socket, isSend){
var localAddress = socket.value.getLocalAddress().toString()
var remoteAddress = socket.value.getRemoteSocketAddress().toString()
if(isSend){
console.log(localAddress +"====>"+ remoteAddress)
}else{
console.log(localAddress +"<===="+ remoteAddress)
}
}
至此,我们Http部分就分析完毕了,然后我们来看Https部分
使用Socket进行Https访问
private void requestHttps() {
try {
final String host = "www.taobao.com";
final int port = 443;
final String path = "/";
SSLSocketFactory sslSocketFactory = (SSLSocketFactory) SSLSocketFactory.getDefault();
SSLSocket socket = (SSLSocket) sslSocketFactory.createSocket(host,port);
StringBuilder sb = new StringBuilder();
sb.append("GET "+path+" HTTP/1.1\r\n");
sb.append("user-Agent: test\r\n");
sb.append("Host: "+host+"\r\n");
sb.append("\r\n");
Log.d("DTA===>", sb.toString());
OutputStream outputStream = socket.getOutputStream();
outputStream.write(sb.toString().getBytes());
InputStream inputStream = socket.getInputStream();
byte[] buffer = new byte[1024];
int len;
while( ( len = inputStream.read(buffer,0,buffer.length) ) != -1 ){
Log.d("DTA===>", new String(Arrays.copyOf(buffer,len)));
}
}catch (Exception e){
e.printStackTrace();
}
}
我们可以发现,跟Http不同:
- 端口跟普通的端口不同,Http默认的端口是80端口,Https默认的是443端口
- 使用SSLSocket,而不是Socket
- SSLSocket是一个抽象类,继承自Socket类。所以不能直接new,需要使用SSLSocketFactory工厂类进行创建SSLSocket连接
Https请求关键点的跟踪
同样的,我们来下个断点观察一下此时outputStream对象的具体实现类
来看ConscryptFileDescriptorSocket类下的内部类SSLOutputStream类对应write方法的实现,我们发现该内部类没有实现我们所调用的write方法,那就是从父类继承过来的
public void write(byte b[]) throws IOException {
write(b, 0, b.length);
}
还是调用了三个参数的write方法,SSLOutputStream类对该方法进行了重写,我们来看实现
public void write(byte[] buf, int offset, int byteCount) throws IOException {
Platform.blockGuardOnNetwork();
checkOpen();
ArrayUtils.checkOffsetAndCount(buf.length, offset, byteCount);
if (byteCount == 0) {
return;
}
synchronized (writeLock) {
synchronized (stateLock) {
if (state == STATE_CLOSED) {
throw new SocketException("socket is closed");
}
if (DBG_STATE) {
assertReadableOrWriteableState();
}
}
ssl.write(Platform.getFileDescriptor(socket), buf, offset, byteCount,
writeTimeoutMilliseconds);
synchronized (stateLock) {
if (state == STATE_CLOSED) {
throw new SocketException("socket is closed");
}
}
}
}
关键方法ssl.write。第一个参数是我们socket的一个文件描述符,后面几个参数不需要过多介绍了,多了一个超时的参数,我们继续看该方法的实现
void write(FileDescriptor fd, byte[] buf, int offset, int len, int timeoutMillis)throws IOException {
NativeCrypto.SSL_write(ssl, fd, handshakeCallbacks, buf, offset, len, timeoutMillis);
}
又调用了NativeCrypto.SSL_write方法,继续往下跟
Frida Hook SSL_write方法
static native void SSL_write(long sslNativePointer, FileDescriptor fd, SSLHandshakeCallbacks shc, byte[] b, int off, int len, int writeTimeoutMillis) throws IOException;
该方法是一个native方法,我们还是无法往下跟了,最终https的数据走到这里,中间也无任何将数据进行加密的环节,所以我们的Https的数据在这里还是一个明文的。至于native层如何处理的,也就无非是我们所说的https的一个ssl层处理,所以这个点就是一个Https的数据还在明文状态下的,我们在Java层能找到的最后的点,我们来Hook这个方法。但是这里要注意一点,该类的包名不能直接使用,需要加上com.android前缀,至于为什么笔者也不清楚,估计是在源码编译的时候,把这个模块下的所有包名都自动加了一个前缀。至于为什么知道是加了com.android前缀呢,也是靠上面断点那张图,com.android.org.conscrypt.ConscryptFileDescriptorSocket,该类的全限定类名前面就有一个com.android,而在阅读源码的过程中,同样是没有这个前缀的。还有就是我使用Objection从内存中搜索了一下类名,确实是有这个前缀的。那么我们来继续Hook这个方法
Java.use("com.android.org.conscrypt.NativeCrypto").SSL_write.implementation = function(sslNativePointer,fd,shc,bytes,off,len,timeout){
printHttpsAddress(fd,true)
hexdump(bytes,off,len)
showStacks()
return this.SSL_write(sslNativePointer,fd,shc,bytes,off,len,timeout)
}
其他的无需介绍,我们来看一下printHttpsAddress方法
function printHttpsAddress(fd, isSend){
var local = Socket.localAddress(fd.getInt$())
var peer = Socket.peerAddress(fd.getInt$())
if(isSend){
console.log(local.ip+":"+local.port +"====>"+ peer.ip+":"+peer.port)
}else{
console.log(local.ip+":"+local.port +"====>"+ peer.ip+":"+peer.port)
}
}
这个跟前面的解析方法不一样,NativeCrypto类没有socket这个成员变量,而我们从分析的过程中可以看到,到了这个SSL_write方法的时候,跟socket有关的参数就只有一个sslNativePointer和fd,这里我们就是通过这个fd,调用了frida提供的API,来帮助我们解析到local跟peer,从而实现了打印地址的功能
Frida Hook SSL_read方法
write跟read都是成对出现的
static native int SSL_read(long sslNativePointer, FileDescriptor fd, SSLHandshakeCallbacks shc, byte[] b, int off, int len, int readTimeoutMillis) throws IOException;
直接来Hook这个方法
Java.use("com.android.org.conscrypt.NativeCrypto").SSL_read.implementation = function(sslNativePointer,fd,shc,bytes,off,len,timeout){
var result = this.SSL_read(sslNativePointer,fd,shc,bytes,off,len,timeout)
printHttpsAddress(fd,false)
hexdump(bytes,off,len)
showStacks()
return result
}
总结
至此我们就分析完了r0capture抓包功能的四个Hook点是怎么来的,都是从一个底层的socket出发,进行Http(s)的请求,然后跟着数据一步一步往下跟,这也是我们静态分析的一个流程。我们从找关键点的过程中也能够发现r0capture的弊端,只要不经过这四个方法的数据,我们都无法进行抓取。我们看一下r0ysue在github中对r0capture的局限总结:
部分开发实力过强的大厂或框架,采用的是自身的SSL框架,比如WebView、小程序或Flutter,这部分目前暂未支持。部分融合App本质上已经不属于安卓App,没有使用安卓系统的框架,无法支持。当然这部分App也是少数。暂不支持HTTP/2、或HTTP/3,该部分API在安卓系统上暂未普及或布署,为App自带,无法进行通用hook。各种模拟器架构、实现、环境较为复杂,建议珍爱生命、使用真机。
这只是一种抓包思想的实现,有它方便的地方,也有它的弱势。所以我们要从我们的目的出发,发现什么工具更适合我们的需求,才能高效率的完成作业。
如果本篇文章对你有帮助,欢迎大家加一下我们的星球,不定时分享各种技术,总有一个对你有帮助,感谢您的阅读