Maia DAO Ecosystem - xuwinnie's results

Efficient liquidity renting and management across chains with Curvenized Uniswap V3.

General Information

Platform: Code4rena

Start Date: 30/05/2023

Pot Size: $300,500 USDC

Total HM: 79

Participants: 101

Period: about 1 month

Judge: Trust

Total Solo HM: 36

Id: 242

League: ETH

Maia DAO Ecosystem

Findings Distribution

Researcher Performance

Rank: 1/101

Findings: 19

Award: $40,561.97

🌟 Selected for report: 6

🚀 Solo Findings: 6

Findings Information

🌟 Selected for report: shealtielanz

Also found by: 0xStalin, 0xnev, Breeje, RED-LOTUS-REACH, kutugu, xuwinnie

Labels

bug
3 (High Risk)
satisfactory
duplicate-823

Awards

333.3031 USDC - $333.30

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/RootBridgeAgent.sol#L659-L696

Vulnerability details

Impact

There is no effective slippage control for _gasSwapIn and _gasSwapOut. The current approach only ensures an abundant level of liquidity within the pool but does not provide protection against sandwich attacks.

Proof of Concept

function _gasSwapIn(uint256 _amount, uint24 _fromChain) internal returns (uint256) { ...... (bool zeroForOneOnInflow, uint24 priceImpactPercentage, address gasTokenGlobalAddress, address poolAddress) = IPort(localPortAddress).getGasPoolInfo(_fromChain); (uint160 sqrtPriceX96,,,,,,) = IUniswapV3Pool(poolAddress).slot0(); uint160 exactSqrtPriceImpact = (sqrtPriceX96 * (priceImpactPercentage / 2)) / GLOBAL_DIVISIONER; uint160 sqrtPriceLimitX96 = zeroForOneOnInflow ? sqrtPriceX96 - exactSqrtPriceImpact : sqrtPriceX96 + exactSqrtPriceImpact; //Swap imbalanced token as long as we haven't used the entire amountSpecified and haven't reached the price limit try IUniswapV3Pool(poolAddress).swap( address(this), zeroForOneOnInflow, int256(_amount), sqrtPriceLimitX96, abi.encode(SwapCallbackData({tokenIn: gasTokenGlobalAddress})) ) returns (int256 amount0, int256 amount1) { return uint256(zeroForOneOnInflow ? amount1 : amount0); } catch (bytes memory) { _forceRevert(); return 0; } }

The sqrtPriceLimitX96 cannot effectively control slippage, as it still relies on sqrtPriceX96, which can be manipulated through sandwich attacks. As long as there is sufficient liquidity within the pool, the price impact will not be excessive, enabling the sandwich attack to suceed.

Tools Used

Manual

sqrtPriceLimitX96 should be specified via user's input.

Assessed type

Uniswap

#0 - c4-judge

2023-07-10T11:09:37Z

trust1995 marked the issue as unsatisfactory: Insufficient proof

#1 - c4-judge

2023-07-26T09:24:54Z

trust1995 marked the issue as satisfactory

#2 - c4-judge

2023-07-26T09:25:04Z

trust1995 marked the issue as duplicate of #823

Findings Information

🌟 Selected for report: Emmanuel

Also found by: xuwinnie

Labels

bug
3 (High Risk)
satisfactory
duplicate-688

Awards

1975.5809 USDC - $1,975.58

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/RootBridgeAgent.sol#L1140-L1156

Vulnerability details

Impact

Attacker can call retrieveDeposit with a future deposit nonce and anyFallback will be triggered to set the deposit status to Failed. Then, the attacker calls redeemDeposit to delete getDeposit[_depositNonce]. When a unlucky depositer makes a deposit with that nonce, everything will go as usual on branch chain, except that executionHistory[fromChainId][nonce] has already been marked as true on root chain. As a result, the root chain transaction will forcerevert() and the depositer has no way to get their assets back.

Proof of Concept

function retrieveDeposit(uint32 _depositNonce) external payable lock requiresFallbackGas { //Encode Data for cross-chain call. bytes memory packedData = abi.encodePacked(bytes1(0x08), _depositNonce, msg.value.toUint128(), uint128(0)); //Update State and Perform Call _sendRetrieveOrRetry(packedData); } function anyExecute(bytes calldata data) external virtual requiresExecutor returns (bool success, bytes memory result) { ...... /// DEPOSIT FLAG: 8 (retrieveDeposit) } else if (flag == 0x08) { //Get nonce uint32 nonce = uint32(bytes4(data[1:5])); //Check if tx has already been executed if (!executionHistory[fromChainId][uint32(bytes4(data[1:5]))]) { //Toggle Nonce as executed executionHistory[fromChainId][nonce] = true; //Retry failed fallback (success, result) = (false, ""); } else { _forceRevert(); //Return true to avoid triggering anyFallback in case of `_forceRevert()` failure return (true, "already executed tx"); } } ...... } function _redeemDeposit(uint32 _depositNonce) internal { Deposit storage deposit = getDeposit[_depositNonce]; // will be skipped since length is zero for (uint256 i = 0; i < deposit.hTokens.length;) { _clearToken(deposit.owner, deposit.hTokens[i], deposit.tokens[i], deposit.amounts[i], deposit.deposits[i]); unchecked { ++i; } } //Delete Failed Deposit Token Info delete getDeposit[_depositNonce]; }

Tools Used

Manual Review

Add a check in retrieveDeposit:

require(getDeposit[_depositNonce].owner == msg.sender);

Assessed type

Invalid Validation

#0 - c4-judge

2023-07-10T11:17:58Z

trust1995 marked the issue as unsatisfactory: Insufficient proof

#1 - c4-judge

2023-07-25T10:28:58Z

trust1995 marked the issue as satisfactory

#2 - c4-judge

2023-07-25T10:29:18Z

trust1995 marked the issue as duplicate of #688

Findings Information

🌟 Selected for report: Emmanuel

Also found by: xuwinnie

Labels

bug
3 (High Risk)
satisfactory
sponsor confirmed
duplicate-687

Awards

1975.5809 USDC - $1,975.58

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/RootBridgeAgent.sol#L50-L64

Vulnerability details

Impact

Users can bridge a mismatched pair of local hToken and underlying Token from branch chain to root chain, thereby infinitely minting any root hToken.

Proof of Concept

Below is the flow when a user bridges asset from branch chain to root chain:

function bridgeOut(address _depositor, address _localAddress, address _underlyingAddress, uint256 _amount, uint256 _deposit) external virtual requiresBridgeAgent { if (_amount - _deposit > 0) { _localAddress.safeTransferFrom(_depositor, address(this), _amount - _deposit); ERC20hTokenBranch(_localAddress).burn(_amount - _deposit); } if (_deposit > 0) { _underlyingAddress.safeTransferFrom( _depositor, address(this), _denormalizeDecimals(_deposit, ERC20(_underlyingAddress).decimals()) ); } }
function bridgeIn(address _recipient, DepositParams memory _dParams, uint24 _fromChain) public requiresAgentExecutor { //Check Deposit info from Cross Chain Parameters. if (!CheckParamsLib.checkParams(localPortAddress, _dParams, _fromChain)) { revert InvalidInputParams(); } //Get global address address globalAddress = IPort(localPortAddress).getGlobalTokenFromLocal(_dParams.hToken, _fromChain); //Check if valid asset if (globalAddress == address(0)) revert InvalidInputParams(); //Move hTokens from Branch to Root + Mint Sufficient hTokens to match new port deposit IPort(localPortAddress).bridgeToRoot(_recipient, globalAddress, _dParams.amount, _dParams.deposit, _fromChain); }
function checkParams(address _localPortAddress, DepositParams memory _dParams, uint24 _fromChain) internal view returns (bool) { if ( (_dParams.amount < _dParams.deposit) //Deposit can't be greater than amount. || (_dParams.amount > 0 && !IPort(_localPortAddress).isLocalToken(_dParams.hToken, _fromChain)) //Check local exists. || (_dParams.deposit > 0 && !IPort(_localPortAddress).isUnderlyingToken(_dParams.token, _fromChain)) //Check underlying exists. ) { return false; } return true; }
function bridgeToRoot(address _recipient, address _hToken, uint256 _amount, uint256 _deposit, uint24 _fromChainId) external requiresBridgeAgent { if (!isGlobalAddress[_hToken]) revert UnrecognizedToken(); if (_amount - _deposit > 0) _hToken.safeTransfer(_recipient, _amount - _deposit); if (_deposit > 0) mint(_recipient, _hToken, _deposit, _fromChainId); }

During the whole process, only _dParams.hToken is LocalToken and _dParams.token is UnderlyingToken are checked. However, no check is performed to ensure that _dParams.hToken corresponds to _dParams.token, which means user can use an arbitary underlying token as input and receive a diffrent (more valuable) hToken. Below are the steps that Alice receives arb-hETH by depositing LIN, a token created by herself:

  1. Alice creates a new ERC20 token "LIN" on BNB Chain and mints herself max(uint256)
  2. Alice addLocalToken for LIN at CoreBranchRouter
  3. Alice callOutAndBridge with _dParams = {hToken: bnb-hETH, Token: LIN, amount: 10e10, deposit: 10e10}
  4. bridgeOut at BranchPort charges Alice 10e10 LIN
  5. checkParams at root chain passes
  6. bridgeToRoot at RootPort mints Alice 10e10 arb-hETH

Tools Used

Manual

Add a check to ensure that local token and underlying token match:

function checkParams(address _localPortAddress, DepositParams memory _dParams, uint24 _fromChain) internal view returns (bool) { if ( (_dParams.amount < _dParams.deposit) //Deposit can't be greater than amount. || (_dParams.amount > 0 && !IPort(_localPortAddress).isLocalToken(_dParams.hToken, _fromChain)) //Check local exists. || (_dParams.deposit > 0 && IPort(_localPortAddress).getLocalTokenFromUnder(_dParams.token, _fromChain) != _dParams.hToken //Check underlying exists and matches. ) { return false; } return true; }

Assessed type

Invalid Validation

#0 - c4-judge

2023-07-10T09:46:26Z

trust1995 marked the issue as primary issue

#1 - c4-judge

2023-07-10T09:46:31Z

trust1995 marked the issue as satisfactory

#2 - c4-sponsor

2023-07-12T13:38:39Z

0xBugsy marked the issue as sponsor confirmed

#3 - c4-judge

2023-07-25T13:39:14Z

trust1995 marked the issue as selected for report

#4 - c4-judge

2023-07-26T09:22:28Z

trust1995 marked the issue as not selected for report

#5 - c4-judge

2023-07-31T14:35:05Z

trust1995 marked the issue as duplicate of #687

Findings Information

🌟 Selected for report: Voyvoda

Also found by: xuwinnie

Labels

bug
3 (High Risk)
satisfactory
sponsor confirmed
upgraded by judge
duplicate-645

Awards

1975.5809 USDC - $1,975.58

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/RootBridgeAgent.sol#L759

Vulnerability details

Impact

Excess gas fee is accumulated within root bridge agent as WETH. Attacker can retrySettlement by virtual account to steal the accumulated fee.

Proof of Concept

function retrySettlement(uint32 _settlementNonce, uint128 _remoteExecutionGas) external payable { //Update User Gas available. if (initialGas == 0) { userFeeInfo.depositedGas = uint128(msg.value); userFeeInfo.gasToBridgeOut = _remoteExecutionGas; } //Clear Settlement with updated gas. _retrySettlement(_settlementNonce); } function _retrySettlement(uint32 _settlementNonce) internal returns (bool) { Settlement memory settlement = getSettlement[_settlementNonce]; if (settlement.owner == address(0)) return false; bytes memory newGas = abi.encodePacked(_manageGasOut(settlement.toChain)); for (uint256 i = 0; i < newGas.length;) { settlement.callData[settlement.callData.length - 16 + i] = newGas[i]; unchecked { ++i; } } ...... _performCall(settlement.callData, settlement.toChain); return true; } function _manageGasOut(uint24 _toChain) internal returns (uint128) { uint256 amountOut; address gasToken; uint256 _initialGas = initialGas; ...... if (_initialGas > 0) { if (userFeeInfo.gasToBridgeOut <= MIN_FALLBACK_RESERVE * tx.gasprice) revert InsufficientGasForFees(); (amountOut, gasToken) = _gasSwapOut(userFeeInfo.gasToBridgeOut, _toChain); } else { if (msg.value <= MIN_FALLBACK_RESERVE * tx.gasprice) revert InsufficientGasForFees(); wrappedNativeToken.deposit{value: msg.value}(); (amountOut, gasToken) = _gasSwapOut(msg.value, _toChain); } IPort(localPortAddress).burn(address(this), gasToken, amountOut, _toChain); return amountOut.toUint128(); }

Steps:

  1. Prepare some failed settlements by callOutSigned with zero _remoteExecutionGas and non-arb toChain.
  2. callOutSigned with nonzero _remoteExecutionGas.
  3. Virtual account calls retrySettlement with a failed nonce. initialGas will be greater than zero and userFeeInfo.gasToBridgeOut amount of WETH will be swapped into remote chain gas token.
  4. Receive remaining gas token when _payExecutionGas at branch chain.
  5. Virtual account repeats 3 until accumulated WETH is drained.

Note: It's better to attack with a larger _remoteExecutionGas so that less gas will be wasted by multiple executions.

function _gasSwapIn(bytes memory gasData) internal virtual returns (uint256 gasAmount) { //Cast to uint256 gasAmount = uint256(uint128(bytes16(gasData))); //Move Gas hTokens from Branch to Root / Mint Sufficient hTokens to match new port deposit IPort(localPortAddress).withdraw(address(this), address(wrappedNativeToken), gasAmount); } function _payExecutionGas(address _recipient, uint256 _initialGas) internal virtual { uint256 gasRemaining = wrappedNativeToken.balanceOf(address(this)); wrappedNativeToken.withdraw(gasRemaining); ...... _replenishGas(minExecCost); SafeTransferLib.safeTransferETH(_recipient, gasRemaining - minExecCost); ...... }

Tools Used

Manual

  1. Store availableGas at the beginning of anyExecute:
uint256 availableGas = userFeeInfo.depositedGas - userFeeInfo.gasToBridgeOut;
  1. Delete userFeeInfo.gasToBridgeOut after _manageGasOut.
  2. Use availableGas as input for _payExecutionGas.

Assessed type

Reentrancy

#0 - c4-judge

2023-07-10T14:30:52Z

trust1995 marked the issue as primary issue

#1 - c4-judge

2023-07-10T14:30:57Z

trust1995 marked the issue as satisfactory

#2 - c4-judge

2023-07-10T14:31:11Z

trust1995 changed the severity to 2 (Med Risk)

#3 - c4-sponsor

2023-07-12T14:39:17Z

0xBugsy marked the issue as sponsor confirmed

#4 - c4-judge

2023-07-25T08:16:51Z

trust1995 changed the severity to 3 (High Risk)

#5 - c4-judge

2023-07-25T13:32:46Z

trust1995 marked the issue as selected for report

#6 - c4-judge

2023-07-27T14:52:35Z

trust1995 marked the issue as not selected for report

#7 - c4-judge

2023-07-27T14:53:52Z

trust1995 marked the issue as duplicate of #645

Findings Information

🌟 Selected for report: Evo

Also found by: xuwinnie

Labels

bug
3 (High Risk)
satisfactory
duplicate-612

Awards

1975.5809 USDC - $1,975.58

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/RootBridgeAgent.sol#L811 https://github.com/anyswap/multichain-smart-contracts/blob/645d0053d22ed63005b9414b5610879094932304/contracts/anycall/v7/AnycallV7Config.sol#L204

Vulnerability details

Impact

The premium is not taken into account when calculating minExecCost in _payExecutionGas. Transaction may cosume more gas than what has been replenished.

Proof of Concept

function _payExecutionGas(uint128 _depositedGas, uint128 _gasToBridgeOut, uint256 _initialGas, uint24 _fromChain) internal { ...... uint256 minExecCost = tx.gasprice * (MIN_EXECUTION_OVERHEAD + _initialGas - gasleft()); if (minExecCost > availableGas) { _forceRevert(); return; } _replenishGas(minExecCost); ...... }

The gas price is determined as tx.gasprice in _payExecutionGas, but the actual charge is tx.gasprice + _feeData.premium. A malicious user can consume an arbitrarily large amount of gas by making external calls at virtual account to generate shortfall. By repeating so, the gas budget of bridge agent will be depleted, and the entire system will become inoperable.

function chargeFeeOnDestChain(address _from, uint256 _prevGasLeft) external onlyAnycallContract { if (!_isSet(mode, FREE_MODE)) { uint256 gasUsed = _prevGasLeft + EXECUTION_OVERHEAD - gasleft(); uint256 totalCost = gasUsed * (tx.gasprice + _feeData.premium); uint256 budget = executionBudget[_from]; require(budget > totalCost, "no enough budget"); executionBudget[_from] = budget - totalCost; _feeData.accruedFees += uint128(totalCost); } }

Tools Used

Manual

Replace tx.gasprice by tx.gasprice + premium

Assessed type

DoS

#0 - c4-judge

2023-07-10T11:06:36Z

trust1995 marked the issue as duplicate of #612

#1 - c4-judge

2023-07-10T11:06:41Z

trust1995 marked the issue as satisfactory

Findings Information

🌟 Selected for report: peakbolt

Also found by: xuwinnie

Labels

bug
3 (High Risk)
satisfactory
duplicate-492

Awards

1975.5809 USDC - $1,975.58

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/RootBridgeAgent.sol#L244 https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/ArbitrumBranchPort.sol#L76-L84

Vulnerability details

Impact

When clearing tokens, _underlyingAddress.safeTransfer is called; however, the lock is missing on retrySettlement, making reentrancy possible.

Proof of Concept

All essential entry points of ulysses omnichain are modified with lock to prevent reentrancy. However, the lock is missing on retrySettlement at RootBridgeAgent.

function retrySettlement(uint32 _settlementNonce, uint128 _remoteExecutionGas) external payable { //Update User Gas available. if (initialGas == 0) { userFeeInfo.depositedGas = uint128(msg.value); userFeeInfo.gasToBridgeOut = _remoteExecutionGas; } //Clear Settlement with updated gas. _retrySettlement(_settlementNonce); }

When clearing tokens, an external call to _underlyingAddress is made. However, _underlyingAddress may be an arbitary contract since token is permissionlessly added, which means it could reenter retrySettlement to receive token multiple times.

function withdraw(address _recipient, address _underlyingAddress, uint256 _deposit) external override(IBranchPort, BranchPort) requiresBridgeAgent { _underlyingAddress.safeTransfer( _recipient, _denormalizeDecimals(_deposit, ERC20(_underlyingAddress).decimals()) ); }

Below are the steps of the attack, starting with 1 BNB on BNB Chain:

  1. Create a contract "XIN" on Arbitrum (the root chain).
contract XIN { string public name = "xiaoxin"; string public symbol = "XIN"; uint8 public decimals = 18; address private _virtualAccount; RootBridgeAgent private _rootBridgeAgent; uint256 private countdown = 0; uint256 private nonce = 0; constructor(address virtualAccount, address rootBridgeAgent) { _virtualAccount = virtualAccount; _rootBridgeAgent = RootBridgeAgent(rootBridgeAgent); } function transfer(address, uint256) external { if (countdown == 0) {return;} else { _rootBridgeAgent.retrySettlement(nonce, 0); --countdown; } } function initiate() external { require(msg.sender == _virtualAccount); countdown = 100; nonce = _rootBridgeAgent.settlementNonce(); } }
  1. addLocalToken for XIN at ArbitrumCoreBranchRouter.
  2. callOutSignedAndBridge (on Arbitrum) with _dParams = {hToken: arb-hXIN, token: XIN, amount: 1, deposit: 1} and receive 1 arb-hXIN at virtual account.
  3. callOutSignedAndBridge (on BNB Chain) with _dParams = {hToken: bnb-hBNB, token: BNB, amount: 1, deposit: 1} and receive 1 arb-hBNB at virtual account.
  4. callOut with params = abi.encode(0x03, [{target: XIN, calldata: abi.encode(selector("initiate()")}], {recipient: attacker, outputTokens: [arb-hXIN, arb-hBNB], amountsOut: [1, 1], depositsOut: [1, 0]}, Arb), initiate() will be called by the virtual account to change safeTransfer into reentrancy mode.
  5. retrySettlement is reentered 100 times and attacker receives 101 arb-hBNB.

Tools Used

Manual Review

Add lock for retrySettlement.

Assessed type

Reentrancy

#0 - c4-judge

2023-07-10T10:33:05Z

trust1995 marked the issue as duplicate of #492

#1 - c4-judge

2023-07-10T11:02:44Z

trust1995 marked the issue as satisfactory

#2 - trust1995

2023-07-26T09:42:21Z

Since both this and #492 specify the same exploitable function and same fix, they shall remain duplicates.

Findings Information

🌟 Selected for report: xuwinnie

Labels

bug
3 (High Risk)
judge review requested
primary issue
satisfactory
selected for report
sponsor confirmed
H-19

Awards

5707.2337 USDC - $5,707.23

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-amm/UlyssesPool.sol#L942-L1019

Vulnerability details

Impact

Users have two methods to add liquidity to the Ulysses Pool: "mint" and "deposit". However, the latter may return an inaccurate output, which could be exploited to drain the pool.

Proof of Concept

In the process to mint amount of share, the state change is A:(band, supply×weight) -> B:(band+update, (supply+amount)×weight). User pays amount-sum(posFee)+sum(negFee) of underlying to acquire amount of share. This approach is precise.

In the process to deposit amount of underlying, the simulated state change is A:(band, supply×weight) -> B:(band+update, (supply+amount)×weight). Then (posFee, negFee) is derived from the simulation of A->B. The actual state change is A:(band, supply×weight) -> B':(band+update+posFee, (supply+amount+sum(posFee)-sum(negFee))×weight). We denote the actual fee of A->B' as (posFee', negFee'). User pays amount of underlying to acquire amount+sum(posFee)-sum(negFee) of share. This approach would be acceptable if sum(pos')-sum(neg') >= sum(pos), but this inequality doesn't always hold. If sum(pos')-sum(neg') < sum(pos), insolvency occurs; if sum(pos')-sum(neg') < sum(pos)-sum(neg), user could take profit.

An example is given below:

amount = 10000000 supply = 1000000000000000013287555072 weight = [1, 59, 47] band = [99452334745147595191585509, 4253569467850027815346666, 216725069177793291903286517]

When Alice deposits 10000000 underlying, she will get 36215776 share. However, the pool actually worsens.

oldRebalancingFee = [0, 10519971631761767037843097, 18152377668510835770992] newRebalancingFee = [0, 10519971631761767000804564, 18152377668510882599904] oldMinusNew = [0, +37038533, -46828912]

Actually there should be a systemic approach to construct states of sum(pos')-sum(neg') < sum(pos)-sum(neg) for attacks. However, due to limited time, I have only conducted random tests. By continuously searching for profitable states and modifying the pool state accordingly, attackers can eventually drain the pool.

FAQ

Here are several questions that readers may have:

Q: Why there are three diffrent scenarios? Why could insolvency and user loss happen simultaneously? A: Imagine when you deposit 100$ to a bank, the bank increases your balance by 80$ and claims itself has got 120$.

Q: Why can sum(pos')-sum(neg') >= sum(pos) not hold? A: Difficult question! Roughly this could happen when amount is significantly smaller than supply and posFee is excessively large.

Q: How can the pool be modified to a target state? A: There are several methods including "mint", "redeem" and "swap", but the "deposit" method should not be used until we reach the target state because attacker will mostly experience losses from that.

Q: Why can the attacker eventually drain the pool? A:When "mint", "redeem" or "swap", the attacker pays exactly the delta value of _calculateRebalancingFee. However, when making a "deposit", the attacker receives more than what they deserve. At last, by adding liquidity, _calculateRebalancingFee can be reduced, so the pool will be drained.

Q: Why don't you provide a coded POC of attack? A: We know "deposit" is dangerous and we deprecate it, that's enough.

Tools Used

Python

# -*- coding: utf-8 -*- """ Created on Mon Jun 19 10:24:56 2023 @author: xuwinnie """ from random import * def getBandwidthUpdateAmounts(roundUp, positiveTransfer, amount, _totalWeights, _totalSupply): # Get the bandwidth state list length global length, weightArray, bandwithArray # Initialize bandwidth update amounts bandwidthUpdateAmounts = [0] * length # Initialize bandwidth differences from target bandwidth diffs = [0] * length # Total difference from target bandwidth of all bandwidth states totalDiff = 0 # Total difference from target bandwidth of all bandwidth states transfered = 0 # Total amount to be distributed according to each bandwidth weights transferedChange = 0 for i in range(length): # Load bandwidth and weight from storage # Bandwidth is the first 248 bits of the slot bandwidth = bandwithArray[i] # Weight is the last 8 bits of the slot weight = weightArray[i] # Calculate the target bandwidth targetBandwidth = (_totalSupply * weight) // _totalWeights # Calculate the difference from the target bandwidth if positiveTransfer: # If the transfer is positive, calculate deficit from target bandwidth if targetBandwidth > bandwidth: # Calculate the difference diff = targetBandwidth - bandwidth # Add the difference to the total difference totalDiff += diff # Store the difference in the diffs array diffs[i] = diff else: # If the transfer is negative, calculate surplus from target bandwidth if bandwidth > targetBandwidth: # Calculate the difference diff = bandwidth - targetBandwidth # Add the difference to the total difference totalDiff += diff # Store the difference in the diffs array diffs[i] = diff # Calculate the amount to be distributed according deficit/surplus # and/or the amount to be distributed according to each bandwidth weights if amount > totalDiff: # If the amount is greater than the total deficit/surplus # Total deficit/surplus is distributed transfered = totalDiff # Set rest to be distributed according to each bandwidth weights transferedChange = amount - totalDiff else: # If the amount is less than the total deficit/surplus # Amount will be distributed according to deficit/surplus transfered = amount for i in range(length): # Increase/decrease amount of bandwidth for each bandwidth state bandwidthUpdate = 0 # If there is a deficit/surplus, calculate the amount to be distributed if transfered > 0: # Load the difference from the diffs array diff = diffs[i] # Calculate the amount to be distributed according to deficit/surplus if roundUp: bandwidthUpdate = (transfered * diff + totalDiff - 1) // totalDiff else: bandwidthUpdate = (transfered * diff) // totalDiff # If there is a rest, calculate the amount to be distributed according to each bandwidth weights if transferedChange > 0: # Load weight from storage weight = weightArray[i] # Calculate the amount to be distributed according to each bandwidth weights if roundUp: bandwidthUpdate += (transferedChange * weight + _totalWeights - 1) // _totalWeights else: bandwidthUpdate += (transferedChange * weight) // _totalWeights # If there is an update in bandwidth if bandwidthUpdate > 0: # Store the amount to be updated in the bandwidthUpdateAmounts array bandwidthUpdateAmounts[i] = bandwidthUpdate return (bandwidthUpdateAmounts, length) def updateBandwidth(depositFees, positiveTransfer, destinationState, difference, _totalWeights, _totalSupply, _newTotalSupply): global weightArray, bandwithArray print(" updating "+str(destinationState)+" with diffrence "+str(difference)) bandwidth = bandwithArray[destinationState] print(" old bandwith "+str(bandwidth)) weight = weightArray[destinationState] # Get the target bandwidth targetBandwidth = (_totalSupply * weight) // _totalWeights # Get the rebalancing fee prior to updating the bandwidth oldRebalancingFee = calculateRebalancingFee(bandwidth, targetBandwidth, positiveTransfer) if positiveTransfer: # If the transfer is positive # Add the difference to the bandwidth bandwidth += difference else: # If the transfer is negative # Subtract the difference from the bandwidth bandwidth -= difference if _newTotalSupply > 0: # True on deposit, mint and redeem # Get the new target bandwidth after total supply change targetBandwidth = (_newTotalSupply * weight) // _totalWeights # Get the rebalancing fee after updating the bandwidth newRebalancingFee = calculateRebalancingFee(bandwidth, targetBandwidth, positiveTransfer) positiveFee, negativeFee = 0, 0 if newRebalancingFee < oldRebalancingFee: # If new fee is lower than old fee # Calculate the positive fee positiveFee = oldRebalancingFee - newRebalancingFee print(" positiveFee "+str(positiveFee)) if depositFees: # If depositFees is true, add the positive fee to the bandwidth bandwidth += positiveFee else: # If new fee is higher than old fee if newRebalancingFee > oldRebalancingFee: # Calculate the negative fee negativeFee = newRebalancingFee - oldRebalancingFee print(" negativeFee "+str(negativeFee)) #raise Exception("good") else: print(" no fee") # Update storage with the new bandwidth bandwithArray[destinationState] = bandwidth print(" new bandwith "+str(bandwidth)) return (positiveFee, negativeFee) def calculateRebalancingFee(bandwidth, targetBandwidth, roundDown): # If the bandwidth is larger or equal to the target bandwidth, return 0 if bandwidth >= targetBandwidth: return 0 # Fee tier 1 (fee % divided by 2) lambda1 = int(20e14) # Fee tier 2 (fee % divided by 2) lambda2 = int(4980e14) # Get sigma2 from the first 8 bytes of the fee slot sigma2 = int(500e14) # Get sigma1 from the next 8 bytes of the fee slot sigma1 = int(6000e14) # Calculate the upper bound for the first fee upperBound1 = (targetBandwidth * sigma1) // DIVISIONER # Calculate the upper bound for the second fee upperBound2 = (targetBandwidth * sigma2) // DIVISIONER if bandwidth >= upperBound1: return 0 maxWidth = upperBound1 - upperBound2 # If the bandwidth is smaller than upperBound2 if bandwidth >= upperBound2: # Calculate the fee for the first interval fee = calcFee(lambda1, maxWidth, upperBound1, bandwidth, 0, roundDown) else: # Calculate the fee for the first interval fee = calcFee(lambda1, maxWidth, upperBound1, upperBound2, 0, roundDown) # offset = lambda1 * 2 lambda1 *= 2 # Calculate the fee for the second interval fee2 = calcFee(lambda2, upperBound2, upperBound2, bandwidth, lambda1, roundDown) # Add the two fees together fee += fee2 return fee def calcFee(feeTier, maxWidth, upperBound, bandwidth, offset, roundDown): # Calculate the height of the trapezium height = upperBound - bandwidth # Calculate the width of the trapezium, rounded up width = ((height * feeTier + maxWidth - 1) // maxWidth) + offset # Calculate the fee for this tier if roundDown: fee = (width * height) // DIVISIONER else: fee = (width * height + DIVISIONER - 1) // DIVISIONER return fee def mint(amount): print("minting "+str(amount)+" underlying") global LPBalance, UnderBalance, totalWeights, totalSupply, poolBalance _totalWeights = totalWeights _totalSupply = totalSupply _newTotalSupply = _totalSupply + amount bandwidthUpdateAmounts, length = getBandwidthUpdateAmounts(True, True, amount, _totalWeights, _newTotalSupply) output = 0 negativeFee = 0 i = 0 while i < length: updateAmount = bandwidthUpdateAmounts[i] if updateAmount > 0: output += updateAmount _positiveFee, _negativeFee = updateBandwidth(False, True, i, updateAmount, _totalWeights, _totalSupply, _newTotalSupply) if _positiveFee > 0: negativeFee += _positiveFee else: output += _negativeFee i += 1 if negativeFee > output: #raise Exception("Underflow()") pass output -= negativeFee LPBalance += output if output > UnderBalance: raise Exception("Underflow()") UnderBalance -= output totalSupply += amount poolBalance += output print("receiving "+str(output)+" lp") print() def deposit(amount): print("depositing "+str(amount)+" underlying") global LPBalance, UnderBalance, totalWeights, totalSupply, poolBalance _totalWeights = totalWeights _totalSupply = totalSupply _newTotalSupply = _totalSupply + amount bandwidthUpdateAmounts, length = getBandwidthUpdateAmounts(False, True, amount, _totalWeights, _newTotalSupply) output = 0 negativeFee = 0 i = 0 while i < length: updateAmount = bandwidthUpdateAmounts[i] if updateAmount > 0: output += updateAmount _positiveFee, _negativeFee = updateBandwidth(True, True, i, updateAmount, _totalWeights, _totalSupply, _newTotalSupply) if _positiveFee > 0: output += _positiveFee else: negativeFee += _negativeFee i += 1 if negativeFee > output: raise Exception("Underflow()") output -= negativeFee LPBalance += output if amount > UnderBalance: raise Exception("Underflow()") UnderBalance -= amount totalSupply += output poolBalance += amount print("receiving "+str(output)+" lp") print() def redeem(amount): print("redeeming "+str(amount)+" lp") global LPBalance, UnderBalance, totalWeights, totalSupply, poolBalance totalSupply -= amount if amount > LPBalance: raise Exception("Underflow()") LPBalance -= amount _totalWeights = totalWeights _newTotalSupply = totalSupply _totalSupply = _newTotalSupply + amount bandwidthUpdateAmounts, length = getBandwidthUpdateAmounts(False, False, amount, _totalWeights, _totalSupply) output = 0 negativeFee = 0 i = 0 while i < length: updateAmount = bandwidthUpdateAmounts[i] if updateAmount > 0: output += updateAmount _positiveFee, _negativeFee = updateBandwidth(False, False, i, updateAmount, _totalWeights, _totalSupply, _newTotalSupply) #if _positiveFee > 0: #raise Exception("nooooo()") negativeFee += _negativeFee i += 1 if negativeFee > output: raise Exception("Underflow()") output -= negativeFee UnderBalance += output poolBalance -= output print("receiving "+str(output)+" underlying") print() def getIdealPoolBalance(): global length, bandwithArray, weightArray, totalWeights, totalSupply assets = 0 for i in range(length): targetBandwidth = totalSupply * weightArray[i] // totalWeights assets += calculateRebalancingFee(bandwithArray[i], targetBandwidth, False) #print(calculateRebalancingFee(bandwithArray[i], targetBandwidth, False)) assets += bandwithArray[i] #print(bandwithArray[i]) return assets def getFeeStatus(): global length, bandwithArray, weightArray, totalWeights, totalSupply assets = 0 for i in range(length): targetBandwidth = totalSupply * weightArray[i] // totalWeights assets += calculateRebalancingFee(bandwithArray[i], targetBandwidth, False) print(i) print(calculateRebalancingFee(bandwithArray[i], targetBandwidth, False)) return assets ''' cnt = 0 cnttt = 0 recordinso = [] recordluck = [] for i in range(100000): DIVISIONER = int(1e18) length = 3 bandwithArray = [randint(0, int(1e27)) for _ in range(length)] weightArray = [1] + [randint(1, 100) for _ in range(length - 1)] #weightArray = [1, 1000] totalWeights = sum(weightArray) totalSupply = int(1e27) poolBalance = getIdealPoolBalance() UnderBalance = 0 beforeFee = getFeeStatus() #amount = randint(0, int(1e25)) amount = 10000000000 LPBalance = amount redeem(amount) afterFee = getFeeStatus() if poolBalance < getIdealPoolBalance(): print(str(poolBalance)+" insolvency! "+str(getIdealPoolBalance())) cnt += 1 recordinso.append(getIdealPoolBalance() - poolBalance) raise Exception("Strange()") if UnderBalance + afterFee > amount + beforeFee : print("lucky!") recordluck.append(UnderBalance + afterFee - amount - beforeFee) cnttt += 1 print(i) print(cnt) print(cnttt) ''' cnt = 0 cnttt = 0 recordinso = [] recordluck = [] recordcomp = [] for i in range(1): DIVISIONER = int(1e18) length = 3 #bandwithArray = [randint(0, int(1e27)) for _ in range(length)] bandwithArray = [99452334745147595191585509, 4253569467850027815346666, 216725069177793291903286517] #weightArray = [1] + [randint(1, 100) for _ in range(length - 1)] weightArray = [1, 59, 47] totalWeights = sum(weightArray) totalSupply = int(1e27) poolBalance = getIdealPoolBalance() LPBalance = 0 UnderBalance = int(1e26) beforeFee = getFeeStatus() #amount = randint(0, int(1e10)) amount = 10000000 deposit(amount) afterFee = getFeeStatus() if poolBalance < getIdealPoolBalance(): print(str(poolBalance)+" insolvency! "+str(getIdealPoolBalance())) cnt += 1 recordinso.append(getIdealPoolBalance() - poolBalance) recordcomp.append(LPBalance + afterFee - amount - beforeFee) #raise Exception("good") if LPBalance + afterFee > amount + beforeFee: print("lucky!") cnttt += 1 recordluck.append(LPBalance + afterFee - amount - beforeFee) #break print(i) print(cnt) print(cnttt)

Deprecate the "deposit" method. It is hard to find a correct way to handle this.

Assessed type

Context

#0 - c4-judge

2023-07-11T06:22:45Z

trust1995 marked the issue as primary issue

#1 - c4-judge

2023-07-11T06:22:50Z

trust1995 marked the issue as satisfactory

#2 - 0xLightt

2023-07-12T21:47:27Z

Hey, was able to recreate this issue in solidity. But finding the actual issue is essential to make sure this is actually being addressed and there isn't any more issue due to this. Blindly removing the deposit function and hoping this fully fixes this is not a sensible approach.

Added these mock functions to UlyssesPool to help recreate this issue:

    function setTotalSupply(uint256 _totalSupply) external {
        totalSupply = _totalSupply;
    }

    function addBandwidthTest(uint248 bandwidth, uint8 weight) external {
        totalWeights += weight;
        bandwidthStateList.push(
            BandwidthState({bandwidth: bandwidth, destination: UlyssesPool(address(0)), weight: weight})
        );
    }

    function getRebalancingFee(uint256 index) external view returns (uint256) {
        return _calculateRebalancingFee(
            bandwidthStateList[index].bandwidth, totalSupply.mulDiv(bandwidthStateList[index].weight, totalWeights), false
        );
    }

Then added this test to InvariantUlyssesPoolBounded to recreate your example:

    function test_435() public {
        setUpHandler();

        vm.startPrank(handler);

        UlyssesPool[] memory pools = createPools(1);
        UlyssesPool pool1 = UlyssesPool(pools[0]);

        MockERC20(pool1.asset()).mint(address(handler), type(uint256).max / 2);
        MockERC20(pool1.asset()).mint(address(pool1), 100000000000 ether);
        MockERC20(pool1.asset()).approve(address(pool1), type(uint256).max);

        pool1.setTotalSupply(1000000000000000013287555072);

        pool1.addBandwidthTest(99452334745147595191585509, 1);
        pool1.addBandwidthTest(4253569467850027815346666, 59);
        pool1.addBandwidthTest(216725069177793291903286517, 47);

        console2.log(pool1.getRebalancingFee(1), pool1.getRebalancingFee(2), pool1.getRebalancingFee(3));
        uint256 feeBefore = pool1.getRebalancingFee(1) + pool1.getRebalancingFee(2) + pool1.getRebalancingFee(3);

        pool1.deposit(10000000, address(handler));

        console2.log(pool1.getRebalancingFee(1), pool1.getRebalancingFee(2), pool1.getRebalancingFee(3));
        uint256 feeAfter = pool1.getRebalancingFee(1) + pool1.getRebalancingFee(2) + pool1.getRebalancingFee(3);

        // Should revert but doesn't
        console2.log(feeAfter - feeBefore);
    }

#3 - c4-sponsor

2023-07-12T21:47:45Z

0xLightt requested judge review

#4 - c4-sponsor

2023-07-12T23:28:04Z

0xLightt marked the issue as sponsor confirmed

#5 - xuwinnie

2023-07-13T01:53:49Z

Hey, was able to recreate this issue in solidity. But finding the actual issue is essential to make sure this is actually being addressed and there isn't any more issue due to this. Blindly removing the deposit function and hoping this fully fixes this is not a sensible approach.

Hey, when redeeming & minting, calculation is share -> amount, but when depositing, calculation is amount -> share, so I believe removing 'deposit' is the best way. In equation amount = share + rebalancingfee(before) - rebalancingfee(after), if we know share, it's straight forward to get amount, but it's hard to get share from amount. The current approach in 'deposit' is just an approximation, and that's why it can be exploited.

#6 - 0xLightt

2023-07-18T19:46:43Z

Thanks for giving more context, I understand why you are suggesting to remove the deposit function as a fix, especially due to the time constraints you mentioned. I just want to make sure this is not being caused by any underlying issue that can still affect other functions.

After looking into it more, it is exactly what you suggested, the issue comes from siphoning the _newTotalSupply when doing calculations, because we are not accounting for shares minted due to rebalancing fees. A possible solution could be to overestimate the new total supply, but it would lead to users overpaying in certain situations, because of this, the deposit function wouldn't make sense to be used over mint, so it is safer to just remove it.

#7 - c4-judge

2023-07-25T13:25:11Z

trust1995 marked the issue as selected for report

#8 - 0xLightt

2023-07-28T13:05:10Z

We recognize the audit's findings on Ulysses AMM. These will not be rectified due to the upcoming migration of this section to Balancer Stable Pools.

Findings Information

🌟 Selected for report: xuwinnie

Also found by: xuwinnie

Labels

bug
3 (High Risk)
primary issue
satisfactory
selected for report
sponsor confirmed
H-20

Awards

5707.2337 USDC - $5,707.23

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-amm/UlyssesPool.sol#L539-L671

Vulnerability details

Impact

The goal with bandwidths is to have a maximum that can be withdrawn (swapped) from a pool, so in case a specific chain (or token from a chain) is exploited, then it only can partially affect these pools. However, the maximum limit can be bypassed by repeatedly "balancing" the pool to increase bandwidth for the exploited chain.

Introducing "Balancing": A Technique for Redistributing Bandwidth

During ulyssesAddLP or ulyssesAddLP, liquidity is first distributed or taken proportionally to diff (if any exists), then distributed or taken proportionally to weight. Suppose integer t is far smaller than diff (since the action itself can also change diff), after repeatedly adding t LP, removing t lp, adding t LP, removing t Lp ...... the pool will finally reach another stable state where the ratio of diff to weight is a constant among destinations. This implies that the currentBandwidth will be proportional to weight.

Proof of Concept

Suppose Avalanche is down. Unluckily, Alice holds 100 ava-hETH. She wants to swap ava-hETH for bnb-hETH.

Let's take a look at bnb-hETH pool. Suppose weights are mainnet:4, Avalanche:3, Linea:2. Total supply is 90. Target bandwidths are mainnet:40, Avalanche:30, Linea:20. Current bandwidths are mainnet:30, Avalanche:2(few left), Linea:22.

Ideally Alice should only be able to swap for 2 bnb-hETH. However, she swaps for 0.1 bnb-hETH first. Then she uses the 0.1 bnb-hETH to "balance" the pool (as mentioned above). Current bandwidths will become mainnet:24, Avalanche:18, Linea:12. Then Alice swaps for 14 bnb-hETH and "balance" the pool again. By repeating the process, she can acquire nearly all of the available liquidity in pool and LP loss will be unbounded.

Tools Used

Manual Review

  1. During ulyssesAddLP or ulyssesAddLP, always distribute or take liquidity proportionally to weight.
  2. When swapping A for B, reduce bandwidth of A in B pool (as is currently done) while add bandwidth of B in A pool (instead of distributing them among all bandwidths).

Assessed type

Context

#0 - c4-judge

2023-07-10T20:25:57Z

trust1995 marked the issue as primary issue

#1 - c4-judge

2023-07-10T20:26:02Z

trust1995 marked the issue as satisfactory

#2 - c4-sponsor

2023-07-12T00:23:28Z

0xLightt marked the issue as sponsor confirmed

#3 - c4-judge

2023-07-25T13:25:51Z

trust1995 marked the issue as selected for report

#4 - 0xLightt

2023-07-28T13:05:16Z

We recognize the audit's findings on Ulysses AMM. These will not be rectified due to the upcoming migration of this section to Balancer Stable Pools.

Findings Information

🌟 Selected for report: xuwinnie

Also found by: xuwinnie

Labels

bug
3 (High Risk)
satisfactory
duplicate-392

Awards

5707.2337 USDC - $5,707.23

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-amm/UlyssesPool.sol#L680-L765

Vulnerability details

Impact

Traders could "balance" the pool at the end of a swap to not pay or pay less rebalancing fees.

Introducing "Balancing": A Technique for Redistributing Bandwidth

During ulyssesAddLP or ulyssesAddLP, liquidity is first distributed or taken proportionally to diff (if any exists), then distributed or taken proportionally to weight. Suppose integer t is far smaller than diff (since the action itself can also change diff), after repeatedly adding t LP, removing t lp, adding t LP, removing t Lp ...... the pool will finally reach another stable state where the ratio of diff to weight is a constant among destinations. This implies that the currentBandwidth will be proportional to weight.

Proof of Concept

function _calculateRebalancingFee(uint256 bandwidth, uint256 targetBandwidth, bool roundDown) internal view returns (uint256 fee) { // If the bandwidth is larger or equal to the target bandwidth, return 0 if (bandwidth >= targetBandwidth) return 0; // Upper bound of the first fee interval uint256 upperBound1; // Upper bound of the second fee interval uint256 upperBound2; // Fee tier 1 (fee % divided by 2) uint256 lambda1; // Fee tier 2 (fee % divided by 2) uint256 lambda2; // @solidity memory-safe-assembly assembly { // Load the rebalancing fee slot to get the fee parameters let feeSlot := sload(fees.slot) // Get sigma2 from the first 8 bytes of the fee slot let sigma2 := shr(192, feeSlot) // Get sigma1 from the next 8 bytes of the fee slot let sigma1 := and(shr(128, feeSlot), 0xffffffffffffffff) // Get lambda2 from the next 8 bytes of the fee slot lambda2 := and(shr(64, feeSlot), 0xffffffffffffffff) // Get lambda1 from the last 8 bytes of the fee slot lambda1 := and(feeSlot, 0xffffffffffffffff) // Equivalent to require(denominator != 0 && (y == 0 || x <= type(uint256).max / y)) if mul(sigma1, gt(targetBandwidth, div(not(0), sigma1))) { // Store the function selector of `MulDivFailed()`. mstore(0x00, 0xad251c27) // Revert with (offset, size). revert(0x1c, 0x04) } // Calculate the upper bound for the first fee upperBound1 := div(mul(targetBandwidth, sigma1), DIVISIONER) // Equivalent to require(denominator != 0 && (y == 0 || x <= type(uint256).max / y)) if mul(sigma2, gt(targetBandwidth, div(not(0), sigma2))) { // Store the function selector of `MulDivFailed()`. mstore(0x00, 0xad251c27) // Revert with (offset, size). revert(0x1c, 0x04) } // Calculate the upper bound for the second fee upperBound2 := div(mul(targetBandwidth, sigma2), DIVISIONER) } if (bandwidth >= upperBound1) return 0; uint256 maxWidth; /// @solidity memory-safe-assembly assembly { // Calculate the maximum width of the trapezium maxWidth := sub(upperBound1, upperBound2) } // If the bandwidth is smaller than upperBound2 if (bandwidth >= upperBound2) { // Calculate the fee for the first interval fee = calcFee(lambda1, maxWidth, upperBound1, bandwidth, 0, roundDown); } else { // Calculate the fee for the first interval fee = calcFee(lambda1, maxWidth, upperBound1, upperBound2, 0, roundDown); /// @solidity memory-safe-assembly assembly { // offset = lambda1 * 2 lambda1 := shl(1, lambda1) } // Calculate the fee for the second interval uint256 fee2 = calcFee(lambda2, upperBound2, upperBound2, bandwidth, lambda1, roundDown); /// @solidity memory-safe-assembly assembly { // Add the two fees together fee := add(fee, fee2) } } }

The marginal fee of liquidity is a function of the ratio between the current bandwidth and the target bandwidth. Therefore, when we "balance" it, we also achieve the global optimal state for rebalancing fee. In other words, if a trader performs a swap and then "balance" the pool, they can reclaim a portion of the fees they paid. Additionally, if a pool is not "balanced", one can also "balance" it to profit from arbitrage.

Tools Used

Manual Review

Incentivize current assets (the sum of current bandwidths) instead of incentivizing current bandwidths separately.

Assessed type

Context

#0 - c4-judge

2023-07-11T05:34:32Z

trust1995 marked the issue as duplicate of #392

#1 - c4-judge

2023-07-11T05:34:36Z

trust1995 marked the issue as satisfactory

Findings Information

🌟 Selected for report: peakbolt

Also found by: Voyvoda, kodyvim, xuwinnie

Labels

bug
3 (High Risk)
satisfactory
duplicate-385

Awards

800.1103 USDC - $800.11

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/RootBridgeAgent.sol#L1263

Vulnerability details

Impact

Excess gas are accumulated as WETH. However, sweep transfers ETH.

Proof of Concept

function sweep() external { if (msg.sender != daoAddress) revert NotDao(); uint256 _accumulatedFees = accumulatedFees - 1; accumulatedFees = 1; SafeTransferLib.safeTransferETH(daoAddress, _accumulatedFees); }

As below, the contract receive WETH at _gasSwapIn. Then, WETH is partially unwrapped at _replenishGas. Excess gas are stored as WETH.

function _gasSwapIn(uint256 _amount, uint24 _fromChain) internal returns (uint256) { //Get fromChain's Gas Pool Info (bool zeroForOneOnInflow, uint24 priceImpactPercentage, address gasTokenGlobalAddress, address poolAddress) = IPort(localPortAddress).getGasPoolInfo(_fromChain); ...... //Swap imbalanced token as long as we haven't used the entire amountSpecified and haven't reached the price limit try IUniswapV3Pool(poolAddress).swap( address(this), zeroForOneOnInflow, int256(_amount), sqrtPriceLimitX96, abi.encode(SwapCallbackData({tokenIn: gasTokenGlobalAddress})) ) returns (int256 amount0, int256 amount1) { return uint256(zeroForOneOnInflow ? amount1 : amount0); } catch (bytes memory) { _forceRevert(); return 0; } } function _payExecutionGas(uint128 _depositedGas, uint128 _gasToBridgeOut, uint256 _initialGas, uint24 _fromChain) internal { ...... //Replenish Gas _replenishGas(minExecCost); //Account for excess gas accumulatedFees += availableGas - minExecCost; } function _replenishGas(uint256 _executionGasSpent) internal { //Unwrap Gas wrappedNativeToken.withdraw(_executionGasSpent); IAnycallConfig(IAnycallProxy(localAnyCallAddress).config()).deposit{value: _executionGasSpent}(address(this)); }

Tools Used

Manual

Transfer WETH in sweep.

Assessed type

Token-Transfer

#0 - c4-judge

2023-07-10T09:55:30Z

trust1995 marked the issue as duplicate of #385

#1 - c4-judge

2023-07-10T09:55:34Z

trust1995 marked the issue as satisfactory

Findings Information

🌟 Selected for report: xuwinnie

Labels

bug
3 (High Risk)
primary issue
satisfactory
selected for report
sponsor confirmed
edited-by-warden
H-23

Awards

5707.2337 USDC - $5,707.23

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/RootBridgeAgent.sol#L1233-L1241

Vulnerability details

Impact

forceRevert() withdraws all deposited gas budget of Root Bridge Agent to ensure that failed AnyCall execution will not be charged. However, if forceRevert() took place during a call made by virtual account, gas can be replenished later manually. As a result, the AnyCall execution will succeed but all withdrawn gas will be frozen.

Proof of Concept

function anyExecute(bytes calldata data) external virtual requiresExecutor returns (bool success, bytes memory result) { uint256 _initialGas = gasleft(); uint24 fromChainId; UserFeeInfo memory _userFeeInfo; if (localAnyCallExecutorAddress == msg.sender) { initialGas = _initialGas; (, uint256 _fromChainId) = _getContext(); fromChainId = _fromChainId.toUint24(); _userFeeInfo.depositedGas = _gasSwapIn( uint256(uint128(bytes16(data[data.length - PARAMS_GAS_IN:data.length - PARAMS_GAS_OUT]))), fromChainId).toUint128(); _userFeeInfo.gasToBridgeOut = uint128(bytes16(data[data.length - PARAMS_GAS_OUT:data.length])); } else { fromChainId = localChainId; _userFeeInfo.depositedGas = uint128(bytes16(data[data.length - 32:data.length - 16])); _userFeeInfo.gasToBridgeOut = _userFeeInfo.depositedGas; } if (_userFeeInfo.depositedGas < _userFeeInfo.gasToBridgeOut) { _forceRevert(); return (true, "Not enough gas to bridge out"); } userFeeInfo = _userFeeInfo; // execution part ............ if (initialGas > 0) { _payExecutionGas(userFeeInfo.depositedGas, userFeeInfo.gasToBridgeOut, _initialGas, fromChainId); } }

To implement the attack, attacker callOutSigned on a branch chain to bypass lock. On the root chain, virtual account makes three external calls.

  1. retryDeposit at Arbitrum Branch Bridge Agent with an already executed nonce. The call will forceRevert() and initialGas will be nonzero since it has not been modified by reentering. As a result, all execution gas budget will be withdrawn.
function _forceRevert() internal { if (initialGas == 0) revert GasErrorOrRepeatedTx(); IAnycallConfig anycallConfig = IAnycallConfig(IAnycallProxy(localAnyCallAddress).config()); uint256 executionBudget = anycallConfig.executionBudget(address(this)); // Withdraw all execution gas budget from anycall for tx to revert with "no enough budget" if (executionBudget > 0) try anycallConfig.withdraw(executionBudget) {} catch {} }
  1. callOut at Arbitrum Branch Bridge Agent. The call should succeed and initialGas is deleted.
function _payExecutionGas(uint128 _depositedGas, uint128 _gasToBridgeOut, uint256 _initialGas, uint24 _fromChain) internal { delete(initialGas); delete(userFeeInfo); if (_fromChain == localChainId) return;
  1. Directly deposit a small amount of gas at Anycall Config to ensure the success of the transaction.
function deposit(address _account) external payable { executionBudget[_account] += msg.value; emit Deposit(_account, msg.value); }

Then, the original call proceeds and _payExecutionGas will be skipped. The call will succeed, with all withdrawn gas budget permanently frozen. (In current implementation, ETH can be sweeped to dao address, but this is another mistake, sweep should transfer WETH instead.)

Tools Used

Manual

Add a msg.sender check in _forceRevert to ensure local call will be directly reverted.

Assessed type

Reentrancy

#0 - c4-judge

2023-07-10T14:14:47Z

trust1995 marked the issue as primary issue

#1 - c4-judge

2023-07-10T14:15:23Z

trust1995 marked the issue as satisfactory

#2 - c4-sponsor

2023-07-12T14:22:48Z

0xBugsy marked the issue as sponsor confirmed

#3 - c4-judge

2023-07-25T13:34:23Z

trust1995 marked the issue as selected for report

#4 - peakbolt

2023-07-28T00:20:25Z

This is an interesting attack vector. However, the impact seems like a Medium as the attack cost could be higher than the frozen execution gas budget, lowering the incentive for such an attack.That is because the attacker has to pay the tx cost and also deposit gas to the AnycallConfig for the attack to succeed. And the execution gas budget in RootBridgeAgent is likely negligible as it is intended to be replenished by the user.

#5 - xuwinnie

2023-07-28T01:48:31Z

This is an interesting attack vector. However, the impact seems like a Medium as the attack cost could be higher than the frozen execution gas budget, lowering the incentive for such an attack.That is because the attacker has to pay the tx cost and also deposit gas to the AnycallConfig for the attack to succeed. And the execution gas budget in RootBridgeAgent is likely negligible as it is intended to be replenished by the user.

Hey @peakbolt! Actually, it could DOS the entire cross chain message sending. "If the gas fee isn't enough when you call anycall, the tx wouldn't execute until you top up with enough gas fees. This status would be reflected in the api." according to anycall V7 doc. (rip multichain) If root bridge agent has zero budget, tx will not execute, but no user is incentivized to top it up manually. The system heavily relies on the pre-deposited gas. To make it clearer, Suppose when deploying, team top up 5 unit gas. A user's tx cost 1 unit gas, then 1 unit gas is replenished. However, if the 5 unit gas is removed, the tx won't execute at all!

#6 - 0xBugsy

2023-07-31T10:28:10Z

This is an interesting attack vector. However, the impact seems like a Medium as the attack cost could be higher than the frozen execution gas budget, lowering the incentive for such an attack.That is because the attacker has to pay the tx cost and also deposit gas to the AnycallConfig for the attack to succeed. And the execution gas budget in RootBridgeAgent is likely negligible as it is intended to be replenished by the user.

Hey @peakbolt! Actually, it could DOS the entire cross chain message sending. "If the gas fee isn't enough when you call anycall, the tx wouldn't execute until you top up with enough gas fees. This status would be reflected in the api." according to anycall V7 doc. (rip multichain) If root bridge agent has zero budget, tx will not execute, but no user is incentivized to top it up manually. The system heavily relies on the pre-deposited gas. To make it clearer, Suppose when deploying, team top up 5 unit gas. A user's tx cost 1 unit gas, then 1 unit gas is replenished. However, if the 5 unit gas is removed, the tx won't execute at all!

System should execute tx as long as executionBudget is > 0, but you are correct if this value reaches 0 execution will be stopped until gas is topped up and this can be continuously depleted which is completely undesired.

#7 - 0xBugsy

2023-07-31T10:29:39Z

We recognize the audit's findings on Anycall Gas Management. These will not be rectified due to the upcoming migration of this section to LayerZero.

Findings Information

🌟 Selected for report: xuwinnie

Labels

bug
3 (High Risk)
satisfactory
selected for report
sponsor confirmed
edited-by-warden
H-24

Awards

5707.2337 USDC - $5,707.23

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/CoreBranchRouter.sol#L78

Vulnerability details

Impact

Malicious user can deliberately set a irrelevant (or even poisonous) local hToken for an underlying token, as anyone can directly access _addLocalToken at root chain without calling addLocalToken at branch chain first.

Proof of Concept

function addLocalToken(address _underlyingAddress) external payable virtual { //Get Token Info string memory name = ERC20(_underlyingAddress).name(); string memory symbol = ERC20(_underlyingAddress).symbol(); //Create Token ERC20hToken newToken = ITokenFactory(hTokenFactoryAddress).createToken(name, symbol); //Encode Data bytes memory data = abi.encode(_underlyingAddress, newToken, name, symbol); //Pack FuncId bytes memory packedData = abi.encodePacked(bytes1(0x02), data); //Send Cross-Chain request (System Response/Request) IBridgeAgent(localBridgeAgentAddress).performCallOut{value: msg.value}(msg.sender, packedData, 0, 0); }

The intended method to add a new local token for an underlying is by calling function addLocalToken at the branch chain. However, it appears that the last line of code, IBridgeAgent(localBridgeAgentAddress).performCallOut{value: msg.value}(msg.sender, packedData, 0, 0); uses performCallOut instead of performSystemCallOut. This means that users can directly callOut at branch bridge agent with _params = abi.encodePacked(bytes1(0x02), abi.encode(_underlyingAddress, anyContract, name, symbol)) to invoke _addLocalToken at the root chain without calling addLocalToken first. As a result, they may set an arbitrary contract as the local token. It's worth noting that the impact is irreversible as there is no mechanism to modify or delete local tokens, meaning that the underlying token can never be properly bridged in the future.

The branch hToken is called by function bridgeIn when redeemDeposit or clearToken:

function bridgeIn(address _recipient, address _localAddress, uint256 _amount) external virtual requiresBridgeAgent { ERC20hTokenBranch(_localAddress).mint(_recipient, _amount); }

Below are several potetial exploiting methods:

  1. If a regular ERC20 contract with admin minting permissions is set, exploiter can mint unlimited amount of local token for himself. By bridging them, he can receive an arbitrary amount of global token at the root chain.
  2. If an unrelated contract with empty mint function is set, the underlying asset would be unable to be bridged in from the root chain, and users who attempt to do so could lose their assets.
  3. If a malicious contract is set, gas griefing is possible.
  4. This contract may serve as an intermediary for reentrancy. (I haven't found a concrete way so far but there is a potential risk)

Tools Used

Manual

Use performSystemCallOut and executeSystemRequest to send Cross-Chain request for adding local token.

Assessed type

Access Control

#0 - c4-judge

2023-07-10T10:20:47Z

trust1995 marked the issue as primary issue

#1 - trust1995

2023-07-10T10:21:04Z

Would like sponsor's input.

#2 - c4-judge

2023-07-10T10:21:09Z

trust1995 marked the issue as satisfactory

#3 - c4-sponsor

2023-07-12T13:42:53Z

0xBugsy marked the issue as sponsor confirmed

#4 - 0xBugsy

2023-07-12T13:44:02Z

In fact the performSystemCallout should be used there and not performCallout since this demands passing execution through the router first

#5 - c4-judge

2023-07-25T13:38:29Z

trust1995 marked issue #687 as primary and marked this issue as a duplicate of 687

#6 - c4-judge

2023-07-26T09:21:23Z

trust1995 marked the issue as not a duplicate

#7 - c4-judge

2023-07-26T09:21:43Z

trust1995 marked the issue as selected for report

#8 - 0xLightt

2023-09-06T22:42:41Z

Findings Information

🌟 Selected for report: 0xTheC0der

Also found by: Atree, BLOS, BPZ, Fulum, KupiaSec, SpicyMeatball, bin2chen, jasonxiale, lsaudit, minhquanym, xuwinnie, zzzitron

Labels

bug
3 (High Risk)
satisfactory
upgraded by judge
duplicate-275

Awards

95.3782 USDC - $95.38

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-amm/UlyssesToken.sol#L72

Vulnerability details

Impact

assetId of lastAsset is incorrectly updated in function removeAsset, leading to data coruption.

Proof of Concept

function removeAsset(address asset) external nonReentrant onlyOwner { // No need to check if index is 0, it will underflow and revert if it is 0 uint256 assetIndex = assetId[asset] - 1; uint256 newAssetsLength = assets.length - 1; if (newAssetsLength == 0) revert CannotRemoveLastAsset(); totalWeights -= weights[assetIndex]; address lastAsset = assets[newAssetsLength]; // Id should be index + 1 assetId[lastAsset] = assetIndex; assets[assetIndex] = lastAsset; weights[assetIndex] = weights[newAssetsLength]; assets.pop(); weights.pop(); assetId[asset] = 0; emit AssetRemoved(asset); updateAssetBalances(); asset.safeTransfer(msg.sender, asset.balanceOf(address(this))); }

When removing an asset, the algorithm moves the last asset to the position of the asset to be removed. However, the Id of the last asset is updated as assetId[lastAsset] = assetIndex; breaking the relationship id = index + 1. In future operations, unexpected outcomes may occur due to this error.

Tools Used

Manual review

assetId[lastAsset] = assetIndex + 1;

Assessed type

Context

#0 - c4-judge

2023-07-09T16:32:06Z

trust1995 marked the issue as duplicate of #703

#1 - c4-judge

2023-07-09T16:33:55Z

trust1995 marked the issue as satisfactory

#2 - c4-judge

2023-07-11T17:20:41Z

trust1995 changed the severity to 2 (Med Risk)

#3 - c4-judge

2023-07-11T17:20:49Z

trust1995 changed the severity to 3 (High Risk)

Findings Information

🌟 Selected for report: zzebra83

Also found by: xuwinnie

Labels

bug
3 (High Risk)
satisfactory
duplicate-183

Awards

1975.5809 USDC - $1,975.58

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/BranchBridgeAgent.sol#L1061-L1072 https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/BranchBridgeAgent.sol#L1410-L1412

Vulnerability details

Impact

requiresFallbackGas cannot guarantee adequate gas for anyFallback. If fallback execution _forceRevert(), the user can no longer redeemDeposit and their asset will be permanently lost.

Proof of Concept

function _payFallbackGas(uint32 _depositNonce, uint256 _initialGas) internal virtual { uint256 gasLeft = gasleft(); uint256 minExecCost = tx.gasprice * (MIN_FALLBACK_RESERVE + _initialGas - gasLeft); if (minExecCost > getDeposit[_depositNonce].depositedGas) { _forceRevert(); return; } ...... } function _requiresFallbackGas() internal view virtual { if (msg.value <= MIN_FALLBACK_RESERVE * tx.gasprice) revert InsufficientGas(); }

There are two possibilities that deposited gas would be insufficient for fallback execution:

  1. msg.value is only slightly larger than tx.gasprice * MIN_FALLBACK_RESERVE but smaller than tx.gasprice * (MIN_FALLBACK_RESERVE + _initialGas - gasLeft)
  2. tx.gasprice rises during two transactions.

Tools Used

Manual review

Charge and replenish fallback gas during the initial deposit call and never forceRevert() fallback execution (consequence is severe).

Assessed type

Context

#0 - c4-judge

2023-07-10T09:30:50Z

trust1995 marked the issue as satisfactory

#1 - c4-judge

2023-07-10T09:31:05Z

trust1995 marked the issue as duplicate of #786

#2 - c4-judge

2023-07-26T09:33:56Z

trust1995 marked the issue as not a duplicate

#3 - c4-judge

2023-07-26T09:34:05Z

trust1995 marked the issue as duplicate of #183

Findings Information

🌟 Selected for report: ltyu

Also found by: BPZ, Koolex, RED-LOTUS-REACH, xuwinnie, yellowBirdy

Labels

bug
3 (High Risk)
satisfactory
duplicate-91

Awards

432.0595 USDC - $432.06

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/BranchBridgeAgent.sol#L1006-L1011

Vulnerability details

Impact

Wrong AnycallFlag FLAG_ALLOW_FALLBACK is used in _performCall of BranchBridgeAgent. Gas are credited locally instead of remotely, despite gas has already been deposited to bridge out.

Proof of Concept

function _performCall(bytes memory _calldata) internal virtual { //Sends message to AnycallProxy IAnycallProxy(localAnyCallAddress).anyCall( rootBridgeAgentAddress, _calldata, rootChainId, AnycallFlags.FLAG_ALLOW_FALLBACK, "" ); }

According to the doc, Execution gas fees are credited to the recipient contract (Bridge Agent) deducting the gas spent from this contract's executionBudget kept in the AnycallConfig contract.

Tools Used

Manual

Use flag FLAG_ALLOW_FALLBACK_DST

function _performCall(bytes memory _calldata) internal virtual { //Sends message to AnycallProxy IAnycallProxy(localAnyCallAddress).anyCall( rootBridgeAgentAddress, _calldata, rootChainId, AnycallFlags.FLAG_ALLOW_FALLBACK_DST, "" ); }

Assessed type

Context

#0 - c4-judge

2023-07-09T11:59:06Z

trust1995 marked the issue as duplicate of #91

#1 - c4-judge

2023-07-09T11:59:09Z

trust1995 marked the issue as satisfactory

Findings Information

🌟 Selected for report: 0xTheC0der

Also found by: xuwinnie

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
edited-by-warden
duplicate-869

Awards

592.6743 USDC - $592.67

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/BranchBridgeAgent.sol#L408

Vulnerability details

Impact

When a remote call with deposit(settlement) fails at bridge agent while it is not force reverted, anyFallback will be triggered to set the deposit(settlement) status to Failed. Then, the user should call redeemDeposit(Settlement) to get their assets back. However, if someone calls retryDeposit(Settlement), the status will be set to Success. The remote call will _forceRevert() and anyFallback will not be triggered again, which means the assets will be permanently lost.

function anyExecute(bytes calldata data) external virtual requiresExecutor returns (bool success, bytes memory result) { ...... if (executionHistory[fromChainId][nonce]) { _forceRevert(); //Return true to avoid triggering anyFallback in case of `_forceRevert()` failure return (true, "already executed tx"); } ...... }

Proof of Concept

function redeemDeposit(uint32 _depositNonce) external lock { if (getDeposit[_depositNonce].status != DepositStatus.Failed) { revert DepositRedeemUnavailable(); } _redeemDeposit(_depositNonce); } function retryDeposit( bool _isSigned, uint32 _depositNonce, bytes calldata _params, uint128 _remoteExecutionGas, uint24 _toChain ) external payable lock requiresFallbackGas { if (getDeposit[_depositNonce].owner != msg.sender) revert NotDepositOwner(); ...... getDeposit[_depositNonce].status = DepositStatus.Success; _performCall(packedData); }

Thanks to the owner check, this situation can only occur as a result of user mistake when depositing.

function redeemSettlement(uint32 _depositNonce) external lock { address depositOwner = getSettlement[_depositNonce].owner; if (getSettlement[_depositNonce].status != SettlementStatus.Failed || depositOwner == address(0)) { revert SettlementRedeemUnavailable(); } else if ( msg.sender != depositOwner && msg.sender != address(IPort(localPortAddress).getUserAccount(depositOwner)) ) { revert NotSettlementOwner(); } _redeemSettlement(_depositNonce); } function _retrySettlement(uint32 _settlementNonce) internal returns (bool) { Settlement memory settlement = getSettlement[_settlementNonce]; if (settlement.owner == address(0)) return false; bytes memory newGas = abi.encodePacked(_manageGasOut(settlement.toChain)); for (uint256 i = 0; i < newGas.length;) { settlement.callData[settlement.callData.length - 16 + i] = newGas[i]; unchecked { ++i; } } Settlement storage settlementReference = getSettlement[_settlementNonce]; settlementReference.gasToBridgeOut = userFeeInfo.gasToBridgeOut; settlementReference.callData = settlement.callData; settlementReference.status = SettlementStatus.Success; _performCall(settlement.callData, settlement.toChain); return true; }

However, when it comes to settlement, there is no owner check in retrySettlement. As a result, any malicious user can call retrySettlement to freeze the assets of innocent users whenever they detect a failed settlement.

Tools Used

Manual

Add a check in retryDeposit(Settlement):

require(getDeposit[_depositNonce].status != DepositStatus.Failed);

Assessed type

Invalid Validation

#0 - c4-judge

2023-07-10T10:04:27Z

trust1995 marked the issue as duplicate of #314

#1 - c4-judge

2023-07-10T10:04:34Z

trust1995 marked the issue as satisfactory

#2 - c4-judge

2023-07-25T14:08:07Z

trust1995 marked the issue as unsatisfactory: Invalid

#3 - c4-judge

2023-07-26T09:28:56Z

trust1995 marked the issue as satisfactory

#4 - c4-judge

2023-07-26T09:31:29Z

trust1995 marked the issue as unsatisfactory: Invalid

#5 - trust1995

2023-07-26T09:31:51Z

The main points of #869 were not stated, therefore the issue remains a dup of #314.

#6 - xuwinnie

2023-07-26T09:36:47Z

@trust1995 Hi, I think it was mentioned at "However, when it comes to settlement, there is no owner check in retrySettlement. As a result, any malicious user can call retrySettlement to freeze the assets of innocent users whenever they detect a failed settlement."

#7 - trust1995

2023-07-26T10:22:15Z

I have noticed that part, however the griefing idea via frontrunning is an essential component of #869 and therefore the ruling stands.

#8 - xuwinnie

2023-07-26T10:33:50Z

I have noticed that part, however the griefing idea via frontrunning is an essential component of #869 and therefore the ruling stands.

@trust1995 Yeah, I agree frontrunning 'retryRedeem' is a way to exploit this. But the same effect could be achieved if attacker call 'retryDeposit' right after they've detected a failed settlement (does not have to be right before 'retryRedeem', through a frontrun)

#9 - c4-judge

2023-07-26T10:49:52Z

trust1995 marked the issue as not a duplicate

#10 - c4-judge

2023-07-26T10:50:15Z

trust1995 marked the issue as duplicate of #869

#11 - c4-judge

2023-07-26T13:58:22Z

trust1995 marked the issue as satisfactory

#12 - c4-judge

2023-07-27T08:24:47Z

trust1995 changed the severity to 2 (Med Risk)

Findings Information

🌟 Selected for report: kutugu

Also found by: kodyvim, xuwinnie

Labels

bug
2 (Med Risk)
downgraded by judge
partial-50
edited-by-warden
duplicate-679

Awards

177.8023 USDC - $177.80

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/RootBridgeAgent.sol#L1219-L1222 https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/RootBridgeAgent.sol#L848-L852

Vulnerability details

Impact

depositGasAnycallConfig will fail because an extra wrappedNativeToken.withdraw is made.

Proof of Concept

function depositGasAnycallConfig() external payable { _replenishGas(msg.value); } function _replenishGas(uint256 _executionGasSpent) internal { wrappedNativeToken.withdraw(_executionGasSpent); IAnycallConfig(IAnycallProxy(localAnyCallAddress).config()).deposit{value: _executionGasSpent}(address(this)); }

When an EOA calls depositGasAnycallConfig, ether is sent to the contract. However, an unnecessary wrappedNativeToken.withdraw is performed.

Tools Used

Manual

Move wrappedNativeToken.withdraw(_executionGasSpent); outside _replenishGas to Line 1172

function anyExecute(bytes calldata data) { ...... if (initialGas > 0) { ++ wrappedNativeToken.withdraw(_executionGasSpent); _payExecutionGas(userFeeInfo.depositedGas, userFeeInfo.gasToBridgeOut, _initialGas, fromChainId); } }

Assessed type

Context

#0 - c4-judge

2023-07-10T11:09:02Z

trust1995 marked the issue as duplicate of #305

#1 - c4-judge

2023-07-10T11:09:06Z

trust1995 marked the issue as satisfactory

#2 - c4-judge

2023-07-11T17:27:27Z

trust1995 changed the severity to 2 (Med Risk)

#3 - c4-judge

2023-07-25T13:36:51Z

trust1995 marked the issue as partial-50

#4 - trust1995

2023-07-26T09:44:58Z

Issue was reduced to partial 50 because not enough context was given, especially when submitting the issue as high severity, which requires concrete proof of correctness.

Findings Information

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
duplicate-534

Awards

23.9127 USDC - $23.91

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/uni-v3-staker/UniswapV3Staker.sol#L342

Vulnerability details

Impact

Incorrect parameter is used for _unstakeToken() in function "restakeToken". This mistake prevents users from restaking other users' token after incentive ends.

Proof of Concept

function restakeToken(uint256 tokenId) external {
    IncentiveKey storage incentiveId = stakedIncentiveKey[tokenId];
    if (incentiveId.startTime != 0) _unstakeToken(incentiveId, tokenId, true);
    ......
}

The third parameter of _unstakeToken is set to true. However, based on the context in the "restakeToken" function, it should be false to enable anyone to call restakeToken if the block time is after the end time of incentive.

function _unstakeToken(IncentiveKey memory key, uint256 tokenId, bool isNotRestake) private {
    ......
    // anyone can call restakeToken if the block time is after the end time of the incentive
    if ((isNotRestake || block.timestamp < endTime) && owner != msg.sender) revert NotCalledByOwner();
    ......
}

Set the third parameter to false.

if (incentiveId.startTime != 0) _unstakeToken(incentiveId, tokenId, false);

Assessed type

Context

#0 - c4-judge

2023-07-10T08:56:21Z

trust1995 marked the issue as duplicate of #745

#1 - c4-judge

2023-07-10T08:56:27Z

trust1995 marked the issue as satisfactory

#2 - c4-judge

2023-07-11T17:13:24Z

trust1995 changed the severity to 2 (Med Risk)

Findings Information

🌟 Selected for report: xuwinnie

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
selected for report
sponsor confirmed
edited-by-warden
M-30

Awards

1712.1701 USDC - $1,712.17

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/RootBridgeAgent.sol#L798-L805

Vulnerability details

Impact

Virtual account can perform external calls during root chain execution process. If it callOut at Arbitrum Branch Bridge Agent, anyExecute in Root Bridge Agent will be reentered. lock will not work if user initiated the process on another branch chain. _payExecutionGas will not charge gas for the reentrancy call, Meanwhile, storage variable initialGas and userFeeInfo will be deleted. As a result, no gas will be charged for the original call.

Proof of Concept

function anyExecute(bytes calldata data) external virtual requiresExecutor returns (bool success, bytes memory result) { uint256 _initialGas = gasleft(); uint24 fromChainId; UserFeeInfo memory _userFeeInfo; if (localAnyCallExecutorAddress == msg.sender) { initialGas = _initialGas; (, uint256 _fromChainId) = _getContext(); fromChainId = _fromChainId.toUint24(); _userFeeInfo.depositedGas = _gasSwapIn( uint256(uint128(bytes16(data[data.length - PARAMS_GAS_IN:data.length - PARAMS_GAS_OUT]))), fromChainId).toUint128(); _userFeeInfo.gasToBridgeOut = uint128(bytes16(data[data.length - PARAMS_GAS_OUT:data.length])); } else { fromChainId = localChainId; _userFeeInfo.depositedGas = uint128(bytes16(data[data.length - 32:data.length - 16])); _userFeeInfo.gasToBridgeOut = _userFeeInfo.depositedGas; } if (_userFeeInfo.depositedGas < _userFeeInfo.gasToBridgeOut) { _forceRevert(); return (true, "Not enough gas to bridge out"); } userFeeInfo = _userFeeInfo; // execution part ............ if (initialGas > 0) { _payExecutionGas(userFeeInfo.depositedGas, userFeeInfo.gasToBridgeOut, _initialGas, fromChainId); } }
function _payExecutionGas(uint128 _depositedGas, uint128 _gasToBridgeOut, uint256 _initialGas, uint24 _fromChain) internal { delete(initialGas); delete(userFeeInfo); if (_fromChain == localChainId) return; uint256 availableGas = _depositedGas - _gasToBridgeOut; uint256 minExecCost = tx.gasprice * (MIN_EXECUTION_OVERHEAD + _initialGas - gasleft()); if (minExecCost > availableGas) { _forceRevert(); return; } _replenishGas(minExecCost); accumulatedFees += availableGas - minExecCost; }

During the reentrancy call, initialGas will not be modified before the execution part; _payExecutionGas will be invoked, but it will directly return after deleting initialGas and userFeeInfo. As a result, after the execution part of original call, _payExecutionGas will be passed as initialGas is now zero.

Tools Used

Manual

Store initialGas and userFeeInfo in memory as local variable inside anyExecute.

Assessed type

Reentrancy

#0 - c4-judge

2023-07-10T14:06:37Z

trust1995 marked the issue as unsatisfactory: Insufficient proof

#1 - c4-judge

2023-07-25T10:28:39Z

trust1995 marked the issue as satisfactory

#2 - c4-judge

2023-07-25T10:28:43Z

trust1995 changed the severity to 2 (Med Risk)

#3 - c4-sponsor

2023-07-25T18:03:10Z

0xBugsy marked the issue as sponsor confirmed

#4 - 0xBugsy

2023-07-31T10:25:19Z

We recognize the audit's findings on Anycall Gas Management. These will not be rectified due to the upcoming migration of this section to LayerZero.

Findings Information

🌟 Selected for report: xuwinnie

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
selected for report
sponsor confirmed
M-31

Awards

1712.1701 USDC - $1,712.17

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/RootBridgeAgent.sol#L823 https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/BranchBridgeAgent.sol#L1044

Vulnerability details

Impact

Incorrect accounting logic for fallback gas will lead to insolvency.

Proof of Concept

// on root chain function _payExecutionGas(uint128 _depositedGas, uint128 _gasToBridgeOut, uint256 _initialGas, uint24 _fromChain) internal { ...... uint256 availableGas = _depositedGas - _gasToBridgeOut; uint256 minExecCost = tx.gasprice * (MIN_EXECUTION_OVERHEAD + _initialGas - gasleft()); if (minExecCost > availableGas) { _forceRevert(); return; } _replenishGas(minExecCost); //Account for excess gas accumulatedFees += availableGas - minExecCost; } // on branch chain function _payFallbackGas(uint32 _depositNonce, uint256 _initialGas) internal virtual { ...... IPort(localPortAddress).withdraw(address(this), address(wrappedNativeToken), minExecCost); wrappedNativeToken.withdraw(minExecCost); _replenishGas(minExecCost); }

As above, when paying execution gas on the root chain, the excessive gas is added to accumulatedFees. So theoretically all deposited gas is used up and no gas has been reserved for anyFallback on the branch chain. The withdrawl in _payFallbackGas on branch chain will cause insolvency.

// on branch chain function _payExecutionGas(address _recipient, uint256 _initialGas) internal virtual { ...... uint256 gasLeft = gasleft(); uint256 minExecCost = tx.gasprice * (MIN_EXECUTION_OVERHEAD + _initialGas - gasLeft); if (minExecCost > gasRemaining) { _forceRevert(); return; } _replenishGas(minExecCost); //Transfer gas remaining to recipient SafeTransferLib.safeTransferETH(_recipient, gasRemaining - minExecCost); ...... } } // on root chain function _payFallbackGas(uint32 _settlementNonce, uint256 _initialGas) internal virtual { uint256 gasLeft = gasleft(); uint256 minExecCost = tx.gasprice * (MIN_FALLBACK_RESERVE + _initialGas - gasLeft); if (minExecCost > getSettlement[_settlementNonce].gasToBridgeOut) { _forceRevert(); return; } getSettlement[_settlementNonce].gasToBridgeOut -= minExecCost.toUint128(); }

As above, when paying execution gas on the branch chain, the excessive gas has be sent to the recipent. So therotically all deposited gas is used up and no gas has been reserved for anyFallback on the root chain. _payFallbackGas does not _replenishGas, which will cause insolvency of gas budget in AnycallConfig.

Tools Used

Manual

Deduct fallback gas from deposited gas.

Assessed type

Context

#0 - c4-judge

2023-07-10T09:55:11Z

trust1995 marked the issue as duplicate of #385

#1 - c4-judge

2023-07-10T09:55:16Z

trust1995 marked the issue as satisfactory

#2 - c4-sponsor

2023-07-25T18:44:25Z

0xBugsy marked the issue as sponsor confirmed

#3 - c4-judge

2023-07-25T20:27:24Z

trust1995 marked the issue as not a duplicate

#4 - c4-judge

2023-07-25T20:27:29Z

trust1995 marked the issue as primary issue

#5 - c4-judge

2023-07-25T20:27:36Z

trust1995 marked the issue as selected for report

#6 - c4-sponsor

2023-07-27T19:10:16Z

0xBugsy marked the issue as sponsor acknowledged

#7 - c4-sponsor

2023-07-27T19:10:21Z

0xBugsy marked the issue as sponsor confirmed

#8 - 0xBugsy

2023-07-28T13:24:19Z

We recognize the audit's findings on Anycall Gas Management. These will not be rectified due to the upcoming migration of this section to LayerZero.

#9 - c4-judge

2023-07-28T13:33:55Z

trust1995 marked the issue as not selected for report

#10 - c4-judge

2023-07-28T13:34:04Z

trust1995 changed the severity to 2 (Med Risk)

#11 - c4-judge

2023-07-28T13:34:17Z

trust1995 marked the issue as duplicate of #786

#12 - xuwinnie

2023-07-28T14:10:11Z

Hey, I believe this is not a dup of #786. This issue is mainly about accounting logic. I have described two scenes

  1. execute on root & fallback on branch: insolvency of port's weth balance.
  2. execute on branch & fallback on root: insolvency of budget.

Even though fix from #786 is applied, the accounting logic is still incorrect, If port's balance is reduced, it comes to scene 1: insolvency of port's balance.

#13 - xuwinnie

2023-07-28T14:12:42Z

And this issue will cause insolvency of h-weth, so I think it reaches high.

#14 - 0xBugsy

2023-07-28T15:20:04Z

As above, when paying execution gas on the root chain, the excessive gas is added to accumulatedFees. So theoretically all deposited gas is used up and no gas has been reserved for anyFallback on the branch chain. The withdrawl in _payFallbackGas on branch chain will cause insolvency.

  1. This isn't accurate, fallback gas for a call from Root -> Branch is enforced and allocated in _manageGasOut not _payExecutionGas, so the proposed fix will not lead to hToken insolvency on Root (although the proposed fix should have the added detail that the balance should be obtained from bridgeToRoot and not a withdrawal and this can only be done once per failed deposit state meaning it would need to be set to true and FALLBACK_RESERVE replenished to be deducted again)

As above, when paying execution gas on the branch chain, the excessive gas has be sent to the recipent. So therotically all deposited gas is used up and no gas has been reserved for anyFallback on the root chain. _payFallbackGas does not _replenishGas, which will cause insolvency of gas budget in AnycallConfig.

  1. This is also invalid since MIN_FALLBACK_RESERVE is enforced keeping deposited gas in Branch Port and gas is replenished upon _payFallbackGas withdrawing from Port in an appropriate manner

I believe this was marked as a duplicate owing to the fact that in 1. you described a situation in #786 where a error exists and proposed the same appropriate fix

#15 - xuwinnie

2023-07-28T23:47:40Z

Thanks for explaining @0xBugsy. To make my point clearer, I'll give an example. Suppose a user retrieveDeposit and deposited 20 unit gas. depositedGas is 20 and gasToBridgeOut(remoteExecutionGas) is 0. On root chain the whole process does not involve _manageGasOut. In _payExecutionGas, suppose 12 unit is replenished, then 8 unit is added to accumulatedFees. On branch chain, fallback costs 14 gas, then 14 unit is withdrawn from port and replenished. Overall, 20 in 34 out. As you mentioned, I believe _manageGasOut should be used to manage fallback gas, but it seems to be only managing remote execution gas. I'm not sure I've understood everything correctly, if I misunderstood something please tell me.

#16 - 0xBugsy

2023-07-29T09:22:41Z

Hey, I believe your are not considering the fact that Fallback Gas is reserved every time a remote call is initiated, so if in your scenario you are calling retrieveDeposit this means that deposit already has fallback gas reserved in the origin branch and we are also sure that fallback is yet to be triggered so this balance has not been double spent. This is enforced directly in the callout functions in branches whereas in the Root this is enforced in the _manageGasOut where gas minimum is checked and assets are converted to destination chain gas.

Hope this made it clearer!

#17 - 0xBugsy

2023-07-29T09:23:13Z

We recognize the audit's findings on Anycall Gas Management. These will not be rectified due to the upcoming migration of this section to LayerZero.

#18 - xuwinnie

2023-07-29T11:59:27Z

Hey @trust1995 @0xBugsy sorry for delaying the judging process but I still need to add something. "that deposit already has fallback gas reserved in the origin branch and we are also sure that fallback is yet to be triggered so this balance has not been double spent." This is not true. The balance is double spent. Let's suppose the user deposited this gas in an tx on branch. On the root chain, although tx fails and anyExecute returns false, gas is still charged (since it is not forceReverted). So double spending occurs(on root anyExecute and branch anyFallback)!

#19 - 0xBugsy

2023-07-29T12:51:45Z

I believe there may have been some language barrier in our communication but what I now believe has happen is:

  1. you disclosed everything that was covered in detail in #786
  2. added the fact that opposed to what #786 claims porting the Branch functioning is not enough since once initiating a cross-chain call we should always deduct the chain's FALLBACK_RESERVE from the deposited gas (in root deduct branch fallback reserve gas units and in branch reverse ) which would mean the solution put forward in #786 is not 100% accurate complete .

By the way this was not at all made obvious in the issue took some reading between the lines but happy we go to some understanding and obviously do correct me if my interpretation of what was said is incorrect in any way.

#20 - xuwinnie

2023-07-29T13:07:54Z

@0xBugsy Yeah, this is what I want to say! I'm sorry if my previous expression is not clear enough!

#21 - xuwinnie

2023-07-29T13:21:27Z

Hi @trust1995, to conclude, the core issue I described here is double spending of deposited gas which will lead to insolvency of port's weth. I believe none of 786 or its dups has mentioned it. Thanks for your attention!

#22 - c4-judge

2023-07-29T13:55:42Z

trust1995 marked the issue as not a duplicate

#23 - c4-judge

2023-07-29T13:58:18Z

trust1995 marked the issue as selected for report

#24 - trust1995

2023-07-29T14:37:19Z

Upon further inspection, warden has uncovered a different root cause than previously dupped submissions. The risks associated are deemed of Medium severity.

#25 - xuwinnie

2023-07-29T15:06:25Z

Oh, sorry for coming again @trust1995. Let me show how this can reach high. Actually attacker can cause a large loss of weth balance of branch port by exploiting this issue within a single tx. In performCallOut at branch chain, attacker can set bytes calldata _params with a huge length. In anyFallback at branch chain, there is a emit LogCalloutFail(flag, data, rootChainId);. They can serve as gas bomb. So the actual loss could be high. I'll go sleep now! If there is anything unclear I'll add tomorrow.

#26 - trust1995

2023-07-29T16:08:21Z

@xuwinnie In C4 we judge based on the merits of the submission during contest time. QA period is meant to clarify validity rather than further developing a certain finding. This decision is now final.

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter