Write-ups and lessons learned from Damn Vulnerable #DeFi
TL;DR;
- I have solved all of the Damn Vulnerable #DeFi challenges by Zeppelin. Here I present the write-ups and lessons learned from the vulnerable contracts.
- Besides, I have added new category to Smart Contracts Security Verification Standard called Decentralized Finance (DeFi) basing on the challenges and recent hacks. Check this out!
- Here is my fork of Zeppelin’s Damn Vulnerable #DeFi with write-ups and lessons: https://github.com/damianrusinek/damn-vulnerable-defi.
Damn Vulnerable #DEFI
A couple of weeks ago @OpenZeppelin team (thanks @tinchoabbate!) has created a set of vulnerable smart contracts that implement some constructions used by #DeFi (e.g. flash loans, governance, on-chain oracles, lending pools).
Here is the list of write-ups for all challenges:
Spoiler alert!
The following write-ups show step by step how to solve the challenges. If you are looking only for hints and want to try to hack them by yourself avoid the Exploit sections.
Challenge #1: Unstoppable
This first challenge is to stop the pool from offering flash loans. Simply, the challenge is to DoS the contract.
All further call to borrow the token should be blocked. In order to block it the pool must revert on any of the requires in the flashLoan function.
Here is the code of the pool’s contract:
There are two important things here to notice:
- The pool keeps its balance in a local variable poolBalance.
- The mentioned variable is used in an assert in the flashLoan function: assert(poolBalance == balanceBefore)
BTW, why would the pool have additional variable to track its balance while it can check its token balance at any moment?
Exploit
In order to block the pool, we must make the expression poolBalance == balanceBefore become false. The balanceBefore variable is the pool’s token balance retrieved using ERC20 balanceOf function whenever someone borrows tokens.
On the other hand, the poolBalance variable tracks the same token balance of the pool whenever someone deposits tokens to the pool using depositToken function. That is why the comment says the mentioned assertion is ensured by the depositTokens function.
If only there was any way to change the pool’s token balance without calling the depositTokens function…
Oh wait! We can call the transfer function directly on the token’s contract to bypass the increase of poolBalance variable (in depositTokens function).
The exploit is quite simple:
Lesson learned
Do not assume that the contract’s token balance can be changed only with contracts’s custom functions. Remember that it is possible to change the token balance of any address by calling the token’s function directly.
It is also worth to remember that the balance can be changed using the selfdestruct function of other contract.
Challenge #2: Naive Receiver
In this challenge there is a pool that lends ETH and a victim contract who has 10 ETH and is capable of receiving flash loans from the lender. The goal of this challenge is to drain all ETH from the victim.
The only way to transfer ETH from the victim is to make it call a transfer function. It happens in the last operation of the victim’s receiveEther function:
In order to drain victim’s ETH:
- I have to call the receiveEther function of the victim contract, however the victim accepts calls only from the lending pool.
- The lending pool’s flashLoan function accepts not only the amount to be borrowed but also the address of the borrower.
Do you get the idea?
Exploit
Basically, we can make the lending pool to call the receiveEther function of any contract that implements such function, including the victim.
When the function is called, the victim returns borrowed (on their behalf) ETH plus the fee. The fee is fixed, so I can borrow 0 ETH.
As the victim has 10 ETH and the fee is 1 Ether I could send 10 transactions to drain victim’s balance. However, the nice to have of the challenge is to do it in one transaction, so I have created a simple attacker contract with the following function:
It just calls the flashLoan function 10 times in one transaction. Now, to run the exploit I have to deploy the contract and send one transaction:
Lesson learned
When building a lending pool, do not allow to call any function of any contract from the pool contract.Specify the function to be called on the receiver contract and, if it is possible, define a list of contracts that can be called — usually the msg.sender should be called back.
However, some lending pools allow borrowers to transfer the borrowed amount to any contract and execute its receiving function. In such situation, the lending pool should clarify that the receiver’s function which handles borrowed ETH or tokens can be called only by the pool and within a process initiated by its owner or other trusted source (e.g. multisig).
Challenge #3: Truster
This challenge is another one where we have to steal all the ETH from the pool while starting with zero balance. No special information is added in the description so we have to analyze the code.
The lender pool contract has only one non-reentrant function — flashLoan. However, there is one important difference from the other pools here:
- There are 4 parameters in the function and two of them (target and data) specify the contract and function which is called by the pool when borrowing the token.
- Basically, I can make the lender contract to call any function of any contract.
Can you think of any dangerous contract and function?
Exploit
Indeed, I can call any function of the token contract. For example, I could call the transfer function, but then the loan would not be payed back and the transaction would be reverted.
However, there is one special function that allows to transfer token later in another transaction. That is the approve function. I am going to make the pool approve a future transfer to my address. Check out the exploit code:
- First we generate the call data parameter in order to call the approve function with the address of attacker and the balance of the lender (1000 tokens).
- Then, we call the flashLoan function without borrowing any token, because we would not be able to return it as we tell the lender to call the function of the token contract that we do not control.
- After the flash loan transaction is finished, the token contract allows us to transfer the tokens. Therefore, we call the transferFrom function to transfer all tokens from the lender contract to the attacker adddress.
Lesson learned
This challenge is similar to the previous one, except that here we are attacking the pool, not the receiver. The lesson learned here is the same — the pool must not allow the borrower to call any function and any contract from the pool’s contract.
The function to be called by the pool must be predefined and if it is possible, a subset of trusted contracts to be called should be defined. Usually, the sender (borrower) contract is the one to be called back.
Challenge #4: Side Entrance
In this challenge we have to steal ETH from another lending pool smart contract which has 1000 ETH and we have nothing.
The description mentions that the smart contract has functions such us deposit and withdraw. This means that we can manipulate the ETH balance of the lender.
There are two important things here to notice:
- The withdraw and deposit functions are not non-reentrant.
- The contract verifies whether the loan was paid back by checking the ETH balance of itself.
Exploit
In order to steal pools’s ETH I have to borrow all its ETH, deposit it (when processing the flash loan) and later withdraw it. Check out the exploit code:
First, my SideEntranceAttacker contract must implement the IFlashLoanEtherReceiver interface that specifies one function — execute. The only thing this function does is to deposit all the Ether it receives.
The right exploit starts by calling the attack function of the deployed SideEntranceAttacker contract:
- The first step of this function is to check the lender’s balance and call the flashLoan function.
- Then, the lender calls my execute function which sends back all Ether to the lender using the deposit function. The flashLoan function will be successful, because the expression address(this).balance >= balanceBefore is true.
- The last step is to withdraw all deposited Ether and send it to the attacker’s address.
Lesson learned
This example shows that you must not allow to change the lender’s balance when processing the loan when the lender verifies the correctness of the loan repayment on the base of its balance.
It can be achieved by specifying all functions that change the balance as non-reentrant.
Challenge #5: The Rewarder
There is a pool that send rewards to those who deposit their token to increase the liquidity of the pool. The rewards are distributed every 5 days. Our goal is to deprive the other users, who already deposited their liquidity tokens, of the rewards.
The description mentions that there is another pool with a lot of tokens that offers flash loans.
Here is the function that distributes the rewards and can be called by anyone to get their reward:
Things to notice:
- The reward token has as many decimals as ETH— 18.
- The amount of reward token to get is the percent value of deposited tokens.
- The contribution (variable reward) is calculated as a percent value without decimal precision.
- The amount of reward token to be transferred (variable rewardInWei) equals the percent value multiplied by 10**18.
A hint: whenever you see a division operations, double check the possible rounding errors!
Exploit
When the attack starts 4 users have already deposited a total of 400 tokens and got 25 reward tokens each in the previous round.
If I deposited another 100 tokens I would get 20 reward tokens — same as every other user, except that they would have 25+20=45 tokens after the second round. In order to deprive them of the reward tokens in the next round, and get as much as possible of all 100 tokens distributed in one round, I have to deposit so many tokens that the rewards for 100 deposited token (by other users) would become 0.
The reward is calculated as follows (all operations are done within the integer type, not float):
(amountDeposited * 100) / totalDeposits
I can borrow the maximum of 1000000 tokens in a flash loan from another pool. Here is the simple code of the attack:
I basically:
- deposit borrowed tokens (the distributeTokens function is called automatically when the deposit function is called) and then …
- withdraw them.
All done in one flash-loan transaction.
Let’s check what is my reward:
((1000000 * 100) * 10**18) / ((1000000 + 400) * 10**18) = 99
I would get the 99 of 100 total tokens distributed. Let’s now check how many reward tokens would get each other user who deposited 100 liquidity tokens:
((100 * 100) * 10**18) / ((1000000 + 400) * 10**18) = 0
You may ask why I include my deposit to calculate the rewards of other users while it is already withdrawn.
The catch is that the liquidity token snapshot is taken every 5 days (for each round) when someone deposits some tokens. It means that my deposited loan is included until the next round is started. That is why it is taken into account when the other users want to get their rewards in further transactions.
And remember that I can repeat this attack for every round… ;)
Lesson learned
This example of vulnerable contract shows that it is very important to perform the math operations (especially the division operation) with the highest possible precision.
In case of a token of the same number of decimals as ETH — 18 — all operations should be performed including 18 decimals. In this particular contract the share was calculated without decimals at all.
Let’s see what would be the reward of other users if the contract calculated it with the 18 decimals precision (in wei) using the following formula:
uint256 rewardInWei = (amountDeposited * 100 * 10 ** 18) / totalDeposits;((100 * 10**18 * 100) * 10**18) / ((1000000 + 400) * 10**18) = 9996001599360255 wei tokens = 0.009996001599360255 tokens
As you can see it is not zero anymore. It is small amount but it reflects the real share of the user.
Additionally, to mitigate the momentary fluctuations in shares that impact the distribution of rewards, the rewarder contract should not allow to calculate and distribute rewards within the same function call that deposits tokens (the distribution function should be defined as non reentrant).
Challenge #6: Selfie
The goal of this challenge is to steal all DVT tokens from the pool contract. The challenge seems to be quite easy because the contract has a drainAllFunds function that sends all its tokens to the sender. The trick is that it is protected by onlyGovernance modifier that requires the transaction to be sent by the governance contract.
Basically, this challenge is an example of a governance mechanism that needs to be abused. The idea of the governance in smart contracts is to decentralize the important functions (e.g. functions that update the contract). The simplest implementation uses voting — when the proposed update is accepted by majority it is applied.
Let’s check the code of governance contract. It has a queueAction function that allows anyone to queue and later call a function on behalf of the governance contract.
The function is protected with two require statements. I will start with the second one because it is simpler. It does not allow proposals that call a function on the governance contract itself.
The first statement calls the _hasEnoughVotes function which makes sure that the queued proposal is proposed by someone who has enough votes. The function simply checks whether you, a proposal submitter, have more than a half of the governance tokens.
Things to notice:
- The drainAllFunds allows to transfer all tokens from the pool and is protected by onlyGovernance modifier.
- The onlyGovernance modifier makes sure that the function in called by the governance contract only.
- The governance contract allows anyone who has more than a half of governance tokens to queue and call a function to be called by the governance contract.
- There is a pool that lends governance tokens.
Can you spot the attack-chain?
Exploit
The scenario of the attack is following and executed withing one flash-loan transaction:
- Borrowing more governance tokens than a half of its current supply from the flash loan pool. This will allow to bypass the _hasEnoughVotes requirement.
- Queue a drainAllFunds(address) function that will transfer all tokens to the attacker.
- Pay back the flash loan.
After that I will be able to execute queued function in another transaction.
Let’s check the exploit contract:
The attack is started with the attack function. The supply of the governance token (called liquidity token by the pool) is 2kk tokens. I borrow all the tokens that the pool has — 1.5kk tokens — but any amount grater than 1kk would be enough.
Later, in the receiveTokens, called back by the pool, I create a snapshot in the governance token to make sure that the borrowed tokens are included in the current state. Next, I queue a function that calls the drainAllFunds and sends all tokens to the owner of the attacker contract — that is me. Finally, I pay off the loan.
After the function is queued I have to wait 2 days until it can be executed and the execute it to drain all tokens as presented on the listing below:
/* Wait until the queued function call can be executed */
await time.increase(time.duration.days(2));/* Execute the function call and drain all tokens */
await this.attContract.drain({ from: attacker });
Lesson learned
The governance can be tricky as shown in this example. There were some security mechanisms, such as a 2 days delay for the execution of queued function calls. Also, the idea to require the majority of votes to accept the proposal seems correct.
However, whenever you build a governance contract you must include a potential threat coming from the flash loans. Your governance token could be available in large amount from the lenders. If that is the case, someone could borrow enough tokens (for a relatively small fee) to validate their malicious proposal.
One possible mitigation to such threat is to require the process of depositing governance tokens and proposing a change to be executed in different transactions included in different blocks. That would make use of flash loans impossible.
Challenge #7: Compromised
This time the goal is to hack an exchange that is selling (absurdly overpriced) collectibles called DVNFT (non fungible token) and steal all ETH.
The exchange gets the price of DVNFT using an on-chain oracle which is controlled by three different trusted sources (the price is the median of the sources’ prices).
Here is the code for price calculation (in the oracle contract):
The only way to update the price is to call the following postPrice function, but it is callable only by the trusted source:
However, what is also given is a strange response from one of the web services with the following data:
4d 48 68 6a 4e 6a 63 34 5a 57 59 78 59 57 45 30 4e 54 5a 6b 59 54 59 31 59 7a 5a 6d 59 7a 55 34 4e 6a 46 6b 4e 44 51 34 4f 54 4a 6a 5a 47 5a 68 59 7a 42 6a 4e 6d 4d 34 59 7a 49 31 4e 6a 42 69 5a 6a 42 6a 4f 57 5a 69 59 32 52 68 5a 54 4a 6d 4e 44 63 7a 4e 57 45 354d 48 67 79 4d 44 67 79 4e 44 4a 6a 4e 44 42 68 59 32 52 6d 59 54 6c 6c 5a 44 67 34 4f 57 55 32 4f 44 56 6a 4d 6a 4d 31 4e 44 64 68 59 32 4a 6c 5a 44 6c 69 5a 57 5a 6a 4e 6a 41 7a 4e 7a 46 6c 4f 54 67 33 4e 57 5a 69 59 32 51 33 4d 7a 59 7a 4e 44 42 69 59 6a 51 34
Things to notice:
- The DVNFT price can be manipulated by the oracle contract.
- Only the trusted sources can post new price in the oracle and thus manipulate it… but they are trusted.
- The price is calculated as the median value of three prices from three different sources therefore we would have to impersonate at least two sources.
- The format of leaked data (2 items!) is very similar and well-known. Can you recognize it?
Exploit
Let’s start with the leaked data. When you look closer you will see that all these bytes (2 * 88 bytes) are hexadecimals for printable ASCII characters — there are between 0x20 and 0x7e (hexadecimal).
Let’s decode them:
>>> print(‘4d 48 68 6a 4e 6a 63 34 5a 57 59 78 59 57 45 30 4e 54 5a 6b 59 54 59 31 59 7a 5a 6d 59 7a 55 34 4e 6a 46 6b 4e 44 51 34 4f 54 4a 6a 5a 47 5a 68 59 7a 42 6a 4e 6d 4d 34 59 7a 49 31 4e 6a 42 69 5a 6a 42 6a 4f 57 5a 69 59 32 52 68 5a 54 4a 6d 4e 44 63 7a 4e 57 45 35’.replace(‘ ‘, ‘’).decode(‘hex’))MHhjNjc4ZWYxYWE0NTZkYTY1YzZmYzU4NjFkNDQ4OTJjZGZhYzBjNmM4YzI1NjBiZjBjOWZiY2RhZTJmNDczNWE5>>> print(‘4d 48 67 79 4d 44 67 79 4e 44 4a 6a 4e 44 42 68 59 32 52 6d 59 54 6c 6c 5a 44 67 34 4f 57 55 32 4f 44 56 6a 4d 6a 4d 31 4e 44 64 68 59 32 4a 6c 5a 44 6c 69 5a 57 5a 6a 4e 6a 41 7a 4e 7a 46 6c 4f 54 67 33 4e 57 5a 69 59 32 51 33 4d 7a 59 7a 4e 44 42 69 59 6a 51 34’.replace(‘ ‘, ‘’).decode(‘hex’))MHgyMDgyNDJjNDBhY2RmYTllZDg4OWU2ODVjMjM1NDdhY2JlZDliZWZjNjAzNzFlOTg3NWZiY2Q3MzYzNDBiYjQ4
Ok, next encoding — this time BASE64.
>>> import base64>>> print(base64.b64decode(‘MHhjNjc4ZWYxYWE0NTZkYTY1YzZmYzU4NjFkNDQ4OTJjZGZhYzBjNmM4YzI1NjBiZjBjOWZiY2RhZTJmNDczNWE5’))0xc678ef1aa456da65c6fc5861d44892cdfac0c6c8c2560bf0c9fbcdae2f4735a9>>> print(base64.b64decode(‘MHgyMDgyNDJjNDBhY2RmYTllZDg4OWU2ODVjMjM1NDdhY2JlZDliZWZjNjAzNzFlOTg3NWZiY2Q3MzYzNDBiYjQ4’))0x208242c40acdfa9ed889e685c23547acbed9befc60371e9875fbcd736340bb48
This time we got two numbers in hexadecimal format, both 32 bytes long. What is 32 bytes long that I could be interested in? Private keys! And I have two — enough to manipulate the price of DVNFT token.
Now, the scenario of the attack is following:
- Impersonating the two trusted sources (using leaked keys) and set the prices of DVNFT to 1 ETH.
- Buying one DVNFT token for 1 ETH from the attacker account. The exchange will get the median price which is controlled by the attacker and is now 1 ETH.
- Again impersonating the two trusted sources (using leaked keys) and set the prices of DVNFT to 10001 ETH — the total balance of the exchange.
- Selling one DVNFT token 10001 ETH from the attacker account. The exchange will transfer all its ETH to the attacker.
This attack does not need any smart contract to exploit. The main problem here are the leaked private keys because they allow to impersonate the trusted sources.
Knowing the private key of the Ethereum account one can easily send a transaction on its behalf using the following code:
Lesson learned
The most important lesson here is to be careful who you trust because in this case the trusted sources had the full control over the price and the exchange had no way to mitigate that risk.
It is important to monitor the prices and have a possibility to pause the oracle functions and fallback to the previous price in case any attack is detected.
Also there should be thresholds defined that would block the big price changes, e.g. the one in the attack from 999 ETH to 1 ETH, and the sources should have additional limitations, e.g. one price update per day.
Challenge #8: Puppet
This challenge is an example of vulnerable lending pool that lends a DVT token available on the well-known Uniswap exchange. In order to borrow DVT you must deposit twice as much ETH first. The goal of the challenge is to get as many DVT tokens from the pool as possible without loosing any ETH.
Here is the borrow function of the lending pool and the require statement that makes sure you deposit enough ETH:
The pool uses the computeOraclePrice function to get the current token price and calculate the required deposit:
Things to notice:
- This is not a flash loan, whatever I borrow stays on my address (as long as I deposit enough ETH).
- As the DVT is available on the Uniswap exchange I can get the token for ETH and the other way.
- By manipulating the token price (in ETH) on the lending pool I could borrow many tokens for a little (or even a zero) ETH.
- The lending pool calculates the token price using the token and ETH balances on the Uniswap exchange. Can you see how to abuse it?
Exploit
In order to borrow tokens without loosing any ETH I have to decrease the deposit value, ideally make it zero and borrow tokens for free. Fortunately (for me, not the pool), the deposit value is calculated as the amount multiplied by twice the current price and the current price is calculated as the division of Uniswap’s ETH and token balances.
The arithmetic operations (including division) are protected with SafeMath. It detects and reverts overflows and underflows but does not protected from all arithmetic bugs. One of them appears when the contract’s creator forgets about the fact that division is actually an integer division. To recall, it means that hen you divide A by B while A is smaller than B, the result is zero!
With that in mind, the scenario of the attack is following:
- At the beginning the token and ETH balance in Uniswap are both qual to 10 and the token price is 1 (=10/10).
- I am buying one ETH for some of my tokens on the Uniswap exchange.
- Now the ETH balance of Uniswap is 9 and token balance is greater than 10. The token price (calculated by the computeOraclePrice function) is 0, because 9/10 is 0.
- I borrow all DVT tokens from the pool without depositing any ETH. Profit!
Lesson learned
The most important lesson here is the same as in one of the previous challenges — remember that the division in smart contracts is an integer division, even the SafeMath’s one.
The multiplication operation should proceed the division operation. Check this example:
9 / 10 * 100 = 0 , because 9 / 10 = 0,9 * 100 / 10 = 90.
Also, make sure that when calculating conversion price (e.g. price in ETH for selling a token), the numerator and denominator are multiplied by the reserves. The numerator should be multiplied by the output reserve (the ETH balance in above example) and denominator should be multiplied by the input reserve (the token balance).
See the code below (based on the getInputPrice function from the Uniswap protocol):
function computeRequiredDeposit(uint256 borrowAmount, uint256 inputReserve, uint256 outputReserve) public view returns (uint256) {
require(inputReserve > 0 && outputReserve > 0, “INVALID_VALUE”);
uint256 inputAmountWithFee = borrowAmount.mul(997);
uint256 numerator = inputAmountWithFee.mul(outputReserve);
uint256 denominator = inputReserve.mul(1000).add(inputAmountWithFee); return (numerator / denominator).mul(2);
}
This function should be called in the borrow function as follows:
uint256 depositRequired = computeRequiredDeposit(borrowAmount,token.balanceOf(uniswapOracle),uniswapOracle.balance);
However, in this particular example (calculating the required deposit basing on the Uniswap market) the call to the getTokenToEthInputPrice function from the Uniswap exchange would be the easiest way to make it safe.
New category in #SCSVS
Based on the challenges and recent hacks in #DeFi I have started a new category in Smart Contracts Security Verification Standard called Decentralized Finance (DeFi).
It contains the security requirements specific to the mechanisms used by the DeFi applications. Check out the list of new requirements!
To sum up…
I hope these write-ups and some lessons will be helpful :)
Remember to check out SCSVS with new #DeFi requirements. If you liked this and want to see more follow me on Twitter (@drdr_zz).
Also, here is my fork of OpenZeppelin’s Damn Vulnerable #DeFi with write-ups -> https://github.com/damianrusinek/damn-vulnerable-defi.