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 areceiveTokens()
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 theonlyGovernance
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 thantotal token supply / 2
. This means the caller must own at least1 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 theactionId
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)
- It calls
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 theflashLoan()
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 thereceiver
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.
TheweiAmount
can be 0 since it's not needed by thedrainAllFunds()
function.
The return value is stored in a storage parameteractionId
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 callexecuteAction()
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.