Platform: Code4rena
Start Date: 18/10/2022
Pot Size: $75,000 USDC
Total HM: 27
Participants: 144
Period: 7 days
Judge: gzeon
Total Solo HM: 13
Id: 170
League: ETH
Rank: 2/144
Findings: 5
Award: $6,718.76
🌟 Selected for report: 4
🚀 Solo Findings: 1
2538.7702 USDC - $2,538.77
When a beaming job is executed, there's a requirement that the gas left would be at least as the gasLimit
set by the user.
Given that there's no limit on the gasLimit
the user can set, a user can set the gasLimit
to amount that's higher than the block gas limit on the dest chain, causing the operator to fail to execute the job.
Operators would be locked out of the pod, unable to execute any more jobs and not being able to get back the bond they paid.
The attacker would have to pay a value equivalent to the gas fee if that amount was realistic (i.e. gasPrice
* gasLimit
in dest chain native token), but this can be a relative low amount for Polygon and Avalanche chain (for Polygon that's 20M gas limit and 200 Gwei gas = 4 Matic, for Avalanche the block gas limit seems to be 8M and the price ~30 nAVAX = 0.24 AVAX ). Plus, the operator isn't going to receive that amount.
The following test demonstrates this scenario:
diff --git a/test/06_cross-chain_minting_tests_l1_l2.ts b/test/06_cross-chain_minting_tests_l1_l2.ts index 1f2b959..a1a23b7 100644 --- a/test/06_cross-chain_minting_tests_l1_l2.ts +++ b/test/06_cross-chain_minting_tests_l1_l2.ts @@ -276,6 +276,7 @@ describe('Testing cross-chain minting (L1 & L2)', async function () { gasLimit: TESTGASLIMIT, }) ); + estimatedGas = BigNumber.from(50_000_000); // process.stdout.write('\n' + 'gas estimation: ' + estimatedGas.toNumber() + '\n'); let payload: BytesLike = await l1.bridge.callStatic.getBridgeOutRequestPayload( @@ -303,7 +304,8 @@ describe('Testing cross-chain minting (L1 & L2)', async function () { '0x' + remove0x((await l1.operator.getMessagingModule()).toLowerCase()).repeat(2), payload ); - + estimatedGas = BigNumber.from(5_000_000); + process.stdout.write(' '.repeat(10) + 'expected lz gas to be ' + executeJobGas(payload, true).toString()); await expect( adminCall(l2.mockLZEndpoint.connect(l2.lzEndpoint), l2.lzModule, 'lzReceive', [ @@ -313,7 +315,7 @@ describe('Testing cross-chain minting (L1 & L2)', async function () { payload, { gasPrice: GASPRICE, - gasLimit: executeJobGas(payload), + gasLimit: 5_000_000, }, ]) )
The test would fail with the following output:
1) Testing cross-chain minting (L1 & L2) Deploy cross-chain contracts via bridge deploy hToken deploy l1 equivalent on l2: VM Exception while processing transaction: revert HOLOGRAPH: not enough gas left
Limit the gasLimit
to the maximum realistic amount that can be used on the dest chain (including the gas used up to the point where it's checked).
#0 - gzeoneth
2022-10-31T12:30:29Z
Duplicate of #505
#1 - ACC01ADE
2022-11-09T15:04:59Z
Good idea to generally limit the maximum gas allowed in an operator job.
#2 - trust1995
2022-11-21T10:16:55Z
Smart find!
456.9786 USDC - $456.98
https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/src/HolographOperator.sol#L202-L340 https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/contracts/HolographOperator.sol#L593-L596 https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/contracts/module/LayerZeroModule.sol#L277-L294
During the beaming process the user compensates the operator for the gas he has to pay by sending some source-chain-native-tokens via hToken
.
The amount he has to pay is determined according to the gasPrice
set by the user, which is supposed to be the maximum gas price to be used on dest chain (therefore predicting the max gas fee the operator would pay and paying him the same value in src chain native tokens).
However, in case the user sets a low price (as low as 1 wei) the operator can't skip the job because he's locked out of the pod till he executes the job.
The operator would have to choose between loosing money by paying a higher gas fee than he's compensated for or being locked out of the pod - not able to execute additional jobs or get back his bonded amount.
Operator would be loosing money by having to pay gas fee that's higher than the compensation (gas fee can be a few dozens of USD for heavy txs). This could also be used by attackers to make operators pay for the attackers' expensive gas tasks:
bridgeIn
event and the data
that's being sent to it to instruct the source contract what operations need to be executedtx.origin
doesn't matter (e.g. USDc gasless send)When an operator is selected for a job, they are temporarily removed from the pod, until they complete the job. If an operator successfully finalizes a job, they earn a reward and are placed back into their selected pod.
Allow operator to opt out of executing the job if the gasPrice
is higher than the current gas price
#0 - alexanderattar
2022-11-07T23:12:07Z
Is a known issue, and we will be fixing it
Jobs might fail due to temporarily conditions, such as an external contract that has been paused, had some bug that was later fixed with an upgrade etc. It can also be a result of an increase of gas needed to execute a job, due to changes to the blockchain state since the gas was calculated (e.g. some cached value has been outdated and needs to recalculated, some condition has been changed and therefore the program flow uses more gas). Therefore, it's important to allow users to execute failed jobs once the reason that caused the job to fail no longer exists.
Tokens of jobs that failed due to temporarily conditions would be lost forever.
As can be seen in the code, in case of a failure all that is done is saving the job to the _failedJobs
mapping. Nothing is done with _failedJobs
later.
try HolographOperatorInterface(address(this)).nonRevertingBridgeCall{value: msg.value}( msg.sender, bridgeInRequestPayload ) { /// @dev do nothing } catch { _failedJobs[hash] = true; emit FailedOperatorJob(hash); }
Add functionality that will allow any user to run a failed job till it succeeds.
#0 - gzeoneth
2022-10-31T12:28:32Z
Duplicate of #102
1015.5081 USDC - $1,015.51
There's a check at line 316 that verifies that there's enough gas left to execute the HolographBridge.bridgeInRequest()
with the gasLimit
set by the user, however the actual amount of gas left during the call is less than that (mainly due to the 1/64 rule, see below).
An attacker can use that gap to fail the job while still having the executeJob()
function complete.
The owner of the bridged token would loose access to the token since the job failed.
Besides using a few units of gas between the check and the actual call, there's also a rule that only 63/64 of the remaining gas would be dedicated to an (external) function call. Since there are 2 external function calls done (nonRevertingBridgeCall()
and the actual call to the bridge) ~2/64 of the gas isn't sent to the bridge call and can be used after the bridge call runs out of gas.
The following PoC shows that if the amount of gas left before the call is at least 1 million then the execution can continue after the bridge call fails:
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.0; import "forge-std/Test.sol"; contract ContractTest is Test { event FailedOperatorJob(bytes32 jobHash); uint256 private _inboundMessageCounter; mapping(bytes32 => bool) private _failedJobs; constructor(){ _inboundMessageCounter = 5; } function testGas64() public { this.entryPoint{gas:1000000}(); } Bridge bridge = new Bridge(); event GasLeftAfterFail(uint left); function entryPoint() public { console2.log("Gas left before call: ", gasleft()); bytes32 hash = 0x987744358512a04274ccfb3d9649da3c116cd6b19c535e633ef8529a80cb06a0; try this.intermediate(){ }catch{ // check out how much gas is left after the call to the bridge failed console2.log("Gas left after failure: ", gasleft()); // simulate operations done after failure _failedJobs[hash] = true; emit FailedOperatorJob(hash); } ++_inboundMessageCounter; console2.log("Gas left at end: ", gasleft()); } function intermediate() public{ bridge.bridgeCall(); } } contract Bridge{ event Done(uint gasLeft); uint256[] myArr; function bridgeCall() public { for(uint i =1; i <= 100; i++){ myArr.push(i); } // this line would never be reached, we'll be out of gas beforehand emit Done(gasleft()); } }
Output of PoC:
Gas left before call: 999772 Gas left after failure: 30672 Gas left at end: 1628
Side note: due to some bug in forge _inboundMessageCounter
would be considered warm even though it's not necessarily the case. However in a real world scenario we can warm it up if the selected operator is a contract and we'er using another operator contract to execute a job in the same tx beforehand.
Reference for the 1/64 rule - EIP-150. Also check out evm.codes.
Modify the required amount of gas left to gasLimit + any amount of gas spent before reaching the call()
, then multiply it by 32/30 to mitigate the 1/64 rule (+ some margin of safety maybe).
#0 - gzeoneth
2022-10-28T08:12:36Z
there are some risk but would require the nested call gas limit to be pretty high (e.g. 1m used in the poc) to have enough gas (1/64) left afterward so that it doesn't revert due to out-of-gas
#1 - trust1995
2022-10-28T08:24:12Z
gzeoneth actually this is not a limitation. When the call argument passes a gaslimit which is lower than the available gas, it instantly reverts with no gas wasted. Therefore we will have 64/64 of the gas amount to work with post-revert. I have explained this in duplicate report #437 .
#2 - 0xA5DF
2022-10-28T09:37:23Z
When the call argument passes a gaslimit which is lower than the available gas, it instantly reverts with no gas wasted.
You mean higher than the available gas? I thought the same, but doing some testing and reading the Yellow Paper it turns out it wouldn't revert just because the gas parameter is higher than the available gas. You can modify the PoC above to test that too.
#3 - trust1995
2022-10-28T09:51:30Z
You can check this example in Remix:
contract Storage { /** * @dev Return value * @return value of 'number' */ function gas_poc() public returns (uint256, uint256){ uint256 left_gas = gasleft(); address this_address = address(this); assembly { let result := call( /// @dev gas limit is retrieved from last 32 bytes of payload in-memory value left_gas, /// @dev destination is bridge contract this_address, /// @dev any value is passed along 0, /// @dev data is retrieved from 0 index memory position 0, /// @dev everything except for last 32 bytes (gas limit) is sent 0, 0, 0 ) } uint256 after_left_gas = gasleft(); return (left_gas, after_left_gas); } fallback() external { } }
We pass a lower gas limit than what we have in the "call" opcode, which reverts. The function returns { "0": "uint256: 3787", "1": "uint256: 3579" } Meaning only the gas consumed by the call opcode was deducted, not 63/64.
#4 - 0xA5DF
2022-10-28T10:03:16Z
In your example the fallback function is actually being called, it's just doesn't use much gas, I've added an event to confirm that:
contract Storage { event Cool(); /** * @dev Return value * @return value of 'number' */ function gas_poc() public returns (uint256, uint256){ uint256 left_gas = gasleft(); address this_address = address(this); assembly { let result := call( /// @dev gas limit is retrieved from last 32 bytes of payload in-memory value left_gas, /// @dev destination is bridge contract this_address, /// @dev any value is passed along 0, /// @dev data is retrieved from 0 index memory position 0, /// @dev everything except for last 32 bytes (gas limit) is sent 0, 0, 0 ) } uint256 after_left_gas = gasleft(); return (left_gas, after_left_gas); } fallback() external { emit Cool(); } }
Output:
#5 - gzeoneth
2022-10-28T10:19:07Z
A child call can never use more than 63/64 of gasleft post eip-150
#6 - trust1995
2022-10-28T10:45:36Z
@0xA5DF
Yeah , it seems my setup when I tested this during the contest was wrong, because it instantly reverted in the CALL opcode.
Page 37 of the Yellow book describes the GASCAP as minimum of gasLeft input and current gas counter minus costs:
Thanks for the good direct counterexample.
@gzeoneth Right, we were discussing if call to child will instantly revert because requestedGas > availableGas, but it doesn't.
#7 - gzeoneth
2022-10-28T10:51:54Z
That's true, and the code also doesn't forward a limited amount of gas explicitly too.
#8 - trust1995
2022-10-28T10:58:25Z
The point was that executor can always craft supplied gas to the contract, so that during the CALL opcode, gas left would be smaller than requested gas limit. If EVM behavior reverts in this check, we have deterministic failing of bridgeIn.
#9 - alexanderattar
2022-11-08T21:09:56Z
Nice find! Gas limit sent by operator could be used maliciously to ensure that job fails. This will be updated to mitigate the issue observed
🌟 Selected for report: 0xA5DF
2538.7702 USDC - $2,538.77
If the following conditions have been met:
gasPrice
set by the user in the bridge out requestThen the bridging request wouldn't complete and the token owner would loos access to the token till the gas price goes back down again.
The fact that no one but the selected operator can execute the job in case of a gas spike has been proven by the test 'Should fail if there has been a gas spike' provided by the sponsor.
An example of a price spike can be in the recent month in the Ethereum Mainnet where the min gas price was 3 at Oct 8, but jumped to 14 the day after and didn't go down since then (the min on Oct 9 was lower than the avg of Oct8, but users might witness a momentarily low gas price and try to hope on it). See the gas price chat on Etherscan for more details.
In case of a gas price spike, instead of refusing to let other operators to execute the job, let them execute the job without slashing the selected operator. This way, after a while also the owner can execute the job and pay the gas price.
#0 - trust1995
2022-10-30T20:28:16Z
If there is a gas spike, it is too expensive to execute the transaction, so we should not force executor to do it. I think it is intended behavior that TX just doesnt execute until gas falls back down. The docs state there is a refund mechanism that is activated in this case, back to origin chain.
#1 - 0xA5DF
2022-10-30T20:38:30Z
The docs state there is a refund mechanism that is activated in this case, back to origin chain.
Can you please point where in the docs does it state that? Also, regardless of the docs, that kind of mechanism is certainly not implemented
#2 - trust1995
2022-10-30T20:59:50Z
https://docs.holograph.xyz/holograph-protocol/operator-network-specification Operator Job Selection: "Operator jobs are given specific gas limits. This is meant to prevent gas spike abuse (e.g., as a form of DoS attack), bad code, or smart contract reverts from penalizing good-faith operators. If an operator is late to finalize a job and another operator steps in to take its place, if the gas price is above the set limit, the selected operator will not get slashed. A job is considered successful if it does not revert, or if it reverts but gas limits were followed correctly. Failed jobs can be re-done (for an additional fee), can be returned to origin chain (for an additional fee), or left untouched entirely. This shifts the financial responsibility towards users, rather than operators."
#3 - 0xA5DF
2022-10-30T21:06:37Z
Thanks, wasn't aware of that at time of submission.
But the docs specifically talk about 'failed jobs', in this case the job wouldn't even be marked as failed since nobody would be able to execute the executeJob()
function (the require(gasPrice >= tx.gasprice
would revert the entire function rather than move to the catch block)
#4 - trust1995
2022-10-30T21:09:46Z
I think the assumption is that tx.gasprice will eventually come back to a non-reverting amount. Agree that it seems like a good idea to add a force-fail after EXPIRY_NUM blocks passed, without executing the TX.
#5 - alexanderattar
2022-11-08T21:15:55Z
Agree that it seems like a good idea to add a force-fail after EXPIRY_NUM blocks passed, without executing the TX.