Open code423n4 opened 1 year ago
trust1995 marked the issue as primary issue
trust1995 marked the issue as satisfactory
0xBugsy marked the issue as sponsor confirmed
0xBugsy marked the issue as disagree with severity
We should add premium()
uint256 to match their gas cost calculation totalCost = gasUsed * (tx.gasprice + _feeData.premium)
and abide by it since these are the calculation under which we will be charged in execution budget
Unless there is additional reasoning to why impact is reduced, HIGH seems appropriate.
trust1995 marked the issue as selected for report
0xBugsy marked the issue as sponsor acknowledged
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.
0xBugsy marked the issue as sponsor confirmed
Lines of code
https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/BranchBridgeAgent.sol#L1061-L1085
Vulnerability details
Impact
Context:
anyFallback
method is called by Anycall Executor on the source chain in case of a failure of anyExecute on the root chain. The user has to pay for the execution gas cost for this, this is done at the end of the call. However, if there is not enough depositedGas, theanyFallback
will be reverted due to a revert caused by the Anycall Executor. This shouldn't happen since the depositor in the first place deposited at least MIN_FALLBACK_RESERVE (185_000).Here is the calculation for the gas used when
anyFallback
is calledhttps://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/BranchBridgeAgent.sol#L1063-L1072
_forceRevert
will withdraw all execution budget.So Anycall Executor will revert if there is not enough budget. This is done at
https://github.com/anyswap/multichain-smart-contracts/blob/main/contracts/anycall/v7/AnycallV7Config.sol#L206C42-L206C58
(1) Gas Calculation in our anyFallback & in AnyCall contracts:
To calculate how much the user has to pay, the following formula is used:
Gas units are calculated as follows:
Store gasleft() at initialGas at the beginning of
anyFallback
methodhttps://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/BranchBridgeAgent.sol#L1233-L1234
Nearly at the end of the method, deduct gasleft() from initialGas. This covers everything between initial gas checkpoint and end gas checkpoint.
https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/BranchBridgeAgent.sol#L1063-L1066
Add MIN_FALLBACK_RESERVE which is 185_000.
This overhead is supposed to cover:
100_000 for anycall. This is extra cost required by Anycall
https://github.com/anyswap/multichain-smart-contracts/blob/main/contracts/anycall/v7/AnycallV7Config.sol#L203
85_000 for our fallback execution. For example, to cover the modifier
requiresExecutor
and to cover everthing after the end gas checkpoint.If we check how much this would actually cost, we can find it nearly 70_000. So, 85_000 is safe enough. A PoC is also provided to prove this. However, there is an overhead gas usage in the Anycall contracts that's not considered which is different than 100_000 extra that's required by AnyCall anyway (see above).
This means that the user is paying less than the actual cost. According to the sponsor, Bridge Agent deployer deposits first time into anycallConfig where the goal is to replenish the execution budget after use every time. The issue leads to:
anyExecute call will fail since the calculation of the gas used in the Anycall contracts is bigger than the minimum reserve. In Anycall, this is done by the modifier
chargeDestFee
modifier
chargeDestFee
https://github.com/anyswap/multichain-smart-contracts/blob/main/contracts/anycall/v7/AnycallV7Upgradeable.sol#L163-L171
function
chargeFeeOnDestChain
https://github.com/anyswap/multichain-smart-contracts/blob/main/contracts/anycall/v7/AnycallV7Config.sol#L203
The gas consumption of
anyExec
method called by the MPC (in AnyCall) herehttps://github.com/anyswap/multichain-smart-contracts/blob/main/contracts/anycall/v7/AnycallV7Upgradeable.sol#L276
The gas is nearly 110_000. It is not taken into account. (proven in the PoCs)
(2) Base Fee & Input Data Fee:
From Ethereum yellow paper:
So
We have 21_000 as a base fee. This should be taken into account. However, it is paid by AnyCall, since the TX is sent by MPC. So, we are fine here. Probably this explains the overhead (100_000) added by anycall
Because
anyFallback
method has bytes data to be passed, we have extra gas consumption which is not taken into account. For every zero byte => 4 For every non-zero byte => 16So generally speaking, the bigger the data is, the bigger the gas becomes. you can simply prove this by adding arbitrary data to
anyFallback
method in PoC#1 test below. and you will see the gas spent increases.Summary
anyExec
method. check next point).anyExec
method called by the MPC is not considered.There are two PoCs proving the first two points above. The third point can be proven by simply adding arbitrary data to
anyFallback
method in PoC#1 test.Note: this is also applicable for RootBridgeAgent which I avoided writing a separate issue for it since the code of
_payFallbackGas
is almost the same. However. those 3 statements don’t exist inRootBridgeAgent._payFallbackGas
So, the gas spent is even less. and 55_000 (from 155_000 MIN_FALLBACK_RESERVE of RootBridgeAgent) is safe enough. but, the second two points are still not taken into account in RootBridgeAgent as well (see above).
Proof of Concept
PoC#1 MIN_FALLBACK_RESERVE is safe enough
Note: (estimation doesn't consider
anyExec
method's actual cost).Overview
This PoC is independent from the codebase (but uses the same code). There are two contracts simulating
BranchBridgeAgent.anyFallback
.We run the same test for both, the difference in gas is what’s at least nearly the minimum required to cover pre 1st gas checkpoint and post last gas checkpoint. In this case here it is 70090 which is smaller than 85_000. So, we are fine.
Here is the output of the test:
71993-1903 = 70090
Explanation
BranchBridgeAgent.anyFallback
method depends on the following external calls:AnycallExecutor.context()
AnycallProxy.config()
AnycallConfig.executionBudget()
AnycallConfig.withdraw()
AnycallConfig.deposit()
WETH9.withdraw()
BranchPort.withdraw()
For this reason, I've copied the same code from multichain-smart-contracts. For WETH9, I've used the contract from the codebase which has minimal code. For BranchPort, copied from the codebase. For libraries, unused methods were removed, this is because I couldn't submit the report, it gave error too long body. However, it doesn't effect the gas spent
Please note that:
_payFallbackGas
method as it is not available in Foundry._replenishGas
, reading the config viaIAnycallProxy(localAnyCallAddress).config()
is replaced with Immediate call for simplicity. In other words, avoiding proxy to make the PoC simpler and shorter. However, if done with proxy the gas used would increase. So in both ways, it is in favor of the PoC.The coded PoC
Foundry.toml
.gitmodules
remappings.txt
Test File
import {Test} from "forge-std/Test.sol"; import "forge-std/console.sol";
import {DSTest} from "ds-test/test.sol";
// copied from https://github.com/transmissions11/solmate/blob/main/src/tokens/ERC20.sol // only decimals is used abstract contract ERC20 { string public name;
}
// copied from Solady // removed unused methods, because I couldn't submit the report with too long code library SafeTransferLib { /// @dev The ETH transfer has failed. error ETHTransferFailed();
}
/// copied from (https://github.com/vectorized/solady/blob/main/src/utils/SafeCastLib.sol) library SafeCastLib {
}
interface IAnycallExecutor { function context() external view returns (address from, uint256 fromChainID, uint256 nonce);
}
interface IAnycallConfig { function calcSrcFees( address _app, uint256 _toChainID, uint256 _dataLength ) external view returns (uint256);
}
interface IAnycallProxy { function executor() external view returns (address);
}
contract WETH9 { string public name = "Wrapped Ether"; string public symbol = "WETH"; uint8 public decimals = 18;
}
contract AnycallExecutor { struct Context { address from; uint256 fromChainID; uint256 nonce; } // Context public override context; Context public context;
}
contract AnycallV7Config { event Deposit(address indexed account, uint256 amount);
}
// IBranchPort interface interface IPort { //////////////////////////////////////////////////////////////// VIEW FUNCTIONS /////////////////////////////////////////////////////////////// /**
@return bool. */ function isBridgeAgent(address _bridgeAgent) external view returns (bool);
/**
@return bool. */ function isStrategyToken(address _token) external view returns (bool);
/**
@return bool. */ function isPortStrategy( address _strategy, address _token ) external view returns (bool);
/**
@return bool. */ function isBridgeAgentFactory( address _bridgeAgentFactory ) external view returns (bool);
//////////////////////////////////////////////////////////////// PORT STRATEGY MANAGEMENT ///////////////////////////////////////////////////////////////
/**
@param _amount amount of tokens. */ function manage(address _token, uint256 _amount) external;
/**
@param _token address */ function replenishReserves( address _strategy, address _token, uint256 _amount ) external;
//////////////////////////////////////////////////////////////// hTOKEN MANAGEMENT ///////////////////////////////////////////////////////////////
/**
*/ function withdraw( address _recipient, address _underlyingAddress, uint256 _amount ) external;
/**
*/ function bridgeIn( address _recipient, address _localAddress, uint256 _amount ) external;
/**
*/ function bridgeInMultiple( address _recipient, address[] memory _localAddresses, uint256[] memory _amounts ) external;
/**
*/ function bridgeOut( address _depositor, address _localAddress, address _underlyingAddress, uint256 _amount, uint256 _deposit ) external;
/**
*/ function bridgeOutMultiple( address _depositor, address[] memory _localAddresses, address[] memory _underlyingAddresses, uint256[] memory _amounts, uint256[] memory _deposits ) external;
//////////////////////////////////////////////////////////////// ADMIN FUNCTIONS ///////////////////////////////////////////////////////////////
/**
@param _bridgeAgent address of the bridge agent to add to the Port */ function addBridgeAgent(address _bridgeAgent) external;
/**
@param _newCoreRouter address of the new core router */ function setCoreRouter(address _newCoreRouter) external;
/**
@param _bridgeAgentFactory address of the bridge agent factory to add to the Port */ function addBridgeAgentFactory(address _bridgeAgentFactory) external;
/**
@param _newBridgeAgentFactory address of the bridge agent factory to add to the Port */ function toggleBridgeAgentFactory(address _newBridgeAgentFactory) external;
/**
@param _bridgeAgent address of the bridge agent to add to the Port */ function toggleBridgeAgent(address _bridgeAgent) external;
/**
@param _token address of the token to add to the Strategy Tokens */ function addStrategyToken( address _token, uint256 _minimumReservesRatio ) external;
/**
@param _token address of the token to add to the Strategy Tokens */ function toggleStrategyToken(address _token) external;
/**
@param _portStrategy address of the bridge agent factory to add to the Port */ function addPortStrategy( address _portStrategy, address _token, uint256 _dailyManagementLimit ) external;
/**
@param _portStrategy address of the bridge agent factory to add to the Port */ function togglePortStrategy(address _portStrategy, address _token) external;
/**
@param _dailyManagementLimit new daily management limit */ function updatePortStrategy( address _portStrategy, address _token, uint256 _dailyManagementLimit ) external;
//////////////////////////////////////////////////////////////// EVENTS ///////////////////////////////////////////////////////////////
event DebtCreated( address indexed _strategy, address indexed _token, uint256 _amount ); event DebtRepaid( address indexed _strategy, address indexed _token, uint256 _amount );
event StrategyTokenAdded( address indexed _token, uint256 _minimumReservesRatio ); event StrategyTokenToggled(address indexed _token);
event PortStrategyAdded( address indexed _portStrategy, address indexed _token, uint256 _dailyManagementLimit ); event PortStrategyToggled( address indexed _portStrategy, address indexed _token ); event PortStrategyUpdated( address indexed _portStrategy, address indexed _token, uint256 _dailyManagementLimit );
event BridgeAgentFactoryAdded(address indexed _bridgeAgentFactory); event BridgeAgentFactoryToggled(address indexed _bridgeAgentFactory);
event BridgeAgentToggled(address indexed _bridgeAgent);
//////////////////////////////////////////////////////////////// ERRORS ///////////////////////////////////////////////////////////////
error InvalidMinimumReservesRatio(); error InsufficientReserves(); error UnrecognizedCore(); error UnrecognizedBridgeAgent(); error UnrecognizedBridgeAgentFactory(); error UnrecognizedPortStrategy(); error UnrecognizedStrategyToken(); }
contract BranchPort { using SafeTransferLib for address;
}
contract BranchBridgeAgent { using SafeCastLib for uint256;
}
contract BranchBridgeAgentEmpty { using SafeCastLib for uint256;
}
contract GasCalc is DSTest, Test { BranchBridgeAgent branchBridgeAgent; BranchBridgeAgentEmpty branchBridgeAgentEmpty;
}
coded PoC
Tools Used
Manual analysis
Recommended Mitigation Steps
Increase the MIN_FALLBACK_RESERVE by 115_000 to consider
anyExec
method in AnyCall. So MIN_FALLBACK_RESERVE becomes 300_000 instead of 185_000.Additionally, calculate the gas consumption of the input data passed, add it to the cost. This should be done when the call was made in the first place.
Note: I suggest that the MIN_FALLBACK_RESERVE should be configurable/changeable. After launching OmniChain for some time, collect stats about the actual gas used for AnyCall on chain then adjust it accordingly. This also keeps you in the safe side in case any changes are applied on AnyCall contracts in future since it is upgradable.
Assessed type
Other