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
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
onlyGovernancemodifier, 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.
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 tokento bypass this check. (Since the token's initial supply is 2 million)
The data is stored in a struct and an
executeAction(): Once the action has been proposed and stored, this function is used to execute the action based on the
actionIdthat 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 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
receiveras 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.
weiAmountcan be 0 since it's not needed by the
The return value is stored in a storage parameter
actionIdbecause 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.
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.