这篇文章深入分析了DeFi中Balancer协议因双入口点代币(如Synthetix SNX)和闪电贷机制而产生的拒绝服务漏洞。攻击者利用这一漏洞,通过操纵内部代币余额,导致协议金库中的资产被误识别为协议费用并转移,从而使用户无法访问这些资金。文章详细解释了漏洞原理、攻击模拟过程,并提供了重要的经验教训及项目方应对措施。

一个 DeFi 项目的全部流动性如何能在瞬间变得无法访问?在本文中,我们探讨了一种拒绝服务攻击向量。具体来说,是通过影响内部代币余额实现的拒绝服务。当 Balancer 多代币闪电贷被用于具有双重入口点的代币时,就会出现这种特定的漏洞。
首先,我们将回顾以下前置概念。
如果你已经理解这些概念,可以直接跳到探索漏洞。
代理架构可以被描述为一种智能合约设计模式,它将状态与业务逻辑分离。代理智能合约存储所有状态,因此如果需要更改或更新逻辑或实现合约,可以在不迁移之前部署的状态(这可能成本过高)的情况下进行更改。此外,用户可以使用相同的代理合约地址继续访问协议,因为它只是将所有调用转发到新的逻辑合约。
代理模式主要通过 delegatecall opcode 实现,它允许智能合约调用另一个智能合约,但在其自己的上下文中执行。术语“上下文”简单地指 EVM 中智能合约的地址或位置。具体来说,它指的是合约的存储和诸如 msg.sender (调用者的地址)、msg.value (调用者发送的 ETH 值) 和 address(this) 等值。
对合约进行 call 和 delegatecall 的区别在于,前者使用的是被调用合约的上下文,而后者使用的是调用合约的上下文。
大多数代理合约都使用 delegatecall 来调用逻辑合约;对代理的传入调用会通过 delegatecall 转发到实现合约,实现合约可以被新的/修改过的合约替换。代理仍然是存储和面向用户的合约。

图 1 代理合约作为存储,向逻辑合约发出 delegate 调用。这也通过下面的代码片段进行了说明:
contract Proxy {
... {
(bool success, bytes memory data) = Logic.delegatecall(
abi.encodeWithSignature("transfer(address,uint256)", _recipient, _amount));
}
...
}
contract Logic {
function transfer(address _recipient, uint256 _amount) external returns(bool) {
...
}
...
}
另一种方法是代理向逻辑合约发出 call 而不是 delegatecall。

图 2 逻辑合约也可以直接调用。存储合约持有状态。这也通过下面的代码片段进行了说明:
contract Proxy {
function transfer(address _recipient, uint256 _amount) external returns (bool) {
...
bool succ = logic.transfer(_recipient, _amount);
...
}
}
contract Logic {
function transfer(address _recipient, uint256 _amount) external returns (bool) {
...
}
}
因此,状态可以存储在一个状态合约中,而业务逻辑可以放在一个单独的、可替换的实现或逻辑合约中。Synthetix Network Token (SNX) 就使用了这种代理结构,其地址是 0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f。
通常,代理合约本身持有状态,并使用实现合约作为逻辑层。然而,正如上面通过基于 call 的代理所解释的,情况并非总是如此。对于某些合约,代理充当“中继”合约,实现合约充当逻辑层,而第三个状态合约充当存储层。
采用基于 call 代理结构的合约可能允许用户通过代理合约或直接通过实现合约地址来调用它们。
在这两种情况下,由于代理和实现共享状态,因此修改的是相同的状态。这类合约被称为双重入口点合约,因为它们可以通过两个不同的地址调用,并被称为具有两个入口点。采用这种结构的 ERC20 代币合约被称为双重入口点代币。
通常在 DeFi 中,用户可以通过提供的抵押品借款,抵押品的价值通常远高于借款金额;因此,这些贷款是超额抵押的。然而,鉴于以太坊等区块链网络上交易的原子性,一种新的借贷范式应运而生:闪电贷 (Flash Loans)。
在闪电贷中,用户可以借入给定数量的加密代币,而无需存入任何抵押品。但是,条件是借入的金额(通常加上费用)必须在同一笔交易中偿还;否则,交易将回滚。一些协议,如 Balancer,甚至允许用户借入多种代币的闪电贷。
闪电贷的一个主要用例是利用套利机会。例如,如果代币 A 在交易所 P 比在交易所 Q 便宜,我们可以借入一笔 ETH 闪电贷,用这些 ETH 从交易所 P 大量购买代币 A,然后在交易所 Q 出售代币 A 换回 ETH,偿还我们的贷款并保留多余的 ETH。其他用例包括清算和抵押品互换。
概述
该漏洞于 2022 年 5 月在 ChainSecurity 团队调查 Curve 只读重入漏洞时首次发现。当 Balancer 闪电贷被用于 Synthetix Network (SNX) 代币时,问题就会出现,当时 SNX 是一种双重入口点代币。这导致所有 SNX(或任何其他双重入口点代币)最终都可能从 Vault 转移到费用收集者合约,从而拒绝用户访问这些资金以进行交易或闪电贷。
值得注意的是,问题来源于代理合约和实现合约都具有相同签名的函数;最重要的是 transfer() 和 transferFrom()。因此,攻击者能够向 flashLoan 函数提供两个 SNX 地址,该函数会调用所提供的代币合约上的 .transfer() (通过 safeERC20.safeTransfer())。一种缓解措施是在代理合约和实现合约中使用不同的函数名称:
contract Proxy {
...
function transfer(address _recipient, uint256 _amount) external returns (bool) {
...
bool succ = logic.transferLogic(_recipient, _amount);
...
}
}
contract Logic {
function transferLogic(address _recipient, uint256 _amount) external returns (bool) {
...
}
}
话虽如此,一个直接的修复方法是在实现合约的函数上添加一个 onlyProxy 修饰符。事实上,Synthetix 团队就是通过这种方式修补了该漏洞。
Balancer 允许用户通过其 Vault 合约的 flashLoan 函数借入多种代币的闪电贷。下面是 Vault.flashLoan 函数的签名:
function flashLoan(
IFlashLoanRecipient recipient,
IERC20[] memory tokens,
uint256[] memory amounts,
bytes memory userData) {
...
}
攻击者可以提供 SNX 代币的两个地址,并以非常高的第一个借款金额和零的第二个借款金额进行闪电贷。这将导致 Balancer 将全部偿还的 SNX 金额(第一个金额)视为协议费用,从而导致拒绝服务,用户将无法交易或借入 SNX。
contract AttackContract {
function attack() public {
BalancerVault.flashLoan
(address(this), [SNXTokenAddress1, SNXTokenAddress2], [SNX.balanceOf(BalancerVault), 0])
}
}
在这里,两个被闪电贷的代币是同一个,并且相应的借款金额分别是 Vault 合约持有的 SNX 的最大余额和 0。接收者地址是 AttackContract 的地址。
contract BalancerVault {
function flashLoan(
IFlashLoanRecipient recipient,
IERC20[] memory tokens,
uint256[] memory amounts,
bytes memory userData
) external override nonReentrant whenNotPaused {
...
for (uint256 i = 0; i < tokens.length; ++i) {
// 确保代币顺序
_require(
token > previousToken,
token == IERC20(0) ? Errors.ZERO_TOKEN : Errors.UNSORTED_TOKENS
);
...
// 记录每个 `tokens` 的 preLoanBalance
preLoanBalances[i] = token.balanceOf(address(this));
...
// 确保请求的闪电贷金额由 `tokens` 的可用储备金覆盖
_require(
preLoanBalances[i] >= amount,
Errors.INSUFFICIENT_FLASH_LOAN_BALANCE
);
// 将每个代币的金额转移到 `recipient`
token.safeTransfer(address(recipient), amount);
}
recipient.receiveFlashLoan(tokens, amounts, feeAmounts, userData);
...
}
}
preLoanBalance。然后,贷款金额被转移给接收方。假设 Vault 中共有 100 SNX。记录的 preLoanBalance 将是 100 SNX。此时,由于所有 SNX 都已转移给接收方,Vault 中有 0 SNX。preLoanBalance,这仍然是 SNX,但地址不同。由于在之前的循环中所有的 SNX 都已借出,因此 preLoanBalance 将设置为 0 SNX。这导致 Vault 余额检查通过,因为有 0 SNX 且借款金额为 0。然后,将 0 SNX 的闪电贷转移给用户。contract AttackContract {
function attack() public {
...
}
function receiveFlashLoan
(IERC20[] memory tokens, uint256[] memory amounts, uint256[] memory amounts, bytes memory userData) {
// 简单地偿还收到的代币金额
for (uint256 i = 0; i < tokens.length; ++i) {
// 循环1:所有 SNX 已偿还
// 循环2:0 SNX 已偿还
IERC20(token).safeTransfer(address(BalancerVault), amount);
}
}
}
postLoanBalance)与两个代币的 preLoanBalance 进行比较:contract BalancerVault {
function flashLoan(
...
) external override nonReentrant whenNotPaused {
...
for (uint256 i = 0; i < tokens.length; ++i) {
...
// 记录本合约中每个 `token` 的 postLoanBalance
uint256 postLoanBalance = token.balanceOf(address(this));
// 确保 postLoanBalance 大于或等于两个代币的 preLoanBalance
_require(
postLoanBalance >= preLoanBalance,
Errors.INVALID_POST_LOAN_BALANCE
);
// 由于两个地址都指向同一个代币,SNX 代币的 preLoanBalance 为 0,但 postLoanBalance 为 100,因为它已偿还。
// 100-0 = 100 是收到的费用金额
uint256 receivedFeeAmount = postLoanBalance - preLoanBalance;
...
// 将费用金额转移到 ProtocolFeesCollector 合约
_payFeeAmount(token, receivedFeeAmount);
...
}
...
}
function _payFeeAmount(IERC20 token, uint256 amount) internal {
if (amount > 0) {
token.safeTransfer(address(getProtocolFeesCollector()), amount);
}
}
由于两个地址都是 SNX 代币的地址,通过 token.balanceOf(address(this)) 检查的 postLoanBalance 报告为 100 SNX。然而,preLoanBalance 仍然是 0。
现在,当用户来交易 SNX(或闪电贷)时,交易会回滚,因为 Vault 合约持有的所有 SNX 现在都位于费用收集者合约中。因此,用户被拒绝服务。
我们可以从这个漏洞中吸取几个教训。
首先,我们应该在代码中考虑不寻常的函数参数。在 Balancer 的 flashLoan 函数中,它假定借用的代币是不同的。然而,正如我们现在所知,在双重入口点代币的情况下,两个不同的地址可以指向同一个合约或代币。值得注意的是,Balancer 曾声明不使用不寻常的 ERC20 代币,例如那些具有双重入口点的代币。然而,尽管有免责声明,此类代币最终还是进入了协议并积累了高流动性。因此,尽管文档中可能有警告或免责声明,但在代码中努力考虑边缘情况总是值得的。
其次,我们可以先记录贷款前余额,然后再转移代币,而不是同时记录贷款前余额和转移代币。这样,即使代币有双重入口点,也可以避免该漏洞。原因是,为第二个代币记录的贷款前余额将不会是 0,因为它是在任何转移发生之前记录的。因此,当闪电贷最终偿还时,它会与准确的 preLoanBalance 进行比较,并且不会将任何金额作为费用转移到费用收集者合约。
第三,我们应该始终警惕提供调用用户提供地址的功能;在这种情况下,它是用户提供的代币合约,在其上调用 transfer 和 transferFrom 等方法。
最后,可以说,拥有多个入口点的合约从一开始就不值得麻烦。DeFi 生态系统越来越像一个乐高积木结构,每个乐高积木都应该正确地拼合,以标准化和可预测的方式工作。双重入口智能合约打破了这种预期。
处理漏洞的一个主要因素是项目所有者对此类发现的回应。当 ChainSecurity 在 2022 年 5 月 13 日向 Balancer 报告此攻击向量时,他们立即作出回应,并开始合作理解和解决问题。随后,Balancer 团队将此问题告知了 Synthetix 团队,详情请见 Balancer 论坛。一项改进提案迅速于 2022 年 6 月 10 日通过,批准了一项补丁,该补丁限制了与 SNX 代币的交互只能通过代理进行,并将所有 SNX 从费用收集者合约转移回 Vault 合约。
因此,在发生此类紧急情况时,项目所有者能够轻松联系到至关重要。项目应该为白帽黑客提供一个专用渠道来传输信息,这些信息随后可以进行分类并传递给相关团队。此外,虽然不必让白帽黑客随时了解最新进展,但应该向他们确认已收到报告并正在处理。理想情况下,应该告知他们修复的时间表,并询问最佳联系方式,以防有更多问题。
在本文中,我们探讨了一种特定的拒绝服务攻击向量。它影响了 Balancer 协议中的内部代币余额,导致用户无法交易代币。我们研究了 Balancer 闪电贷功能如何与 SNX 等双重入口代币结合使用以利用此漏洞。本文还考虑了从这一发现中吸取的教训和未来的发展方向。我们还强调了项目所有者快速响应的重要性,Balancer 和 Synthetix 团队的示例就体现了这一点。
- 原文链接: chainsecurity.com/blog/d...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!