Platform: Code4rena
Start Date: 09/12/2022
Pot Size: $90,500 USDC
Total HM: 35
Participants: 84
Period: 7 days
Judge: GalloDaSballo
Total Solo HM: 12
Id: 192
League: ETH
Rank: 12/84
Findings: 3
Award: $1,737.79
🌟 Selected for report: 1
🚀 Solo Findings: 1
🌟 Selected for report: HE1M
1640.8181 USDC - $1,640.82
In the contract GovNFT
, it is possible to bridge the governance NFT to other chains. It is also stated in the document that:
NFT holders only earn the profits generated by the platform on the chain that the NFT is on.
It is assumed that there is only one unique NFT per token id. But there is a scenario that can lead to have more than one NFT with the same token id on different chains.
crossChain
to bridge the NFT from chain B to chain A. Thus, his NFT will be burnt on chain B, and it is supposed to be minted on chain A.
https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/GovNFT.sol#L124endpoint
is responsible to complete the bridging task on chain A.endpoint
calls the function lzReceive
with low gas on chain A, so that the transaction will be not successful.function lzReceive( uint16 _srcChainId, bytes memory _srcAddress, uint64 _nonce, bytes memory _payload ) external override { require(_msgSender() == address(endpoint), "!Endpoint"); (bool success, bytes memory reason) = address(this).excessivelySafeCall(gasleft()*4/5, 150, abi.encodeWithSelector(this.nonblockingLzReceive.selector, _srcChainId, _srcAddress, _nonce, _payload)); // try-catch all errors/exceptions if (!success) { failedMessages[_srcChainId][_srcAddress][_nonce] = keccak256(_payload); emit MessageFailed(_srcChainId, _srcAddress, _nonce, _payload, reason); } }
failedMessages[chainB][Bob's address][_nonce] = keccak256(_payload);
endpoint
), the endpoint
assumes that the transaction is not sent, and it again calls this function with enough gas, so, the NFT with token id X will be minted to Bob's address on chain A. The flow is as follows:
lzReceive
==> nonblockingLzReceive
==> _nonblockingLzReceive
==> _bridgeMint
crossChain
to bridge that NFT from chain A to chain B. So, this NFT will be burnt on chain A, and minted to Bob's address on chain B.retryMessage
to retry the failed message on chain A.
https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/GovNFT.sol#L206retryMessage
==> _nonblockingLzReceive
==> _bridgeMint
retryMessage
while he owns the NFT on chain A. Because during minting the NFT, it checks whether the token id exists or not. That is why Bob first bridges the NFT to another chain, and then retries the failed message.The vulnerability is that when the message is failed, it is not considered as consumed, so in case of a failure in endpoint
it is possible to both having failed message and being able to mint it at the same time.
Please note that if this scenario happens again, more NFT with the same token id X will be minted to Bob on different chains.
It is recommended to track the consumed messages, and add a consumed flag whenever the function lzReceive
is called, because it will either immediately mint the NFT or add it to he failed messages to be minted later.
mapping(uint16 => mapping(bytes => mapping(uint64 => bool))) public consumedMessage; function lzReceive( uint16 _srcChainId, bytes memory _srcAddress, uint64 _nonce, bytes memory _payload ) external override { require(!consumedMessage[_srcChainId][_srcAddress][_nonce], "already consumed"); consumedMessage[_srcChainId][_srcAddress][_nonce] = true; require(_msgSender() == address(endpoint), "!Endpoint"); (bool success, bytes memory reason) = address(this).excessivelySafeCall(gasleft()*4/5, 150, abi.encodeWithSelector(this.nonblockingLzReceive.selector, _srcChainId, _srcAddress, _nonce, _payload)); // try-catch all errors/exceptions if (!success) { failedMessages[_srcChainId][_srcAddress][_nonce] = keccak256(_payload); emit MessageFailed(_srcChainId, _srcAddress, _nonce, _payload, reason); } }
#0 - c4-sponsor
2023-01-10T17:22:38Z
GainsGoblin marked the issue as sponsor confirmed
#1 - GalloDaSballo
2023-01-17T15:56:02Z
I believe the finding to be invalid, https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/GovNFT.sol#L212
The hash of the payload will be deleted, meaning that you cannot re-try a failed message that then succeded
So the attack will fail as once the token is minted on chain A, it will no longer be mintable again on chain A (doing so would require failedMessages[_srcChainId][_srcAddress][_nonce]
to be available, but it will have been deleted by then)
@GainsGoblin Can you please check if this makes sense and lmk if you think the finding is valid for some other reason?
#2 - GainsGoblin
2023-01-17T20:04:35Z
@GalloDaSballo The report explains that due to a potential issue with the endpoint, if it were to send two transactions to call lzReceive() then it can cause double minting, which is possible because the second call does not remove the failed message created by the first call. The failed message gets removed only when someone calls retryMessage().
#3 - GalloDaSballo
2023-01-18T14:40:38Z
@GainsGoblin Thank you for the clarification
While the LZ endpoint seems to indeed also be able to call lzReceive
again with the same data
https://etherscan.io/address/0x66A71Dcef29A0fFBDBE3c6a460a3B5BC225Cd675#code#F1#L128
#4 - GalloDaSballo
2023-01-18T14:41:19Z
The Warden has shown a flaw in the FSM in lzReceive
that, due to an unexpected revert, could cause the ability to have the same tokenId on multiple chains.
Because of it's reliance on external conditions, I agree with Medium Severity
#5 - c4-judge
2023-01-22T17:55:16Z
GalloDaSballo marked the issue as selected for report
#6 - GainsGoblin
2023-01-28T20:57:43Z
Mitigation: https://github.com/code-423n4/2022-12-tigris/pull/2#issuecomment-1419174114 This implementation of consumedMessage check returns instead of reverts. We don't want it to revert because that would cause the message queue to be blocked.
🌟 Selected for report: 0xA5DF
Also found by: 0xA5DF, 0xNazgul, 0xSmartContract, 0xbepresent, 0xdeadbeef0x, 8olidity, Englave, Faith, HE1M, JohnnyTime, Madalad, Mukund, Ruhum, SmartSek, __141345__, aviggiano, carlitox477, cccz, chaduke, francoHacker, gz627, gzeon, hansfriese, hihen, imare, jadezti, kwhuo68, ladboy233, orion, peanuts, philogy, rbserver, wait, yjrwkk
1.1472 USDC - $1.15
Malicious/compromised owner can lead to limitless minting of gov NFT.
A malicious or compromised governor can set the endpoint
address to any malicious address.
function setEndpoint(ILayerZeroEndpoint _endpoint) external onlyOwner { require(address(_endpoint) != address(0), "ZeroAddress"); endpoint = _endpoint; }
The malicious endpoint
can mint NFT to his own address without any limitation by calling the function lzReceive
.
function lzReceive( uint16 _srcChainId, bytes memory _srcAddress, uint64 _nonce, bytes memory _payload ) external override { require(_msgSender() == address(endpoint), "!Endpoint"); (bool success, bytes memory reason) = address(this).excessivelySafeCall(gasleft()*4/5, 150, abi.encodeWithSelector(this.nonblockingLzReceive.selector, _srcChainId, _srcAddress, _nonce, _payload)); // try-catch all errors/exceptions if (!success) { failedMessages[_srcChainId][_srcAddress][_nonce] = keccak256(_payload); emit MessageFailed(_srcChainId, _srcAddress, _nonce, _payload, reason); } }
endpoint
. Because, it is almost impossible to notice the malicious endpoint
address assignment with the immediate address change. While, having a delay between setting the pending endpoint
and assigning it, gives the protocol or users enough time to investigate the validity of the new endpoint
address.#0 - c4-judge
2022-12-23T17:45:42Z
GalloDaSballo marked the issue as duplicate of #184
#1 - c4-judge
2023-01-16T08:15:23Z
GalloDaSballo marked the issue as duplicate of #377
#2 - c4-judge
2023-01-22T17:34:51Z
GalloDaSballo marked the issue as satisfactory
95.824 USDC - $95.82
Judge has assessed an item in Issue #113 as M risk. The relevant finding follows:
During handling the open fees, the _tigAsset is distributed to gov. But, it is not approved before to be consumed by gov. So, the first user's transaction to initiate a market order, will fail. https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/Trading.sol#L749
During handling the close fees, the approve max is applied every time before distributing to gov. Actually, this is redundant to approve max every time. https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/Trading.sol#L807
So, the following check is better to be added before distributing to gov in both functions _handleOpenFees and _handleCloseFees:
if(IStable(_tigAsset).allowance(address(this), address(gov)) < _daoFeesPaid){ IStable(_tigAsset).approve(address(gov), type(uint).max); }
#0 - c4-judge
2023-01-22T21:26:25Z
GalloDaSballo marked the issue as duplicate of #649
#1 - GalloDaSballo
2023-01-22T21:26:37Z
Ultimately same impact so awarding fully
#2 - c4-judge
2023-01-22T21:26:41Z
GalloDaSballo marked the issue as satisfactory