R0capture介绍

r0capture是一个基于firda实现的优秀的开源的抓包工具。它能完成对TCP协议层及上层所有协议的报文捕获,且不用考虑证书校验等问题。

它的实现基于frida_ssl_logger,两项目侧重不同。

项目地址:(github) https://github.com/r0ysue/r0capture

安卓应用层抓包通杀脚本

  • 仅限安卓平台,测试安卓7、8、9、10、11、12、13、14 可用 ;
  • 无视所有证书校验或绑定,不用考虑任何证书的事情;
  • 通杀TCP/IP四层模型中的应用层中的全部协议;
  • 通杀协议包括:Http,WebSocket,Ftp,Xmpp,Imap,Smtp,Protobuf等等、以及它们的SSL版本;
  • 通杀所有应用层框架,包括HttpUrlConnection、Okhttp1/3/4、Retrofit/Volley等等;
  • 无视加固,不管是整体壳还是二代壳或VMP,不用考虑加固的事情;

原理介绍

用一句话概括r0capture的核心原理即为:通过frida hook libssl.so中的 SSL_read和SSL_write等函数以实现在ssl通道加密之前拿到明文内容并储存。

不同于我们一般对于ssl报文捕获的思路,我们通常对于https的报文都是通过中间人(MITM)原理,借助HTTP代理完成中间人建立,从而捕获明文请求。但是r0capture则是直达底层,通过hook安卓基础ssl收发包的so,完成对所有报文的捕获。这两种方案均有优劣,下面具体介绍:

优劣势

相较于传统抓包方案,r0capture解决了安卓端证书校验(ssl pinning)、代理检测、自建协议等抓不到包的问题。正是因为它hook到tcp层报文收发函数,它支持了tcp层及上层所有报文捕获,但是也正因如此,它失去了一些便捷性,如依赖frida、依赖wireshark解决报文展示、对安卓版本有限制等。

所以,当我们初次尝试抓包时,可以先使用Charles或者burp,但是当我们遇到上述问题抓不到包时,可以尝试使用R0capture这个工具。

源码解读

整个项目的代码分为两个文件r0capture.py和script.js。

入口文件为r0capture.py。

r0capture.py

if __name__ == "__main__":
show_banner()


class ArgParser(argparse.ArgumentParser):

def error(self, message):
print("ssl_logger v" + __version__)
print("by " + __author__)
print("Modified by BigFaceCat")
print("Error: " + message)
print()
print(self.format_help().replace("usage:", "Usage:"))
self.exit(0)


parser = ArgParser(
add_help=False,
description="Decrypts and logs a process's SSL traffic.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=r"""
Examples:
%(prog)s -pcap ssl.pcap openssl
%(prog)s -verbose 31337
%(prog)s -pcap log.pcap -verbose wget
%(prog)s -pcap log.pcap -ssl "*libssl.so*" com.bigfacecat.testdemo
""")

args = parser.add_argument_group("Arguments")
args.add_argument("-pcap", '-p', metavar="<path>", required=False,
help="Name of PCAP file to write")
args.add_argument("-host", '-H', metavar="<192.168.1.1:27042>", required=False,
help="connect to remote frida-server on HOST")
args.add_argument("-verbose", "-v", required=False, action="store_const", default=True,
const=True, help="Show verbose output")
args.add_argument("process", metavar="<process name | process id>",
help="Process whose SSL calls to log")
args.add_argument("-ssl", default="", metavar="<lib>",
help="SSL library to hook")
args.add_argument("--isUsb", "-U", default=False, action="store_true",
help="connect to USB device")
args.add_argument("--isSpawn", "-f", default=False, action="store_true",
help="if spawned app")
args.add_argument("-wait", "-w", type=int, metavar="<seconds>", default=0,
help="Time to wait for the process")

parsed = parser.parse_args()
logger.add(f"{parsed.process.replace('.','_')}-{int(time.time())}.log", rotation="500MB", encoding="utf-8", enqueue=True, retention="10 days")

ssl_log(
int(parsed.process) if parsed.process.isdigit() else parsed.process,
parsed.pcap,
parsed.host,
parsed.verbose,
isUsb=parsed.isUsb,
isSpawn=parsed.isSpawn,
ssllib=parsed.ssl,
wait=parsed.wait
)

1、调用 show_banner() 函数,这个函数主要是展示当前项目的一些基本信息

2、接下来是 ArgParser 类的定义和使用,它继承自 argparse.ArgumentParser,用于解析命令行参数:

class ArgParser(argparse.ArgumentParser):

def error(self, message):
print("ssl_logger v" + __version__)
print("by " + __author__)
print("Modified by BigFaceCat")
print("Error: " + message)
print()
print(self.format_help().replace("usage:", "Usage:"))
self.exit(0)

  • 创建一个新的类 ArgParser,它扩展了 argparse.ArgumentParser
  • 重写 error 方法,当解析参数时发生错误时这个方法会被调用。在这里,当出现错误时,它会打印出工具的版本、作者信息,以及错误信息和帮助信息。最后调用 self.exit(0) 以结束程序。

3、接下来是参数解析的代码,详细描述了工具的功能,并通过示例用法帮助用户了解如何使用该工具。

4、后面是对应参数解析,参数含义如下:

image-20241024095846806

参数 含义 必需性
-p 抓到的报文保存成pcap的文件名 非必需,不指定则不保存
-H 自定义指定frida的host和port 非必需
-v 显示更详细的日志输出 非必需,默认是
-ssl 指定ssl库名称 非必需,默认libssl.so
-U 连接usb设备的 非必需,默认否
-f 以spawn形式启动 非必需,默认否
-w 等待几秒后执行抓取 非必需,默认0
processname 进程名或进程id 必须

5、接下来进入ssl_log函数,内部定义了log_pcap和on_message,分别是保存pcap具体函数和frida的消息回调函数。

image-20241024100121547

  • log_pcap函数:要功能是将捕获到的 SSL 数据包写入指定的 pcap 文件中,以便进行后续的分析和处理
  • 定义on_message函数:是 ssl_log 中的重要组成部分,负责接收并处理来自 Frida 的消息。它不仅管理错误情况,还记录捕获的数据,并将其输出到控制台或文件中。关键代码说明如下:

(1)首先连接设备获取到devices和session

if isUsb:
try:
device = frida.get_usb_device()
except:
device = frida.get_remote_device()
else:
if host:
manager = frida.get_device_manager()
device = manager.add_remote_device(host)
else:
device = frida.get_local_device()

if isSpawn:
pid = device.spawn([process])
time.sleep(1)
session = device.attach(pid)
time.sleep(1)
device.resume(pid)
else:
print("attach")
session = device.attach(process)
if wait > 0:
print(f"wait for {wait} seconds")
time.sleep(wait)

(2)如果命令参数有 pcap,就创建 pcap 文件,将hook到的数据储存进去。这里只是先写了一个文件头。pcap 的格式可看:

https://blog.csdn.net/in7deforever/article/details/6460595

if pcap:
pcap_file = open(pcap, "wb", 0)
for writes in (
("=I", 0xa1b2c3d4), # Magic number
("=H", 2), # Major version number
("=H", 4), # Minor version number
("=i", time.timezone), # GMT to local correction
("=I", 0), # Accuracy of timestamps
("=I", 65535), # Max length of captured packets
("=I", 228)): # Data link type (LINKTYPE_IPV4)
pcap_file.write(struct.pack(writes[0], writes[1]))

(3)加载script.js脚本,调用定义的RPC方法

with open(Path(__file__).resolve().parent.joinpath("./script.js"), encoding="utf-8") as f:
_FRIDA_SCRIPT = f.read()
# _FRIDA_SCRIPT = session.create_script(content)
# print(_FRIDA_SCRIPT)
script = session.create_script(_FRIDA_SCRIPT)
script.on("message", on_message)
script.load()

if ssllib != "":
script.exports.setssllib(ssllib)

(4)监听中断信号(Ctrl+C)

print("Press Ctrl+C to stop logging.")

def stoplog(signum, frame):
print('You have stoped logging.')
session.detach()
if pcap:
pcap_file.flush()
pcap_file.close()
exit()

signal.signal(signal.SIGINT, stoplog)
signal.signal(signal.SIGTERM, stoplog)
sys.stdin.read()

script.js

首先,折叠一下代码,可以看到定义了如下函数:

image-20241024101631698

rpc.exports

rpc.exports = {
setssllib: function (name) {
console.log("setSSLLib => " + name);
libname = name;
initializeGlobals();
return;
}
};

刚进来看到的导出的setssllib函数,用于从外部指定ssl库名称,主要用于部分非原始ssl.so文件名的场景。

uuid

image-20241024102432361

接下来的是uuid生成算法,主要功能是生成一个随机的唯一标识符。在本脚本中是用于生成文件名的一部分。特别是,在保存证书为 p12 格式文件时,使用了这个随机字符串,以确保文件名的唯一性,避免在同一目录中产生重复文件。

return_zero

image-20241024103553934

没啥作用,就是返回了一个 0

initializeGlobals

image-20241024103746769

1、这里使用了一个 frida 的 api,ApiResolver。

var resolver = new ApiResolver("module");
var exps = [
[Process.platform == "darwin" ? "*libboringssl*" : "*libssl*", ["SSL_read", "SSL_write", "SSL_get_fd", "SSL_get_session", "SSL_SESSION_get_id"]], // for ios and Android
[Process.platform == "darwin" ? "*libsystem*" : "*libc*", ["getpeername", "getsockname", "ntohs", "ntohl"]]
];

枚举当前进程中已经加载的动态链接库的导入导出函数名称。

image-20241024104533162

还兼容了 ios。ApiResolver 会自动搜索符合匹配规则的符号地址,如果是安卓则从libssl和libc中找到相关函数,如果是ios则……。可以看到这里关注的函数分别有:libssl.so里面的”SSL_read”, “SSL_write”, “SSL_get_fd”, “SSL_get_session”, “SSL_SESSION_get_id”和libc.so里面的”getpeername”, “getsockname”, “ntohs”, “ntohl”。

2、接下来通过enumerateMatches函数找到这两个lib库的这些导出函数,并通过函数名保存为名称-地址的映射。

image-20241024104938933

3、最后,经过上述拿到了函数的地址,再通过NativeFunction生成可调用的函数。就是通过函数地址和参数类型生成NativeFunction,后续使用时即可直接调用。

image-20241024105049587

接下来执行了initializeGlobals函数。

ipToNumber

image-20241024105302463

主要作用是用于将四段式的ip串转换为整数数字形式。如下所示,192.168.0.1 转换后为 16820416

image-20241024105905521

getPortsAndAddresses

image-20241024105952949

主要功能是根据socket拿到本地地址和远端地址,这里主要依赖的是两个接口Socket.localAddress和Socket.peerAddress。这两个地址就是wireshark显示的src和dest地址了,根据这俩地址也就能清楚地知道是本地哪个端口和外部哪个ip的哪个端口通信了。

getSslSessionId

接下来是getSslSessionId函数,该函数主要用于根据ssl对象生成唯一了hex字符串以代表该ssl对象。为什么要有这么个东西呢?想象一下,如果app中建立了多个信道进行ssl通信,然后同时hook到了大量的报文,那如何将这些报文区分开来?则是通过该id区分不同ssl通信信道。也就是我们使用wireshark时,使用follow tcp(http) stream时,一个stream下的所有收发报文都是用一个session_id。

image-20241024110354488

Interceptor.attach(addresses[“SSL_read”]

通过hook http://aospxref.com/android-8.1.0_r81/xref/external/conscrypt/common/src/main/java/org/conscrypt/NativeCrypto.java
的 native 方法 SSL_read SSL_write, 使流量进入SSL层时对数据进行dump

image-20241025091520178

接下来则是真正实现hook的代码。上述代码实现了对SSL_read的hook,该函数原型如下:

图片

一旦该函数被调用,触发onEnter,程序会将ssl对象及相关session_id和src,dest地址及参数2中的buf指针保存起来。在函数执行完成后触发onLeave,这时app已经完成了消息接收,buf中已经拿到明文信息,程序根据返回值得到接收的包体大小,并将之前保存的相关信息一并,通过send发回到on_message回调函数,on_message回根据发回的消息进行处理和保存。这样就完成了收消息的捕获和保存。

Interceptor.attach(addresses[“SSL_write”]

image-20241025092521115

SSL_write函数原型:

图片

发消息的hook更简单,不用等到函数执行完,在SSL_write函数被调用时,触发onEnter,程序开始保存ssl相关信息及发送的buf信息,一并发回到on_message处理。

客户端证书导出

hook了java.security.KeyStore$PrivateKeyEntry的getPrivateKey函数和getCertificateChain函数,在他们被调用时通过执行getPrivateKey和getCertificate函数拿到私钥和证书对象,实现将客户端证书导出保存的功能。默认保存的路径在/sdcard/Download/+包名+随机字符串.p12的位置。

image-20241025092947370

SSLpinning helper

接下来是辅助分析ssl pinning位置的代码,如果一旦程序加载证书文件则会被该段代码捕获,并主动查询堆栈是否存在关键词checkServerTrusted,如果存在则很可能是证书绑定的相关代码,此时发送堆栈信息到外部,便于定位。

考虑到App在验证证书时会打开证书文件判断是否是App自身所信任的,因此一定会使用File类的构造函数打开证书文件获得文件的句柄,所以我们在测试时可以Hook上所有File类的构造函数,即对File.init函数进行hook。

image-20241025093040207

Http请求&响应捕获

接下来实现的两个hook则是在java层完成了对socketWrite0和socketRead0的hook,这俩函数是安卓端非ssl加密的正常socket通信的收发函数。至此也完成了对非加密的报文的捕获。

image-20241025094530920

堆栈打印

最后这一段,是为了支持对ssl_read和ssl_write的堆栈信息打印单独写的hook。因为在so层已经拿不到java层的堆栈信息了,所以此处hook了java层的write和read函数(这其实也就是libssl.so导出函数在java层的调用函数),虽然此处一部分在外部一部分在内部略显不优雅,但是在辅助我们分析发包位置或报文构建时还是很有裨益的。

image-20241025094648117

参考链接:

https://mp.weixin.qq.com/s/lMV1UZYOaSRJjMa8PNFkow

https://mp.weixin.qq.com/s/A5snvJAxQQBUJ6ZTTkHgCA

https://www.cnblogs.com/gradyblog/p/17320025.html