Holograph contest - Trust's results

Omnichain protocol for deploying, minting, & bridging NFTs between blockchains.

General Information

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

Holograph

Findings Distribution

Researcher Performance

Rank: 1/144

Findings: 12

Award: $27,733.34

QA:
grade-b

🌟 Selected for report: 7

πŸš€ Solo Findings: 5

Findings Information

🌟 Selected for report: Trust

Labels

bug
3 (High Risk)
resolved
sponsor confirmed
selected for report
responded

Awards

8462.5673 USDC - $8,462.57

External Links

Lines of code

https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/contracts/module/LayerZeroModule.sol#L431-L445

Vulnerability details

Description

Holograph gets it's cross chain messaging primitives through Layer Zero. To get pricing estimate, it uses the DstConfig price struct exposed in LZ's RelayerV2

The issue is that the important baseGas and gasPerByte configuration parameters, which are used to calculate a custom amount of gas for the destination LZ message, use the values that come from the source chain. This is in contrast to LZ which handles DstConfigs in a mapping keyed by chainID. The encoded gas amount is described here

Impact

The impact is that when those fields are different between chains, one of two things may happen:

  1. Less severe - we waste excess gas, which is refunded to the lzReceive() caller (Layer Zero)
  2. More severe - we underprice the delivery cost, causing lzReceive() to revert and the NFT stuck in limbo forever.

The code does not handle a failed lzReceive (differently to a failed executeJob). Therefore, no failure event is emitted and the NFT is screwed.

Tools Used

Manual audit

Firstly,make sure to use the target gas costs. Secondly, re-engineer lzReceive to be fault-proof, i.e. save some gas to emit result event.

#0 - gzeoneth

2022-10-31T13:17:13Z

Might also cause the LZ channel to stuck #244

#1 - ACC01ADE

2022-11-09T14:40:28Z

I respectfully disagree that this is even a valid issue. @trust1995 please re-review the affected code. You'll notice that we are in fact extracting destination chain gas data. And if you review the 100s of cross-chain testnet transactions that we have already made with that version of code, you will notice that the math if exact.

#2 - ACC01ADE

2022-11-09T14:41:36Z

Maybe I am miss-understanding something, so some clarification would be great if you think I'm wrong on this.

#3 - trust1995

2022-11-09T14:50:11Z

Please take a look at LayerZeroModule.sol 's send function:

function send( uint256, /* gasLimit*/ uint256, /* gasPrice*/ uint32 toChain, address msgSender, uint256 msgValue, bytes calldata crossChainPayload ) external payable { require(msg.sender == address(_operator()), "HOLOGRAPH: operator only call"); LayerZeroOverrides lZEndpoint; assembly { lZEndpoint := sload(_lZEndpointSlot) } // need to recalculate the gas amounts for LZ to deliver message lZEndpoint.send{value: msgValue}( uint16(_interfaces().getChainId(ChainIdType.HOLOGRAPH, uint256(toChain), ChainIdType.LAYERZERO)), abi.encodePacked(address(this), address(this)), crossChainPayload, payable(msgSender), address(this), abi.encodePacked(uint16(1), uint256(_baseGas() + (crossChainPayload.length * _gasPerByte()))) ); }

The function uses _baseGas() and _gasPerByte() as the relayer adapter parameters as described in the submission description's link. These two getters are global for all chains.

I agree that the getMessage() function takes into account the correct fees for the destination chain.

#4 - ACC01ADE

2022-11-09T15:20:45Z

Please take a look at LayerZeroModule.sol 's send function:

function send( uint256, /* gasLimit*/ uint256, /* gasPrice*/ uint32 toChain, address msgSender, uint256 msgValue, bytes calldata crossChainPayload ) external payable { require(msg.sender == address(_operator()), "HOLOGRAPH: operator only call"); LayerZeroOverrides lZEndpoint; assembly { lZEndpoint := sload(_lZEndpointSlot) } // need to recalculate the gas amounts for LZ to deliver message lZEndpoint.send{value: msgValue}( uint16(_interfaces().getChainId(ChainIdType.HOLOGRAPH, uint256(toChain), ChainIdType.LAYERZERO)), abi.encodePacked(address(this), address(this)), crossChainPayload, payable(msgSender), address(this), abi.encodePacked(uint16(1), uint256(_baseGas() + (crossChainPayload.length * _gasPerByte()))) ); }

The function uses _baseGas() and _gasPerByte() as the relayer adapter parameters as described in the submission description's link. These two getters are global for all chains.

I agree that the getMessage() function takes into account the correct fees for the destination chain.

Ya but these refer to destination gas limits. BaseGas and GasPerByte is the amount of gas that is used by the crossChainMessage function that LayerZero triggers on cross-chain call https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/contracts/HolographOperator.sol#L484

#5 - ACC01ADE

2022-11-09T15:40:59Z

Discussed this in more detail with @trust1995, definitely a critical issue. Need to add destination chain-specific _baseGas and _gasPerByte to mitigate EVM differences in opcode costs.

Findings Information

🌟 Selected for report: Trust

Labels

bug
3 (High Risk)
sponsor confirmed
selected for report
responded

Awards

8462.5673 USDC - $8,462.57

External Links

Lines of code

https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/contracts/HolographOperator.sol#L354

Vulnerability details

Description

Operators in Holograph do their job by calling executeJob() with the bridged in bytes from source chain. If the primary job operator did not execute the job during his allocated block slot, he is punished by taking a single bond amount and transfer it to the operator doing it instead. The docs and code state that if there was a gas spike in the operator's slot, he shall not be punished. The way a gas spike is checked is with this code in executeJob:

require(gasPrice >= tx.gasprice, "HOLOGRAPH: gas spike detected");

However, there is still a way for operator to claim primary operator's bond amount although gas price is high. Attacker can submit a flashbots bundle including the executeJob() transaction, and one additional "bribe" transaction. The bribe transaction will transfer some incentive amount to coinbase address (miner), while the executeJob is submitted with a low gasprice. Miner will accept this bundle as it is overall rewarding enough for them, and attacker will receive the base bond amount from victim operator. This threat is not theoretical because every block we see MEV bots squeezing value from such opportunities.

info about coinbase transfer info about bundle selection

Impact

Dishonest operator can take honest operator's bond amount although gas price is above acceptable limits.

Tools Used

Manual audit, flashbot docs

Do not use current tx.gasprice amount to infer gas price in a previous block. Probably best to use gas price oracle.

#0 - gzeoneth

2022-10-31T13:06:20Z

Note that this is not possible with 1559 due to block base fee, but might be possible in some other chain.

#1 - alexanderattar

2022-11-08T04:40:53Z

EIP-1559 does not allow for tx gas less than block base fee

#2 - trust1995

2022-11-08T07:06:50Z

Dispute: it is incorrect to assume bridge request sender did not add a priority fee, making it possible to bribe with tx.gasprice < gasPrice. Also, cannot assume all chains in the multichain implement EIP1559

#3 - ACC01ADE

2022-11-09T15:16:31Z

The EIP-1559 for all EVM chains assumption is the gotcha here. I don't really see a solution for this at the moment πŸ€”

Findings Information

🌟 Selected for report: Chom

Also found by: Lambda, Trust

Labels

bug
duplicate
3 (High Risk)
sponsor confirmed
responded

Awards

781.1601 USDC - $781.16

External Links

Lines of code

https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/contracts/HolographOperator.sol#L354

Vulnerability details

Description

Operators in Holograph do their job by calling executeJob() with the bridged in bytes from source chain. If the primary job operator did not execute the job during his allocated block slot, he is punished by taking a single bond amount and transfer it to the operator doing it instead. The docs and code state that if there was a gas spike in the operator's slot, he shall not be punished. The way a gas spike is checked is with this code in executeJob:

require(gasPrice >= tx.gasprice, "HOLOGRAPH: gas spike detected");

The issue is that tx.gasprice was possibly high during the primary operator's block, but a couple of blocks later went down to acceptable levels. But at this point another operator will call executeJob() and punish the primary operator.

Impact

Honest operator can lose their bonded amount although gas price was unacceptable during their slot

Tools Used

Manual audit

Proof of Concept

  1. User submits a transaction with gas price X on target chain
  2. Gas price goes high (1.2X), primary operator cannot execute the job
  3. 4 blocks later, the gas price goes back down to 0.9X. Now the 3rd backup operator submits the transaction and claims primary operator's bonded amount.

Do not use current tx.gasprice amount to infer gas price in a previous block. Probably best to use gas price oracle.

#0 - gzeoneth

2022-10-31T13:04:09Z

Duplicate of #44

#1 - ACC01ADE

2022-11-09T13:51:35Z

This is not an issue but rather a design choice. There are plenty of ways that an operator can prevent such a scenario from playing out, but that logic is out of scope of this audit.

#2 - trust1995

2022-11-09T14:25:07Z

That may be the case, but as you said this additional logic is out of scope and was not discussed in the docs presented to wardens and the code. Therefore, I believe the finding to be valid, based on the code.

#3 - ACC01ADE

2022-11-09T15:10:44Z

I'll have to agree, it is an issue that should be acknowledged since there is no prevention logic in place on protocol side.

Findings Information

🌟 Selected for report: 0xA5DF

Also found by: Trust, V_B

Labels

bug
duplicate
3 (High Risk)
sponsor confirmed
responded

Awards

781.1601 USDC - $781.16

External Links

Lines of code

https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/contracts/HolographOperator.sol#L415

Vulnerability details

Operator can fail transactions causing user to lose their send asset, due to incorrect gas check calculation. Line 416 in Operator. If gasleft() = gasLimit + small amount, gas reaching nonRevertingBridgeCall will be 63/64 of casleft() - CALL opcode code, which is less than gasLimit, causing nonRevertingBridgeCall to revert. executeJob remains with 1/64 of sent gas, enough to store job as failed. From EVM.codes: From the Tangerine Whistle fork, gas is capped at all but one 64th (remaining_gas / 64) of the remaining gas of the current context.

Description

executeJob() function is called by Operator to complete the bridgeIn procedure. As a guarantee that valid transactions cannot be intentionally failed by operators, the bridging is done in a sandboxed environment.

Here is the meat of executeJob():

require(gasleft() > gasLimit, "HOLOGRAPH: not enough gas left"); /** * @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); }

And below is nonRevertingBridgeCall:

function nonRevertingBridgeCall(address msgSender, bytes calldata payload) external payable { require(msg.sender == address(this), "HOLOGRAPH: operator only call"); assembly { /** * @dev remove gas price from end */ calldatacopy(0, payload.offset, sub(payload.length, 0x20)) /** * @dev hToken recipient is injected right before making the call */ mstore(0x84, msgSender) /** * @dev make non-reverting call */ let result := call( /// @dev gas limit is retrieved from last 32 bytes of payload in-memory value mload(sub(payload.length, 0x40)), /// @dev destination is bridge contract sload(_bridgeSlot), /// @dev any value is passed along callvalue(), /// @dev data is retrieved from 0 index memory position 0, /// @dev everything except for last 32 bytes (gas limit) is sent sub(payload.length, 0x40), 0, 0 ) if eq(result, 0) { revert(0, 0) } return(0, 0) } }

The premise is that this require ensures there is enough gas for the bridge call to execute successfully, and if there isn't then the bringIn request is actually not legitimate. require(gasleft() > gasLimit, "HOLOGRAPH: not enough gas left");

This is wrong because the gas actually sent to nonRevertingBridgeCall is not gasleft(), but actually (gasleft() - CALL_COST ) * 63/64.

From evm.codes: "From the Tangerine Whistle fork, gas is capped at all but one 64th (remaining_gas / 64) of the remaining gas of the current context. If a call tries to send more, the gas is changed to match the maximum allowed."

Suppose operator crafts gas cost so that during the require statement, gasleft() = gasLimit + 10. This check will pass, and nonRevertingBridgeCall is called. In the assembly call operation, gasLimit is passed as the gas limit. Therefore, we have a situation where we can with a lower gasleft() than the gas limit sent to the call, which causes the call opcode to revert instantly. After the revert, executeJob() will report the failure and user's transaction does not carry through.

The effect is that any user asset sent through the Holograph bridge can be sniped by an operator. I am not familiar with how Holograph wishes to implement a rollback process (which is described in the docs), but from the existing code it seems like the NFT is lossed forever.

Impact

Any user asset sent through the bridge can be frozen in limbo by a malicious operator.

Tools Used

Manual audit

For the sandbox model to work, the code needs to perform the gasleft() comparison as close to the actual call site as possible, and manually perform arithmatic to make sure the actual bridging operation is able to receive the gas limit.

#0 - 0xA5DF

2022-10-25T22:14:19Z

Dupe of #176

#1 - ACC01ADE

2022-11-09T14:51:14Z

This is a very critical issue that can essentially brick an asset.

Findings Information

🌟 Selected for report: Trust

Labels

bug
2 (Med Risk)
sponsor confirmed
selected for report
responded

Awards

2538.7702 USDC - $2,538.77

External Links

Lines of code

https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/contracts/abstract/ERC721H.sol#L185 https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/contracts/abstract/ERC721H.sol#L121

Vulnerability details

Description

ERC20H and ERC721H are base contracts for NFTs / coins to inherit from. They supply the modifier onlyOwner and function isOwner which are used in the implementations for access control. However, there are several functions which when using these the answer may be corrupted to true by an attacker.

The issue comes from confusion between calls coming from HolographERC721's fallback function, and calls from actually implemented functions.

In the fallback function, the enforcer appends an additional 32 bytes of msg.sender :

assembly { calldatacopy(0, 0, calldatasize()) mstore(calldatasize(), caller()) let result := call(gas(), sload(_sourceContractSlot), callvalue(), 0, add(calldatasize(), 32), 0, 0) returndatacopy(0, 0, returndatasize()) switch result case 0 { revert(0, returndatasize()) } default { return(0, returndatasize()) } }

Indeed these are the bytes read as msgSender:

function msgSender() internal pure returns (address sender) { assembly { sender := calldataload(sub(calldatasize(), 0x20)) } }

and isOwner simply compares these to the stored owner:

function isOwner() external view returns (bool) { if (msg.sender == holographer()) { return msgSender() == _getOwner(); } else { return msg.sender == _getOwner(); } }

However, the enforcer calls these functions directly in several locations, and in these cases it of course does not append a 32 byte msg.sender. For example, in safeTransferFrom:

function safeTransferFrom( address from, address to, uint256 tokenId, bytes memory data ) public payable { require(_isApproved(msg.sender, tokenId), "ERC721: not approved sender"); if (_isEventRegistered(HolographERC721Event.beforeSafeTransfer)) { require(SourceERC721().beforeSafeTransfer(from, to, tokenId, data)); } _transferFrom(from, to, tokenId); if (_isContract(to)) { require( (ERC165(to).supportsInterface(ERC165.supportsInterface.selector) && ERC165(to).supportsInterface(ERC721TokenReceiver.onERC721Received.selector) && ERC721TokenReceiver(to).onERC721Received(address(this), from, tokenId, data) == ERC721TokenReceiver.onERC721Received.selector), "ERC721: onERC721Received fail" ); } if (_isEventRegistered(HolographERC721Event.afterSafeTransfer)) { require(SourceERC721().afterSafeTransfer(from, to, tokenId, data)); } }

Here, caller has arbitrary control of the data parameter, and can pass owner's address.When the implementation, SourceERC721(), gets called, beforeSafeTransfer / afterSafeTransfer will behave as if they are called by owner.

Therefore, depending on the actual implementation, derived contracts can lose funds by specifying owner-specific logic.

This pattern occurs with the following functions, which have an arbitrary data parameter:

  • beforeSafeTransfer / after SafeTransfer
  • beforeTransfer / afterTransfer
  • beforeOnERC721Received / afterOnERC721Received
  • beforeOnERC20Received / aferERC20Received

Impact

Owner-specific functionality can be initiated on NFT / ERC20 implementation contracts

Tools Used

Manual audit

Refactor the code to represent msg.sender information in a bug-free way.

#0 - gzeoneth

2022-10-28T16:39:36Z

#1 - trust1995

2022-10-28T17:12:57Z

isOwner and onlyOwner are utilities implemented in ERC721H, to be used in implementation contracts. The actual implementations are out of scope, and defined by NFT / ERC20 creators. We can see such an example in the SampleERC721.sol file, which indeed uses onlyOwner:

function mint( address to, uint224 tokenId, string calldata URI ) external onlyHolographer onlyOwner { HolographERC721Interface H721 = HolographERC721Interface(holographer()); if (tokenId == 0) { _currentTokenId += 1; while (H721.exists(uint256(_currentTokenId)) || H721.burned(uint256(_currentTokenId))) { _currentTokenId += 1; } tokenId = _currentTokenId; } H721.sourceMint(to, tokenId); uint256 id = H721.sourceGetChainPrepend() + uint256(tokenId); _tokenURIs[id] = URI; }

The submission proves that these modifiers, which ARE in scope, are NOT safe to use in certain function implementations, as they can be bypassed. Since there is no warning label to not use those utilities in the list of functions I mentioned, this could potentially result in real damage to the protocol.

#2 - gzeoneth

2022-10-29T13:19:38Z

Is there a codepath that the Holographer will call mint without appending sender address? This might be easy to misuse (which I doubt) but would be QA at best. Imo the modifier is working as intended and it is the developers responsibility to understand the consequences of making a call from the Holographer (which is a privileged account) regardless. Everything can be misused does not mean they are Med/High risk unless you can provide an actual exploit.

#3 - trust1995

2022-10-29T13:48:51Z

I have brought up mint() as an example of using onlyOwner in the ERC721 implementation. I will reiterate that the issue is confusion between calls coming from HolographERC721's fallback function, and calls from Enforcer's transferFrom / safeTransferFrom / etc. When the list of functions above (beforeTransferFrom/ afterTransferFrom / etc) are called NOT from the fallback, which happens in transferFrom / safeTransferFrom / onERC20Received, the sender can pass any "data" parameter they wish, which will be interpreted by the isOwner function as the passed sender in the last 32 bytes.

"Everything can be misused does not mean they are Med/High risk unless you can provide an actual exploit." - The problem is that it will NOT be developer misuse to use isOwner / onlyOwner in ERC721/ERC20 implementation, it's use of inherited functionality (like in SampleERC721.sol example). There is no warning that owner check is not safe from "beforeOnERC20Received", for example. Protocol is likely shooting themselves in the foot if they don't protect from owner checks in these functions.

If it is required I have no problem coding example innocent ERC20/ERC721 implementation that is vulnerable to the attack.

#4 - gzeoneth

2022-10-30T02:03:06Z

I will reopen this to let sponsor comment, but intended to judge as QA. Will review when judging.

Also they can't manipulate unless it is called from the Holographer, which have limited affordance.

#5 - alexanderattar

2022-11-08T04:44:16Z

This is a valid find. We will revisit the isOwner / onlyOwner modifiers and ensure this is handled appropriately so developers inheriting the mentioned Holograph contracts don't accidentally introduce unexpected logic in their contracts

#6 - ACC01ADE

2022-11-18T14:06:52Z

Fixing this by ensuring that any calls to implementation contracts from HolographERC20 and HolographERC721 do not call directly, but first have the caller attached to end of calldata so that isOwner and onlyOwner are consistent.

Findings Information

🌟 Selected for report: d3e4

Also found by: 2997ms, Bnke0x0, Dinesh11G, Jeiwan, Lambda, RedOneN, Trust, V_B, __141345__, ballx, brgltd, cccz, chaduke, d3e4, joestakey, martin, pashov, vv7

Awards

0.0184 USDC - $0.02

Labels

bug
duplicate
2 (Med Risk)
disagree with severity
sponsor confirmed
responded

External Links

Lines of code

https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/contracts/enforcer/PA1D.sol#L416 https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/contracts/enforcer/PA1D.sol#L439

Vulnerability details

Description

ERC20 royalties are paid using _payoutTokens and _payoutToken functions in PA1D.sol. Unfortunately these functions use ERC20's transfer() instead of implementing safeTransfer:

for (uint256 i = 0; i < length; i++) { sending = ((bps[i] * balance) / 10000); require(erc20.transfer(addresses[i], sending), "PA1D: Couldn't transfer token"); // sent = sent + sending; }

The transfer signature includes a bool return value:

function transfer(address _to, uint256 _value) external returns (bool success);

Therefore, the calling code discovers there is no bool return parameter, it will revert. The impact is any non conforming ERC20 tokens will be forever stuck in the Holographer contract. There are hundreds of such tokens, such as USDT, BNB etc.

Because of the likelihood of non-conforming ERC20 tokens being a royalty token, this represents a high severity threat.

Impact

USDT, BNB and any other no-return-value coin will be stuck in the Holographer contract.

Tools Used

Manual audit

Use OpenZeppelin's SafeERC20 library to interact with ERC20 tokens.

#0 - d3e4

2022-10-26T15:35:10Z

Duplicate of #456

#1 - gzeoneth

2022-10-28T10:03:31Z

Duplicate of #456

#2 - ACC01ADE

2022-11-09T14:15:44Z

Other exact same issue submissions have a maximum of 2 Med Risk severity, this one has to be same as the rest.

Findings Information

🌟 Selected for report: Trust

Also found by: adriro

Labels

bug
2 (Med Risk)
sponsor confirmed
selected for report
responded

Awards

761.6311 USDC - $761.63

External Links

Lines of code

https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/contracts/enforcer/HolographERC721.sol#L467

Vulnerability details

Description

HolographERC721.sol is an enforcer contract that fully implements ERC721. In its safeTransferFromFunction there is the following code:

if (_isContract(to)) { require( (ERC165(to).supportsInterface(ERC165.supportsInterface.selector) && ERC165(to).supportsInterface(ERC721TokenReceiver.onERC721Received.selector) && ERC721TokenReceiver(to).onERC721Received(address(this), from, tokenId, data) == ERC721TokenReceiver.onERC721Received.selector), "ERC721: onERC721Received fail" ); }

If the target address is a contract, the enforcer requires the target's onERC721Received() to succeed. However, the call deviates from the standard:

interface ERC721TokenReceiver { /// @notice Handle the receipt of an NFT /// @dev The ERC721 smart contract calls this function on the recipient /// after a `transfer`. This function MAY throw to revert and reject the /// transfer. Return of other than the magic value MUST result in the /// transaction being reverted. /// Note: the contract address is always the message sender. /// @param _operator The address which called `safeTransferFrom` function /// @param _from The address which previously owned the token /// @param _tokenId The NFT identifier which is being transferred /// @param _data Additional data with no specified format /// @return `bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"))` /// unless throwing function onERC721Received(address _operator, address _from, uint256 _tokenId, bytes _data) external returns(bytes4); }

The standard mandates that the first parameter will be the operator - the caller of safeTransferFrom. The enforcer passes instead the address(this) value, in other words the Holographer address. The impact is that any bookkeeping done in target contract, and allow / disallow decision of the transaction, is based on false information.

Impact

ERC721 transferFrom's "to" contract may fail to accept transfers, or record credit of transfers incorrectly.

Tools Used

Manual audit

Pass the msg.sender parameter, as the ERC721 standard requires.

#0 - alexanderattar

2022-11-08T04:42:25Z

This will be updated to pass msg.sender instead of Holograph address to match the standard

Findings Information

🌟 Selected for report: Trust

Also found by: csanuragjain

Labels

bug
2 (Med Risk)
primary issue
sponsor confirmed
selected for report
responded

Awards

761.6311 USDC - $761.63

External Links

Lines of code

https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/contracts/HolographOperator.sol#L875

Vulnerability details

Description

Operators are organized into different pod tiers. Every time a new request arrives, it is scheduled to a random available pod. It is important to note that pods may be empty, in which case the pod array actually has a single zero element to help with all sorts of bugs. When a pod of a non existing tier is created, any intermediate tiers between the current highest tier to the new tier are filled with zero elements. This happens at bondUtilityToken():

if (_operatorPods.length < pod) { /** * @dev activate pod(s) up until the selected pod */ for (uint256 i = _operatorPods.length; i <= pod; i++) { /** * @dev add zero address into pod to mitigate empty pod issues */ _operatorPods.push([address(0)]); } }

The issue is that any user can spam the contract with a large amount of empty operator pods. The attack would look like this:

  1. bondUtilityToken(attacker, large_amount, high_pod_number)
  2. unbondUtilityToken(attacker, attacker)

The above could be wrapped in a flashloan to get virtually any pod tier filled.

The consequence is that when the scheduler chooses pods uniformally, they will very likely choose an empty pod, with the zero address. Therefore, the chosen operator will be 0, which is referred to in the code as "open season". In this occurrance, any operator can perform the executeJob() call. This is of course really bad, because all but one operator continually waste gas for executions that will be reverted after the lucky first transaction goes through. This would be a practical example of a griefing attack on Holograph.

Impact

Any user can force chaotic "open season" operator behavior

Tools Used

Manual audit

It is important to pay special attention to the scheduling algorithm, to make sure different pods are given execution time according to the desired heuristics.

#0 - gzeoneth

2022-10-30T17:13:50Z

Duplicate of #32

#1 - ACC01ADE

2022-11-09T14:59:14Z

Good catch. This will be updated to mitigate.

#2 - trust1995

2022-11-21T23:22:27Z

Sorry I don't see any similarity between your issue and this submission.

Findings Information

🌟 Selected for report: joestakey

Also found by: Aymen0909, Jeiwan, Trust, d3e4, joestakey

Labels

bug
duplicate
2 (Med Risk)
sponsor confirmed
responded

Awards

50.6192 USDC - $50.62

External Links

Lines of code

https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/contracts/enforcer/PA1D.sol#L382-L398

Vulnerability details

Description

PA1D.sol's _payoutEth function is responsible for distributing ETH holdings in the Holographer. It uses this code:

uint256 gasCost = (23300 * length) + length; uint256 balance = address(this).balance; require(balance - gasCost > 10000, "PA1D: Not enough ETH to transfer"); balance = balance - gasCost; uint256 sending; // uint256 sent; for (uint256 i = 0; i < length; i++) { sending = ((bps[i] * balance) / 10000); addresses[i].transfer(sending); // sent = sent + sending; }

The issue is that there is no reason to decrement balance by gasCost, since the gas for the entire payoutEth function will be paid for by the _payoutEth transaction. There is some confusion between ETH passed in calls to GAS passed in calls. ETH will be taken from address(this).balance. Gas is spent from current remaining gas.

The impact is that gasCost value is always leaked and will be stuck in the Holographer forever. Additionally, the minimum required balance for payout to execute is higher than it should be, by gasCost amount.

Impact

Some royalty ETH will be stuck in the Holographer contract forever.

Tools Used

Manual audit

Do not calculate gas costs for ETH transfers.

#0 - gzeoneth

2022-10-28T09:43:28Z

Duplicate of #476

Findings Information

🌟 Selected for report: Trust

Labels

bug
2 (Med Risk)
disagree with severity
sponsor confirmed
selected for report
responded

Awards

2538.7702 USDC - $2,538.77

External Links

Lines of code

https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/contracts/enforcer/HolographERC20.sol#L539

Vulnerability details

Description

HolographERC20 is the ERC20 enforcer for Holograph. In the safeTransferFrom operation, it calls _checkOnERC20Received:

if (_isEventRegistered(HolographERC20Event.beforeSafeTransfer)) { require(SourceERC20().beforeSafeTransfer(account, recipient, amount, data)); } _transfer(account, recipient, amount); require(_checkOnERC20Received(account, recipient, amount, data), "ERC20: non ERC20Receiver"); if (_isEventRegistered(HolographERC20Event.afterSafeTransfer)) { require(SourceERC20().afterSafeTransfer(account, recipient, amount, data)); }

The checkOnERC20Received function:

if (_isContract(recipient)) { try ERC165(recipient).supportsInterface(ERC165.supportsInterface.selector) returns (bool erc165support) { require(erc165support, "ERC20: no ERC165 support"); // we have erc165 support if (ERC165(recipient).supportsInterface(0x534f5876)) { // we have eip-4524 support try ERC20Receiver(recipient).onERC20Received(address(this), account, amount, data) returns (bytes4 retv return retval == ERC20Receiver.onERC20Received.selector; } catch (bytes memory reason) { if (reason.length == 0) { revert("ERC20: non ERC20Receiver"); } else { assembly { revert(add(32, reason), mload(reason)) } } } } else { revert("ERC20: eip-4524 not supported"); } } catch (bytes memory reason) { if (reason.length == 0) { revert("ERC20: no ERC165 support"); } else { assembly { revert(add(32, reason), mload(reason)) } } } } else { return true; }

In essence, if the target is a contract, the enforcer requires it to fully implement EIP-4524. The problem is that this EIP is just a draft proposal, which the project cannot assume to be supported by any receiver contract, and definitely not every receiver contract.

The specs warn us:

⚠️ This EIP is not recommended for general use or implementation as it is likely to change.

Therefore, it is a very dangerous requirement to add in an ERC20 enforcer, and must be left to the implementation to do if it so desires.

Impact

ERC20s enforced by HolographERC20 are completely uncomposable. They cannot be used for almost any DeFi application, making it basically useless.

Tools Used

Manual audit

Remove the EIP-4524 requirements altogether.

#0 - gzeoneth

2022-10-31T15:12:58Z

Low risk unless this is not a design decision.

#1 - alexanderattar

2022-11-08T05:06:25Z

Originally a design choice, but it can be updated to not revert if the EIP is not supported

#2 - trust1995

2022-11-08T06:56:45Z

Will argue that philosophically any code is originally a design choice. If it's later made clear the choice has unintended dire consequences then the finding should not be penalized because of that alone.

Findings Information

🌟 Selected for report: Trust

Labels

bug
2 (Med Risk)
sponsor confirmed
selected for report
responded

Awards

2538.7702 USDC - $2,538.77

External Links

Lines of code

https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/contracts/enforcer/HolographERC721.sol#L962

Vulnerability details

Description

HolographERC721.sol is an enforcer of the ERC721 standard. In its fallback function, it calls the actual implementation in order to handle additional logic.

If Holographer is called with no calldata and some msg.value, the call will reach the receive() function, which does not forward the call down to the implementation.

This can be a serious value leak issue, because the underlying implementation may have valid behavior for handling sending of value. For example, it can mint the next available tokenID and credit it to the user. Since this logic is never reached, the entire msg.value is just leaked.

Impact

Leak of value when interacting with an NFT using the receive() or fallback() callback. Note that if NFT implements fallback OR receive() function, execution will never reach either of them from the enforcer's receive() function.

Tools Used

Manual audit

Funnel receive() empty calls down to the implementation.

#0 - alexanderattar

2022-11-08T04:43:15Z

Receive function will need to be updated to pass value down like the fallback function

#1 - trust1995

2022-11-08T07:11:02Z

Upon further thoughts, believe it may qualify as high severity because it is a leak of value without requiring user error.

#2 - ACC01ADE

2022-11-18T05:31:53Z

This is intended behavior, mgs.value is never leaked directly to custom implementations. For ERC721 there is a direct and secure method of withdrawing that value via the PA1D contract logic.

#3 - trust1995

2022-11-18T09:26:15Z

Yeah, but the withdrawal in PA1D will split the money between payout addresses. If developer implemented an ERC721 with receive() fallback, this call would be intended for that logic but instead the money is now treated as royalties to payout.

#4 - ACC01ADE

2022-11-19T16:39:56Z

Developer can implement custom payable functions that guarantee msg.value transfer to their custom implementation. Receive function is reserved for royalty payouts that directly send funds to contract address. Plus it’s expected to be limited to 21k gas units, so there is no real use case where any logic can be accomplished with that much gas.

#5 - ACC01ADE

2022-11-21T16:08:11Z

That being said, this is a valid issue and sponsor confirms it. There is no clearly communicated code/documentation that explains this limitation to developers. Will make an attempt at mitigating this potential issue from happening on custom implementation side by providing clearer language and also adding revert functionality in the receive functions to get the attention of developers that might have missed this.

Lines of code

https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/contracts/HolographOperator.sol#L567

Vulnerability details

Description

The Operator CLI uses jobEstimator() static function to calculate the required gas cost to perform executeJob(). The problem is that it overestimates used gas. The gas() opcode is called at the end of the assembly block, but needs to be calculated right after the call and correct for the memory storage of "result" variable:

assembly { calldatacopy(0, bridgeInRequestPayload.offset, sub(bridgeInRequestPayload.length, 0x40)) /** * @dev bridgeInRequest doNotRevert is purposefully set to false so a rever would happen */ mstore8(0xE3, 0x00) let result := call(gas(), sload(_bridgeSlot), callvalue(), 0, sub(bridgeInRequestPayload.length, 0x40), 0, 0) /** * @dev if for some reason the call does not revert, it is force reverted */ if eq(result, 1) { returndatacopy(0, 0, returndatasize()) revert(0, returndatasize()) } /** * @dev remaining gas is set as the return value */ mstore(0x00, gas()) return(0x00, 0x20) }

The impact of this overestimation of gas is that honest operators may reject a job to be executed, as they calculate higher gas consumption which means it will not be executed successfully. Therefore, executions may be stuck on the destination chain indefinitely.

Impact

Executions may be stuck in destination chain.

Tools Used

Manual audit

Gas needs to be calculated right after the call and correct for the memory storage of "result" variable:

#0 - gzeoneth

2022-10-31T15:35:51Z

Minor difference, execution won't be stuck as operation only need to supply gas as specified regardless of the estimation result.

#1 - ACC01ADE

2022-11-09T14:55:17Z

The estimation is accurate, caller of function knows gasIn, function returns gasLeft, caller then knows that gasLimit = gasIn - gasLeft. Whether or not an operator trusts the formula, follows it, or refuses to operate is entirely out of scope.

#2 - ACC01ADE

2022-11-09T14:58:13Z

To clarify, the estimation is accurate in the sense that it will have more gas limit available than is actually used. Meaning that there will be a bit of dust wei leftover still. An operator should never be in the negative if executing a job with this gas limit being set.

#3 - trust1995

2022-11-09T15:03:25Z

The issue highlights that the gas which sender pays for, which is just the bridgeIn request, is less than what the operator estimates it will cost, because of the surrounding overhead. Therefore, it may be the case that operator decides the gas cost is too expensive and refuse to execute it, although user was honest and specified gas correctly. Does that make sense?

#4 - gzeoneth

2022-11-19T17:25:09Z

Agree there would be an edge case that if the user submit the exact amount of gas will be rejected by operator. The risk seems to be low because at worst this should only cause some delay.

#5 - trust1995

2022-11-19T17:51:51Z

I think it would cause more than delay, because if operator would refuse to executeJob this operation, the NFT would not be available in either chain. The current code doesn't support recovery of the NFT in this scenario

#6 - gzeoneth

2022-11-19T17:53:59Z

I think it would cause more than delay, because if operator would refuse to executeJob this operation, the NFT would not be available in either chain. The current code doesn't support recovery of the NFT in this scenario

I think executeJob is permissionless after certain delay?

#7 - trust1995

2022-11-19T18:13:33Z

Sure, but why would any executor take up an execution that is calculated to be too expensive?

#8 - gzeoneth

2022-11-19T18:17:46Z

Sure, but why would any executor take up an execution that is calculated to be too expensive?

The user himself can execute the job, my point being the nft would not be lost. And I believe in most of the case the flow should prompt the user to include sufficient buffer to avoid this situation.

#9 - trust1995

2022-11-19T19:12:31Z

Agree that user can always self execute, if it wasn't the case it would be HIGH risk as there is actually no way to rescue the NFT. However we are still talking about:

  1. temporary freeze of asset until user self rescues. That itself is M/H level impact in most bug bounties I've come across (Latest immunefi bug bounty here)
  2. user is forced to become an operator, stake funds, run CLI client etc. Definitely not what they signed up for. M is applicable when assets are not at direct risk, but some basic functionality of the protocol is impaired, like in our case.

#10 - gzeoneth

2022-11-19T19:17:53Z

The fact that this require the user to intentionally submit the exact gas limit manually (which can be reasonably assumed to be prevented by UI) and an attacker cannot force a user into such situation (delay attack is not possible) make this issue low risk.

#11 - trust1995

2022-11-19T19:34:22Z

Don't agree that this could only happen when user manually submits. We have no information ahead of time about how the UI works and whether it passes a spare gas limit over the actual gas amount. If it passes gas limit without buffers, the overestimation would lead gas limit to be too high and therefore not attractive for honest operators. I've made my case. If judge's opinion is still Low I accept it.

#12 - gzeoneth

2022-11-21T07:31:08Z

Judging this as low risk and as a QA report

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax Β© 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter