Closed code423n4 closed 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
The variable data cost should be addressed by consulting premium()
, the value is used in their calcualtions here uint256 totalCost = gasUsed * (tx.gasprice + _feeData.premium);
and we should abide and only pay as much as they will credit us
@0xBugsy pls share reasoning for reduced severity. Thanks.
trust1995 marked the issue as selected for report
@0xBugsy pls share reasoning for reduced severity. Thanks.
Was mistakenly gauging from other gas related issues that did not involve deposited assets for trading for example like #349 but I now see it has since then been updated to High.
trust1995 marked the issue as not selected for report
trust1995 marked the issue as duplicate of #607
0xBugsy marked the issue as sponsor acknowledged
0xBugsy marked the issue as sponsor confirmed
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.
Lines of code
https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/RootBridgeAgent.sol#L798-L824
Vulnerability details
Impact
Context:
anyExecute
method is called by Anycall Executor on the destination chain to execute interaction. The user has to pay for the remote call execution gas, this is done at the end of the call. However, if there is not enough available gas, theanyExecute
will be reverted due to a revert caused by the Anycall Executor.Here is the calculation for the gas used
https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/RootBridgeAgent.sol#L798-L824
_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:
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
anyExecute
methodhttps://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/RootBridgeAgent.sol#L867
Nearly at the end of the method, deduct gasleft() from initialGas. This covers everything between initial gas checkpoint and end gas checkpoint.
Add MIN_EXECUTION_OVERHEAD which is 155_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
30_000 Pre 1st Gas Checkpoint Execution. For example, to cover the modifier
requiresExecutor
25_000 Post Last Gas Checkpoint Execution. To cover everthing after the end gas checkpoint.
The issue is that 55_000 is not enough to cover pre 1st gas checkpoint and post last gas checkpoint. This means that the user paying less than the actual gas 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 could possibly lead to:
anyExecute calls will fail since the calculation of the gas used in the Anycall contracts is way bigger. 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
(3) Gas Calculation in AnyCall:
There is also a gas consumption at
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.
(3) 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
anyExecute
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
anyExecute
method in PoC#1 test below. and you will see the gas spent increases.Summary
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
anyExecute
method in PoC#1 test.Proof of Concept
PoC#1 (MIN_EXECUTION_OVERHEAD is underestimated)
Overview
This PoC is independent from the codebase (but uses the same code). There are two contracts simulating
RootBridgeAgent.anyExecute
.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 76987 which is bigger than 55_000.
Here is the output of the test:
Explanation
RootBridgeAgent.anyExecute
method depends on the following external calls:AnycallExecutor.context()
AnycallProxy.config()
AnycallConfig.executionBudget()
AnycallConfig.withdraw()
AnycallConfig.deposit()
WETH9.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.
Please note that:
_payExecutionGas
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
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";
/// IAnycallConfig interface of the anycall config interface IAnycallConfig { function checkCall( address _sender, bytes calldata _data, uint256 _toChainID, uint256 _flags ) external view returns (string memory _appID, uint256 _srcFees);
}
/// IAnycallExecutor interface of the anycall executor interface IAnycallExecutor { function context() external view returns (address from, uint256 fromChainID, uint256 nonce);
}
/// IApp interface of the application interface IApp { /// (required) call on the destination chain to exec the interaction function anyExecute(bytes calldata _data) external returns (bool success, bytes memory result);
}
library AnycallFlags { // call flags which can be specified by user uint256 public constant FLAG_NONE = 0x0; uint256 public constant FLAG_MERGE_CONFIG_FLAGS = 0x1; uint256 public constant FLAG_PAY_FEE_ON_DEST = 0x1 << 1; uint256 public constant FLAG_ALLOW_FALLBACK = 0x1 << 2;
}
contract AnycallV7Config { uint256 public constant PERMISSIONLESS_MODE = 0x1; uint256 public constant FREE_MODE = 0x1 << 1;
}
contract AnycallExecutor { bytes32 public constant PAUSE_ALL_ROLE = 0x00;
function _isSet(uint256 _value, uint256 _testBits) internal pure returns (bool) { return (_value & _testBits) == _testBits; }
}
contract AnycallV7 {
// Note: changed from callback to memory so we can call it from the test contract function anyExec( address _to, bytes memory _data, string memory _appID, RequestContext memory _ctx, bytes memory _extdata ) external virtual lock whenNotPaused chargeDestFee(_to, _ctx.flags) onlyMPC { IAnycallConfig(config).checkExec(_appID, _ctx.from, _to); bytes32 uniqID = calcUniqID( _ctx.txhash, _ctx.from, _ctx.fromChainID, _ctx.nonce ); require(!execCompleted[uniqID], "exec completed");
}
contract GasCalcAnyCallv7 is DSTest, Test {
}