Selfie - Damn Vulnerable DeFi #06

Selfie - Damn Vulnerable DeFi #06

Analysis and PoC of Damn Vulnerable DeFi Level 06 - Selfie

Objectives

There's a pool as always, and it offers flash loans of DVT tokens. There's also a governance mechanism that controls the pool.

The initial token supply is 2 million, and the pool has 1.5 million DVT. We have 0. Our goal is to drain the pool. Let's get started.

Smart Contract Analysis

SelfiePool.sol

The DVT token being used is an ERC20 Snapshot token that can be used to take snapshots at a certain point in time to record the balances.

There are two important functions here:

  • flashLoan() is the same thing that we have seen in the last few contracts, users can take flash loans for free, it calls a receiveTokens() function in their contract to handle the tokens and repay them back to the pool.

  • drainAllFunds() drains all the tokens from the pool which we need to do, but notice the onlyGovernance modifier, it only allows the Governance contract (address(governance)) to call the function. This gives us a hint that we somehow need to make the Governance contract call this function for us.


SimpleGovernance.sol

Let's now take a look at the Governance contract. There are two external functions that can be called by anyone:

  • queueAction() : This function is used to propose actions/changes to the contract if enough votes allow them to. to check enough votes, the function calls:

    • _hasEnoughVotes() : This function fetches the balance of the last snapshot assuming a snapshot is already taken, calculates the total token supply at the last snapshot, and compares both of them. It returns true if the caller's balance is greater than total token supply / 2. This means the caller must own at least 1 million + 1 token to bypass this check. (Since the token's initial supply is 2 million)
  • The data is stored in a struct and an actionId is returned.

  • executeAction() : Once the action has been proposed and stored, this function is used to execute the action based on the actionId that was returned.

    • It calls _canBeExecuted() which makes sure that the action has not already been executed, and that there's a sufficient delay after proposing the action. (2 days)
  • Once all the validations pass, it makes a functionCallWithValue() with the bytes data that we stored in the action along with some Wei to the receiver's address which we also stored in the action.

The Exploit

The above analysis shows that if we can get our action to store and execute, we should be able to make the governance contract make an external call to the SelfiePool and make it drain the tokens for us. We can always take a flash loan from the pool to get enough tokens to pass the validation in _hasEnoughVotes(). Let's write the attacker's contract.

After initializing all the addresses, we have defined 3 functions:

  • attack() : This is responsible for triggering the flashLoan() with at least 1 million + 1 token. We'll just take a loan for the complete amount in the pool, i.e., 1.5 million DVT.

  • The flow comes back to our other function, receiveTokens() where the attack starts:

    • ITokenSnapshot(token).snapshot(); : We have to first take a snapshot because otherwise, the transaction will fail inside _hasEnoughVotes() since it needs an already created snapshot to fetch balances from.

    • We are calling gov.queueAction with the receiver as the pool's address, since it'll be making a function call to the pool to drain the tokens. The second parameter is the bytes encoded data: abi.encodeWithSignature("drainAllFunds(address)", attacker). It contains the encoded function signature with the argument as the attacker's address since we need to get all the funds to our attacker from the pool.
      The weiAmount can be 0 since it's not needed by the drainAllFunds() function.
      The return value is stored in a storage parameter actionId because it'll be needed again to execute the action.

    • Once we are able to propose the action, we transfer the tokens back to the pool on Line 54 since we took a flash loan.

  • After 2 days have passed, we can make a call to the execute() function to call executeAction() which executes the action, drains all the pool funds, and transfers them to the attacker's address.

Here's how the test case looks:

We deploy the attacker's contract and call the attack() function to borrow TOKENS_IN_POOL (1.5 million) DVT tokens.

We then use evm_increaseTime to fast forward 2 days and then call the execute() function to finish the exploit.

Run the script using yarn run selfie and the test case will pass.

Key Takeaways

There are multiple issues with these contracts. The key ones are:

  • Critical functions such as voting proposals should account for tokens coming through flash loans.

  • The function to create snapshots should be behind proper access controls.

  • Creating your own Governance requires a lot of thorough testing and diligence which is why it is recommended to use OpenZeppelin's Governor.