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: 10/144
Findings: 4
Award: $1,241.69
π Selected for report: 2
π Solo Findings: 0
1015.5081 USDC - $1,015.51
Gas price spikes cause the selected operator to be vulnerable to frontrunning and be slashed.
require(gasPrice >= tx.gasprice, "HOLOGRAPH: gas spike detected");
/** * @dev select operator that failed to do the job, is slashed the pod base fee */ _bondedAmounts[job.operator] -= amount; /** * @dev the slashed amount is sent to current operator */ _bondedAmounts[msg.sender] += amount;
Since you have designed a mechanism to prevent other operators to slash the operator due to "the selected missed the time slot due to a gas spike". It can induce that operators won't perform their job if a gas price spike happens due to negative profit.
But your designed mechanism has a vulnerability. Other operators can submit their transaction to the mempool and queue it using gasPrice in bridgeInRequestPayload
. It may get executed before the selected operator as the selected operator is waiting for the gas price to drop but doesn't submit any transaction yet. If it doesn't, these operators lose a little gas fee. But a slashed reward may be greater than the risk of losing a little gas fee.
require(timeDifference > 0, "HOLOGRAPH: operator has time");
Once 1 epoch has passed, selected operator is vulnerable to slashing and frontrunning.
Modify your operator node software to queue transactions immediately with gasPrice in bridgeInRequestPayload
if a gas price spike happened. Or allow gas fee loss tradeoff to prevent being slashed.
#0 - alexanderattar
2022-11-08T22:04:14Z
Valid, we have not fully finalized this mechanism and will consider mitigation strategies
#1 - gzeoneth
2022-11-19T17:13:38Z
High risk because potential slashing.
219.3497 USDC - $219.35
https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/contracts/HolographOperator.sol#L329 https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/contracts/HolographOperator.sol#L419-L429
Failed job can't be recovered. NFT may be lost.
function executeJob(bytes calldata bridgeInRequestPayload) external payable { ... delete _operatorJobs[hash]; ... try HolographOperatorInterface(address(this)).nonRevertingBridgeCall{value: msg.value}( msg.sender, bridgeInRequestPayload ) { /// @dev do nothing } catch { _failedJobs[hash] = true; emit FailedOperatorJob(hash); } }
First, it will delete _operatorJobs[hash];
to have it not replayable.
Next, assume nonRevertingBridgeCall failed. NFT won't be minted and the catch block is entered.
_failedJobs[hash] is set to true and event is emitted
Notice that _operatorJobs[hash] has been deleted, so this job is not replayable. This mean NFT is lost forever since we can't retry executeJob.
Move delete _operatorJobs[hash];
to the end of function executeJob covered in if (!_failedJobs[hash])
... if (!_failedJobs[hash]) delete _operatorJobs[hash]; ...
But this implementation is not safe. The selected operator may get slashed. Additionally, you may need to check _failedJobs flag to allow retry for only the selected operator.
#0 - gzeoneth
2022-10-30T16:38:27Z
While the use of non-blocking call is good to unstuck operator, consider making the failed job still executable by anyone (so the user can e.g. use a higher gas limit) to avoid lost fund. Kinda like how Arbitrum retryable ticket works. Can be high risk due to asset lost.
#1 - trust1995
2022-10-30T20:41:06Z
I think it's a design choice to make it not replayable. Sponsor discussed having a refund mechanism at the source chain, if we were to leave it replayable the refunding could lead to double mint attack.
#2 - alexanderattar
2022-11-08T21:48:54Z
This is a valid point and the desired code is planned but wasn't implemented in time for the audit. We will add logic to handle this case
#3 - gzeoneth
2022-11-19T17:27:56Z
Since asset can be lost, I think it is fair to judge this as High risk.
#4 - alexanderattar
2022-12-14T17:00:11Z
We have a fix for this that will be merged in today https://github.com/holographxyz/holograph-protocol/pull/98/files#diff-552f4c851fa3089f9c8efd33a2f10681bc27743917bb63000a5d19d5b41e0d3f
6.8336 USDC - $6.83
https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/contracts/HolographOperator.sol#L301-L439 https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/contracts/HolographOperator.sol#L849-L891
executeJob does not validate msg.sender against bonded operators of that pod on timeDifference >= 6. Unbonded operators can perform executeJob, slash selected operator, and receive the reward (if implemented) as well. This is unexpected since your document clearly indicates that "To become an operator, you must view the pods available to join, select a pod, and bond at least the minimum bond requirement". Unbonded operators shouldn't execute jobs and have fee rewards without bonding.
To become an operator, you must view the pods available to join, select a pod, and bond at least the minimum bond requirement. ... At step 3, you are now able to call the bondUtilityToken
function with the pod and amounts you want to use to enter the pod. Please note, there is a minimum bond requirement to join but no maximum.
function bondUtilityToken( address operator, uint256 amount, uint256 pod ) external { ... _operatorPods[pod - 1].push(operator); _operatorPodIndex[operator] = _operatorPods[pod - 1].length - 1; _bondedOperators[operator] = pod; _bondedAmounts[operator] = amount; /** * @dev transfer tokens last, to prevent reentrancy attacks */ require(_utilityToken().transferFrom(msg.sender, address(this), amount), "HOLOGRAPH: token transfer failed"); } }
_bondedOperators[operator] will be set to bonded pod
You must join a pod to become an Operator
...
At step 5, the wallet sends a transaction to the executeJob
method on the HolographOperator.sol contract. In here, further checks are done to validate the job and user's wallet. After this transaction is mined on the blockchain, the NFT will become finalized and available on the new blockchain.
function executeJob(bytes calldata bridgeInRequestPayload) external payable { ... if (job.operator != msg.sender) ... if (timeDifference < 6) { uint256 podIndex = uint256(job.fallbackOperators[timeDifference - 1]); /** * @dev do a quick sanity check to make sure operator did not leave from index or is a zero address */ if (podIndex > 0 && podIndex < _operatorPods[pod].length) { address fallbackOperator = _operatorPods[pod][podIndex]; /** * @dev ensure that sender is currently valid backup operator */ require(fallbackOperator == msg.sender, "HOLOGRAPH: invalid fallback"); } } ... /** * @dev execute the job */ try HolographOperatorInterface(address(this)).nonRevertingBridgeCall{value: msg.value}( msg.sender, bridgeInRequestPayload ) { /// @dev do nothing } catch { _failedJobs[hash] = true; emit FailedOperatorJob(hash); } /** * @dev every executed job (even if failed) increments total message counter by one */ ++_inboundMessageCounter; /** * @dev reward operator (with HLG) for executing the job * @dev this is out of scope and is purposefully omitted from code */ //// _bondedOperators[msg.sender] += reward; }
I haven't seen anywhere checking msg.sender for operator that executed on timeDifference >= 6 is bonding to job.pod (_bondedOperators[msg.sender] == job.pod
)
But the document clearly indicates that You must join a pod to become an Operator and an operator is one that should call executeJob
Manual review
Check that msg.sender is bonded to job.pod by checking
require(_bondedOperators[msg.sender] == job.pod);
#0 - gzeoneth
2022-10-30T16:30:13Z
Duplicate of #322
π Selected for report: Rolezn
Also found by: 0x1f8b, 0x52, 0x5rings, 0xNazgul, 0xSmartContract, 0xZaharina, 0xhunter, 0xzh, 8olidity, Amithuddar, Aymen0909, B2, Bnke0x0, Chom, Deivitto, Diana, Diraco, Dravee, Franfran, JC, Jeiwan, Josiah, JrNet, Jujic, KingNFT, KoKo, Lambda, Margaret, Migue, Ocean_Sky, PaludoX0, Picodes, Rahoz, RaoulSchaffranek, RaymondFam, RedOneN, ReyAdmirado, Shinchan, Tagir2003, Trust, Waze, Yiko, __141345__, a12jmx, adriro, ajtra, arcoun, aysha, ballx, bin2chen, bobirichman, brgltd, bulej93, catchup, catwhiskeys, caventa, cccz, cdahlheimer, ch0bu, chaduke, chrisdior4, cloudjunky, cryptostellar5, cryptphi, csanuragjain, cylzxje, d3e4, delfin454000, djxploit, durianSausage, erictee, fatherOfBlocks, francoHacker, gianganhnguyen, gogo, hansfriese, i_got_hacked, ignacio, imare, karanctf, kv, leosathya, louhk, lukris02, lyncurion, m_Rassska, malinariy, martin, mcwildy, mics, minhtrng, nicobevi, oyc_109, pashov, peanuts, pedr02b2, peiw, rbserver, ret2basic, rotcivegaf, rvierdiiev, ryshaw, sakman, sakshamguruji, saneryee, securerodd, seyni, sikorico, svskaushik, teawaterwire, tnevler, w0Lfrum
0 USDC - $0.00
https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/src/module/LayerZeroModule.sol#L81-L123 https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/src/HolographOperator.sol#L385-L440 https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/src/HolographOperator.sol#L201-L340
Compromised layerzero endpoint can steal all NFT. Hackers can craft a malicious message and operators can't prevent it.
function lzReceive( uint16, /* _srcChainId*/ bytes calldata _srcAddress, uint64, /* _nonce*/ bytes calldata _payload ) external payable { ... // Validation is bypassed if layerzero endpoint is compromised. _operator().crossChainMessage(_payload); } function crossChainMessage(bytes calldata bridgeInRequestPayload) external payable { ... } function executeJob(bytes calldata bridgeInRequestPayload) external payable { ... }
If layerzero endpoint is compromised, hackers can craft a malicious message for example unlocking BAYC to the hacker. That message will bypass the validation in lzReceive and get passed to crossChainMessage. Then it will be assigned an operator. Since the operator is forced to perform the assigned job without any doubt, the operator will surely perform that job and BAYC will surely unlock to the hacker's wallet.
Add a step to default operator node software to validate the validity of cross-chain transaction first and allows the operator to pause that transaction if a malicious transaction is found.
#0 - gzeoneth
2022-10-31T15:48:43Z
Technically true but also by design.
#1 - alexanderattar
2022-11-08T05:43:37Z
Agree that this is a security issue, but it is by design. Cross-chain messages will always have a degree of associated security risks
#2 - gzeoneth
2022-11-21T07:20:11Z
As QA report