CTF Challenge #1: Unstoppable

post image
  • 借贷池有 100w DVT 代币

  • 免费提供闪电贷

  • 你账户有100 DVT

  • 找到一个方法攻击合约,使合约不能提供闪电贷服务

//UnstoppableLender.sol
pragma solidity ^0.6.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/math/SafeMath.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

interface IReceiver {
    function receiveTokens(address tokenAddress, uint256 amount) external;
}

contract UnstoppableLender is ReentrancyGuard {
    using SafeMath for uint256;

    IERC20 public damnValuableToken;
    uint256 public poolBalance;

    constructor(address tokenAddress) public {
        require(tokenAddress != address(0), "Token address cannot be zero");
        damnValuableToken = IERC20(tokenAddress);
    }

    function depositTokens(uint256 amount) external nonReentrant {
        require(amount > 0, "Must deposit at least one token");
        // Transfer token from sender. Sender must have first approved them.
        damnValuableToken.transferFrom(msg.sender, address(this), amount);
        poolBalance = poolBalance.add(amount);
    }

    function flashLoan(uint256 borrowAmount) external nonReentrant {
        require(borrowAmount > 0, "Must borrow at least one token");

        uint256 balanceBefore = damnValuableToken.balanceOf(address(this));
        require(balanceBefore >= borrowAmount, "Not enough tokens in pool");

        // Ensured by the protocol via the `depositTokens` function
        assert(poolBalance == balanceBefore);
        
        damnValuableToken.transfer(msg.sender, borrowAmount);
        
        IReceiver(msg.sender).receiveTokens(address(damnValuableToken), borrowAmount);
        
        uint256 balanceAfter = damnValuableToken.balanceOf(address(this));
        require(balanceAfter >= balanceBefore, "Flash loan hasn't been paid back");
    }

}
//ReceiverUnstoppable.sol
pragma solidity ^0.6.0;

import "../unstoppable/UnstoppableLender.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract ReceiverUnstoppable {

    UnstoppableLender private pool;
    address private owner;

    constructor(address poolAddress) public {
        pool = UnstoppableLender(poolAddress);
        owner = msg.sender;
    }

    // Pool will call this function during the flash loan
    function receiveTokens(address tokenAddress, uint256 amount) external {
        require(msg.sender == address(pool), "Sender must be pool");
        // Return all tokens to the pool
        require(IERC20(tokenAddress).transfer(msg.sender, amount), "Transfer of tokens failed");
    }

    function executeFlashLoan(uint256 amount) external {
        require(msg.sender == owner, "Only owner can execute flash loan");
        pool.flashLoan(amount);
    }
}
  • flashLoan方法

  • 防重入

  • 验证借 > 0

  • assert(poolBalance == balanceBefore); 重点

  • poolBalance 是个记账的数据,可以通过 闪电贷回调去调用 deposite 往里面存钱

  • deposite 限制了 transferform 需要有approve

  • 可通过借贷直接把钱借出来然后通过抵押还回去,数量变化,导致 poolBalance 》 池子的金额,是闪电贷没法工作

UnstoppableLender.sol 合约。

通过观察 UnstoppableLender.sol 合约,我们知道,该合约提供了2个功能,分别是

  • deposit

  • flashLoan

分别对应充值和闪电贷功能。其中闪电贷功能要求用户在一笔交易内同时完成代币借出和归还操作。在 #44 行对完成闪电贷之后的余额进行了校验。而充值功能是简单的代币转移操作,通过 transferFrom 函数把对应的代币打入到合约当中,充当闪电贷的资金。deposit 函数逻辑简单,使用的是 transfrFrom 函数,这种情况下,由于没有检验在 transferFrom 函数后合约是否收到了参数指定的 amount 值,在遇到使用假充值写法的代币时,容易造成代币假充值问题。但是通过观察发现,DVTToken 本身并不存在假充值写法,所以不存在假充值问题。同时,该问题并不能使合约停止运行,所以我们把分析重点放在了 flashLoan 函数上。

通过分析 flashLoan 函数,我们不难发现 flashLoan 函数逻辑相对简单。函数逻辑做了以下事情 - 在用户进行闪电贷之前,先获取一遍 DVT 代币的余额 - 检查合约余额是否大于当前借贷金额 - 检查 poolBalance 是否等于通过 DVT 代币合约的 balanceOf 函数获取的值,保证代币是通过 deposit 函数充值的 - 向用户发放闪电贷资金,并调用对应用户指定合约(borrower)的 receiveToekens 函数 - 检查闪电贷之后用户是否归还余额,并结束闪电贷流程

FlashLoan 的流程咋一看是没有问题的,但是通过仔细观察可以发现。UnstoppableLender 合约限制了代币只能通过 deposit 合约提供闪电贷资金,但是实际上,我们是可以通过直接使用 DVT 代币合约的 transfer 函数直接向 UnstoppableLender 合约进行打币。绕过 deposit 函数的限制。通过这个操作,能打破 flashLoan 函数中关于 poolBalance 的检查,因为直接通过 transfer 进行代币充值,导致 poolBalance 没有发生变化,但是 UnstoppableLender 通过 DVT 合约获取的代币余额是发生变化了。由于两值不相等。导致 #37 行的 assert 检查无法通过。这样就直接把整个借贷池的借贷功能变得不可用了。