Web3 死亡回声:发现并修复 NEAR 中导致链瘫痪的漏洞

  • zellic
  • 发布于 2024-09-27 15:36
  • 阅读 52

本文详细介绍了 NEAR Protocol 的 P2P 网络层中的一项漏洞,该漏洞允许攻击者通过发送恶意握手消息来崩溃任何节点,从而导致整个区块链网络的瘫痪。文章深入分析了区块链的内部组件,特别是网络层和握手机制的实现,并探讨了代码中的两个主要漏洞。最后,作者分享了如何进行漏洞验证和修复的过程。

当谈到 Web3 安全时,智能合约漏洞是首先想到的。然而,如果我们更深入一点呢?

我们可以关注智能合约相关功能之外的其他组件,例如共识层或网络层。我们能否发现攻击者可以利用的漏洞,以使整个区块链网络崩溃?

当然可以。在 NEAR Protocol 的 P2P 网络层,我发现了一个漏洞(现已修复),攻击者可以通过发送一条恶意握手消息来崩溃网络上的任何节点,使其能够瞬间瘫痪整个网络。这实际上相当于 Web3 死亡中的 Ping↗

非常感谢 NEAR 团队对这个报告的专业和及时处理。

让我们深入了解一下机制的运作、一个概念证明漏洞以及最终的严重性分类。

介绍 NEAR 区块链

如今大多数区块链都支持智能合约。这些智能合约可以是与 EVM 兼容的(也就是说,编译为 EVM 字节码),但它们也可以以与 WebAssembly 完全不同的方式表示。对于这些区块链,我更喜欢将内部组件划分为多个“层”,每一层都是整个区块链的一部分。

我将列出这些层中的一些,但请注意以下列表并不详尽:

  • 智能合约层 — 智能合约位于这一层。智能合约包含用户定义的代码,并可以被网络上的其他用户执行。智能合约彼此隔离。它们仅允许通过外部调用进行通信。
  • 共识层 — 该层处理验证节点之间的共识。
  • 执行层 — 当执行智能合约时,该层处理智能合约内每个操作码/指令的执行以及状态转换逻辑。
  • 存储层 — 此层包含有关存储内部的详细信息,例如用于存储各种区块链数据的数据结构。这包括合约存储状态、账户状态、交易数据等。
  • 网络层 — 当节点需要向其他节点传播交易、区块和其他数据时,它们使用此层。这个层是本文中描述的 NEAR Protocol 漏洞被发现的地方。

这里有一个示意图来说明区块链上下文中这些层的样子。

为了理解漏洞的细节,我现在将介绍一些与网络层相关的概念。

网络层

区块链中的节点在相互通信时通常被称为“对等节点”。这就是“点对点”或 P2P 一词诞生的由来。

网络中的每个对等节点通常为每个连接的远程对等节点分配一个线程。它始终保持一个通信通道,该通道可用于发送数据到远程对等节点和从远程对等节点接收数据。

实际通信通常通过消息系统进行。例如,两个对等节点之间的初始 P2P 连接可能是这样建立的:

每次接收到消息时,消息处理函数将根据消息的类型和有效负载决定采取什么操作。

我现在将介绍 NEAR 协议使用的握手机制。这是理解漏洞所需的最终组件。

NEAR Protocol 的握手机制

NEAR Protocol 的主要代码库是 nearcore↗

在 NEAR 协议使用的 P2P 系统中,握手经过三个阶段:

  1. 初始 P2P 连接的建立
  2. 握手消息的验证
  3. 握手签名的验证

阶段 1 — 初始 P2P 连接的建立

在本地对等节点的上下文中,每个远程对等节点可以处于以下两种状态之一:

  1. 连接中 — 在此模式下,只处理来自远程对等节点的握手相关消息。
  2. 准备就绪 — 在此模式下,处理所有远程对等节点发送的消息,除了握手相关消息。

当刚刚与远程对等节点建立了 TCP 连接但尚未建立 P2P 连接时,握手机制开始发挥作用。在这种情况下,远程对等节点将处于 PeerStatus::Connecting 状态。

假设是远程对等节点连接到本地对等节点(也就是说,连接是入站的),在成功建立 P2P 连接时观察到以下流程:

  1. 远程对等节点发送 PeerMessage::Tier1HandshakePeerMessage::Tier2Handshake 消息。为了本文的目的,Tier 1 和 Tier 2 之间的差异并不重要。
  2. 本地对等节点验证并处理此握手消息。如果被发现有效,则发送相应的 PeerMessage::TierXHandshake 消息以建立连接。此消息还包含本地对等节点正在连接的其他节点的信息,以便远程对等节点也可以连接这些节点。

阶段 2 — 握手消息的验证

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() 函数 这里↗ 以查看代码中的所有检查:

  1. protocol_version 必须与本地对等节点的协议版本匹配。
  2. sender_chain_info 字段的 genesis_id 必须与本地对等节点的 genesis_id 匹配。
  3. target_peer_id 必须与本地节点的对等节点 ID 匹配。
  4. owned_account 字段的验证如下。
    • owned_account.payload 字段必须由 owned_account.account_key 签名。
    • owned_account.account_key 必须与握手中的 sender_peer_id 匹配。
    • owned_account.timestamp 不能太久置于过去或未来。
  5. partial_edge_info 字段经过多个步骤进行验证。

特别是 owned_account 字段非常有趣。它是验证的关键组成部分,因为签名检查与对等节点 ID 检查结合证明了发送此握手消息的远程对等节点拥有相应的公钥。

让我们更深入地看看签名验证是如何进行的。

阶段 3 — 握手签名验证

握手的 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 结构的 signaturepayload 一起用于恢复签署了 payload 的公钥。如果这与 OwnedAccount 结构的 account_key 不匹配,则签名验证失败。

签名验证函数最终调用 Signature::verify() 函数。查看代码 这里↗,显然支持两种密钥类型 —— ED25519SECP256K1

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臂中。如果你之前没有发现这两个漏洞,现在能发现它们吗?

漏洞 1 — data 的长度不为 32 字节

在 Rust 语言中,有两个众所周知的结构可能导致 panic — .unwrap().expect()。如果调用它们的变量是 Error 类型,则这两个函数都会 panic。

ED25519match 臂中,代码调用 public_key.verify() 函数,然后调用 .is_ok() 来处理返回值。这将根据是否返回了错误返回 truefalse。在这里不会发生 panic。

但在 SECP256K1match 臂中,存在三次调用 .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↗

漏洞 2 — 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 签名时它将直接崩溃。


我的补丁做了几件事:

  1. 它修补了 Signature::sign()Signature::verify() 函数中的 .expect() 漏洞。这样,恶意节点就可以创建 SECP256K1 签名而不崩溃。
  2. 它修补了 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,我欣然接受了。

结论

这是我到目前为止职业生涯中发现的影响力最大的漏洞。我曾对将其分类为如此漏洞感到犹豫,因为其简洁易懂,但我意识到这样的漏洞可能一生只会出现一次,而这也只是如果你足够幸运之前发现了它。

我希望这篇博客文章对希望开始寻找区块链漏洞的审计人员和悬赏猎人有所启发,同时我希望我对代码的详细分解能帮助你更容易地接触到如此复杂的代码库。

我也希望概念证明部分展示了可以用来确认和验证在审计代码时做出假设的可重复方法。快速的方法来验证假设是一个实用工具,我希望我能够展示这样的方法通常可以在任何区块链实现中复现。

披露时间表

  • 2023年12月25日 — 漏洞报告通过 HackenProof 提交,评级 10.0 且严重性为关键。
  • 2024年1月3日 — NEAR 确认该问题并将严重性降级为高,评级 8.8。
  • 2024年1月9日 — NEAR 在 PR 10385↗ 中修复了该问题,确保签名验证代码处理任何返回的错误,而不是 panic。
  • 2024年1月4日至7月6日 — 在与 NEAR 进行广泛讨论后,我接受了高严重性分类以及 $150,000 的悬赏金额。

关于我们

Zellic 专注于保护新兴技术。我们的安全研究人员在最有价值的目标中发现了漏洞,从财富500强公司到 DeFi 巨头。

开发者、创始人和投资者信任我们的安全评估,以便快速、自信地发布,没有严重漏洞。凭借我们在现实世界攻击性安全研究的背景,我们发现了其他人所遗漏的内容。

联系我们↗ 进行更好的审计。真正的审计,而不是形式主义。

  • 原文链接: zellic.io/blog/near-prot...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
zellic
zellic
Security reviews and research that keep winners winning. https://www.zellic.io/