Platform: Code4rena
Start Date: 21/11/2022
Pot Size: $90,500 USDC
Total HM: 18
Participants: 101
Period: 7 days
Judge: Picodes
Total Solo HM: 4
Id: 183
League: ETH
Rank: 54/101
Findings: 2
Award: $57.56
๐ Selected for report: 0
๐ Solo Findings: 0
๐ Selected for report: xiaoming90
Also found by: 0xSmartContract, 8olidity, PaludoX0, Ruhum, adriro, bin2chen, cccz, koxuan, ladboy233, pashov, poirots, rvierdiiev, unforgiven
41.6303 USDC - $41.63
https://github.com/code-423n4/2022-11-redactedcartel/blob/main/src/vaults/AutoPxGlp.sol#L177-L195 https://github.com/code-423n4/2022-11-redactedcartel/blob/main/src/vaults/AutoPxGmx.sol#L199-L217
The ERC4626 contract's previewWithdraw()
function is supposed to round the value up: https://eips.ethereum.org/EIPS/eip-4626#security-considerations
Finally, EIP-4626 Vault implementers should be aware of the need for specific, opposing rounding directions across the different mutable and view methods, as it is considered most secure to favor the Vault itself during calculations over its users: If (1) itโs calculating how many shares to issue to a user for a certain amount of the underlying tokens they provide or (2) itโs determining the amount of the underlying tokens to transfer to them for returning a certain amount of shares, it should round down. If (1) itโs calculating the amount of shares a user has to supply to receive a given amount of the underlying tokens or (2) itโs calculating the amount of underlying tokens a user has to provide to receive a certain amount of shares, it should round up.
But, the overwritten implementation of AutoPxGlp and AutoPxGmx round down. Deviating from the specified standard can cause issues for other parties integrating these two vaults.
Also, the withdraw()
function in the standard implementation doesn't verify that the previewWithdraw()
function doesn't return 0
shares because it expects the function to round up. The AutoPxGlp and AutoPxGmx contracts don't have that check added which could result in the function sending assets to the caller while burning no shares.
The overwritten previewWithdraw()
function rounds down:
function previewWithdraw(uint256 assets) public view override returns (uint256) { // Calculate shares based on the specified assets' proportion of the pool uint256 shares = convertToShares(assets); // Save 1 SLOAD uint256 _totalSupply = totalSupply; // Factor in additional shares to fulfill withdrawal if user is not the last to withdraw return (_totalSupply == 0 || _totalSupply - shares == 0) ? shares : (shares * FEE_DENOMINATOR) / (FEE_DENOMINATOR - withdrawalPenalty); }
In the withdraw()
function it doesn't verify that shares != 0
. It still has the comment that previewWithdraw()
is expected to round up: https://github.com/code-423n4/2022-11-redactedcartel/blob/main/src/vaults/AutoPxGmx.sol#L323
function withdraw( uint256 assets, address receiver, address owner ) public override returns (uint256 shares) { // Compound rewards and ensure they are properly accounted for prior to withdrawal calculation compound(poolFee, 1, 0, true); shares = previewWithdraw(assets); // No need to check for rounding error, previewWithdraw rounds up. // .... }
See https://github.com/transmissions11/solmate/blob/main/src/mixins/ERC4626.sol for the standard implementation.
none
previewWithdraw()
should round up.
#0 - c4-judge
2022-12-04T12:50:09Z
Picodes marked the issue as duplicate of #264
#1 - c4-judge
2022-12-04T12:50:14Z
Picodes marked the issue as partial-25
#2 - Picodes
2022-12-04T12:50:18Z
No impact described
#3 - c4-judge
2023-01-01T11:34:39Z
Picodes changed the severity to 3 (High Risk)
#4 - C4-Staff
2023-01-10T21:57:30Z
JeeberC4 marked the issue as duplicate of #264
#5 - liveactionllama
2023-01-11T18:43:59Z
Duplicate of #178
๐ Selected for report: deliriusz
Also found by: 0x52, 0xLad, 0xbepresent, Englave, R2, Ruhum, cccz, gzeon, hihen, keccak123, ladboy233, pashov, pedroais, perseverancesuccess, rbserver, rvierdiiev, simon135, unforgiven, wagmi, xiaoming90
15.9293 USDC - $15.93
https://github.com/code-423n4/2022-11-redactedcartel/blob/main/src/vaults/AutoPxGmx.sol#L268-L278 https://github.com/code-423n4/2022-11-redactedcartel/blob/main/src/vaults/AutoPxGmx.sol#L227 https://github.com/code-423n4/2022-11-redactedcartel/blob/main/src/vaults/AutoPxGmx.sol#L321 https://github.com/code-423n4/2022-11-redactedcartel/blob/main/src/vaults/AutoPxGmx.sol#L345
The AutoPxGmx contract swaps the rewarded WETH to GMX every time a user deposits or withdraws from the vault without slippage protection. With the current state of MEV, you can be sure that your swaps will be sandwiched. With every swap, you will receive less GMX than you should. That's a direct loss of funds for the users since it reduces the actual yield users receive.
I think this issue warrants a HIGH classification because of the prevalence of MEV. About 90% of all blocks are produced using MEV-Boost. So your transactions will most likely land in a block where the majority of searchers are also present. Since sandwiching is one of the core strategies you can be sure that your transaction will also be picked up. It won't be a rare occurrence. The majority, if not all, of your swaps, will be sandwiched. As specified in the judging criteria, I believe this classifies as an indirect attack without "hand-wavy hypotheticals":
High: Assets can be stolen/lost/compromised directly (or indirectly if there is a valid attack path that does not have hand-wavy hypotheticals)
https://docs.code4rena.com/awarding/judging-criteria
For each swap, the amountOutMinimum
value is set to the parameter passed to the compound()
function:
function compound( uint24 fee, uint256 amountOutMinimum, uint160 sqrtPriceLimitX96, bool optOutIncentive ) public returns ( uint256 gmxBaseRewardAmountIn, uint256 gmxAmountOut, uint256 pxGmxMintAmount, uint256 totalFee, uint256 incentive ) { // ... if (gmxBaseRewardAmountIn != 0) { gmxAmountOut = SWAP_ROUTER.exactInputSingle( IV3SwapRouter.ExactInputSingleParams({ tokenIn: address(gmxBaseReward), tokenOut: address(gmx), fee: fee, recipient: address(this), amountIn: gmxBaseRewardAmountIn, amountOutMinimum: amountOutMinimum, sqrtPriceLimitX96: sqrtPriceLimitX96 }) );
In depositGmx()
the compound()
function is triggered through beforeDeposit()
where amountOutMinimum
is set to 1
:
function beforeDeposit( address, uint256, uint256 ) internal override { compound(poolFee, 1, 0, true); }
In withdraw()
and redeem()
compound()
is triggered directly also with amountOutMinimum
set to 1
:
function redeem( uint256 shares, address receiver, address owner ) public override returns (uint256 assets) { // Compound rewards and ensure they are properly accounted for prior to redemption calculation compound(poolFee, 1, 0, true); // ... } function withdraw( uint256 assets, address receiver, address owner ) public override returns (uint256 shares) { // Compound rewards and ensure they are properly accounted for prior to withdrawal calculation compound(poolFee, 1, 0, true); // ... }
none
You have to get the price from an on-chain oracle and then use that to compute an actual min value and use that. Make sure to add that computation to the compound()
function itself. compound()
is a public function that can be called by anyone directly. If you do it outside of it and pass the result to it, you still have the risk of an attacker triggering compound()
without slippage protection.
#0 - c4-judge
2022-12-03T20:26:35Z
Picodes marked the issue as duplicate of #185
#1 - c4-judge
2023-01-01T11:11:29Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2023-01-01T11:37:14Z
Picodes changed the severity to 2 (Med Risk)
#3 - C4-Staff
2023-01-10T22:10:37Z
JeeberC4 marked the issue as duplicate of #137