Platform: Code4rena
Start Date: 07/03/2024
Pot Size: $63,000 USDC
Total HM: 20
Participants: 36
Period: 5 days
Judge: cccz
Total Solo HM: 11
Id: 349
League: BLAST
Rank: 1/36
Findings: 8
Award: $14,067.26
🌟 Selected for report: 7
🚀 Solo Findings: 3
1256.8429 USDC - $1,256.84
MagicLP provides a TWAP value which can be accessed via _BASE_PRICE_CUMULATIVE_LAST_
It is updated in the function below:
function _twapUpdate() internal { uint32 blockTimestamp = uint32(block.timestamp % 2 ** 32); uint32 timeElapsed = blockTimestamp - _BLOCK_TIMESTAMP_LAST_; if (timeElapsed > 0 && _BASE_RESERVE_ != 0 && _QUOTE_RESERVE_ != 0) { /// @dev It is desired and expected for this value to /// overflow once it has hit the max of `type.uint256`. unchecked { _BASE_PRICE_CUMULATIVE_LAST_ += getMidPrice() * timeElapsed; } } _BLOCK_TIMESTAMP_LAST_ = blockTimestamp; }
It is updated by any function that changes the reserves, for example:
function _resetTargetAndReserve() internal returns (uint256 baseBalance, uint256 quoteBalance) { baseBalance = _BASE_TOKEN_.balanceOf(address(this)); quoteBalance = _QUOTE_TOKEN_.balanceOf(address(this)); if (baseBalance > type(uint112).max || quoteBalance > type(uint112).max) { revert ErrOverflow(); } _BASE_RESERVE_ = uint112(baseBalance); _QUOTE_RESERVE_ = uint112(quoteBalance); _BASE_TARGET_ = uint112(baseBalance); _QUOTE_TARGET_ = uint112(quoteBalance); _RState_ = uint32(PMMPricing.RState.ONE); _twapUpdate(); } function _setReserve(uint256 baseReserve, uint256 quoteReserve) internal { _BASE_RESERVE_ = baseReserve.toUint112(); _QUOTE_RESERVE_ = quoteReserve.toUint112(); _twapUpdate(); }
The root cause of the issue is that the TWAP is updated after reserve changes. Since the TWAP multiplies the duration of time since the last update with the new reserves, an attacker has control over the registered price for the entire passed duration.
For reference, the Uniswap and Beanstalk TWAPs are provided below:
RareSkills details how TWAP operation works here.
Any application making use of the MagicLP's TWAP to determine token prices will be exploitable.
_BASE_PRICE_CUMULATIVE_LAST_
to execute a large swap(A->B). The swap could be done through MIMswap or any other AMM.(X+Y*T-X)/(T0+T-T0) = Y*T/T = Y
. Attacker has succeeded in manipulating the price to Y.Manual audit
_twapUpdate()
needs to be called before reserves are updated.
Timing
#0 - 0xCalibur
2024-03-14T18:59:02Z
It's as designed. Integrating protocol should always check for min output to avoid frontrunning
#1 - c4-pre-sort
2024-03-15T12:57:51Z
141345 marked the issue as primary issue
#2 - 141345
2024-03-15T12:57:56Z
updated after reserve changes
#3 - c4-pre-sort
2024-03-15T12:57:58Z
141345 marked the issue as sufficient quality report
#4 - c4-sponsor
2024-03-28T17:58:24Z
0xCalibur (sponsor) disputed
#5 - thereksfour
2024-03-29T03:34:42Z
I think this is valid, the problem is that the TWAP algorithm is wrong, TWAP: https://en.wikipedia.org/wiki/Time-weighted_average_price By the way, the code here is consistent with DODOV2.
#6 - c4-judge
2024-03-29T05:13:42Z
thereksfour marked the issue as satisfactory
#7 - c4-judge
2024-03-31T06:25:33Z
thereksfour marked the issue as selected for report
#8 - 0xCalibur
2024-04-04T12:56:18Z
We decided to removed the TWAP functionality at the end. But during the time of this review, this was in the code
🌟 Selected for report: Trust
6896.2571 USDC - $6,896.26
One of the two key parameters in MagicLP pools is I
, which is defined to be the ideal ratio between the two reserves. It is set during MagicLP initialization:
_I_ = i;
It is used when performing the initial LP deposit, in buyShares()
:
if (totalSupply() == 0) { // case 1. initial supply if (quoteBalance == 0) { revert ErrZeroQuoteAmount(); } shares = quoteBalance < DecimalMath.mulFloor(baseBalance, _I_) ? DecimalMath.divFloor(quoteBalance, _I_) : baseBalance; _BASE_TARGET_ = shares.toUint112(); _QUOTE_TARGET_ = DecimalMath.mulFloor(shares, _I_).toUint112(); if (_QUOTE_TARGET_ == 0) { revert ErrZeroQuoteTarget(); } if (shares <= 2001) { revert ErrMintAmountNotEnough(); } _mint(address(0), 1001); shares -= 1001;
The QUOTE_TARGET
is determined by multiplying the BASE_TARGET
with I
.
The flaw is in the check below:
shares = quoteBalance < DecimalMath.mulFloor(baseBalance, _I_) ? DecimalMath.divFloor(quoteBalance, _I_) : baseBalance;
Essentially there needs to be enough quoteBalance
at the I
ratio to mint baseBalance
shares, if there's not enough then shares are instead determined by dividing the quoteBalance
with I
.
An attacker can abuse the mulFloor()
to create a major inconsistency.
Suppose quoteBalance = 1
, baseBalance = 19999
, I = 1e14
. Then we have:
1 < 19999 * 1e14 / 1e18 => 1 < 1 => False
Therefore shares = 19999
.
It sets the targets:
_BASE_TARGET_ = 19999 _QUOTE_TARGET_ = 19999 * 1e14 / 1e18 = 1
The result is the ratio 1:19999, when the intended ratio from I
is 1:1000.
Essentially a small rounding error is magnified. The rounding direction should instead be:
quoteBalance < DecimalMath.mulCeil(baseBalance, _I_)
This would ensure that DecimalMath.divFloor(quoteBalance, _I_)
is executed. At this point, when calculating QUOTE_TARGET
there will not be a precision error as above (it performs the opposing actions to the divFloor).
An attacker can abuse it by making users perform trades under the assumption I
is the effective ratio, however the ratio is actually 2I
. The pool's pricing mechanics will be wrong. Note that users will legitimately trust any MagicLP pool created by the Factory as it is supposed to enforce that ratio.
The attack can be performed on another entity's pool right after the init()
call, or on a self-created pool. The initial cost for the attack is very small due to the small numbers involved.
Attacker can initialize a Pool with malicious pricing mechanics that break the assumed invariants of the pool, leading to incorrect pool interactions.
Step by step of the initial buyShares()
was provided above. _QUOTE_TARGET_
is used by the pricer:
function getPMMState() public view returns (PMMPricing.PMMState memory state) { state.i = _I_; state.K = _K_; state.B = _BASE_RESERVE_; state.Q = _QUOTE_RESERVE_; state.B0 = _BASE_TARGET_; // will be calculated in adjustedTarget state.Q0 = _QUOTE_TARGET_; state.R = PMMPricing.RState(_RState_); PMMPricing.adjustedTarget(state); } ... function sellBaseToken(PMMState memory state, uint256 payBaseAmount) internal pure returns (uint256 receiveQuoteAmount, RState newR) { if (state.R == RState.ONE) { // case 1: R=1 // R falls below one receiveQuoteAmount = _ROneSellBaseToken(state, payBaseAmount); newR = RState.BELOW_ONE; } else if (state.R == RState.ABOVE_ONE) { uint256 backToOnePayBase = state.B0 - state.B; uint256 backToOneReceiveQuote = state.Q - // @@@@@@@@@@ USED HERE state.Q0;
Note that deposits/withdrawals will continue to apply the bad ratio:
} else if (baseReserve > 0 && quoteReserve > 0) { // case 2. normal case uint256 baseInputRatio = DecimalMath.divFloor(baseInput, baseReserve); uint256 quoteInputRatio = DecimalMath.divFloor(quoteInput, quoteReserve); uint256 mintRatio = quoteInputRatio < baseInputRatio ? quoteInputRatio : baseInputRatio; shares = DecimalMath.mulFloor(totalSupply(), mintRatio); _BASE_TARGET_ = (uint256(_BASE_TARGET_) + DecimalMath.mulFloor(uint256(_BASE_TARGET_), mintRatio)).toUint112(); _QUOTE_TARGET_ = (uint256(_QUOTE_TARGET_) + DecimalMath.mulFloor(uint256(_QUOTE_TARGET_), mintRatio)).toUint112();
Manual audit
Use DecimalMaths.mulCeil()
to protect against the rounding error.
Math
#0 - 0xCalibur
2024-03-14T14:17:55Z
Based on our previous audit, it was discussed that using mulCeil here would not be the right answer since that would just create imprecision in the ratio in the other direction.
It would be good if the submitter could provide a PoC showing a veritable exploit case for this one.
#1 - 141345
2024-03-15T13:41:27Z
rounding could break I-invariant
need to check the dup
#2 - c4-pre-sort
2024-03-15T13:41:31Z
141345 marked the issue as sufficient quality report
#3 - c4-pre-sort
2024-03-15T13:41:38Z
141345 marked the issue as primary issue
#4 - c4-sponsor
2024-03-18T02:22:47Z
0xCalibur (sponsor) acknowledged
#5 - c4-sponsor
2024-03-18T17:15:28Z
0xCalibur (sponsor) disputed
#6 - c4-sponsor
2024-03-18T17:18:12Z
0xCalibur (sponsor) acknowledged
#7 - 0xCalibur
2024-03-19T14:39:36Z
Ack, but will not fix it on contract level but filtering pool quality and legitimacy on our main frontend.
#8 - c4-sponsor
2024-03-28T13:56:43Z
0xCalibur marked the issue as disagree with severity
#9 - thereksfour
2024-03-29T05:35:43Z
Downgraded to M based on the impact
> Attacker can initialize a Pool with malicious pricing mechanics that break the assumed invariants of the pool, leading to incorrect pool interactions.
#10 - c4-judge
2024-03-29T05:37:22Z
thereksfour changed the severity to 2 (Med Risk)
#11 - c4-judge
2024-03-29T09:15:59Z
thereksfour marked the issue as satisfactory
#12 - c4-judge
2024-03-31T06:40:27Z
thereksfour marked the issue as selected for report
#13 - trust1995
2024-04-02T12:07:52Z
Hi,
The impact demonstrated is doubling the ratio of the pool, which is a core invariant of the MIMswap platform. It means pricing will be incorrect, which is the core functionality of an AMM. A user who will make a trade assuming they will follow the price set out by the I parameter will make incorrect trades, losing their funds inappropriately. I believe the direct risk of loss of funds by innocent traders who are not making a mistake, warrants the severity of High.
#14 - blutorque
2024-04-03T06:42:01Z
This findings should be invalid for 2 reasons;
_I_
never changes during the above fuction call, it remains same as what admin set initially, meaning if a user performs trade, the shares amount received would only determined from _I_
previoulsy sets by admin, not an 2I
which manipulated by attacker
shares = quoteBalance < DecimalMath.mulFloor(baseBalance, _I_) ? DecimalMath.divFloor(quoteBalance, _I_) : baseBalance;
the only things it will do, changing BASE_TARGET to QUOTE_TARGET ratio which protocol never uses during the trade. Instead, it uses the adjusted targets which solely determined from the base/quote reserve, not what warden mentioned above.
One can verified in code below, the adjusted targets are independent of current base/quote targets, https://github.com/code-423n4/2024-03-abracadabra-money/blob/1f4693fdbf33e9ad28132643e2d6f7635834c6c6/src/mimswap/libraries/PMMPricing.sol#L190-L201
Also in formulaes below,
#15 - trust1995
2024-04-03T06:52:21Z
You can see the last section of the report no how deposits/withdrawals will use the bad ratio. You can also see that the state is used for pricing:
function getPMMState() public view returns (PMMPricing.PMMState memory state) { state.i = _I_; state.K = _K_; state.B = _BASE_RESERVE_; state.Q = _QUOTE_RESERVE_; state.B0 = _BASE_TARGET_; // will be calculated in adjustedTarget state.Q0 = _QUOTE_TARGET_; state.R = PMMPricing.RState(_RState_); PMMPricing.adjustedTarget(state); }
function querySellQuote( address trader, uint256 payQuoteAmount ) public view returns (uint256 receiveBaseAmount, uint256 mtFee, PMMPricing.RState newRState, uint256 newQuoteTarget) { PMMPricing.PMMState memory state = getPMMState(); // call below (receiveBaseAmount, newRState) = PMMPricing.sellQuoteToken(state, payQuoteAmount); (uint256 lpFeeRate, uint256 mtFeeRate) = _MT_FEE_RATE_MODEL_.getFeeRate(trader, _LP_FEE_RATE_); mtFee = DecimalMath.mulFloor(receiveBaseAmount, mtFeeRate); receiveBaseAmount = receiveBaseAmount - DecimalMath.mulFloor(receiveBaseAmount, lpFeeRate) - mtFee; newQuoteTarget = state.Q0; }
function sellQuoteToken(PMMState memory state, uint256 payQuoteAmount) internal pure returns (uint256 receiveBaseAmount, RState newR) { if (state.R == RState.ONE) { receiveBaseAmount = _ROneSellQuoteToken(state, payQuoteAmount); newR = RState.ABOVE_ONE; } else if (state.R == RState.ABOVE_ONE) { receiveBaseAmount = _RAboveSellQuoteToken(state, payQuoteAmount); newR = RState.ABOVE_ONE; } else { uint256 backToOnePayQuote = state.Q0 - state.Q; uint256 backToOneReceiveBase = state.B - state.B0; if (payQuoteAmount < backToOnePayQuote) { receiveBaseAmount = _RBelowSellQuoteToken(state, payQuoteAmount); newR = RState.BELOW_ONE; if (receiveBaseAmount > backToOneReceiveBase) { receiveBaseAmount = backToOneReceiveBase; } } else if (payQuoteAmount == backToOnePayQuote) { receiveBaseAmount = backToOneReceiveBase; newR = RState.ONE; } else { receiveBaseAmount = backToOneReceiveBase + _ROneSellQuoteToken(state, payQuoteAmount - backToOnePayQuote); newR = RState.ABOVE_ONE; } } }
function _RAboveSellQuoteToken( PMMState memory state, uint256 payQuoteAmount ) internal pure returns ( uint256 // receiveBaseToken ) { // Using both current price and target price return Math._SolveQuadraticFunctionForTrade(state.B0, state.B, payQuoteAmount, DecimalMath.reciprocalFloor(state.i), state.K); }
#16 - blutorque
2024-04-03T14:04:31Z
The attack can be performed on another entity's pool right after the init() call, or on a self-created pool. The initial cost for the attack is very small due to the small numbers involved.
This attack require to be performed right after the init() call, and we know that the initially pool are at equilibrium, means state.R==RState.ONE
. However, in the PoC, warden pointed to the use of target to the section where state.R==ABOVE_ONE
, which does not make any sense.
if (state.R == RState.ONE) { // case 1: R=1 // R falls below one receiveQuoteAmount = _ROneSellBaseToken(state, payBaseAmount); newR = RState.BELOW_ONE; } else if (state.R == RState.ABOVE_ONE) { uint256 backToOnePayBase = state.B0 - state.B; uint256 backToOneReceiveQuote = state.Q - // @@@@@@@@@@ USED HERE state.Q0;
#17 - trust1995
2024-04-03T14:17:25Z
The attack (buyShares() call) is done right after init().
The corrupted ratio will be used later in buyShares()/sellShares(), and trade calls after R moves to ABOVE_ONE/BELOW_ONE.
#18 - blutorque
2024-04-04T13:05:47Z
This is all going back to my first comment above, the affected ratio due to buyShares will re-adjust when trade occur and R get aways from ONE. No way the algorithm will let trader to use corrupted ratio.
#19 - thereksfour
2024-04-05T17:05:01Z
@0xCalibur Could you please give your opinion on the validity of this issue?
#20 - thereksfour
2024-04-06T06:07:43Z
function createPool( address baseToken, address quoteToken, uint256 lpFeeRate, uint256 i, uint256 k, address to, uint256 baseInAmount, uint256 quoteInAmount ) external returns (address clone, uint256 shares) { _validateDecimals(IERC20Metadata(baseToken).decimals(), IERC20Metadata(quoteToken).decimals()); clone = IFactory(factory).create(baseToken, quoteToken, lpFeeRate, i, k); baseToken.safeTransferFrom(msg.sender, clone, baseInAmount); quoteToken.safeTransferFrom(msg.sender, clone, quoteInAmount); (shares, , ) = IMagicLP(clone).buyShares(to); <======== }
function sellBase(address to) external nonReentrant returns (uint256 receiveQuoteAmount) { uint256 baseBalance = _BASE_TOKEN_.balanceOf(address(this)); uint256 baseInput = baseBalance - uint256(_BASE_RESERVE_); uint256 mtFee; uint256 newBaseTarget; PMMPricing.RState newRState; (receiveQuoteAmount, mtFee, newRState, newBaseTarget) = querySellBase(tx.origin, baseInput); <============== ... function querySellBase( address trader, uint256 payBaseAmount ) public view returns (uint256 receiveQuoteAmount, uint256 mtFee, PMMPricing.RState newRState, uint256 newBaseTarget) { PMMPricing.PMMState memory state = getPMMState(); <======== (receiveQuoteAmount, newRState) = PMMPricing.sellBaseToken(state, payBaseAmount); <====== ... function getPMMState() public view returns (PMMPricing.PMMState memory state) { state.i = _I_; state.K = _K_; state.B = _BASE_RESERVE_; state.Q = _QUOTE_RESERVE_; state.B0 = _BASE_TARGET_; // will be calculated in adjustedTarget state.Q0 = _QUOTE_TARGET_; state.R = PMMPricing.RState(_RState_); PMMPricing.adjustedTarget(state); <====== } ... function adjustedTarget(PMMState memory state) internal pure { if (state.R == RState.BELOW_ONE) { state.Q0 = Math._SolveQuadraticFunctionForTarget(state.Q, state.B - state.B0, state.i, state.K); } else if (state.R == RState.ABOVE_ONE) { state.B0 = Math._SolveQuadraticFunctionForTarget( state.B, state.Q - state.Q0, DecimalMath.reciprocalFloor(state.i), state.K ); } }
function sellBaseToken(PMMState memory state, uint256 payBaseAmount) internal pure returns (uint256 receiveQuoteAmount, RState newR) { if (state.R == RState.ONE) { // case 1: R=1 // R falls below one receiveQuoteAmount = _ROneSellBaseToken(state, payBaseAmount); <====== newR = RState.BELOW_ONE; ... function _ROneSellBaseToken( PMMState memory state, uint256 payBaseAmount ) internal pure returns ( uint256 // receiveQuoteToken ) { // in theory Q2 <= targetQuoteTokenAmount // however when amount is close to 0, precision problems may cause Q2 > targetQuoteTokenAmount return Math._SolveQuadraticFunctionForTrade(state.Q0, state.Q0, payBaseAmount, state.i, state.K); <====== }
@blutorque @trust1995 If the above is correct, I would upgrade it to H
#21 - c4-judge
2024-04-06T07:22:24Z
thereksfour changed the severity to 3 (High Risk)
#22 - blutorque
2024-04-07T20:22:40Z
hey @thereksfour, I agree, you are right above.
After giving one more last thought to it. I realized, Is there going to be enough liquidity in quoteToken for swap, when trader sellBaseToken
. Its obvious, there wont.
And even if someone do provide liquidity keeping state.R==ONE, the ratio will not be same anymore.
#23 - thereksfour
2024-04-08T03:33:05Z
Sorry, the judging results are already wrapped up. Also, it seems that the attacker can add liquidity without changing state by donating tokens and then calling sync
930.9947 USDC - $930.99
Users are expected to add liquidity through addLiquidity()
of the Router. It receives base and quote amounts which are adjusted through _adjustAddLiquidity()
:
function addLiquidity( address lp, address to, uint256 baseInAmount, uint256 quoteInAmount, uint256 minimumShares, uint256 deadline ) external ensureDeadline(deadline) returns (uint256 baseAdjustedInAmount, uint256 quoteAdjustedInAmount, uint256 shares) { (baseAdjustedInAmount, quoteAdjustedInAmount) = _adjustAddLiquidity(lp, baseInAmount, quoteInAmount); IMagicLP(lp)._BASE_TOKEN_().safeTransferFrom(msg.sender, lp, baseAdjustedInAmount); IMagicLP(lp)._QUOTE_TOKEN_().safeTransferFrom(msg.sender, lp, quoteAdjustedInAmount); shares = _addLiquidity(lp, to, minimumShares); }
The adjustment function has DODOv2 code which determines the effective base:quote ratio and adjusts the higher of the two input values so that no input is wasted. Additionally Abrakadbra inserted the following logic at the start of the function:
(uint256 baseReserve, uint256 quoteReserve) = IMagicLP(lp).getReserves(); uint256 baseBalance = IMagicLP(lp)._BASE_TOKEN_().balanceOf(address(lp)) + baseInAmount; uint256 quoteBalance = IMagicLP(lp)._QUOTE_TOKEN_().balanceOf(address(lp)) + quoteInAmount; baseInAmount = baseBalance - baseReserve; quoteInAmount = quoteBalance - quoteReserve;
The intention is that if the current balance of token X is higher than reserve, the input should be reduced so that only the delta needs to be sent. However the logic is miswritten.
The balanceOf() and getReserve() calls have been reversed. The correct logic should be:
requested amount = reserves + requested amount - balance
Instead it is:
requested amount = balance + requested amount - reserves
.
The higher the current balanceOf()
, the higher the final requested amount becomes. This is a critical issue because tokens can be donated to the MagicLP by an attacker, making the victim send more tokens than expected. The POC gives an example of such a sequence.
Essentially this makes addLiquidity(amountX, amountY, minShares) become: (amountX2, amountY2, minShares) where amountX2 > amountX, amountY2 > amountY. By definition this is unauthorized use of the victim's funds.
Unauthorized spending of victim's tokens, leading to monetary losses.
Therefore LP::buyShares() is called with (1500, 1500) for 200 shares instead of (1000, 1000)
User has now put an extra ($500,$500) of unauthorized funds in the contract. They may or may not be aware of it when the TX is executed. Note that funds put in the LP are obviously in risk of impermanent loss alongside any other economic, systemic and security-related risks. This is a simple example to prove the point but could be tweaked for different tokens (more or less volatile), amounts and ratios.
Manual audit
Change the usage of getReserves()
and balanceOf()
in the lines below as described:
(uint256 baseReserve, uint256 quoteReserve) = IMagicLP(lp).getReserves(); uint256 baseBalance = IMagicLP(lp)._BASE_TOKEN_().balanceOf(address(lp)) + baseInAmount; uint256 quoteBalance = IMagicLP(lp)._QUOTE_TOKEN_().balanceOf(address(lp)) + quoteInAmount;
A great extra invariant check would be to require in addLiquidity()
that the results baseAdjustedInAmount
, quoteAdjustedInAmount
are smaller than the original input amounts respectively.
Note that the issue also occurs in the preview function:
function previewAddLiquidity( address lp, uint256 baseInAmount, uint256 quoteInAmount ) external view returns (uint256 baseAdjustedInAmount, uint256 quoteAdjustedInAmount, uint256 shares) { (uint256 baseReserve, uint256 quoteReserve) = IMagicLP(lp).getReserves(); uint256 baseBalance = IMagicLP(lp)._BASE_TOKEN_().balanceOf(address(lp)) + baseInAmount; uint256 quoteBalance = IMagicLP(lp)._QUOTE_TOKEN_().balanceOf(address(lp)) + quoteInAmount; baseInAmount = baseBalance - baseReserve; quoteInAmount = quoteBalance - quoteReserve;
It should also be fixed here. We view it as the same root cause, but of a lesser end impact.
Math
#0 - c4-pre-sort
2024-03-15T13:26:30Z
141345 marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-03-15T13:26:34Z
141345 marked the issue as primary issue
#2 - 141345
2024-03-15T13:26:40Z
addLiquidity
miscalculation, misuse of balanceOf()
and getReserve()
#3 - 0xCalibur
2024-03-15T21:01:55Z
Please validate the fix here
https://github.com/Abracadabra-money/abracadabra-money-contracts/pull/141
#4 - c4-sponsor
2024-03-15T21:02:07Z
0xCalibur (sponsor) confirmed
#5 - thereksfour
2024-03-28T16:56:19Z
Referring to #92, consider downgrade to M. This is more of a griefing attack, and it seems the victim's 200 share would be worth 2000 USDC, 1666 DAI?
#6 - c4-judge
2024-03-28T16:56:27Z
thereksfour changed the severity to 2 (Med Risk)
#7 - c4-judge
2024-03-29T08:30:36Z
thereksfour marked the issue as satisfactory
#8 - c4-judge
2024-03-29T15:15:10Z
thereksfour marked the issue as duplicate of #92
#9 - trust1995
2024-04-02T13:14:51Z
Referring to #92, consider downgrade to M. This is more of a griefing attack, and it seems the victim's 200 share would be worth 2000 USDC, 1666 DAI?
Unauthorized spending of tokens is breaking of a core invariant (do not use money user did not intend to spend), which by itself achieves High impact. The submission also explains the risks affecting the unapproved funds.
Note that sponsor has confirmed the issue at High severity and views it as such.
#10 - thereksfour
2024-04-05T09:39:25Z
Hey @trust1995, in POC, if I understand correctly, while the victim spends more tokens, the shares the victim receives will be worth more due to the donations of the griefer (3000 -> 3666), and there doesn't seem to be any locks here, so I don't think this as a higher impact than DOS.
As for the high severity confirmed by sponsors, I'd like to hear from the sponsors. Thanks @0xCalibur
#11 - trust1995
2024-04-05T12:33:36Z
I can confirm that the user spends more tokens and receives more shares. In my opinion that suffices for H severity as it is not blocking a victim interaction, it is taking unapproved funds (until the user figures out how to redeem them back). Again, touching user's assets without their permission is very severe.
#12 - thereksfour
2024-04-05T17:11:11Z
In terms of this attack scenario
so I don't think this is high severity
#13 - c4-judge
2024-04-06T08:39:54Z
thereksfour marked the issue as selected for report
#14 - thereksfour
2024-04-06T08:43:34Z
Make this as selected report based on the focus of the description
🌟 Selected for report: Trust
Also found by: SpicyMeatball, hals
558.5968 USDC - $558.60
The BlastOnboardingBoot contract contains the bootstraping funds. After launch it will start a reward staking pool and allow users to claim tokens into it. This is in the launch sequence:
staking = new LockingMultiRewards(pool, 30_000, 7 days, 13 weeks, address(this)); staking.setOperator(address(this), true); staking.transferOwnership(owner); // Approve staking contract pool.safeApprove(address(staking), totalPoolShares);
Note that the code approves the staking pool towithdraw from the created LP. The code also provisions setting of the staking contract to another one.
// Just in case we need to change the staking contract after // the automatic bootstrapping process function setStaking(LockingMultiRewards _staking) external onlyOwner { if (ready) { revert ErrCannotChangeOnceReady(); } staking = _staking; emit LogStakingChanged(address(_staking)); }
The issue is that this function lacks two actions:
As a result, owner which assumes changing to a new staking pool is perfectly safe, will in fact cause reverts when users try claiming in the new pool. It is still possible to revert back to the old staking pool, but in fact it will never be possible to migrate to a new pool as no other approve() call exists in the contract.
Loss of assumed functionality of the Onboarding contract in a highly-sensitive area.
Manual audit
ERC20
#0 - c4-pre-sort
2024-03-15T13:27:40Z
141345 marked the issue as primary issue
#1 - 141345
2024-03-15T13:27:40Z
change staking contract, miss approve update and reset
#2 - c4-pre-sort
2024-03-15T13:27:43Z
141345 marked the issue as sufficient quality report
#3 - 0xCalibur
2024-03-17T13:12:07Z
we can also upgrade the bootstrapper if we were to use the function. I agree with the finding but we just decided to remove the function setStaking
#4 - c4-sponsor
2024-03-17T13:12:09Z
0xCalibur (sponsor) acknowledged
#5 - c4-judge
2024-03-29T08:40:45Z
thereksfour marked the issue as satisfactory
#6 - c4-judge
2024-03-31T06:31:23Z
thereksfour marked the issue as selected for report
🌟 Selected for report: Trust
2068.8771 USDC - $2,068.88
The LockingMultiRewards allows anyone to stake MagicLP tokens in return for rewards. LP tokens are sent with either functions below:
function stake(uint256 amount, bool lock_) public whenNotPaused { _stakeFor(msg.sender, amount, lock_); } /// @notice Locks an existing unlocked balance. function lock(uint256 amount) public whenNotPaused { if (amount == 0) { revert ErrZeroAmount(); } _updateRewardsForUser(msg.sender); _balances[msg.sender].unlocked -= amount; unlockedSupply -= amount; _createLock(msg.sender, amount); }
This will lead to _createLock()
, which calculates the nextUnlockTime()
:
function _createLock(address user, uint256 amount) internal { Balances storage bal = _balances[user]; uint256 _nextUnlockTime = nextUnlockTime();
function nextUnlockTime() public view returns (uint256) { return nextEpoch() + lockDuration; }
Note that nextEpoch()
would always return the next 1-week (or reward duration) slot.
function epoch() public view returns (uint256) { return (block.timestamp / rewardsDuration) * rewardsDuration; } function nextEpoch() public view returns (uint256) { return epoch() + rewardsDuration; }
An issue arises because the user cannot specify the latest desired unlock time. This opens the path for the tokens to be locked for longer than expected, which may have significant impact for users if they need the funds. Consider the following case, where x
is week number:
7x + 7 + lockDuration
7x + 14
7x + 14 + lockDuration
This means user's funds are locked for an additional 7 days more than expected.
Note that delayed execution of a user's TX has always been considered in scope, certainly for Med severity impacts: - https://solodit.xyz/issues/m-13-interactions-with-amms-do-not-use-deadlines-for-operations-code4rena-paraspace-paraspace-contest-git - https://solodit.xyz/issues/m-04-lack-of-deadline-for-uniswap-amm-code4rena-asymmetry-finance-asymmetry-contest-git - https://solodit.xyz/issues/m-03-missing-deadline-param-in-swapexactamountout-allowing-outdated-slippage-and-allow-pending-transaction-to-be-executed-unexpectedly-code4rena-pooltogether-pooltogether-git
Essentially the locking function lacks a deadline
parameter similar to swapping functions, and the impact is temporary freeze of funds.
A user's tokens could be locked for an extended duration beyond their intention and without their control.
Manual audit
Consider adding a latestUnlockTime
deadline parameter for the locking functions.
Timing
#0 - c4-pre-sort
2024-03-15T12:35:26Z
141345 marked the issue as primary issue
#1 - 141345
2024-03-15T12:35:33Z
extend lock by others
#2 - c4-pre-sort
2024-03-15T12:35:36Z
141345 marked the issue as sufficient quality report
#3 - c4-sponsor
2024-03-15T12:54:04Z
0xCalibur (sponsor) acknowledged
#4 - 0xCalibur
2024-03-15T14:08:31Z
#5 - c4-sponsor
2024-03-15T16:01:16Z
0xCalibur marked the issue as disagree with severity
#6 - 0xCalibur
2024-03-15T16:01:25Z
Should be a low
#7 - thereksfour
2024-03-29T09:07:35Z
Here the latest discussion on transaction delays. https://github.com/code-423n4/2024-02-uniswap-foundation-findings/issues/331#issuecomment-2021292618
And I would consider it QA which is due to low likelihood and medium impact (temporary freeze of funds)
#8 - c4-judge
2024-03-29T09:07:48Z
thereksfour changed the severity to QA (Quality Assurance)
#9 - trust1995
2024-04-02T12:26:11Z
Hi,
I would like to argue that likelihood is medium and impact is high. likelihood - Assuming there will be many stakers on the platform, at any moment there could be a request close to the end of epoch. The closer the request is to the end of an epoch, the more likely it is a TX will not be executed until the next one. Additionally, the users of the stake() function are completely different in understanding/sophistication from the ones in the Unistaker contest. They cannot be assumed to understand TX delays, requirement of invalidating one's TX if it is undesirable, etc. Additionally one needs to take into consideration that gas prices are fluctuating, so censorship of a TX is absolutely not required for the FoF to occur. It just needs to be on a local low-point for the duration until the next interval for the impact to be achieved. Again, consider that this is not a targetted attack , the issue is a constant probabilistic leak that given enough time will materialize.
For impact, temporary FoF is a very serious issue, on the low end of the High impact (imo). Consider for example that a user assumes their tokens will be available at the unlock period in order to pay off a loan, and because of the FoF they will now default. This is just one example of hundred of different very serious scenarios that would occur to a user that assumes funds will be available in the future.
Considering all the arguments above, I believe the finding to be between M and H severity, rather than between L and M severity.
#10 - thereksfour
2024-04-04T15:59:32Z
Agreed, the lock duration is jumping, which may compromise user
#11 - c4-judge
2024-04-05T07:27:45Z
This previously downgraded issue has been upgraded by thereksfour
#12 - c4-judge
2024-04-05T07:30:13Z
thereksfour marked the issue as satisfactory
#13 - c4-judge
2024-04-05T07:30:19Z
thereksfour marked the issue as selected for report
🌟 Selected for report: Trust
2068.8771 USDC - $2,068.88
MagicLpAggregator is used to price LP tokens for "closely-tied" underlying tokens. It calculates the price below:
function latestAnswer() public view override returns (int256) { uint256 baseAnswerNomalized = uint256(baseOracle.latestAnswer()) * (10 ** (WAD - baseOracle.decimals())); uint256 quoteAnswerNormalized = uint256(quoteOracle.latestAnswer()) * (10 ** (WAD - quoteOracle.decimals())); uint256 minAnswer = baseAnswerNomalized < quoteAnswerNormalized ? baseAnswerNomalized : quoteAnswerNormalized; (uint256 baseReserve, uint256 quoteReserve) = _getReserves(); baseReserve = baseReserve * (10 ** (WAD - baseDecimals)); quoteReserve = quoteReserve * (10 ** (WAD - quoteDecimals)); return int256(minAnswer * (baseReserve + quoteReserve) / pair.totalSupply()); }
The code takes the minimal answer between the underlying oracles and considers all reserves to be worth that amount:
return int256(minAnswer * (baseReserve + quoteReserve) / pair.totalSupply());
The issue is that any difference in price between the assets represents an easy arbitrage opportunity. Suppose we have tokens (A,B), where real oracle shows:
The Pool has 1000000 LP tokens and contains:
The LP value would calculate as:
0.99 * 2000000 / 1000000 = $1.98
The actual value is:
(0.99 * 1000000 + 1 * 1000000) / 1000000 = $1.99
Suppose a platform trades LPs using the aggregator pricing. An attacker could:
The delta comes at the expense of LP holders whose position gets minimized.
Loss of value due to arbitrage of any platform using MagicLpAggregator pricing.
Manual audit
Always calculate the value based on the real underlying token value multiplied by amount.
Consider creating two separate oracles for lower-bound and upper-bound results. Then a lending protocol could indeed use the lower-bound for determining collateral value.
MEV
#0 - 0xm3rlin
2024-03-15T00:35:15Z
Intended behavior, disputed
#1 - c4-pre-sort
2024-03-15T14:34:05Z
141345 marked the issue as primary issue
#2 - c4-pre-sort
2024-03-15T14:34:13Z
141345 marked the issue as sufficient quality report
#3 - 141345
2024-03-15T14:34:51Z
rounding error could accumlate in MagicLpAggregator
#4 - c4-sponsor
2024-03-28T17:58:15Z
0xCalibur (sponsor) disputed
#5 - c4-judge
2024-03-29T09:15:17Z
thereksfour marked the issue as satisfactory
#6 - c4-judge
2024-03-31T07:18:30Z
thereksfour marked the issue as selected for report
271.4781 USDC - $271.48
The LockingMultiRewards contract facilitates distribution of rewards across the remaining epoch duration. The calculation for reward rate is provided below:
reward.rewardRate = amount / _remainingRewardTime;
The rate
is later multiplied by the duration to get the total rewards for elapsed time, demonstrated below:
function rewardsForDuration(address rewardToken) external view returns (uint256) { return _rewardData[rewardToken].rewardRate * rewardsDuration; }
An issue occurs because there is no sufficient wrapping of the amount before dividing by _remainingRewardTime
. The number is divided and later multiplied by elapsed time, causing a loss of precision of the amount modulo remaining time. For the provided period of 1 week
by the sponsor, the maximum amount lost can be calculated:
7 * 24 * 3600 - 1 = 604799
.
Note that the average case loss is half of the worst case assuming even distribution across time. However since rewards are usually not sent at the low end of remaining time (see the minRemainingTime
variable, the actual average would be higher).
The effect of this size of loss depends on the decimals and value of the reward token. For USDC, this would be $0.6. For WBTC, it would be $423 (at time of writing). The loss is shared between all stakers relative to their stake amount. The loss occurs for every notification, so it is clear losses will be severe.
Sponsor remarked in the channel that reward tokens would be real, popular tokens. We believe USDC / WBTC on ARB are extremely popular tokens and therefore remain fully in scope as reward tokens.
Impact - high Likelihood - medium -> Severity - high
Permanent loss of yield for stakers in reward pools due to precision loss.
Manual audit
Store the rewardRate
scaled by 1e18, so loss of precision will be lower by magnitude of 1e18
.
Math
#0 - 0xm3rlin
2024-03-15T00:15:38Z
irrelevant numbers of 6 USDC if using USDC
#1 - c4-pre-sort
2024-03-15T12:55:20Z
141345 marked the issue as duplicate of #166
#2 - c4-judge
2024-03-29T13:30:35Z
thereksfour changed the severity to QA (Quality Assurance)
#3 - trust1995
2024-04-06T08:44:10Z
Hi,
The duplicate has been closed due to loss of "dust amounts". However I show in my submission that it is far from dust, I believe any submissions that have identified material loss of funds should be awarded appropriately.
#4 - c4-judge
2024-04-07T08:52:28Z
This previously downgraded issue has been upgraded by thereksfour
#5 - c4-judge
2024-04-07T08:52:59Z
thereksfour marked the issue as satisfactory
#6 - c4-judge
2024-04-07T08:53:05Z
thereksfour marked the issue as selected for report
🌟 Selected for report: ether_sky
Also found by: 0x11singh99, 0xE1, 0xJaeger, Bauchibred, Bigsam, Bozho, Breeje, DarkTower, HChang26, SpicyMeatball, Trust, ZanyBonzy, albahaca, bareli, blutorque, grearlake, hals, hassan-truscova, hihen, oualidpro, pfapostol, ravikiranweb3, slvDev, zhaojie
15.328 USDC - $15.33
The MagicLP contract implements swapping through the sellBase()
and sellQuote()
functions. It calculates the out amount based on the input amount and the tx.origin
:
(receiveQuoteAmount, mtFee, newRState, newBaseTarget) = querySellBase(tx.origin, baseInput);
The origin is passed as trader
to the getFeeRate()
function to get the custom lpFeeRate, mtFeeRate for that user:
function querySellBase( address trader, uint256 payBaseAmount ) public view returns (uint256 receiveQuoteAmount, uint256 mtFee, PMMPricing.RState newRState, uint256 newBaseTarget) { PMMPricing.PMMState memory state = getPMMState(); (receiveQuoteAmount, newRState) = PMMPricing.sellBaseToken(state, payBaseAmount); // Passed here @@@@@@@ (uint256 lpFeeRate, uint256 mtFeeRate) = _MT_FEE_RATE_MODEL_.getFeeRate(trader, _LP_FEE_RATE_); mtFee = DecimalMath.mulFloor(receiveQuoteAmount, mtFeeRate); receiveQuoteAmount = receiveQuoteAmount - DecimalMath.mulFloor(receiveQuoteAmount, lpFeeRate) - mtFee; newBaseTarget = state.B0; }
The idea to use tx.origin
is due to the expected interaction with the contract through the Router
periphery contract, in this case the msg.sender
is not an accurate representation of the trader.
The issue is that when the trade is executed from a smart contract wallet, for example Gnosis Safe, tx.origin
is at the control of the attacker. They may observe the execTransaction()
call in the mempool and submit it by themselves, copying the parameters including the signatures. Control of the tx.origin
grants the attacker control over the lpFeeRate
and mtFeeRate
charged in the TX. This means that the victim could assume a certain fee for the swap, while they would be overcharged due to an attacker frontrunning the TX. Note that this attack works both on direct interactions with the MagicLP or TXs through the Router contract.
When a trader swaps from a smart contract wallet, anyone could make them lose additional value through the trade.
sellBaseTokensForTokens()
tx.origin
for fee determination will be the attacker instead of the real sender.trader
passed in the second argument. However that is not valid grounds for downgrading since the issue is in the MagicLP contract, which cannot make any assumptions on the non-usage of any passed parameters. It is very reasonable to assume the trader
parameter would be used, otherwise it wouldn't be passed to the function in a newly-written codebase.Without severity reduction from above it seems clear the loss of capital translates to high-severity.
Manual audit
Abolish the use of tx.origin
. The Router can pass the msg.sender
down to the MagicLP contract to determine the intended trader.
Note that the issue extends also to the flashloan()
function, to a lesser impact.
Invalid Validation
#0 - 0xCalibur
2024-03-14T18:29:00Z
It's by design and we don't plan to be using the trader parameter but If we were to use it we would take extra precautious by whitelisting allowed addresses.
#1 - c4-pre-sort
2024-03-15T14:32:34Z
141345 marked the issue as sufficient quality report
#2 - 141345
2024-03-15T14:32:42Z
tx.origin exploit need to take a close look at the POC
#3 - c4-pre-sort
2024-03-15T14:32:46Z
141345 marked the issue as primary issue
#4 - thereksfour
2024-03-28T16:38:59Z
The victim is smart wallet reduces its likelihood, and the issue has predictions of future code which further reduces its likelihood and impact. All things considered I would downgrade it to QA
#5 - c4-judge
2024-03-28T16:39:09Z
thereksfour changed the severity to QA (Quality Assurance)
#6 - trust1995
2024-04-02T13:37:12Z
Hi,
The baseline impact we are dealing with is loss of funds due to paying more fees than intended (High). I have made a case why speculation of future code is valid in this case in the description, but I agree it decreases likelihood somewhat (It does not decrease impact though). I don't think use of smart contract wallets should be out of scope since the contracts never check it and there aren't any remarks around that option anywhere. Users should be free to use whatever compliant wallet they would like while working with the contract safely. All things considered it seems the lowest the finding could get downgraded to is Medium.
#7 - thereksfour
2024-04-05T15:14:15Z
I still think predicting future code will reduce the impact of the issue, or it will directly reduce the severity of the issue.