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: 13/144
Findings: 7
Award: $874.77
π Selected for report: 1
π Solo Findings: 0
351.522 USDC - $351.52
https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/contracts/HolographBridge.sol#L248-L249 https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/contracts/HolographOperator.sol#L593-L596
User can avoid paying gas fees by setting gasPrice
to 1 wei and gasLimit
to 0. Operators will not receive a gas compensation. Also, fallback operators won't be able to pick up such jobs.
gasLimit
and gasPrice
are passed to the operator contract as is (HolographBridge.sol#L274-L282).send
function, the operator contract calculates hlgFee
based on the gasLimit
and gasPrice
set by the user (HolographOperator.sol#L593-L596). Actual calculation happens in the getHlgFee
function of LayerZeroModule
(LayerZeroModule.sol#L178).gasPrice == 0
, but no check for gasLimit == 0
(LayerZeroModule.sol#L191-L193).gasPrice
to 0 (otherwise a real gas price will be used). So we set gasPrice
to 1 wei and gasLimit
to 0. getHlgFee
will return 0 (LayerZeroModule.sol#L194).hlgFee
is 0, we need to send 1 wei to pass this checkΒ β HolographOperator.sol#L595.gasLimit
and gasPrice
are then encoded and passed to LayerZeroModule
(HolographOperator.sol#L635-L647).LayerZeroModule
ignores them (LayerZeroModule.sol#L129-L130).crossChainPayload
.gasPrice
is 1 wei, fallback operator will never be able to pick up the job since tx.gasprice
will always be higher (on most blockchains) (HolographOperator.sol#L354).gasLimit
is 0 (HolographOperator.sol#L415).hTokenValue
will be 0 here, so the operator who's executing the job won't receive a gas compensation (HolographBridge.sol#L218). This value was set to 0 on step 7.Even though the jobEstimator
function can be used to estimate the gas consumption of a bridgeInRequest
call, it still doesn't tell whether the gas compensation is higher that the cost of a bridge-in transaction.
Manual review
Mitigation steps might include:
bridgeOutRequest
.#0 - gzeoneth
2022-10-30T16:08:22Z
I believe such job will simply not be executed. Operator will simply ignore the job and will not be slashed because slashing require the job to be picked up by other operator within the gas and gas price limit.
#1 - gzeoneth
2022-10-30T16:13:58Z
Related to #364 but does not describe the exploit.
#2 - alexanderattar
2022-11-08T06:14:59Z
We've made some updates that handle this issue
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
Royalties withdrawing of a Holographed ERC721 collection can be locked indefinitely due to a royalty token not returning a boolean during transfers.
The _payoutToken
and _payoutTokens
functions of the PA1D
contract are used to withdraw royalties accrued in ERC20 tokens (PA1D.sol#L405-L443):
function _payoutToken(address tokenAddress) private { address payable[] memory addresses = _getPayoutAddresses(); uint256[] memory bps = _getPayoutBps(); uint256 length = addresses.length; ERC20 erc20 = ERC20(tokenAddress); uint256 balance = erc20.balanceOf(address(this)); require(balance > 10000, "PA1D: Not enough tokens to transfer"); uint256 sending; //uint256 sent; 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; } }
To make a payout, the transfer
function is called and it's expected that it returns a true
. Otherwise, the call wil fail with the "PA1D: Couldn't transfer token" error. However, not all ERC20 implementations return a boolean in the transfer
function. If such token is used for royalties, _payoutToken
and _payoutTokens
will always revert and it won't be possible to withdraw royalties.
Manual review
Consider improving the validation of the return value of the ERC20 transfer function. Use this library from OpenZeppelin as a reference.
#0 - gzeoneth
2022-10-28T10:02:04Z
Duplicate of #456
A portion of collected royalties in ETH will be locked in the PA1D
contract due to the subtraction of the ETH transfer stipend. There's no function that uses this amount to compensate gas spending, thus it'll get accumulated in the contract and won't be withdrawable.
The _payoutEth
function in the PA1D
contract subtracts (23300 * payoutAddresses.length) + payoutAddresses.length
from the total withdrawable balance (PA1D.sol#L382-L399):
function _payoutEth() private { address payable[] memory addresses = _getPayoutAddresses(); uint256[] memory bps = _getPayoutBps(); uint256 length = addresses.length; // accommodating the 2300 gas stipend // adding 1x for each item in array to accomodate rounding errors 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 remaining balance is then distributed to payout addresses proportionally to each address' share.
Thus, gasCost
will never be withdrawn from the contract. Moreover, accruing the amount doesn't make sense for several reasons:
gasCost
is calculated wrongly: it counts only gas limit, without multiplying it by gas price;23300
), the correct one is 2300
;Manual review
Consider removing the gas cost calculation and subtraction.
#0 - gzeoneth
2022-10-28T09:44:09Z
Duplicate of #476
π Selected for report: Jeiwan
Also found by: __141345__, m9800
304.6524 USDC - $304.65
https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/contracts/enforcer/HolographERC721.sol#L500 https://github.com/code-423n4/2022-10-holograph/blob/f8c2eae866280a1acfdc8a8352401ed031be1373/contracts/enforcer/HolographERC721.sol#L577
A source contract can burn and transfer NFTs of users without their permission.
Every Holographed ERC721 collection is paired with a source contract, which is the user created contract that's extended by the Holographed ERC721 contract (HolographFactory.sol#L234-L246). A source contract, however, has excessive privileges in the Holographed ERC721. Specifically, it can burn and transfer users' NFTs without their approval (HolographERC721.sol#L500, HolographERC721.sol#L577):
function sourceBurn(uint256 tokenId) external onlySource { address wallet = _tokenOwner[tokenId]; _burn(wallet, tokenId); } function sourceTransfer(address to, uint256 tokenId) external onlySource { address wallet = _tokenOwner[tokenId]; _transferFrom(wallet, to, tokenId); }
While this might be desirable for extensibility and flexibility, this puts users at the risk of being robbed by the source contract owner or a hacker who hacked the source contract owner's key.
Manual review
Consider removing the sourceBurn
and sourceTransfer
functions of HolographERC721
and requiring user approval to transfer or burn their tokens (burn
and safeTransferFrom
can be called by a source contract instead of sourceBurn
and sourceTransfer
).
#0 - gzeoneth
2022-10-31T15:41:01Z
Also #403 brought up that source contract can also steal NFTs from burn address.
#1 - alexanderattar
2022-11-08T06:19:29Z
Need to add a require(!_burnedTokens[tokenId], "ERC721: token has been burned");
check to sourceTransfer function
6.8336 USDC - $6.83
Non-operators are allowed to execute jobs, slash other operators, and receive the hToken reward.
In the executeJob
function of HolographOperator
, there's no check for whether a call was made by an operator (HolographOperator.sol#L301), which allows non-operators to execute jobs. Moreover, when nonRevertingBridgeCal
is called in the function (HolographOperator.sol#L420), msg.sender
is passed as the hToken receiver address:
HolographOperatorInterface(address(this)).nonRevertingBridgeCall{value: msg.value}( msg.sender, bridgeInRequestPayload )
/** * @dev hToken recipient is injected right before making the call */ mstore(0x84, msgSender)
Which is then passed to the bridgeInRequest
call of HolographBridge
to receive hTokens (HolographBridge.sol#L195):
if (hTokenValue > 0) { /** * @dev mint the specific hToken amount for hToken recipient * this value is equivalent to amount that is deposited on origin chain's hToken contract * recipient can beam the asset to origin chain and unwrap for native gas token at any time */ require( HolographERC20Interface(hToken).holographBridgeMint(hTokenRecipient, hTokenValue) == HolographERC20Interface.holographBridgeMint.selector, "HOLOGRAPH: hToken mint failed" ); }
There's a test that was supposed to cover this behavior (14_holograph_operator_tests.ts#L821-L833):
it('Should fail non-operator address tries to execute job', async () => { operatorJobTokenId++; while (!(await createOperatorJob(l1, l2, operatorJobTokenId, true))) { operatorJobTokenId++; } let payloadHash: string = availableJobs[0] as string; let payload: string = availableJobs[1] as string; let operatorJob = await l1.operator.getJobDetails(payloadHash); let jobOperator = pickOperator(l1, operatorJob[2], true); await expect(l1.operator.connect(jobOperator).executeJob(payload)).to.be.revertedWith( 'HOLOGRAPH: operator has time' ); });
However, it's a false positive since the revert error is different (HolographOperator.sol#L342-L350).
There are two scenarios how this can play out:
Manual review
In the executeJob
function, consider checking whether the caller is an operator.
#0 - gzeoneth
2022-10-28T10:06:37Z
I believe this is the intended behavior.
#1 - gzeoneth
2022-10-30T16:23:30Z
Related to #322 for the stuck token part.
π 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
55.6726 USDC - $55.67
A bridge can mint an arbitrary amount of ERC20 tokens in a Holographed ERC20 contract.
/** * @dev Allows the bridge to mint tokens (used for hTokens only). */ function holographBridgeMint(address to, uint256 amount) external onlyBridge returns (bytes4) { _mint(to, amount); return HolographERC20Interface.holographBridgeMint.selector; }
While the function comment says the function will be used only in the hToken, leaving thus function in a contract that will be used by users of Holograph creates unnecessary risks.
Manual review
Consider removing the holographBridgeMint
function and leaving it only in the hToken contract.
When picking random fallback operators, there's a chance of getting duplicates, which means the real number of fallback operators will be less than expected (less than 5). It's also possible that all five fallback operators will be the same operator. In some rare situations (e.g. when the picked operator hasn't executed the job) this may lead to a stalled job because none of the fallback operators (it can be only one operator) is willing to execute the job.
When creating a new job, the list of fallback operators is filled and each operator is chosen pseudo-randomly (HolographOperator.sol#L522-L533):
_operatorJobs[jobHash] = uint256( ((pod + 1) << 248) | (uint256(_operatorTempStorageCounter) << 216) | (block.number << 176) | (_randomBlockHash(random, podSize, 1) << 160) | (_randomBlockHash(random, podSize, 2) << 144) | (_randomBlockHash(random, podSize, 3) << 128) | (_randomBlockHash(random, podSize, 4) << 112) | (_randomBlockHash(random, podSize, 5) << 96) | (block.timestamp << 16) | 0 ); // 80 next available bit position && so far 176 bits used with only 128 left
HolographOperator.sol#L1185-L1193:
function _randomBlockHash( uint256 random, uint256 podSize, uint256 n ) private view returns (uint256) { unchecked { return (random + uint256(blockhash(block.number - n))) % podSize; } }
Since there's no check for whether a fallback operator is already in the list, duplicates are possible.
Manual review
Consider checking whether a fallback operator is already in the list before adding it.
A failed operator gets slashed for a full minimal bond amount, instead of only a percentage, as explained in Important flows.
HolographOperator.sol#L371-L385:
/** * @dev time to reward the current operator */ uint256 amount = _getBaseBondAmount(pod); /** * @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;
Manual Review
Consider either updating the documentation to reflect the real behavior or updating the code to reflect the documentation.
#0 - gzeoneth
2022-11-01T12:51:32Z
[L-03] Full minimal bond amount is slashed, instead of a percentage - Duplicate of #307