本文详细介绍了 NEAR Protocol 的 P2P 网络层中的一项漏洞,该漏洞允许攻击者通过发送恶意握手消息来崩溃任何节点,从而导致整个区块链网络的瘫痪。文章深入分析了区块链的内部组件,特别是网络层和握手机制的实现,并探讨了代码中的两个主要漏洞。最后,作者分享了如何进行漏洞验证和修复的过程。
当谈到 Web3 安全时,智能合约漏洞是首先想到的。然而,如果我们更深入一点呢?
我们可以关注智能合约相关功能之外的其他组件,例如共识层或网络层。我们能否发现攻击者可以利用的漏洞,以使整个区块链网络崩溃?
当然可以。在 NEAR Protocol 的 P2P 网络层,我发现了一个漏洞(现已修复),攻击者可以通过发送一条恶意握手消息来崩溃网络上的任何节点,使其能够瞬间瘫痪整个网络。这实际上相当于 Web3 死亡中的 Ping↗。
非常感谢 NEAR 团队对这个报告的专业和及时处理。
让我们深入了解一下机制的运作、一个概念证明漏洞以及最终的严重性分类。
如今大多数区块链都支持智能合约。这些智能合约可以是与 EVM 兼容的(也就是说,编译为 EVM 字节码),但它们也可以以与 WebAssembly 完全不同的方式表示。对于这些区块链,我更喜欢将内部组件划分为多个“层”,每一层都是整个区块链的一部分。
我将列出这些层中的一些,但请注意以下列表并不详尽:
这里有一个示意图来说明区块链上下文中这些层的样子。
为了理解漏洞的细节,我现在将介绍一些与网络层相关的概念。
区块链中的节点在相互通信时通常被称为“对等节点”。这就是“点对点”或 P2P 一词诞生的由来。
网络中的每个对等节点通常为每个连接的远程对等节点分配一个线程。它始终保持一个通信通道,该通道可用于发送数据到远程对等节点和从远程对等节点接收数据。
实际通信通常通过消息系统进行。例如,两个对等节点之间的初始 P2P 连接可能是这样建立的:
每次接收到消息时,消息处理函数将根据消息的类型和有效负载决定采取什么操作。
我现在将介绍 NEAR 协议使用的握手机制。这是理解漏洞所需的最终组件。
NEAR Protocol 的主要代码库是 nearcore↗。
在 NEAR 协议使用的 P2P 系统中,握手经过三个阶段:
在本地对等节点的上下文中,每个远程对等节点可以处于以下两种状态之一:
当刚刚与远程对等节点建立了 TCP 连接但尚未建立 P2P 连接时,握手机制开始发挥作用。在这种情况下,远程对等节点将处于 PeerStatus::Connecting
状态。
假设是远程对等节点连接到本地对等节点(也就是说,连接是入站的),在成功建立 P2P 连接时观察到以下流程:
PeerMessage::Tier1Handshake
或 PeerMessage::Tier2Handshake
消息。为了本文的目的,Tier 1 和 Tier 2 之间的差异并不重要。PeerMessage::TierXHandshake
消息以建立连接。此消息还包含本地对等节点正在连接的其他节点的信息,以便远程对等节点也可以连接这些节点。Handshake
消息的实际结构如下:
pub struct Handshake {
/// 当前协议版本。
pub(crate) protocol_version: u32,
/// 最早支持的协议版本。
pub(crate) oldest_supported_version: u32,
/// 发送者的对等节点 ID。
pub(crate) sender_peer_id: PeerId,
/// 接收者的对等节点 ID。
pub(crate) target_peer_id: PeerId,
/// 发送者的监听地址。
pub(crate) sender_listen_port: Option<u16>,
/// 对等节点的链信息。
pub(crate) sender_chain_info: PeerChainInfoV2,
/// 代表新边缘的信息。仅包含发送者的 `none` 和 `Signature`。
pub(crate) partial_edge_info: PartialEdgeInfo,
/// 发送者拥有的账户。
pub(crate) owned_account: Option<SignedOwnedAccount>,
}
握手消息通过 NEAR 协议中的 process_handshake()
函数进行验证和处理。该函数的代码可以在 这里↗ 找到。
每个远程对等节点都被分配一个 PeerInfo
结构:
pub struct PeerId(Arc<PublicKey>);
pub struct AccountId(pub(crate) Box<str>);
pub struct PeerInfo {
pub id: PeerId,
pub addr: Option<SocketAddr>,
pub account_id: Option<AccountId>,
}
从以上结构可以看出,远程对等节点主要通过其公钥来识别自己。连接地址和账户 ID 是可选的;但是,重要的是要注意,在大多数情况下,连接地址也将提供。
process_handshake()
函数采取了许多步骤以确保发送握手的远程对等节点不是恶意的。下面列出了其中一些步骤,但我鼓励读者查看 process_handshake()
函数 这里↗ 以查看代码中的所有检查:
protocol_version
必须与本地对等节点的协议版本匹配。sender_chain_info
字段的 genesis_id
必须与本地对等节点的 genesis_id
匹配。target_peer_id
必须与本地节点的对等节点 ID 匹配。owned_account
字段的验证如下。
owned_account.payload
字段必须由 owned_account.account_key
签名。owned_account.account_key
必须与握手中的 sender_peer_id
匹配。owned_account.timestamp
不能太久置于过去或未来。partial_edge_info
字段经过多个步骤进行验证。特别是 owned_account
字段非常有趣。它是验证的关键组成部分,因为签名检查与对等节点 ID 检查结合证明了发送此握手消息的远程对等节点拥有相应的公钥。
让我们更深入地看看签名验证是如何进行的。
握手的 owned_account
字段是 SignedOwnedAccount
的一个实例,其结构如下。
pub struct OwnedAccount {
pub(crate) account_key: PublicKey,
pub(crate) peer_id: PeerId,
pub(crate) timestamp: time::Utc,
}
pub struct AccountKeySignedPayload {
payload: Vec<u8>,
signature: near_crypto::Signature,
}
pub struct SignedOwnedAccount {
owned_account: OwnedAccount,
// 序列化并签名的 OwnedAccount。
payload: AccountKeySignedPayload,
}
这里,AccountKeySignedPayload
结构的 signature
与 payload
一起用于恢复签署了 payload
的公钥。如果这与 OwnedAccount
结构的 account_key
不匹配,则签名验证失败。
签名验证函数最终调用 Signature::verify()
函数。查看代码 这里↗,显然支持两种密钥类型 —— ED25519
和 SECP256K1
:
pub fn verify(&self, data: &[u8], public_key: &PublicKey) -> bool {
match (&self, public_key) {
(Signature::ED25519(signature), PublicKey::ED25519(public_key)) => {
// [ ... ]
}
(Signature::SECP256K1(signature), PublicKey::SECP256K1(public_key)) => {
// [ ... ]
}
_ => false,
}
}
深入一点查看每种情况,让我们看看我们的输入如何处理。在这种情况下,参数映射如下:
self
— 这是 owned_account.payload.signature
。完全受控。data
— 这是 owned_account.payload
。完全受控。public_key
— 这是 owned_account.owned_account.account_key
。完全受控。对于 ED25519
,代码将签名验证委托给 ed25519-dalek
crate:
pub fn verify(&self, data: &[u8], public_key: &PublicKey) -> bool {
match (&self, public_key) {
(Signature::ED25519(signature), PublicKey::ED25519(public_key)) => {
match ed25519_dalek::VerifyingKey::from_bytes(&public_key.0) {
Err(_) => false,
Ok(public_key) => public_key.verify(data, signature).is_ok(),
}
}
// [ ... ]
}
}
对于 SECP256K1
,代码将签名验证委托给 secp256k1
crate:
pub fn verify(&self, data: &[u8], public_key: &PublicKey) -> bool {
match (&self, public_key) {
// [ ... ]
(Signature::SECP256K1(signature), PublicKey::SECP256K1(public_key)) => {
let rsig = secp256k1::ecdsa::RecoverableSignature::from_compact(
&signature.0[0..64],
secp256k1::ecdsa::RecoveryId::from_i32(i32::from(signature.0[64])).unwrap(),
)
.unwrap();
let sig = rsig.to_standard();
let pdata: [u8; 65] = {/* 将公钥转换为字节切片 */};
SECP256K1
.verify_ecdsa(
&secp256k1::Message::from_slice(data).expect("32 bytes"),
&sig,
&secp256k1::PublicKey::from_slice(&pdata).unwrap(),
)
.is_ok()
}
_ => false,
}
}
在继续到下一个部分之前,你能发现上述代码片段中的漏洞吗?
请记住——这些都是可能被用来使整个网络崩溃的漏洞。
在用于验证签名 data
的代码中有两个漏洞。具体来说,这些漏洞存在于上面代码中 SECP256K1
分支的 match
臂中。如果你之前没有发现这两个漏洞,现在能发现它们吗?
data
的长度不为 32 字节在 Rust 语言中,有两个众所周知的结构可能导致 panic — .unwrap()
和 .expect()
。如果调用它们的变量是 Error
类型,则这两个函数都会 panic。
在 ED25519
的 match
臂中,代码调用 public_key.verify()
函数,然后调用 .is_ok()
来处理返回值。这将根据是否返回了错误返回 true
或 false
。在这里不会发生 panic。
但在 SECP256K1
的 match
臂中,存在三次调用 .unwrap()
和一次调用 .expect()
。我报告的漏洞特别与这里使用的 .expect()
相关:
&secp256k1::Message::from_slice(data).expect("32 bytes"),
请记住,data
字段是 owned_account.payload
。查看 secp256k1::Message::from_slice()
函数时,如果传入的 data
不为 32 字节,则会返回错误:
// constants::MESSAGE_SIZE = 32
pub fn from_slice(data: &[u8]) -> Result<Message, Error> {
match data.len() {
constants::MESSAGE_SIZE => {
let mut ret = [0u8; constants::MESSAGE_SIZE];
ret[..].copy_from_slice(data);
Ok(Message(ret))
}
_ => Err(Error::InvalidMessage),
}
}
这里的问题在于 owned_account.payload
字段的大小不为 32 字节。通过查看 send_handshake()
函数生成 owned_account.payload
的方式可以验证这一点。我会把更新的代码留给好奇的读者去查看 这里↗ ,以了解其原因。
因此,当调用 .expect()
时,代码会 panic 并崩溃节点。由于握手消息是远程对等节点连接到本地对等节点时发送的第一条消息,因此此漏洞实际上导致了 Web3 死亡中的 Ping↗。
signature.0[64]
可在 0 到 255 之间(包含)另一个漏洞出现在 SECP256K1
match
臂中的以下代码行中:
secp256k1::ecdsa::RecoveryId::from_i32(i32::from(signature.0[64])).unwrap(),
具体来说,内部的 i32::from()
将签名的最后一个字节转换为 u8
。然后,如果该字节不在 0 到 3 之间,secp256k1::ecdsa::RecoveryId::from_i32()
实际上会返回错误:
pub fn from_i32(id: i32) -> Result<RecoveryId, Error> {
match id {
0..=3 => Ok(RecoveryId(id)),
_ => Err(Error::InvalidRecoveryId),
}
}
触发此错误条件非常简单,因为我们完全控制 signature
。最终的 .unwrap()
会导致 panic 并崩溃节点。
我现在将解释如何编写了一个概念证明漏洞,以便在本地网络环境中崩溃验证者节点。
当我开始编写概念证明以演示本地网环境下的此漏洞时,发现没有代码路径允许 NEAR 节点生成 SECP256K1
类型密钥,这有些令人惊讶。
这在某种程度上解释了上面显示的两个漏洞为何如此简单 —— 在本地网络环境中根本没有生成 SECP256K1
密钥的方法,因此这段代码路径根本没有经过测试。所有生成的密钥都是硬编码为 ED25519
密钥。
我首先设置了一个本地网络,配置如下:
在此设置中,验证者节点将是一个合法节点,正在持续生产区块。全节点将是我补丁并引入到网络中的恶意节点。
最终目标是让恶意全节点连接到网络,并立即崩溃验证者节点。
为此,我拉取了 nearcore 仓库(见 这里↗,提交 e0f0da5c3dde29122e956dfd905811890de9a570
),并运行 make neard-debug -j8
构建节点的调试版本。你可以在 target/debug/neard
找到最终的节点二进制文件。我将二进制文件重命名为 neard_legit
,因为我之后会使用我应用了恶意补丁重新构建该二进制文件。
然后我使用以下命令生成一个包含一个验证者节点和一个全节点的本地网络配置:
$ target/debug/neard_legit --home ./localnet_config localnet -v 1 -n 1
验证者节点配置可在 ./localnet_config/node0
中找到,而全节点可在 ./localnet_config/node1
中找到。
在继续之前,我需要重新构建 neard
二进制文件,但这次要添加我的恶意补丁。
最后的补丁 diff 文件可以在 这里↗ 找到。
请注意,.expect()
漏洞在同一代码文件中的 Signature::sign()
函数中也存在。然而,该函数仅由发送对等节点使用,因此不会导致安全影响。
然而,我仍然需要修补恶意节点中的漏洞,否则在对 owned_account.payload
签名时它将直接崩溃。
我的补丁做了几件事:
Signature::sign()
和 Signature::verify()
函数中的 .expect()
漏洞。这样,恶意节点就可以创建 SECP256K1
签名而不崩溃。neard localnet
命令所使用的代码,使其生成 SECP256K1
密钥,而不是 ED25519
密钥。该补丁应干净地应用于提交 e0f0da5c3dde29122e956dfd905811890de9a570
。
完成此操作后,我再次重建了 neard
二进制文件。然后我用于生成恶意网络配置。这使我能够将恶意节点的验证者密钥 json 和节点密钥 json 文件复制到 ./localnet_config/node1
中,从而使我在本地网络环境中的恶意全节点现在将使用 SECP256K1
密钥:
$ target/debug/neard --home ./localnet_malicious_config localnet -v 1
$ cat localnet_malicious_config/node0/validator_key.json
{
"account_id": "node0",
"public_key": "secp256k1:nUsQNkHfWWPWP5bkF73AN43VXKmztJdcuqL44yKT2GfyezYbWAu9wK8MLLjxPWxjJgeGu2qapnQVnGBZKW4tFcd",
"secret_key": "secp256k1:E7rvMjFtqC1KddPt8pqF1HGBxqbAUJMkP8EXbNAUwokB"
}
$ cp localnet_malicious_config/node0/*key.json localnet_config/node1/
为了演示崩溃,我首先在一个终端中启动合法验证者节点:
$ target/debug/neard_legit --home ./localnet_config/node0/ run
然后我在另一个终端中启动我的恶意验证者节点。请注意,target/debug/neard
是恶意节点,因为它是第二次编译的。它还使用了已复制到其配置目录中的 SECP256K1
密钥:
$ target/debug/neard --home localnet_config/node1/ run
刚启动这个节点后,合法的验证者节点就会崩溃,崩溃时的堆栈跟踪显示的内容片段(日志可以在 ./localnet_config/node0/logs.txt
中找到):
thread 'actix-rt|system:0|arbiter:11' panicked at core/crypto/src/signature.rs:557:63:
32 bytes: InvalidMessage
stack backtrace:
0: rust_begin_unwind
at /rustc/79e9716c980570bfd1f666e3b16ac583f0168962/library/std/src/panicking.rs:597:5
1: core::panicking::panic_fmt
at /rustc/79e9716c980570bfd1f666e3b16ac583f0168962/library/core/src/panicking.rs:72:14
2: core::result::unwrap_failed
at /rustc/79e9716c980570bfd1f666e3b16ac583f0168962/library/core/src/result.rs:1652:5
3: core::result::Result<T,E>::expect
at /rustc/79e9716c980570bfd1f666e3b16ac583f0168962/library/core/src/result.rs:1034:23
4: near_crypto::signature::Signature::verify
at ./core/crypto/src/signature.rs:551:27
5: near_network::network_protocol::AccountKeySignedPayload::verify
at ./chain/network/src/network_protocol/mod.rs:211:15
于是,这就是——死亡的握手。我现在可以百分之百确定,这个漏洞是真实的,并且可以用来崩溃网络上的任何节点。作为附加收益,如果任何合法节点在恶意节点仍在运行时重新上线,它们会再次立即崩溃。
能够崩溃验证者节点的漏洞通常在严重性上被分类为关键,因为一个链停顿可能会造成巨大的影响。然而,在与 HackenProof 和 NEAR 广泛讨论之后,这种特定漏洞被分类为高严重性,CVSS 评分为 8.8(9.0 及以上被视为关键)。最终悬赏金额为 $150,000,我欣然接受了。
这是我到目前为止职业生涯中发现的影响力最大的漏洞。我曾对将其分类为如此漏洞感到犹豫,因为其简洁易懂,但我意识到这样的漏洞可能一生只会出现一次,而这也只是如果你足够幸运之前发现了它。
我希望这篇博客文章对希望开始寻找区块链漏洞的审计人员和悬赏猎人有所启发,同时我希望我对代码的详细分解能帮助你更容易地接触到如此复杂的代码库。
我也希望概念证明部分展示了可以用来确认和验证在审计代码时做出假设的可重复方法。快速的方法来验证假设是一个实用工具,我希望我能够展示这样的方法通常可以在任何区块链实现中复现。
Zellic 专注于保护新兴技术。我们的安全研究人员在最有价值的目标中发现了漏洞,从财富500强公司到 DeFi 巨头。
开发者、创始人和投资者信任我们的安全评估,以便快速、自信地发布,没有严重漏洞。凭借我们在现实世界攻击性安全研究的背景,我们发现了其他人所遗漏的内容。
联系我们↗ 进行更好的审计。真正的审计,而不是形式主义。
- 原文链接: zellic.io/blog/near-prot...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!