Beating The Damn Vulnerable DeFi CTF (Part 1/2)

Jat
14 min readJul 22, 2022

--

Introduction

“Ethereum hacker” image generated by craiyon (an AI model drawing images from any prompt!)

Solidity is an object-oriented, statically typed, high-level programming language for implementing smart contracts. It is inspired from JavaScript and C++ and is by far the most widely used language for developing smart contracts on Ethereum and other EVM-compatible blockchains.

For an experienced developer, it is easy to learn to code smart contracts in Solidity in a few days. However, despite its apparent simplicity, it is actually one of the hardest forms of programming. Indeed, due to the immutability of the blockchain, you only get one chance. A smart contract is a financial product at its core, which has to be bug free from the get go, or users are at risk of losing funds, as transactions are irreversible on a public blockchain. Hence, the security issue is of crucial importance. Another issue is that smart contracts should preferably be optimized to minimize gas consumption which adds development complexity. In this blog post however we will be focused only on the security issue.

Despite the availability of a plethora of automated and semi-automated tools for detecting solidity bugs, such as tests (Test Driven Development is the recommended process for developing smart contracts), Slither (static analysis), Echidna (fuzzing), Manticore (symbolic execution) and the K Framework or Certora (formal verification), manual auditing is still an unavoidable step in any serious project. Indeed, manual code auditing is required for detecting application logic vulnerabilities. Automated security tools are cheap and fast, however they are only as good as the properties they are made aware of, which are typically limited to Solidity and EVM related constraints. These tools are best suited to detect common security pitfalls and best-practices at the Solidity and EVM level. Some of them can also be configured to check for business-logic constraints, but this will always require a lot of manual assistance.

The Capture The Flag (CTF) challenges we are going to solve here will let us sharpen our manual auditing skills. CTFs are a good and funny way to become proficient in security audit. The goal of this type of wargame is to find and exploit vulnerabilities hidden in a program. @tinchoabbate from OpenZeppelin released Damn Vulnerable DeFi , a CTF comprising 13 different DeFi exploits from Solidity contracts. This CTF is quite challenging because it is based on new Decentralized Finance (DeFi) primitives such as Flash Loans, which does not exist in traditional finance and are exclusive to the realm of DeFi. It requires a deep understanding of the high level business logic of those new protocols. Also, the Damn Vulnerable DeFi CTF is more interesting from an auditing perspective than other popular blockchain-based CTFs such as Ethernaut which are focused more on lower level solidity and EVM specific features. Indeed, those lower level vulnerabilities are easier to detect with automated tools.

In this first blogpost we will explore the solutions of the first 5 challenges. The other 8 challenges will be solved in a future blogpost.

1. Unstoppable

“Unstoppable” image generated by craiyon

In the first challenge, a lending pool smart contract offering flash loans for free in $DVT (Damn Valuable Token) token (ERC20) is deployed.

The goal is to cause a Denial of Service (DoS) attack of this lending pool, i.e we must find a way to render the flashLoan function of the UnstoppableLender contract inoperable. The code of the lending pool contract is available in the UnstoppableLender.sol file. We notice from Line 44 of this file that the borrower taking a flash loan is expected to be a smart contract possessing a receiveTokens function which will be called during the flash loan, right after the transfer of the borrowed amount of $DVT. Supposedly, this call to an external function will allow the borrower contract to reimburse (at least) the amount of $DVT borrowed back to the lending pool. This is the essence of the require check at line 47.

We notice the rather unusual keyword assert on line 40, which is less often used than the more popular require which uses less gas. According to the Solidity documentation:

Properly functioning code should never reach a failing assert statement; if this happens there is a bug in your contract which you should fix.

Actually, the assert keyword could be very useful if we wanted to use the builtin formal prover SMTChecker, included in the solc compiler. This tool is able to automatically verify if a statement could become false because of some bug (by finding a specific set of transactions causing the bug as a counter-example) or, on the contrary, to prove that it is indeed always true. But it is far from perfect, with many false positives and sometimes taking too long to find any proof nor counter-example. Anyways, formal verification methods are out of scope of this article.

It happens that the author of the smart contract did not have the foresight that the statement poolBalance == balanceBefore inside the assert could be easily made false, which would render the flashLoan function completely unusable. The poolBalance is a state variable which is tracking the sum of all the deposits of $DVT token made to the pool through the call of the depositTokens function, while the balanceBefore local variable is the balance in $DVT token of the pool at the very beginning of the flash loan, before transferring any fund to the borrower. The attacker begins with a balance of 100 $DVT tokens in his wallet. He can easily break the equality between poolBalance and balanceBefore by sending directly any non null amount of $DVT token to the lending pool, i.e without calling the depositTokens function. Indeed, contrarily to the native Ether token, ERC20 tokens can be transferred to any smart contract even though they do not possess any fallback nor receive functions without reverting.

The attack would then simply look like this inside the unstoppable.challenge.js test file (42 could be replaced here by any integer between 1 and 100):

We verify that the test of the first challenge is now passing, by typing the following command in a terminal :

yarn unstoppable

We solved our first challenge!

Note that this vulnerability could be fixed for example by replacing the equality in assert(poolBalance==balanceBefore) by an inequality assert(poolBalance<=balanceBefore) or by simply removing the line containing the assert all together.

2. Naive receiver

“Naive receive” generated by craiyon

In the second challenge we have a lending pool NaiveReceiverLenderPool offering flash loans in ETH which are very expensive this time: the borrower must pay a fee of 1 ETH for each flash loan. A user has deployed a contract FlashLoanReceiver with 10 ETH in balance, capable of interacting with the lending pool and receiving flash loans of ETH. The lending pool has initially 1000 ETH in balance.

The goal is to drain the 10 ETH from the borrower’s smart contract, and we get extra points by doing it in a single transaction.

The vulnerability is due to a bad access control : the receiveEther function of the borrower contract FlashLoanReceiver which is called during the execution of the flashLoan function of the lending pool contract is only checking that msg.sender corresponds to the pool address, but does not verify at any moment that the user who initiated the call to flashLoan is indeed the borrower. This means that anyone can make a flash loan by calling flashLoan with the borrower’s public address as the first argument (and any amount less than 1000 ETH as a second argument) and it suffices to do it 10 times in a row to totally drain the borrower’s smart contract from all its ETH, as each loan must pay a 1 ETH fee to the lending pool.

A quick and dirty fix to this exploit could be to add at the very beginning of the receiveEther function a check such as : require(tx.origin==borrower_address) where borrower_address could be a state variable defined as the borrower who deployed the FlashLoanReceiver contract (for example by adding borrower_address=msg.owner in a constructor of FlashLoanReceiver). But this solution is not recommended at all, because the use of tx.origin is bad practice in most cases and is known to expose the user to phishing attacks. A more serious fix would involve also changing the logic of the NaiveReceiverLenderPool contract to avoid relying on tx.origin in FlashLoanReceiver .

A first natural approach would be to create a smart contract Attack which contains an attack function calling the flashLoan from the lending pool 10 times in a row:

Then an attacker would make 2 transactions, a first one to deploy this contract, then a second one calling the attack function with the address of the FlashLoanReceiver contract as an argument, hence this would be the exploit added in the naive-receiver.challenge.js file:

However, to win extra points, we should do the exploit in a single transaction, by appending a second contract in the Attack.sol file:

This SingleAttack contract is simply deploying in its constructor the previous Attack contract and then directly calls the attack function. Hence, the exploit happens now in a single transaction, during the deployment of the SingleAttack contract, as can be seen in the final version of the naive-receiver.challenge.js file:

Then we type in the CLI:

yarn naive-receiver

The test is passing! Challenge solved!

3. Truster

“Truster” generated by craiyon

In this third challenge, we have a lending pool offering free flash loans in $DVT token, with an initial balance of 1 million $DVT. The contract TrusterLenderPool suffer from a vulnerability: an attacker can steal all the $DVT tokens owned by the pool. But how?

At a first glance, the last line of the flashLoan function :

require(balanceAfter >= balanceBefore, "Flash loan hasn't been paid back");

is enforcing the fact that the borrower contract must reimburse the pool of all its $DVT tokens by the end of the flash loan. The exploit must happen in several steps, as the hacker must be able to steal the funds from the lending pool after having reimbursed the flash loan. We can do this by exploiting the low level function call on line 36:

target.functionCall(data);

This dangerous line allows a huge amount of flexibility : it allows an external call to any function specified by its signature in the data local variable from any contract which address is target . data and target can be chosen to have any value by the user or the contract calling the flashLoan function. The smart contract author probably expected this line to be used to call the borrower’s smart contract in order to let it reimburse the funds taken from the flash loan, but he should have at least restricted the target address to be equal to borrower. Indeed, the exploit consists to use this line to make the lending pool call the approve function of the $DVT token, which will later let the hacker’s contract to transfer the funds from the pool to itself.

Concretely, we can write the following borrower smart contract :

The attacker can deploy this contract and then call its attack function. The attack is calling on line 25 the flashLoan function of the lending pool to do a flash loan borrowing 0 $DVT token (hence avoiding to reimburse anything after), and also sending, through the address(dvt) and data arguments, instructions to the lending pool to approve the borrower contract to spend all its $DVT tokens. Those funds are then transferred to the owner of the contract (i.e the attacker) on line 26.

This exploit is executed in the javascript test file by adding:

However, like in the previous challenge, by doing it this way, the attacker is still sending two transactions : one for deploying the smart contract (line 6) and one for the attack (line 7). We can rewrite the AttackTruster.sol solidity file to make the attack possible in a single transaction:

This way, the attack in the truster.challenge.js test file is done in a single deployment transaction:

However, if this attack had to be executed in a real world scenario on main net, it would probably be better not to try the single step version and to keep using multiple transactions, to avoid being devoured by Generalized Frontrunners. Another solution could be to rely on flashbots, but in this case beware of the uncle bandit risk.

4. Side entrance

“Side entrance” generated by craiyon

The fourth challenge is about a lending pool SideEntranceLenderPool allowing anyone to deposit some ETH in it by calling the deposit function and letting any borrower calling flashLoan to use some of this ETH in a free flash loan. The pool is initially deployed with 1000 ETH in total deposit. There is a mapping state variable balances which tracks how much ETH anyone has deposited in the pool. The balance of an address is reset to 0 if it calls the withdraw function. When a flash loan ends, the require at line 35 only checks if the ETH balance of the lending pool is greater than its initial balance before the loan.

The big issue is that after calling the execute function from the receiver smart contract, the contract does not check nor take into consideration the evolution of the balances of each depositor, but only the global balance of the pool address(this).balance .

Here is an easy way to cheat the system and steal all the ETH deposited in the lending contract: deploy the following FlashLoanEtherReceiver contract and call its attack function:

In the test file side-entrance.challenge.js the exploit would look as usual:

Finally, the test should pass and the challenge is solved after typing yarn side-entrance a terminal.

This is how the attack works in summary: the receiver contract FlashLoanEtherReceiver requests a flash loan from the lending pool to borrow the whole 1000 ETH (line 20 of FlashLoanEtherReceiver.sol) during which the execute function from the same receiver is called. This makes the receiver reimburse the 1000 ETH to the pool while at the same time updating the balances mapping which will permit the receiver to withdraw the whole 1000 ETH (line 21 of FlashLoanEtherReceiver.sol) and then transfer the funds to the attacker’s EOA at the owner address (line 22 of same file).

A quick fix to avoid this costly bug could have been to add a totalDeposited state variable (of uint type) to the lending pool contract SideEntranceLenderPool which would have been updated accordingly each time there was a call to the deposit or withdraw functions. In this scenario, a check should also be added at the end of the deposit function:

require(totalDeposited<=address(this).balance);

With those modifications, the previously described exploit would become impossible because it would violate this new require statement. Note that this fix is arguably not “perfect” because it is always possible to forcibly send some ETH to any smart contract (for example by using the SELFDESTRUCT opcode from another contract or by sending the miner’s block reward to the contract): this means that the balance of the lending pool could be increased in other ways than by using the expected deposit function. In any case, this proposed fix is good enough, as it would only allow an attacker to claim back the extra funds which where forcibly sent to the contract, and there will still be no way to steal the funds which were normally deposited (i.e through the use of deposit ).

5. The rewarder

“The rewarder” generated by craiyon

In the fifth challenge there’s a pool offering rewards in tokens every 5 days for those who deposit their $DVT tokens into it.

This time we have 4 different contracts : AccountingToken, FlashLoanerPool, RewardToken and TheRewarderPool.

  1. RewardToken is simply an ERC20 token with AccessControl support, such as only the deployer of the token is able to mint it by calling mint function. This is the token which will be rewarded every 5 days by the TheRewarderPool to all the depositors of the $DVT token.
  2. AccountingToken is an ERC20Snapshot token with AccessControl support, such as only the deployer could mint or burn it. Also, this child class of ERC20Snapshot has been modified such as any transfer nor approval by a holder could never be done. Compared to the standard ERC20 , the ERC20Snapshot possess an additional snapshot function (in the case of AccountingToken subclass it is only callable by the contract deployer) which creates a snapshot of both the total supply (accessible through totalSupplyAt(snapshotId) ) and the balance of every account at the time of the snapshot (accessible via balanceOfAt(account,snapshotId) .
  3. TheRewarderPool is the most important contract of this challenge. When it is deployed, it also deploys an instance of RewardToken called rewardToken and one of AccountingToken called accToken and a first snapshot of accToken is taken. When an EOA or a contract wants to deposit some $DVT tokens in TheRewarderPool, it must first approve the address of the pool and then call the deposit function (see line 56 of TheRewarderPool). During this function call, the depositor will be credited an amount of (non-transferable)accToken equal to the amount of $DVT being deposited. After the mint of those new accToken , the distributeRewards function is automatically called (this function could also be called directly, outside of deposit , as it is public). During the distributeRewards call, if more than 5 days have elapsed since the last snapshot, then a new snapshot for accToken is taken. Then, the amount rewardToken awarded to the depositor is computed as being proportional to the share of $DVT token that he deposited in the TheRewarderPool on line 76. If the deserved reward has not been claimed yet, TheRewarderPool mints and send the correct amount of rewardToken to the depositor (line 79). TheRewarderPool contract also have a withdraw function which burns the accToken
  4. FlashLoanerPool is a lending pool offering flash loans in $DVT tokens for free. It is initially credited with 1 million $DVT tokens, as can be seen in the original test file. The attacker, who do not own any $DVT, will leverage this lending pool to claim almost all the total amount of rewards from TheRewarderPool for himself.

Alice, Bob, Charlie and David have already deposited 100 DVT tokens and, after 5 days have elapsed from the deployment of TheRewarderPool, they won their rewards of 25 rewardToken each, as a total of 100 rewardToken are shared among all the depositors at each distribution.

When an amount of rewards is distributed depending on a single value taken at a specific time, it can be easily gamed if we have access to flash loans. This kind of attack is reminiscent of the popular oracle manipulation via flash loans. In essence, when 5 more days will elapse, the attacker will rush to take a flash loan of a huge amount of $DVT tokens, deposit them directly in TheRewarderPool (by calling deposit) to be the first to initiate the new snapshot of accToken , receive his rewardToken reward and then call withdraw to reimburse his 1M $DVT to the TheFlashLoanerPool .

To summarize the previous description, the attacker will deploy the following contract to take the flash loan and receive the rewards:

The code source is also available on my Github. The exploit in my test file is:

It looks as usual with one notable novelty, we added the first line :

await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]);

as it was necessary to let 5 days pass from the previous reward distribution, in order to be able claim the new reward.

The amount of $DVT token borrowed during the flash loan was so big compared to the initial balance of the other users (1M vs 100) that the attacker received almost all (above 99.96%) of the 100 rewardToken distributed during the second round of rewards.

To fix this issue, a solution could have been to change the distribution logic to take also into account the duration of the deposit, for example by saving the timestamps when some $DVT was deposited or withdrawn for each depositor, and to weight the rewards at each round by :

average_duration_of_deposit * number_of_deposited_DVT

This is a common way to compute staking rewards in a vault, and it solves the issue with flash loans as in this case the duration of deposit is actually null (deposit and withdraw take place in the same block, with a unique timestamp).

I really enjoy solving this kind of challenges. Please tell me if you find some better ways to solve any of these and stay tuned for the solutions of the last 8 challenges. Part 2/2 is coming very soon!

EDIT : Part 2/2 with the solutions to the remaining challenges is finally published here.

--

--