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: 29/103
Findings: 3
Award: $433.71
🌟 Selected for report: 1
🚀 Solo Findings: 0
89.8177 USDC - $89.82
https://github.com/AstariaXYZ/astaria-gpl/blob/4b49fe993d9b807fe68b3421ee7f2fe91267c9ef/src/ERC4626-Cloned.sol#L123-L127 https://github.com/AstariaXYZ/astaria-gpl/blob/4b49fe993d9b807fe68b3421ee7f2fe91267c9ef/src/ERC4626-Cloned.sol#L129-L133
The ERC4626Cloned
contract is an implementation of the ERC4626 used for vaults. The standard contains a deposit
function to deposit a specific amount of the underlying asset, and a mint
function that will calculate the amount needed of the underlying token to mint a specific number of shares.
This calculation is done in previewDeposit
and previewMint
:
function previewDeposit( uint256 assets ) public view virtual returns (uint256) { return convertToShares(assets); } function convertToShares( uint256 assets ) public view virtual returns (uint256) { uint256 supply = totalSupply(); // Saves an extra SLOAD if totalSupply is non-zero. return supply == 0 ? assets : assets.mulDivDown(supply, totalAssets()); }
function previewMint(uint256 shares) public view virtual returns (uint256) { uint256 supply = totalSupply(); // Saves an extra SLOAD if totalSupply is non-zero. return supply == 0 ? 10e18 : shares.mulDivUp(totalAssets(), supply); }
In the case of the first deposit (i.e. when supply == 0
), previewDeposit
will return the same assets
amount for the shares (this is the standard implementation), while previewMint
will simply return 10e18
.
It seems the intention was to mint a high initial number of shares on first deposit, so an attacker couldn't mint a low number of shares and manipulate the pool to frontrun an initial depositor.
However, the protocol has failed to replicate this logic in the deposit
function, as both deposit
and mint
logic differ (see PoC).
An attacker can still use the deposit
function to mint any number of shares.
contract MockERC20 is ERC20("Mock ERC20", "MERC20", 18) { function mint(address account, uint256 amount) external { _mint(account, amount); } } contract TestERC4626 is ERC4626Cloned { ERC20 _asset; constructor() { _asset = new MockERC20(); } function asset() public override view returns (address assetTokenAddress) { return address(_asset); } function minDepositAmount() public override view returns (uint256) { return 0; } function totalAssets() public override view returns (uint256) { return _asset.balanceOf(address(this)); } function symbol() external override view returns (string memory) { return "TEST4626"; } function name() external override view returns (string memory) { return "TestERC4626"; } function decimals() external override view returns (uint8) { return 18; } } contract AuditTest is Test { function test_ERC4626Cloned_DepositMintDiscrepancy() public { TestERC4626 vault = new TestERC4626(); MockERC20 token = MockERC20(vault.asset()); // Amount we deposit uint256 amount = 25e18; // Shares we get if we deposit amount uint256 shares = vault.previewDeposit(amount); // Amount needed to mint shares uint256 amountNeeded = vault.previewMint(shares); // The following values should be equal but they not assertFalse(amount == amountNeeded); // An attacker can still mint a single share by using deposit to manipulate the pool token.mint(address(this), 1); token.approve(address(vault), type(uint256).max); uint256 mintedShares = vault.deposit(1, address(this)); assertEq(mintedShares, 1); } }
The deposit
function should also implement the same logic as the mint
function for the case of the first depositor.
#0 - c4-judge
2023-01-23T16:20:16Z
Picodes marked the issue as primary issue
#1 - c4-sponsor
2023-01-31T15:00:03Z
androolloyd marked the issue as sponsor confirmed
#2 - androolloyd
2023-02-03T18:32:33Z
@SantiagoGregory
#3 - SantiagoGregory
2023-02-06T01:08:36Z
@androolloyd how should we handle this along with https://github.com/code-423n4/2023-01-astaria-findings/issues/367?
#4 - c4-judge
2023-02-19T16:40:37Z
Picodes marked the issue as satisfactory
#5 - c4-judge
2023-02-19T16:58:09Z
Picodes changed the severity to 3 (High Risk)
#6 - c4-judge
2023-02-24T10:50:33Z
Picodes marked the issue as selected for report
🌟 Selected for report: rbserver
Also found by: Jeiwan, adriro, cccz, chaduke, rvierdiiev, unforgiven
49.6437 USDC - $49.64
The ERC4626RouterBase
contract contains a set of functions that act as wrappers for a ERC4626 contract, providing a base periphery functionality around a ERC4626 vault.
There are a number of different flaws in the wrapped implementations of mint
, deposit
, withdraw
and redeem
.
All four functions present in the contract are marked as payable (because of the IERC4626RouterBase
interface definitions) but don't need to handle ETH payments, as these operate on the assumption that the underlying asset
is an ERC20 token. This will cause loss of funds if a user sends ETH to any of the functions.
Note that these functions are exposed as payable functions in the AstariaRouter
contract, as this contract inherits from ERC4626Router
.
mint
function mint( IERC4626 vault, address to, uint256 shares, uint256 maxAmountIn ) public payable virtual override returns (uint256 amountIn) { ERC20(vault.asset()).safeApprove(address(vault), shares); if ((amountIn = vault.mint(shares, to)) > maxAmountIn) { revert MaxAmountError(); } }
The mint
function uses shares
as the amount to ERC20.approve
but this value is wrong, as shares is the output of the vault. It should first call previewMint
to get the amount of the underlying token needed to mint that number of shares. Compare that amount against the maxAmountIn
and then call ERC20.approve
with that value.
withdraw
and redeem
function withdraw( IERC4626 vault, address to, uint256 amount, uint256 maxSharesOut ) public payable virtual override returns (uint256 sharesOut) { ERC20(address(vault)).safeApprove(address(vault), amount); if ((sharesOut = vault.withdraw(amount, to, msg.sender)) > maxSharesOut) { revert MaxSharesError(); } }
function redeem( IERC4626 vault, address to, uint256 shares, uint256 minAmountOut ) public payable virtual override returns (uint256 amountOut) { ERC20(address(vault)).safeApprove(address(vault), shares); if ((amountOut = vault.redeem(shares, to, msg.sender)) < minAmountOut) { revert MinAmountError(); } }
Both function calls to ERC20.approve
are not valid, since ERC4626.withdraw
will burn the shares and no transfers of shares are needed.
In all cases, since functions are marked as payable, verify the value sent is 0 require(msg.value == 0)
to prevent accidental loss of funds.
For mint
, use previewMint
to know how much assets are needed to mint the given shares:
function mint( IERC4626 vault, address to, uint256 shares, uint256 maxAmountIn ) public payable virtual override returns (uint256 amountIn) { require(msg.value == 0); amountIn = vault.previewMint(shares); if (amountIn > maxAmountIn) { revert MaxAmountError(); } ERC20(vault.asset()).safeApprove(address(vault), amountIn); vault.mint(shares, to); }
withdraw
and redeem
, remove the call to ERC20.approve
function withdraw( IERC4626 vault, address to, uint256 amount, uint256 maxSharesOut ) public payable virtual override returns (uint256 sharesOut) { require(msg.value == 0); if ((sharesOut = vault.withdraw(amount, to, msg.sender)) > maxSharesOut) { revert MaxSharesError(); } } function redeem( IERC4626 vault, address to, uint256 shares, uint256 minAmountOut ) public payable virtual override returns (uint256 amountOut) { require(msg.value == 0); if ((amountOut = vault.redeem(shares, to, msg.sender)) < minAmountOut) { revert MinAmountError(); } }
#0 - Picodes
2023-01-23T16:18:31Z
For the payable part: QA at best.
#1 - c4-judge
2023-01-25T14:27:16Z
Picodes marked the issue as duplicate of #118
#2 - c4-judge
2023-02-19T11:08:52Z
Picodes marked the issue as satisfactory
#3 - c4-judge
2023-02-24T08:53:29Z
Picodes marked the issue as not a duplicate
#4 - c4-judge
2023-02-24T08:53:53Z
Picodes changed the severity to QA (Quality Assurance)
#5 - c4-judge
2023-02-24T08:54:09Z
This previously downgraded issue has been upgraded by Picodes
#6 - c4-judge
2023-02-24T08:54:09Z
This previously downgraded issue has been upgraded by Picodes
#7 - c4-judge
2023-02-24T08:54:52Z
Picodes marked the issue as duplicate of #488
#8 - c4-judge
2023-02-24T09:52:50Z
Picodes marked the issue as not a duplicate
#9 - c4-judge
2023-02-24T09:53:09Z
Picodes marked the issue as duplicate of #488
🌟 Selected for report: unforgiven
Also found by: adriro
294.2522 USDC - $294.25
Judge has assessed an item in Issue #596 as 2 risk. The relevant finding follows:
withdraw and redeem https://github.com/AstariaXYZ/astaria-gpl/blob/4b49fe993d9b807fe68b3421ee7f2fe91267c9ef/src/ERC4626RouterBase.sol#L41-L52
function withdraw( IERC4626 vault, address to, uint256 amount, uint256 maxSharesOut ) public payable virtual override returns (uint256 sharesOut) {
ERC20(address(vault)).safeApprove(address(vault), amount); if ((sharesOut = vault.withdraw(amount, to, msg.sender)) > maxSharesOut) { revert MaxSharesError(); } } https://github.com/AstariaXYZ/astaria-gpl/blob/4b49fe993d9b807fe68b3421ee7f2fe91267c9ef/src/ERC4626RouterBase.sol#L55-L66
function redeem( IERC4626 vault, address to, uint256 shares, uint256 minAmountOut ) public payable virtual override returns (uint256 amountOut) {
ERC20(address(vault)).safeApprove(address(vault), shares); if ((amountOut = vault.redeem(shares, to, msg.sender)) < minAmountOut) { revert MinAmountError(); } } Both function calls to ERC20.approve are not valid, since ERC4626.withdraw will burn the shares and no transfers of shares are needed.
#0 - c4-judge
2023-02-24T08:54:46Z
Picodes marked the issue as duplicate of #175
#1 - c4-judge
2023-02-24T08:54:54Z
Picodes marked the issue as satisfactory