现实中的智能合约拒绝服务攻击

  • zealynx
  • 发布于 2023-04-27 12:48
  • 阅读 10

本文深入探讨了智能合约中的五种拒绝服务攻击,包括下溢、无界数组导致的Gas限制、重入保护修饰符的错误使用、外部预言机故障和恶意接收者。文章基于真实审计案例,提供了详细的代码示例、漏洞分析及修复建议,旨在帮助开发者理解和防范这些攻击。

如果你一直在尝试了解智能合约中潜在的 DoS 攻击案例,却总是发现相同的通用示例,那么本文正适合你。在这里,我们涵盖了审计竞赛和实际项目中报告的真实发现,为你提供了关于拒绝服务漏洞如何在生产代码中出现的实用见解。

你将学到什么

  • 来自竞争性审计平台的真实审计发现

  • 五种独特的 DoS 攻击向量,附带代码示例和推荐修复方案

  • 看似无害的设计选择如何冻结整个协议

    • *

由下溢引起的 DoS

漏洞:harvestFees() 将借入的 USDC 推高至借款上限以上

DnGmxJuniorVaultManager 合约中,harvestFees() 函数通过将 WETH 转换为 USDC 并直接通过 Aave 进行质押,从而向高级金库授予费用。

问题在于这间接增加了初级金库的债务。如果初级金库已达到其借款上限,则总借款金额会超过上限,导致 availableBorrow 下溢并回滚。

由于每次用户从初级金库存款或提款时都会调用 harvestFees(),这实际上锁定了所有存款和提款。

1if (_seniorVaultWethRewards > state.wethConversionThreshold) {
2    uint256 minUsdcAmount = _getTokenPricsdc(state, state.weth).mulDivDown(
3        _seniorVaultWethRewards * (MAX_BPS - state.slippageThresholdSwapEthBps),
4        MAX_BPS * PRICE_PRECISION
5    );
6    // 将 WETH 兑换成 USDC
7    (uint256 aaveUsdcAmount, ) = state._swapToken(
8        address(state.weth),
9        _seniorVaultWethRewards,
10        minUsdcAmount
11    );
12    // 将 USDC 供应到 AAVE
13    state._executeSupply(address(state.usdc), aaveUsdcAmount);
14    // 重置高级金库奖励
15    state.seniorVaultWethRewards = 0;
16}

借入的 USDC 是根据 aUSDC 余额计算的:

1function getUsdcBorrowed() public view returns (uint256 usdcAmount) {
2    return uint256(
3        state.aUsdc.balanceOf(address(this)).toInt256() -
4            state.dnUsdcDeposited -
5            state.unhedgedGlpInUsdc.toInt256()
6    );
7}

当借款超过上限时,availableBorrow 函数会下溢:

1function availableBorrow(address borrower) public view returns (uint256 availableAUsdc) {
2    uint256 availableBasisCap =
3        borrowCaps[borrower] - IBorrower(borrower).getUsdcBorrowed(); // 在这里下溢
4    uint256 availableBasisBalance = aUsdc.balanceOf(address(this));
5    availableAUsdc = availableBasisCap < availableBasisBalance
6        ? availableBasisCap
7        : availableBasisBalance;
8}

建议

在减法前添加边界检查以防止下溢:

1function availableBorrow(address borrower) public view returns (uint256 availableAUsdc) {
2    uint256 borrowCap = borrowCaps[borrower];
3    uint256 borrowed = IBorrower(borrower).getUsdcBorrowed();
4
5    if (borrowed > borrowCap) return 0;
6
7    uint256 availableBasisCap = borrowCap - borrowed;
8    uint256 availableBasisBalance = aUsdc.balanceOf(address(this));
9    availableAUsdc = availableBasisCap < availableBasisBalance
10        ? availableBasisCap
11        : availableBasisBalance;
12}

由 gas 限制引起的 DoS

漏洞:无界数组无限增长

一个合约使用一个不断增长的 userDepositsIndex 数组来跟踪存款:

1Receipt[] public deposits;
2mapping(address => uint256[]) public userDepositsIndex;

每次调用 depositUSDC 都会向数组中添加元素:

1function depositUSDC(uint256 _amount) external {
2    require(_amount >= minUSDCAmount, "deposit amount smaller than minimum OTC amount");
3    IERC20(usdc).transferFrom(msg.sender, address(this), _amount);
4
5    usdBalance[msg.sender] = usdBalance[msg.sender] + _amount;
6    deposits.push(Receipt(msg.sender, _amount));
7    userDepositsIndex[msg.sender].push(deposits.length - 1);
8
9    emit USDCQueued(msg.sender, _amount, usdBalance[msg.sender], deposits.length - 1);
10}

withdrawUSDC 遍历整个数组时,关键问题浮出水面:

1uint256 toRemove = _amount;
2uint256 lastIndexP1 = userDepositsIndex[msg.sender].length;
3for (uint256 i = lastIndexP1; i > 0; i--) {
4    Receipt storage r = deposits[userDepositsIndex[msg.sender][i - 1]];
5    if (r.amount > toRemove) {
6        r.amount -= toRemove;
7        toRemove = 0;
8        break;
9    } else {
10        toRemove -= r.amount;
11        delete deposits[userDepositsIndex[msg.sender][i - 1]];
12    }
13}

在足够多的存款之后,从 lastIndexP1 循环到零的 gas 成本会超过区块 gas 限制,从而永久锁定用户的资金。

建议

在删除存款时,使用 pop()userDepositsIndex 中移除元素,保持数组有界且 gas 成本可预测。


由 nonReentrant 修饰符引起的 DoS

漏洞:内部调用链触发自身重入保护

在一个质押协议中,多个函数带有 nonReentrant 修饰符。问题出现在 unstake() 触发内部调用链,最终调用另一个 nonReentrant 函数时:

11. HighStreetPoolBase.unstake()          (nonReentrant)
22.   └─ HighStreetCorePool._unstake()
33.       └─ HighStreetPoolBase._unstake()
44.           ├─ HighStreetPoolBase._sync()
55.           └─ HighStreetCorePool._processRewards()
66.               └─ HighStreetCorePool._processVaultRewards()
77.                   └─ HighStreetCorePool.transferHighToken()  (nonReentrant)

pendingVaultRewards > 0 时,_processVaultRewards 函数调用 transferHighToken

1function _processVaultRewards(address _staker) private {
2    User storage user = users[_staker];
3    uint256 pendingVaultClaim = pendingVaultRewards(_staker);
4    if (pendingVaultClaim == 0) return;
5
6    uint256 highBalance = IERC20(HIGH).balanceOf(address(this));
7    require(highBalance >= pendingVaultClaim, "contract HIGH balance too low");
8
9    if (poolToken == HIGH) {
10        poolTokenReserve -= pendingVaultClaim > poolTokenReserve
11            ? poolTokenReserve
12            : pendingVaultClaim;
13    }
14    user.subVaultRewards = weightToReward(user.totalWeight, vaultRewardsPerWeight);
1// 此调用回滚,因为 unstake() 已持有重入锁
2transferHighToken(_staker, pendingVaultClaim);

}

由于 unstake() 已经获取了重入锁,调用 transferHighToken()(它也需要锁)会以 ReentrancyGuard: reentrant call 回滚。只要有待处理的金库奖励(一种正常的运行状态),这种 DoS 就会被激活。

建议

transferHighToken() 中移除 nonReentrant 修饰符,因为它已在受保护的 unstake() 流程中被调用,因此已经受到保护。


由外部调用引起的 DoS

漏洞:被阻止的 Chainlink 预言机回滚所有依赖函数

当协议在没有回退机制的情况下依赖 Chainlink 价格数据流时,被阻止的预言机可能会冻结整个系统:

function viewPrice(address token, uint collateralFactorBps) external view returns (uint) {
    if (fixedPrices[token] > 0) return fixedPrices[token];
    if (feeds[token].feed != IChainlinkFeed(address(0))) {
        uint price = feeds[token].feed.latestAnswer();
        require(price > 0, "Invalid feed price");
        // 规范化价格 ...
    }
}

正如 OpenZeppelin 所记录的,Chainlink 多重签名可以阻止对价格数据流的访问。当 latestAnswer() 回滚时,所有依赖于 viewPrice() 的函数也会回滚——在整个协议中造成级联 DoS。

建议

将预言机调用包装在 try/catch 块中,并添加回退逻辑:

1try feeds[token].feed.latestAnswer() returns (int256 price) {
2    // 正常处理价格
3} catch Error(string memory) {
4    // 回退到替代价格来源或缓存值
5}

由恶意接收者引起的 DoS

漏洞:攻击者控制的 supportsInterface 回滚关键函数

在这种情况下,任何人都可以购买留置权代币,并通过 buyoutLien 将其发送到任意接收者合约:

1function buyoutLien(ILienToken.LienActionBuyout calldata params) external {
2    // ... 验证逻辑 ...
3    _transfer(ownerOf(lienId), address(params.receiver), lienId);
4}

恶意实体可以购买一个小的留置权,并将其路由到一个实现 supportsInterface() 并故意 revert 的合约。协议在多个关键路径中调用 supportsInterface,这使得攻击者能够控制这些执行流程。

攻击面 1 —— 阻止 endAuction():阻止抵押品释放给拍卖赢家。

1for (uint256 i = 0; i < liensRemaining.length; i++) {
2    ILienToken.Lien memory lien = LIEN_TOKEN.getLien(liensRemaining[i]);
3    if (
4        PublicVault(LIEN_TOKEN.ownerOf(i)).supportsInterface(
5            type(IPublicVault).interfaceId
6        ) // 攻击者在此处回滚
7    ) {
8        PublicVault(LIEN_TOKEN.ownerOf(i)).decreaseYIntercept(lien.amount);
9    }
10}

攻击面 2 —— 阻止 liquidate():阻止清算开始,允许不健康的头寸持续存在。

攻击面 3 —— 阻止 _payment():在遍历开放留置权时阻止任何留置权支付成功。

建议

将外部 supportsInterface 调用包装在带有 gas 限制的低级调用中,或者使用 try/catch 模式来防止单个恶意接收者阻止协议范围的操作。


主要收获

DoS 向量 根本原因 影响
Underflow (下溢) 减法前缺少边界检查 金库存款/提款被冻结
Gas limit (Gas 限制) 无界数组迭代 用户资金被永久锁定
nonReentrant (不可重入) 内部调用链触发自身保护 核心质押函数回滚
External call (外部调用) 预言机故障无回退机制 所有依赖价格的函数被冻结
Malicious receiver (恶意接收者) 未经检查的外部接口调用 拍卖、清算、支付被阻止

阅读审计报告是了解新漏洞模式的最佳方式之一。你可以使用 Solodit 等平台浏览竞争性审计平台的发现,并研究在生产协议中发现的真实漏洞。


联系我们

在 Zealynx,我们专注于识别复杂的漏洞,例如可能冻结整个协议的拒绝服务攻击。无论你是正在构建 DeFi 协议,准备进行审计 (https://www.zealynx.io/services/smart-contract-audits),还是希望强化智能合约以抵御这些攻击向量,我们的团队都随时准备提供帮助——**联系我们** (https://www.zealynx.io/quote?utm_source=blog&utm_medium=content&utm_campaign=dos-attacks-smart-contracts)。

希望通过更多此类深入分析保持领先?订阅我们的新闻通讯 (https://www.zealynx.io/blogs/dos-attacks-smart-contracts#newsletter),确保你不会错过未来的洞察

FAQ: 智能合约上的拒绝服务攻击

  1. 在智能合约的上下文中,什么是拒绝服务攻击?

智能合约上的 DoS 攻击是任何阻止合法用户按预期与合约交互的漏洞。与通过流量淹没服务器的传统 DoS 攻击不同,智能合约 DoS 攻击利用逻辑缺陷、gas 限制或外部依赖项,使函数永久回滚或调用成本过高。

  1. 下溢如何导致 DoS?

在 Solidity 0.8+ 中,算术下溢会自动回滚交易。如果 borrowCap - borrowed 这样的减法因 borrowed 超过上限而产生负结果,那么每次调用该函数都会回滚。当此函数处于关键路径(如存款或提款)时,整个功能将变得无法使用。

  1. 为什么无界循环在智能合约中很危险?

循环中的每个操作都消耗 gas,而 Ethereum 区块有 gas 限制。如果一个循环迭代一个随着每次用户操作而增长的数组,最终单个交易的 gas 成本将超过区块 gas 限制。此时,该函数将永远无法完成,永久锁定它控制的任何资金或状态。

  1. nonReentrant 修饰符本身会导致问题吗?

是的。nonReentrant 修饰符使用一个锁,防止同一个合约在执行期间被重入。如果一个内部调用链通过了两个都带有此修饰符的函数,第二个函数将回滚,因为锁已经被持有。这是一个设计缺陷,而不是重入攻击。

  1. 协议如何防范基于预言机的 DoS?

协议应将预言机调用包装在 try/catch 块中,并实现回退机制,例如缓存价格、二级预言机(如 Uniswap TWAP)或断路器,以便在受影响的函数上优雅地暂停而不是回滚。永远不要假设外部依赖项将始终可用。

  1. 恶意接收者攻击的危险性何在?

它很危险,因为攻击者只需购买极少的留置权,就能获得阻止协议范围操作(如拍卖、清算和支付)的能力。攻击面被放大,因为协议在多个关键代码路径中检查留置权所有者的 supportsInterface,允许单个恶意合约同时对多个独立功能执行 DoS。

词汇表

术语 定义
Denial of Service (DoS) 一种通过导致智能合约函数持续回滚或超出 gas 限制而使其无法使用的攻击。
Underflow (下溢) 一种算术错误,其中减法产生负结果,导致 Solidity 0.8+ 中自动回滚。
Reentrancy Guard (重入保护) 一种修饰符模式(nonReentrant),它使用互斥锁防止函数在执行期间再次被调用。
Gas Limit (Gas 限制) 单个交易或区块在 Ethereum 上可以消耗的最大 gas 量,为每笔交易的计算设定了上限。
Oracle (预言机) 一种外部数据源(如 Chainlink),它向链上智能合约提供链下信息,例如资产价格。

查看完整词汇表 →

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

0 条评论

请先 登录 后评论
zealynx
zealynx
江湖只有他的大名,没有他的介绍。