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
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 the remainder belonging to the user
Similar to #764 but different LOC and ultimately different vulnerability.
trust1995 marked the issue as selected for report
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/BranchBridgeAgent.sol#L1018-L1054
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 gasRemaining, 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/BranchBridgeAgent.sol#L1018-L1054
_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/BranchBridgeAgent.sol#L1125
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 160_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
35_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 60_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
BranchBridgeAgent.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 78097 which is bigger than 60_000.
Here is the output of the test:
92852-14755 = 78097
Explanation
BranchBridgeAgent.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.if (gasLeft - gasAfterTransfer > TRANSFER_OVERHEAD)
is replaced withif (gasLeft - gasAfterTransfer > TRANSFER_OVERHEAD && false)
. This is to avoid entering the forceRevert. The increase of gas here is negligible anyway.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";
library SafeTransferLib { /´:°•.°+.•´.:˚.°.˚•´.°:°•.°•.•´.:˚.°.˚•´.°:°•.°+.•´.:/ / CUSTOM ERRORS / /.•°:°.´+˚.°.˚:.´•.+°.•°:´.´•.•°.•°:°.´:•˚°.°.˚:.´+°.•*/
}
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);
}
contract BranchBridgeAgent { error AnycallUnauthorizedCaller(); error GasErrorOrRepeatedTx();
/**
@param _initialGas gas used by Branch Bridge Agent. */ function _payExecutionGas(address _recipient, uint256 _initialGas) internal virtual { //Gas remaining uint256 gasRemaining = wrappedNativeToken.balanceOf(address(this));
}
function anyExecute( bytes memory data ) public virtual requiresExecutor returns (bool success, bytes memory result) { //Get Initial Gas Checkpoint uint256 initialGas = gasleft();
}
function depositIntoWeth(uint256 amt) external { wrappedNativeToken.deposit{value: amt}(); }
fallback() external payable {} }
contract BranchBridgeAgentEmpty { error AnycallUnauthorizedCaller(); error GasErrorOrRepeatedTx();
/**
@param _initialGas gas used by Branch Bridge Agent. */ function _payExecutionGas(address _recipient, uint256 _initialGas) internal virtual { //Gas remaining uint256 gasRemaining = wrappedNativeToken.balanceOf(address(this));
}
function anyExecute( bytes memory data ) public virtual // requiresExecutor returns (bool success, bytes memory result) { //Get Initial Gas Checkpoint uint256 initialGas = gasleft();
}
function depositIntoWeth(uint256 amt) external { wrappedNativeToken.deposit{value: amt}(); }
fallback() external payable {} }
contract GasCalc is DSTest, Test { BranchBridgeAgent branchBridgeAgent; BranchBridgeAgentEmpty branchBridgeAgentEmpty;
// add weth balance branchBridgeAgentEmpty.depositIntoWeth(100 ether);
}
coded PoC
Tools Used
Manual analysis
Recommended Mitigation Steps
Increase the MIN_EXECUTION_OVERHEAD by:
RootBridgeAgent.anyExecute
.anyExec
method in AnyCall.20_000 + 110_000 = 130_000 So MIN_EXECUTION_OVERHEAD becomes 290_000 instead of 160_000.
Additionally, calculate the gas consumption of the input data passed, add it to the cost.
Note: I suggest that the MIN_EXECUTION_OVERHEAD 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