Tigris Trade contest - HE1M's results

A multi-chain decentralized leveraged exchange featuring instant settlement and guaranteed price execution on 30+ pairs.

General Information

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

Tigris Trade

Findings Distribution

Researcher Performance

Rank: 12/84

Findings: 3

Award: $1,737.79

🌟 Selected for report: 1

🚀 Solo Findings: 1

Findings Information

🌟 Selected for report: HE1M

Labels

bug
2 (Med Risk)
selected for report
sponsor confirmed
edited-by-warden
M-05

Awards

1640.8181 USDC - $1,640.82

External Links

Lines of code

https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/GovNFT.sol#L168

Vulnerability details

Impact

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.

Proof of Concept

  • Suppose Bob (honest user who owns an NFT with token id X on chain B) plans to bridge this NFT from chain B to chain A. So, Bob calls the function 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#L124
  • The endpoint is responsible to complete the bridging task on chain A.
  • Suppose the 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); } }

https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/GovNFT.sol#L168

  • Since the transaction was not successful, the message will be added as a failed message.
failedMessages[chainB][Bob's address][_nonce] = keccak256(_payload);

https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/GovNFT.sol#L178

  • Then, due to network lag (or any server issue, or any failure in 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
  • Now Bob has the NFT on chain A. Moreover, he has a failed message on chain A.
  • Then Bob calls the function 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.
  • Now, Bob has the NFT with token id X on chain B. Moreover, he has a failed message on chain A.
  • He calls the function retryMessage to retry the failed message on chain A. https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/GovNFT.sol#L206
  • By doing so the NFT with token id X will be minted to Bob on chain A. The flow is as follows: retryMessage ==> _nonblockingLzReceive ==> _bridgeMint
  • Now Bob has the NFT with token id X on both chain A and chain B. This is the vulnerability.
  • Now he can for example sell the NFT on chain B while he is earning the profits generated by the platform on the chain A that the NFT is on.
  • Please note that Bob can not call the function 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.

Tools Used

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.

Awards

1.1472 USDC - $1.15

Labels

bug
2 (Med Risk)
satisfactory
edited-by-warden
duplicate-377

External Links

Lines of code

https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/GovNFT.sol#L240

Vulnerability details

Impact

Malicious/compromised owner can lead to limitless minting of gov NFT.

Proof of Concept

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; }

https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/GovNFT.sol#L240

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); } }

https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/GovNFT.sol#L168

Tools Used

  • There should be a pausing mechanism so that in case of a leaked governor, another security roles are able to pause the malicious minting.
  • There should be a delay between changing the address of the 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

Findings Information

🌟 Selected for report: rbserver

Also found by: HE1M, KingNFT, bin2chen, cccz, stealthyz, unforgiven

Labels

2 (Med Risk)
satisfactory
duplicate-649

Awards

95.824 USDC - $95.82

External Links

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

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