用BPF拦截Zoom的加密数据

2020-09-11 22:43:08

我最初在3月底写了这篇文章的早期版本,当时我正致力于向redbpf添加uProbe支持。我想写一篇关于我正在做的工作的博客,为了这篇文章的目的,我需要一个仪器应用程序。当时Zoom的受欢迎程度正在迅速上升,我碰巧在某处读到它支持这一令人毛骨悚然的注意力跟踪功能,允许会议主持人监控与会者是否注意到了这一点。我想我可以试着使用UProbe窥探Zoom发送给他们服务器的数据,看看跟踪是如何进行的。

但随后Zoom很快就开始受到猛烈抨击。轰炸机成为了一件事,发现了几个安全问题,几乎每个人都开始对公司大加抨击。考虑到这一切,我得到了建议,最终决定不发表这篇帖子。

现在事情似乎已经解决了,Zoom提高了他们的安全性,并根据大众的需求取消了注意力跟踪。所以我想我终于可以发表这篇文章了!我删掉了关于注意力跟踪(已经不存在了)的部分,以及其他几件可能会给我带来麻烦的事情。

TLDR:我编写了一个命令行工具,它使用BPF uProbe拦截Zoom通过网络发送的TLS加密数据,这里我将展示我编写它的过程。在我写完这篇文章后,我使该工具成为通用工具,以便它现在可以插装任何使用OpenSSL的程序。我在https://github.com/alessandrod/snuffy.上发布了代码。

UProbe允许您通过将自定义代码附加到目标进程内的任意位置来检测用户空间应用程序。它有点像在调试器中运行应用程序,设置断点和摆弄东西,但是以编程的方式,没有调试器的开销。

UProbe必须像其他BPF程序一样编译和加载,然后可以附加以下接口:

PUB FN ATTACH_uProbe(&;mut self,fn_name:option<;&;str>;,偏移量:U64,target:&;str,pid:option<;PID_t>;,)->;结果<;()>;

Attach_uProbe()解析目标ELF二进制或共享库,查找函数fn_name,一旦目标开始运行,它就会在解析的地址处注入探测代码。如果偏移量为非零,则将其值添加到fn_name的地址。如果FN_NAME为NONE,则偏移量将被解释为从目标.text部分的开头开始。最后,如果给定了PID,则探测器将仅附加到具有给定ID的进程。

在本文的其余部分,我将展示一些uProbe的示例,重点放在编译成BPF字节码、加载到内核,然后注入到目标进程中的代码(在我们的例子中是zoom)。我将不会显示加载探测的大部分用户空间代码。这部分是非常标准的锈代码,它进行一些设置,然后在接收到来自探测器的数据时将其打印出来。如果您感兴趣,您仍然可以在https://github.com/alessandrod/snuffy/blob/master/src/main.rs.找到所有的用户空间代码。

我们将使用UProbe来检查缩放客户端和公司服务器之间的网络流量。缩放使用传输层安全性来加密数据。为了拦截未加密的数据,我们需要找出客户端使用的是哪个TLS库,然后将uProbe附加到其中的战略位置。

$OBJDUMP-CT/OPT/ZOOM/ZOOM|grep-ie";ssl|gntls";000000000080d5b0 g df.text 0000000000000013基本PreMeetingUIMgr::sig_blockUnknownSSLCertChanged()000000000080d590 g df.text 00000000000013基本预会议UIMgr::sig_sslCertWarningChanged()。

这些看起来像是证书无效时调用的回调,如果您试图使用Mitmproxy等工具拦截其流量,Zoom确实会显示警告。回调处理证书,而不是未加密的缓冲区,因此它们对我们没有用处。

查看LDD的输出,我们可以看到Zoom链接到Qt Network,其中包括一些潜在的相关API:

$OBJDUMP-CT/OPT/ZOOM/ZOOM|grep-ie";QNetworkReq";0000000000000000 DF*UND*0000000000000000 Qt_5 QNetworkRequest::QNetworkRequest(QURL常量&A;)0000000000000000 DF*UND*00000000000000 Qt_5 QNetworkRequest::~QNetworkRequest()0000000000000000 DF*UND*00000000000000 Qt_5 QNetworkAccessManager::GET(QNetworkAccessManager::GET。

QNetworkRequest(QUrl const&;)看起来像是可以用来与后端通信的东西,并且确实支持TLS。我尝试附加到它和框架导出的其他函数,但都没有被调用。Zoom支持许多平台和设备,它们可能只将Qt用于Linux上的UI,然后拥有一些可以与其他客户端共享的较低级别的网络代码。

此时,ZOOM很可能静态链接到TLS库。让我们看看在二进制文件的.rodata部分中是否有任何东西可以为我们指明正确的方向:

$readelf-p.rodata/opt/ZOOM/ZOOM|grep-iSSL|wc-L739$#😏$readelf-p.rodata/opt/zoom/zoom|grep-i&39;openssl 1';[4a1b66]openssl 1.1.1g 2020年4月21日[58cd50]openssl 1.1.1g 21 2020年4月21日。

啊哈!客户端使用的是OpenSSL版本1.1.1g(知道这将会非常有用),并且库是静态链接的。

Ssl_read读取远程对等方发送的加密数据,对其进行解密,并将解密数据存储在提供的缓冲区中。SSL_WRITE加密给定的缓冲区并将其发送到远程对等方。在ssl_read返回的位置附加一个uProbe,并在ssl_write的入口处附加一个uProbe,因此我们可以访问未加密的内存。

使用redbpf_Probe::prelude::*;struct SSLArgs{ssl:usize,buf:usize,}//临时存储映射静态屏蔽SSL_args:HashMap<;U64,SSLArgs>;=HashMap::with_max_entry(1024);fn output_buf(regs:register,mode:AccessMode,buf_addr:usize,len:假设它从`buf_addr`//读取`len`字节,并将它们发送到我们的用户空间进程,在那里它们被十六进制转储。...}fn SSL_WRITE_ENTRY(regs:registers){设ssl=reg.。Parm1()as usize;设buf=regs。Parm2()as usize;设num=regs。Parm3()as I32;if num<;=0{return;}//这是SSL_WRITE开始的地方,缓冲区尚未加密//因此我们将其发送到用户空间output_buf(regs,AccessMode::write,buf,num as usize);}fn ssl_read_entry(regs:registers){let ssl=regs。Parm1()as usize;设buf=regs。Parm2()as usize;//存储函数参数,以便在//函数返回unsafe{ssl_args}时可以检索它们。Set(&;bpf_get_current_pid_tgid(),&;SSLArgs{SSL,buf});}}fn ssl_read_ret(regs:register){//ssl_read的返回值包含缓冲区的长度let num=regs。Rc()as I32;if num<;0{return;}//这是SSL_Read返回的位置,缓冲区现在被解密//因此我们将其发送到用户空间let tgid=bpf_get_current_pid_tgid();让args=unsafe{ssl_args。GET(&;tgid)};如果让Some(SSLArgs{ssl,buf})=args{output_buf(regs,AccessMode::read,*buf,num as usize);unsafe{ssl_args。删除(&;tgid)};}}。

UProbe使用#[uProbe]属性进行注释。一旦它们被触发,就会向它们传递一个寄存器参数,它们可以通过该参数访问内存。

SSL_WRITE_ENTRY探测器是最简单的。它读取包含传递给ssl_write的buf和num参数值的寄存器,并在加密之前将缓冲区的副本发送到用户空间。

SSL_READ_ENTRY探测器与之类似,因为它读取传递给SSL_READ的SSL、buf和num参数的内容。不过,它不会将缓冲区发送到用户空间。请记住,数据是在ssl_read返回之后解密的,因此我们需要第二个附加到函数返回地址的uProbe。这就是ssl_read_ret的用途。它与其他两个探测器类似,但是使用#[uretbe]注释,这意味着一旦它附加到返回的函数,它就会触发。

但是,为什么我们需要两个用于ssl_read的探测器,为什么不直接使用ssl_read_ret呢?答案是,当ssl_read返回时,以前包含函数参数的寄存器很可能被修改了,因此我们需要在函数开始时读取它们的值并存储它们,以便以后可以检索它们。这是编写BPF代码时非常常见的模式。

最后,如果动态链接到OpenSSL的缩放,或者如果存在调试符号,则附加探针的用户空间代码将非常简单:

使用redbpf::load::loader;让mut loader=Loader::load_file(COMPILED_BPF_BINARY)?;让PID=NONE;用于加载器中的uProbe。Uspects_mut(){//附加到libssl内部的ssl_read和ssl_write。//让redbpf解析符号地址。匹配uProbe。名称()。As_str(){";ssl_read_entry";|";ssl_read_ret";=>;{uProbe。Attach_uProbe(Some(";SSL_Read";),0,";libssl";,PID)?;}";SSL_WRITE_ENTRY";==>;{uProbe。Attach_uProbe(一些(";SSL_WRITE";),0,";libssl";,PID)?;}_=>;继续,}}。

不幸的是,由于OpenSSL是静态链接的,并且符号已被剥离,所以redbpf无法自动解析SSL_READ和SSL_WRITE的地址,相反,我们必须显式提供要附加到的偏移量:

使用redbpf::load::loader;让mut loader=Loader::load_file(COMPILED_BPF_BINARY)?;让PID=NONE;用于加载器中的uProbe。UProbe_mut(){let zoom_inary=";/opt/zoom/zoom";;//zoom';s.text部分中SSL_READ的偏移量let SSL_READ_OFFSET=?;//SSL_WRITE的偏移量让SSL_WRITE_OFFSET=?;匹配uProbe。名称()。As_str(){";ssl_read_entry";|";ssl_read_ret";=>;{uProbe。ATTACH_uProbe(NONE,SSL_READ_OFFSET,ZOOM_BINARY,PID)?;}";SSL_WRITE_ENTRY";=>;{uProbe。ATTACH_uProbe(NONE,SSL_WRITE_OFFSET,ZOOM_BINARY,PID)?;}_=>;继续,}}。

但是我们怎样才能找到补偿呢?我们应该为SSL_READ_OFFSET和SSL_WRITE_OFFSET赋予什么值?

我有一个很好的小节,介绍如何在这里找到偏移量。当我第一次写它的时候,我确信发布两个地址不可能让我因为反向工程而被起诉。不过,一些看过这篇帖子草稿的人改变了我的想法,毕竟现在是2020年,所以现在不是乐观的好时机。

假设可以通过使用objdump分解zoom,然后分解openssl 1.1.1g并比较两者来找到偏移量。我猜代码不会完全匹配,但是函数前言和SSL_READ和SSL_WRITE使用的SSL*上下文的相对寻址可能会产生足够好的模式。通过对反汇编代码进行几次精心设计的Ripgrep-U(多行)搜索,我打赌查找函数不会花那么长时间。

本文的其余部分假设我们确实找到了偏移量,并将它们放入名为zoom-offsets.toml的文件中,格式如下:

#下面的值只是示例,不是真实的SSL_READ=0xBAAAAAD SSL_WRITE=0xDECAFBAD。

找到SSL_READ和SSL_WRITE的偏移量后,如果我们加载上面编写的uProbe,然后开始缩放,我们将得到如下所示的输出:

$sudo target/debug/snnffy--十六进制转储--偏移缩放偏移量。toml写入575字节|504f5354 202f7265 6c656173 656e6f74|POST/Release不00000000|65732048 5454502f 312e310d 0a486f73|ES HTTP/1.1..宿主00000010|743a2075 73303477 65622e7a 6f6f6d2e。00000020|75730d0a 55736572 2d416765 6e743a20|us..用户代理:00000030|4d6f7a69 6c6c612f 352e3020 285a4f4f|mozilla/5.0(ZOO 00000040|4d2e4c69 6e757820 5562756e 74752031|M.Linux Ubuntu 1 00000050...读取3088字节|48545450 2f312e31 20323030。

当ZOOM开始时,它检查该HTTP POST请求的更新。UProbe被触发,将未加密的数据发送到Snuffy进程,并在那里以十六进制转储数据。成功!

结果是,Zoom同时使用多个TLS连接,因此Snuffy的输出很快就变成了属于不同连接的混杂数据的无法读取的乱七八糟的乱七八糟的东西。

为了提高可读性,我们将通过更深入地研究OpenSSL来尝试将读取和写入与IP地址相关联。

OpenSSL提供了实现IO的BIO接口。查看相关的头文件,我们可以看到:

//将此类型的指针传递给ssl_read和ssl_write tyfinf struct ssl_st ssl;struct ssl_st{int version;const ssl_method*method;/*由ssl_read*/bio*rbio;使用;/*由ssl_write*/bio*wbio;使用。。。};tyecif struct bio_st bio;struct bio_st{const bio_method*method;bio_callback_fn callback;BIO_CALLBACK_FN_EX CALLBACK_EX;char*cb_arg;int init;int shutdown;int flag;int retry_ason;int num;//<;-这是套接字描述符。。。};

给定一个SSL*指针-这是传递给SSL_READ和SSL_WRITE的第一个参数-我们可以检索相关的BIO值。在这些BIO值中,num字段保存底层套接字描述符。下面是一些老套的BPF代码,用于在给定SSL*的情况下获取描述符:

//这相当于SSL->;rbio FN SSL_rbio(SSL:usize)->;result<;*const c_void,I32>;{unsafe{bpf_probe_read((SSL+16)as*const*const c_void)}}//这相当于SSL->;wbio FN SSL_wbio(SSL:usize)->;result<;*const c_void,I32>;{unsafe{bpf_probe_read((SSL+24)as*const c_void)}}//这相当于BIO-&>num,恰好是套接字描述符FN BIO_FD(BIO:*const c_void)->;result<;I32,I32>;{不安全{bpf_probe_read((bio as usize+48)as*const I32)}}。

注意:为简洁起见,我在这里手动计算了rbio、wbio和num的偏移量,查看标题,但我可以使用Cargo BPF bindgen为struct SSL生成Rust绑定。

让我们更新uProbe,以便将文件描述符与数据一起发送。以下是相关的更改:

Fn SSL_WRITE_ENTRY(regs:register){...//将FD与缓冲区一起发送,让FD=SSL_wbio(SSL)。AND_THEN(BIO_FD)。OK();output_buf(regs,fd,AccessMode::write,buf,num as usize);}fn ssl_read_ret(regs:register){...//将FD与缓冲区一起发送,让fd=ssl_rbio(*ssl)。AND_THEN(BIO_FD)。OK();output_buf(regs,fd,AccessMode::Read,*buf,num as usize);...}。

与以前基本相同,除了现在对于每个被拦截的缓冲区,我们还发送其相应的套接字描述符。

现在我们有了套接字描述符,但是如何从它们获得IP地址呢?让我们来看看connect()的签名:

CONNECT函数用于建立从给定套接字描述符sockfd到网络地址addr的连接。让我们编写一个新的uProbe,它将所有(sockfd,addr)对发送到用户空间:

使用redbpf_Probe::uProbe::prelude::*;pub struct connection{pub fd:u64,pub addr:u32,pub port:u32,}//用户空间将收到我们在此BPF映射静态mut connection_events:PerfMap<;connection>;=PerfMap::with_max_Entries(1024)中插入的所有值;Fn connect(regs:register){let_=do_connect(Regs);}fn do_connect(regs:register)->;option<;()>;{let fd=regs。Parm1()as I32;让addr=regs。Parm2()as*const sockaddr;//如果不安全{&;*addr},则仅记录IPv4连接。Sa_family()?As u32!=AF_INET{return NONE;}//并且仅在以下情况下才适用于ZOOM命令!Comm_is_zoom(){return NONE;}让addr=unsafe{&;*(addr as*const sockaddr_in)};让conn=connection{fd:fd as U64,addr:addr。Sin_addr()?S_addr()?,端口:u16::from_be(add.。Sin_port()?)。As u32,};unsafe{//将值发送到用户空间CONNECTION_EVENTS。插入(规则。Ctx,&;conn);}无}fn comm_is_zoom()->;bool{let comm=bpf_get_current_comm();let cmd=unsafe{core::Slice::from_raw_part(com.。As_ptr()as*const U8,16)};return&;cmd[.。4]==b";缩放";;}。

当ZOOM启动一个连接时,do_connect会被调用,创建一个包含连接的套接字描述符和地址的连接值,并将其发送到Snuffy用户空间进程。在那里,我们将所有连接值存储在以套接字描述符为关键字的散列映射中。然后,每当我们收到被截取的SSL_READ或SSL_WRITE的数据和套接字描述符时,我们就可以通过使用描述符索引连接散列映射来查找IP地址。

由于connect()是通过libpthread(glibc的一部分)动态链接的,要附加,我们可以简单地调用:

$sudo target/debug/snnffy--十六进制转储--跟踪-连接--偏移缩放偏移量。toml写入577字节到3.235.82.213:443|504f5354 202f7265 6c656173 656e6f74|POST/Release不00000000|65732048 5454502f 312e310d 0a486f73|ES HTTP/1.1.HOS 00000010|743a2075 733034.。00000020|75730d0a 55736572 2d416765 6e743a20|us..User-Agent:00000030|4d6f7a69 6c6c612f 352e3020 285a4f4f|Mozilla/5.0(ZOO 00000040|4d2e4c69 6e757820 5562756e 74752031|M.Linux Ubuntu 1 00000050...Read 3088 bytes from 3.235.82.213:443|48545450 2f312e31 20323030 200d0a44|HTTP/1.1 200..D 00000000|6174653a 20467269 2c203034 20536570|ate:Fri,04 Sep 00000010|20323032 30203035 3a30383a 31322047|2020 05:08:12 G 00000020|4d540d0a 436f6e74 656e742d 54797065|MT..Content-Type 00000030|3a206170 706c6963 6174696f 6e2f782d|:application/x-00000040|70726f74 6f627566 3b636861 72736574|protobuf;charset 00000050...。

最后,为了使读取输出更加容易,我们可以通过检测getaddrinfo()来查看这些IP对应的域名,getaddrinfo()是Zoom用来将域解析为地址的函数:

Getaddrinfo解析节点域名,并使用相应的IP地址填充res out参数。因此,我们将创建一个新的#[uretprobe],一旦getaddrinfo()返回,它将构建从res中的每个IP到域节点的散列映射。因为从概念上讲,这正是我们刚刚为connect()所做的,所以我不打算sh。

.