This page looks best with JavaScript enabled

eBPF Uprobe bypass Go tls——抓取https明文流量

 ·  ☕ 7 min read · 👀... views

Background

某日在看冰蝎流量 冰蝎连上后客户端会跟含有冰蝎开发者id的众多域名进行tls交互 固数据不可见

学弟提及看到过某项目可以直接hook底层库并抓取明文信息

由于之前参照gin实现过简单的web框架了解到go中数据都是net/http库和crypto/tls库处理的 所以想自己实现一下hook一下golang tls库实现抓取明文流量的功能,当然这对所有go编写的https是通用的

至于用途 可以是自己开发https时测试、可以是客户端监控、可以是蜜罐获取用户payload

后注[2022-06-18]:bcc自带的tools中有现成的sslsniff.py其中自带trace众多lib库valid_types = frozenset(['openssl', 'gnutls', 'nss'])实现获取明文的功能。当然此处的bypass go tls是没有现成的工具。对于firefox等程序,通常sslsniff不起作用,因为firefox使用的是安装目录自带的so库。

Concepts

扩展的BPF(eBPF) 是 Linux 4.x+ 里的一项内核技术. 类似运行在 Linux 内核中的轻量级的沙箱虚拟机, 可以提供对内核内存的经过验证的访问.
eBPF 允许内核运行 BPF 字节码 并且 BPF 探针每次被触发时, 都会执行对应的字节码

uprobe 可以通过插入触发软中断的调试陷阱指令(x86 上的 int3)来拦截用户态程序 [实际编译调试后发现确实是修改hook处第一个指令为cc PS:这不是随便反调]

image-20220604214156642

经过编译和验证的 BPF 程序将作为 uprobe 的一部分执行, 并且可以将结果写入缓冲区.

Implementation

target.go——目标程序编写

首先编写编译简单的go程序可以访问https网站

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main

import (
	"fmt"
	"io/ioutil"
	"net/http"
	"os"
)

func main() {
	client := &http.Client{}
	// url := "https://www.baidu.com"
	reqest, err := http.NewRequest("GET", os.Args[1], nil)
	reqest.Header.Set("Accept-Encoding", "")
	// reqest.Header.Set("Accept-Encoding", "gzip, deflate, br")
	if err != nil {
		panic(err)
	}
	response, err := client.Do(reqest)
	if err != nil {
		panic(err)
	}
	defer response.Body.Close()
	data, err := ioutil.ReadAll(response.Body)
	if err != nil {
		panic(err)
	}
	fmt.Println(string(data))
}

抓取流量http header及http body均不可见

trace.c && trace.py——探针代码及注入代码

注入方式

py程序的作用是通过符号或者地址注入uprobe,主要函数是两个 其中name是编译好的用户程序 sym是符号 fn_name是要用作处理接收到数据的python回调函数

1
2
b.attach_uprobe(name="../target", sym="crypto/tls.(*Conn).writeRecordLocked", fn_name="crack_https")
b.attach_uretprobe(name="../target", sym="crypto/tls.(*halfConn).decrypt", fn_name="crack_response")

其中go的符号比较特殊,可以在跟进代码找到函数后通过nm和grep获取,也可以通过ida来获取 其中nm更好用

1
2
3
➜  uprobe nm target | grep writeRecordLocked
000000000058c960 T crypto/tls.(*Conn).writeRecordLocked
000000000058d040 T crypto/tls.(*Conn).writeRecordLocked.func1

当然也可以手动编写,最前面自然是go的包名,用/连接;接着是函数名,在go编程中通过使用结构体保存数据并编写结构体的函数来处理,所以也就是指明结构体的函数名

只能通过sym和addr实现注入 在清除符号表的情况下就不能很好地胜任了

数据获取

接着便是最重要的编写bpf获取数据

最重要的是知道数据在哪里

看到的第一个参考资料里就是跟踪go tls,但是他的写法是go传入的参数在栈上,这显然不正确,而且他返回数据的编写方法也不太正确(疯狂鞭尸)

经过go程序逆向分析总结如下(其实也就是常规的传参方式)

  • 短的数据如int直接通过一个寄存器传输
  • []byte 数组是其实是一个数据结构 包含一个指针一个长度值和一个容量值 通过三个寄存器传输 (看过go源码的应该都知道 而且容量可以动态扩容)
1
2
3
4
5
type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}

我们要获取相关数据的函数原型如下(都在crypto/tls/conn.go文件中)

其中writeRecordLocked是发送方明文入口data是我们的明文数据,我们需要根据recordType下条件断点获取data中的数据

decrypt在readRecordOrCCS中调用 所以我们需要在decrypt函数出口(逆向时在readRecordOrCCS中decrypt的下一条命令下条件断定)注入 也根据recordType下条件断点获取解密后数据

1
2
3
func (c *Conn) writeRecordLocked(typ recordType, data []byte) (int, error) 
func (c *Conn) readRecordOrCCS(expectChangeCipherSpec bool) error 
	func (hc *halfConn) decrypt(record []byte) ([]byte, recordType, error)

逆向分析

阅读Go crypto/tls 源码发现其有多种数据交换类型类型标志(毕竟tls要经过握手密钥交换和数据加密多个步骤)

1
2
3
4
5
6
7
8
9
// TLS record types.
type recordType uint8

const (
	recordTypeChangeCipherSpec recordType = 20
	recordTypeAlert            recordType = 21
	recordTypeHandshake        recordType = 22
	recordTypeApplicationData  recordType = 23
)

我们要获取的是 recordTypeApplicationData

由于开发环境linux 主机windows
启动ida server,f2在 writeRecordLocked 处下条件断点

image-20220604210256411

再次断下后观察发现参数byte数组通过RCX RDX RSI三个寄存器传输,其中RCX传输指针 RDX 传输长度 RSI传输容量(可以忽略)

image-20220604210351958

我们编写bpf程序如下获取其中数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
inline int crack_https(struct pt_regs *ctx) {
    ...
    u64* addr = 0;
    u64 size = 0;
    // cx寄存器放地址 dx寄存器放大小
    addr = (u64*)ctx->cx;
    size = ctx->dx;
    ...
    return 0;
}

上面是获取我们发送的信息,也就是请求头和请求体,接下去我们要分析响应头和响应体在哪里

从最顶端Request对象切入,数据都在response.Body中,这是一个ioReader,显然我们可以从这里获取数据,我们尽量往更底层找。

查看conn.go的大纲,之前数据是在write相关函数中,我们通过read->readRecord找到了readRecordOrCCS,其中switch了我们上面说到的recordType,所以这就是我们要找到的地方,case recordTypeApplicationData时c.input.Reset(data),再寻找data找到了decrypt函数

decrypt函数x寻找交叉引用找其在readRecordOrCCS中的位置,由于readRecordOrCCS函数复杂,在寻找recordType时花费了些许功夫,最后发现rdi寄存器放置此时代码中的typ变量也就是recordType,下条件断点如下

image-20220604212451285

f9再次运行到断点处 逐一查看寄存器中保存的地址最终在RAX中发现了http header

但是由于只看到明文的header 所以经过多次调试尝试运行body无果差点自闭,最后观察header发现时gzip压缩的body(我像是个傻逼) ida d确定数据类型右键convert to array指定大小导出 数据用010保存成文件删掉明文header直接用压缩软件打开就是网页的文本

再次观察RBX中防止的就是数据的长度 RCX就是slice的容量

image-20220604212215233

编写代码如下获取数据

1
2
3
4
5
6
7
8
9
inline int crack_response(struct pt_regs *ctx) {
    u64* addr = 0;
    u32 size = 0;
    ...
    addr = (u64*)ctx->ax;
    size = ctx->bx;
	...
    return 0;
}

最终代码

bpf代码的编译检测十分严格,这里还有几个注意点

经过调试发现go处理大数据的时候每次最多解密0x4000的数据 所以MAX_LEN设置成了0x4000,经过测试也是正确的没有数据丢失

BPF_PERF_OUTPUT的正确用法应该是用来触发事件而不是发送数据的,真正传输数据还是要靠BPF_PERCPU_ARRAY包结构体

perf_submit返回数据指定大小应该是data的大小加上一个sizeof(u32) 也就是结构体中len的大小否则最后会丢失四字节

python open_perf_buffer通过指定page_cnt来开一个大的ring buffer以确保能够及时输出否则会丢失数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <uapi/linux/ptrace.h>
#define MAX_LEN 0x4000
struct send_data_t {
    u32 len;
    u8  data[MAX_LEN];
};
struct recv_data_t {
    u32 len;
    u8  data[MAX_LEN];
};
BPF_PERF_OUTPUT(write_events);
BPF_PERCPU_ARRAY(write_data, struct send_data_t, 1);
BPF_PERF_OUTPUT(read_events);
BPF_PERCPU_ARRAY(read_data, struct recv_data_t, 1);

inline int crack_https(struct pt_regs *ctx) {
    u32 zero = 0;
    u64* addr;
    u64 size = 0;
    struct send_data_t *data = write_data.lookup(&zero);
    if (!data)
        return 0;
    // crypto/tls/common.go/const recordTypeApplicationData uint8 = 23
    if (ctx->bx != 23)
        return 0;
    // cx寄存器放地址 dx寄存器放大小
    addr = (u64*)ctx->cx;
    size = ctx->dx;
    if(size > MAX_LEN)
        return 0;
    data->len = size;
    bpf_probe_read(data->data, size, addr);
    write_events.perf_submit(ctx, data, size+sizeof(u32));
    return 0;
}

inline int crack_response(struct pt_regs *ctx) {
    u32 zero = 0;
    u64* addr;
    u32 size = 0;
    struct recv_data_t *data = read_data.lookup(&zero);
    if (!data)
        return 0;
    if (ctx->di != 23)
        return 0;
    addr = (u64*)ctx->ax;
    size = ctx->bx;
    if(size > MAX_LEN)
        return 0;
    data->len = size;
    bpf_probe_read(data->data, size, addr);
    read_events.perf_submit(ctx, data, size+sizeof(u32));
    return 0;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from bcc import BPF
import ctypes as ct

def write_printer(cpu, data, size):
    print("write:",size)
    snd_data = b["write_events"].event(data)
    event = ct.cast(snd_data.data, ct.POINTER(ct.c_char * snd_data.len)).contents
    print(bytes(event))

def read_printer(cpu, data, size):
    print("read:",size)
    rcv_data = b["read_events"].event(data)
    event = ct.cast(rcv_data.data, ct.POINTER(ct.c_char * rcv_data.len)).contents
    print(bytes(event))


if __name__ == "__main__":
    perf_ring_buffer_page_cnt = 256 
    # load BPF program
    b = BPF(text=open("./trace.c").read())
    # https://github.com/iovisor/bcc/blob/master/docs/reference_guide.md#4-attach_uprobe
    b.attach_uprobe(name="../uprobe", sym="crypto/tls.(*Conn).writeRecordLocked", fn_name="crack_https")
    b.attach_uretprobe(name="../uprobe", sym="crypto/tls.(*halfConn).decrypt", fn_name="crack_response")
    # https://github.com/toru/h2olog/pull/28
    # page_cnt to improve performance to avoid "Possibly lost %d samples"
    b["write_events"].open_perf_buffer(write_printer, page_cnt=perf_ring_buffer_page_cnt)
    b["read_events"].open_perf_buffer(read_printer, page_cnt=perf_ring_buffer_page_cnt)

    while 1:
        try:
            b.perf_buffer_poll()
        except KeyboardInterrupt:
            exit()

image-20220604214803116

image-20220604214413198

image-20220604214418731

================================================================

后续:了解到Go在1.17之前其实是用栈传参数的 1.17之后才是用寄存器传参数

看了《BPF Performance Tools》这本书 学了点bpftrace 用bpftrace写起来十分方便

这是Go1.17之后的写法 1.17 1.18都编译试过寄存器没变都能用
但是response并不能%s输出 因为会有\0截断 bpftrace里面没有for不能%c 没有sys_write 不知道具体要怎么输出了 只完成了request部分

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#!/usr/local/bin/bpftrace

uprobe:$1:"crypto/tls.(*Conn).writeRecordLocked"
/ reg("bx") == 23 /
{
    printf("Request:\n%s\n" , str(reg("cx"), reg("dx")) );
}

uretprobe:$1:"crypto/tls.(*halfConn).decrypt"
/ reg("di") == 23 /
{
    // printf("Response:\n%s" , str(reg("ax"), reg("bx")) );
}

但是书中没有写到sarg的用法 bpftrace专门对用栈传值的程序的内置参数 用作Go1.17之前的程序十分方便

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#!/usr/local/bin/bpftrace

uprobe:$1:"crypto/tls.(*Conn).writeRecordLocked"
/ sarg1 == 23 /
{
    // 74624 23 1134592 113 4096
    // printf("%d %d %d %d %d\n" , sarg0,sarg1,sarg2,sarg3,sarg4);
    // type slice struct {
    //    array unsafe.Pointer
    //    len   int
    //    cap   int
    // }
    printf("Request:\n%s\n" , str(sarg2, sarg4) );
}

image-20220615224601382

Refer

Share on

ruokeqx
WRITTEN BY
ruokeqx