Platform: Code4rena
Start Date: 12/07/2023
Pot Size: $80,000 USDC
Total HM: 11
Participants: 47
Period: 9 days
Judge: berndartmueller
Total Solo HM: 1
Id: 260
League: ETH
Rank: 2/47
Findings: 4
Award: $11,945.25
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: immeas
Also found by: nobody2018
5436.0009 USDC - $5,436.00
InterchainTokenService.expressReceiveTokenWithData does not apply the Check-Effect-Interaction pattern. In some edge cases, the caller can lose funds.
The life cycle of sending token across chains contains 3 phases:
//P1: launch on source chain TokenManager.callContractWithInterchainToken interchainTokenService.transmitSendToken interchainTokenService._callContract gateway.callContract emit ContractCall //P2: The off-chain program collects all events of the target chain, //builds the Command array, and operators sign for them. //Call AxelarGateway.execute of the target chain to set each command to be executed to true. AxelarGateway.execute IAxelarAuth(AUTH_MODULE).validateProof //Loop through setting each command to true _setCommandExecuted(commandId, true); _setCommandExecuted(commandId, true); ...... //P3: The relayer calls InterchainTokenService.execute for each command, //or it can be called by the user himself. InterchainTokenService.execute //we only consider SELECTOR_SEND_TOKEN_WITH_DATA here. gateway.validateContractCall InterchainTokenService._execute _processSendTokenWithDataPayload _popExpressReceiveTokenWithData tokenManager.giveToken destinationAddress.executeWithInterchainToken
expressReceiveTokenWithData uses the caller's tokens to fullfill a callContractWithInterchainToken ahead of time. It is only called before the command's execution status was set to true, due to this check. That is, before the completion of P2 in the above life cycle. It first transfers the token from the caller to destinationAddress
, then calls destinationAddress.expressExecuteWithInterchainToken, and finally calls _setExpressReceiveTokenWithData to set the value in the corresponding slot to the caller address(called expressCaller). Such a process is actually the Check-Interaction-Effect mode. destinationAddress
can be a malicious contract, and it is very dangerous to interact with the malicious contract before setting the slot.
Suppose the following scenario:
Bob deploys a malicious contract A and calls TokenManager.callContractWithInterchainToken
to send 100e18 token.
Alice's program periodically (every 1 minute) checks whether there is an outgoing sendToken. No such events were detected this time.
When the off-chain program calls AxelarGateway.execute
, this tx1 enters the memory pool, pending.
Alice's program checks again. This time the command initiated by contract A in step 1 is detected. Therefore, call InterchainTokenService.expressReceiveTokenWithData
. tx2 enters the mempool.
Bob notices the parameter input of tx1. Call A.setParram
to submit this parameter to contract A (higher gas). tx3 enters the memory pool.
tx3 is prioritized due to higher gas payment.
When tx2 is executed, expressReceiveTokenWithData
first transfers the token of 100e18 from alice to A, and then calls A.expressExecuteWithInterchainToken, where there are 2 parts.
AxelarGateway.execute(input)
, this input
is obtained in step 5. This is to set all commands contained in input
as executable.InterchainTokenService.execute
executes the command initiated in step 1. Since expressCaller has not been set at this time, 100e18 token is sent to A here.Execution flow returns here, where expressCaller is set to alice. However, alice will never be able to obtain the 100e18 token. A gets 200e18 tokens.
tx1 is executed without affecting the result.
In summary, A gets an extra 100e18, which belongs to alice. This scenario is not common. It's an edge case.
Manual Review
File: contracts\its\interchain-token-service\InterchainTokenService.sol 467: function expressReceiveTokenWithData( 468: bytes32 tokenId, 469: string memory sourceChain, 470: bytes memory sourceAddress, 471: address destinationAddress, 472: uint256 amount, 473: bytes calldata data, 474: bytes32 commandId 475: ) external { 476: if (gateway.isCommandExecuted(commandId)) revert AlreadyExecuted(commandId); 477: 478: address caller = msg.sender; 479: ITokenManager tokenManager = ITokenManager(getValidTokenManagerAddress(tokenId)); 480: IERC20 token = IERC20(tokenManager.tokenAddress()); 481: 482: SafeTokenTransferFrom.safeTransferFrom(token, caller, destinationAddress, amount); 483: 484:- _expressExecuteWithInterchainTokenToken(tokenId, destinationAddress, sourceChain, sourceAddress, data, amount); 484:+ _setExpressReceiveTokenWithData(tokenId, sourceChain, sourceAddress, destinationAddress, amount, data, commandId, caller); 485: 486:- _setExpressReceiveTokenWithData(tokenId, sourceChain, sourceAddress, destinationAddress, amount, data, commandId, caller); 486:+ _expressExecuteWithInterchainTokenToken(tokenId, destinationAddress, sourceChain, sourceAddress, data, amount); 487: }
Reentrancy
#0 - c4-pre-sort
2023-07-29T00:35:04Z
0xSorryNotSorry marked the issue as duplicate of #296
#1 - c4-judge
2023-09-08T16:16:03Z
berndartmueller marked the issue as not a duplicate
#2 - c4-judge
2023-09-08T16:18:04Z
berndartmueller marked the issue as duplicate of #349
#3 - c4-judge
2023-09-08T16:18:16Z
berndartmueller changed the severity to 3 (High Risk)
#4 - c4-judge
2023-09-08T16:18:22Z
berndartmueller marked the issue as satisfactory
🌟 Selected for report: Jeiwan
Also found by: nobody2018
5436.0009 USDC - $5,436.00
https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/token-manager/TokenManager.sol#L83-L88 https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/token-manager/TokenManager.sol#L109-L114 https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/token-manager/TokenManager.sol#L136-L142
Anyone can deploy a TokenManagerLockUnlock for the existing ERC20 via registerCanonicalToken, and deploy the corresponding token on a target chain via deployRemoteCanonicalToken. After the TokenManagers of the two chains are created, users can transfer token between the two chains via sendToken/callContractWithInterchainTokenfer. If the existing ERC20 is ERC777, such as imBTC, TokenManager will suffer reentrancy attack. This will allow the attacker to steal token from TokenManager.
Let's take a look at the _transfer function of ERC777:
function _transfer(address recipient, uint256 amount) internal returns (bool) { require(recipient != address(0), "ERC777: transfer to the zero address"); address from = msg.sender; //@audit callback to from _callTokensToSend(from, from, recipient, amount, "", ""); _move(from, from, recipient, amount, "", ""); //@audit callback to recipient _callTokensReceived(from, from, recipient, amount, "", "", false); return true; }
sendToken/callContractWithInterchainToken/transmitInterchainTransfer call _takeToken(sender, amount)
internally, and overwrite amount
with the return value, which is used as a parameter for interchainTokenService.transmitSendToken
.
File: contracts\its\token-manager\implementations\TokenManagerLockUnlock.sol 44: function _takeToken(address from, uint256 amount) internal override returns (uint256) { 45: IERC20 token = IERC20(tokenAddress()); 46: uint256 balance = token.balanceOf(address(this)); 47: 48: SafeTokenTransferFrom.safeTransferFrom(token, from, address(this), amount);//@audit callback to from 49: 50: // Note: This allows support for fee-on-transfer tokens 51: return IERC20(token).balanceOf(address(this)) - balance; 52: }
If from
is a contract that implements IERC777Sender.tokensToSend
, the execution flow will callback to from
. from
can call sendToken
/callContractWithInterchainToken
again. The attack flow is as following:
//assume that token=imBTC //token.balanceOf(address(this))=10e8 //token.balanceOf(address(from))=2e8 TokenManager.sendToken //amount=1e8 _takeToken uint256 balance = 10e8; SafeTokenTransferFrom.safeTransferFrom(token, from, address(this), 1e8); from.tokensToSend TokenManager.sendToken //amount=1e8 _takeToken uint256 balance = 10e8; SafeTokenTransferFrom.safeTransferFrom(token, from, address(this), 1e8); from.tokensToSend return IERC20(token).balanceOf(address(this)) - balance; //11e8 - 10e8 = 1e8 interchainTokenService.transmitSendToken //@@@@@emit event, amount=1e8 return IERC20(token).balanceOf(address(this)) - balance; //12e8 - 10e8 = 2e8 interchainTokenService.transmitSendToken //@@@@@emit event, amount=2e8
From the above flow, we can see that interchainTokenService.transmitSendToken
is called twice. The amount of the first call is 1e8, and the amount of the second call is 2e8. The total amount sent to the target chain is 3e8, but the amount of token held by from
is only 2e8. This opens up the opportunity for an attacker to drain the tokens of the TokenManager.
Manual Review
Add reentrancy protection for sendToken
/callContractWithInterchainToken
/transmitInterchainTransfer
.
Reentrancy
#0 - c4-pre-sort
2023-07-29T00:36:16Z
0xSorryNotSorry marked the issue as duplicate of #317
#1 - c4-judge
2023-09-01T10:30:28Z
berndartmueller marked the issue as satisfactory
🌟 Selected for report: bart1e
Also found by: immeas, nobody2018
978.4802 USDC - $978.48
InterchainTokenService._processSendTokenPayload/_processSendTokenWithDataPayload is used to process token sending commands. Both will eventually call tokenManager.giveToken to send the token to the receiver. If the token is ERC777, the giveToken
may be manipulated by the receiver, which can make the subsequent giveToken
revert from other users. This will consume the relayer's gas, which is paid by the user in the source chain.
For existing ERC20, TokenManager can be TokenManagerLockUnlock or TokenManagerLiquidityPool. TokenManager.giveToken
first calls _giveToken
from the child contract to send the token to the receiver, and then calls _addFlowIn to add a flow in amount.
File: contracts\its\token-manager\TokenManager.sol 161: function giveToken(address destinationAddress, uint256 amount) external onlyService returns (uint256) { 162: amount = _giveToken(destinationAddress, amount); 163: _addFlowIn(amount); 164: return amount; 165: }
TokenManagerLockUnlock._giveToken and TokenManagerLiquidityPool._giveToken have similar logic. They use the AfterBalance(to)-BeforeBalance(to)
mode to return the amount of token transferred. This method is no problem for erc20 without callback. But if the token is ERC777 and to
is a malicious contract, the return value of _giveToken
can be manipulated.
File: contracts\its\token-manager\implementations\TokenManagerLockUnlock.sol 60: function _giveToken(address to, uint256 amount) internal override returns (uint256) { 61: IERC20 token = IERC20(tokenAddress()); 62: uint256 balance = IERC20(token).balanceOf(to); 63: 64: SafeTokenTransfer.safeTransfer(token, to, amount);//@audit if token is ERC777, there is callback to 'to' inside this function 65: 66: return IERC20(token).balanceOf(to) - balance; 67: }
The attack flow is as follows:
TokenManager.giveToken //amount=1e18 TokenManagerLockUnlock._giveToken balance = IERC20(token).balanceOf(to); //assuming that to's balance is 0. SafeTokenTransfer.safeTransfer(token, to, amount); to.tokensReceived token.transterFrom(EOA, address(this), 100000e18); //EOA is wallet from attacker. return IERC20(token).balanceOf(to) - balance; //return value is 100001e18. _addFlowIn(amount) //here amount is 100001e18.
If the current flow limit is not 0, then if the amount returned by _giveToken
just meets this condition, that is, flowToAdd + flowAmount
is close to flowToCompare + flowLimit
. flowAmount
is the amount returned by _giveToken
. In this way, the subsequent _addFlowIn
will revert because the condition is not met.
However, _addFlowIn
internally reads FlowInSlot and FlowOutSlot by epoch, and the interval between each epoch is EPOCH_TIME (six hours). 4 epochs a day. The attacker can send tokens (very small amount) across chains every 6 hours without paying gas, then the relayer will not call such a transaction. In this way, when a new epoch arrives, the attacker calls InterchainTokenService.execute
to trigger _processSendTokenPayload. Make subsequent calls to revert by attack flow mentioned above.
Manual Review
For TokenManagerLockUnlock:
File: contracts\its\token-manager\implementations\TokenManagerLockUnlock.sol 60: function _giveToken(address to, uint256 amount) internal override returns (uint256) { 61: IERC20 token = IERC20(tokenAddress()); 62:- uint256 balance = IERC20(token).balanceOf(to); 62:+ uint256 balance = IERC20(token).balanceOf(address(this)); 63: 64: SafeTokenTransfer.safeTransfer(token, to, amount); 65: 66:- return IERC20(token).balanceOf(to) - balance; 66:+ return balance - IERC20(token).balanceOf(address(this); 67: }
For TokenManagerLiquidityPool:
File: contracts\its\token-manager\implementations\TokenManagerLiquidityPool.sol 094: function _giveToken(address to, uint256 amount) internal override returns (uint256) { 095: IERC20 token = IERC20(tokenAddress()); 096:- uint256 balance = IERC20(token).balanceOf(to); 096:+ uint256 balance = IERC20(token).balanceOf(liquidityPool()); 097: 098: SafeTokenTransferFrom.safeTransferFrom(token, liquidityPool(), to, amount); 099: 100:- return IERC20(token).balanceOf(to) - balance; 100:+ return balance - IERC20(token).balanceOf(liquidityPool()); 101: }
DoS
#0 - c4-pre-sort
2023-07-29T00:36:12Z
0xSorryNotSorry marked the issue as duplicate of #317
#1 - c4-judge
2023-09-01T10:15:26Z
berndartmueller marked the issue as not a duplicate
#2 - c4-judge
2023-09-01T10:25:58Z
berndartmueller marked the issue as duplicate of #345
#3 - c4-judge
2023-09-01T10:26:05Z
berndartmueller marked the issue as satisfactory
#4 - c4-judge
2023-09-01T10:27:49Z
berndartmueller marked the issue as not a duplicate
#5 - c4-judge
2023-09-01T10:28:19Z
berndartmueller marked the issue as duplicate of #332
🌟 Selected for report: Jeiwan
Also found by: 0xkazim, Emmanuel, KrisApostolov, T1MOH, Toshii, UniversalCrypto, Viktor_Cortess, immeas, libratus, nobody2018, qpzm
94.7708 USDC - $94.77
An proposal will be finally executed from InterchainProposalExecutor contract on the destination chain. A proposal contains an array of InterchainCalls.Call
, where each Call structure includes call information. If Call.value
is greater than 0, it means that the native token needs to be sent when executing the call. However, InterchainProposalExecutor.execute/executeWithToken
(from the parent class AxelarExecutable) is not payable and do not implement the payable fallback function. Therefore, executing such a proposal will only revert.
When a proposal is executed in the target chain, the flow is as follows:
AxelarExecutable.execute InterchainProposalExecutor._execute InterchainProposalExecutor._executeProposal //loop InterchainCalls.Call[] call.target.call call.target.call ...
Let's look at the code snippet of execute:
File: contracts\gmp-sdk\executable\AxelarExecutable.sol 17: function execute( 18: bytes32 commandId, 19: string calldata sourceChain, 20: string calldata sourceAddress, 21: bytes calldata payload 22: ) external { //@audit no payable 23: bytes32 payloadHash = keccak256(payload); 24: 25: if (!gateway.validateContractCall(commandId, sourceChain, sourceAddress, payloadHash)) revert NotApprovedByGateway(); 26: 27: _execute(sourceChain, sourceAddress, payload); 28: }
Then look at the code of _executeProposal:
File: contracts\interchain-governance-executor\InterchainProposalExecutor.sol 73: function _executeProposal(InterchainCalls.Call[] memory calls) internal { 74: for (uint256 i = 0; i < calls.length; i++) { 75: InterchainCalls.Call memory call = calls[i]; 76: (bool success, bytes memory result) = call.target.call{ value: call.value }(call.callData); 77: 78: if (!success) { 79: _onTargetExecutionFailed(call, result);//@audit If the contract does not have enough value, it will revert inside this function. 80: } else { 81: _onTargetExecuted(call, result); 82: } 83: } 84: }
Finally, the implementation of function receive()/fallback() payable
was not found in InterchainProposalExecutor and the parent class AxelarExecutable.
Therefore, we can conclude that if a proposal needs to send native token to the target contract, then the proposal will never succeed.
Manual Review
Two fixes:
payable
keyword to InterchainProposalExecutor.execute
/executeWithToken
.function receive() external payable
to the InterchainProposalExecutor contract.Payable
#0 - c4-pre-sort
2023-07-29T00:04:39Z
0xSorryNotSorry marked the issue as duplicate of #319
#1 - c4-judge
2023-09-08T11:00:08Z
berndartmueller marked the issue as satisfactory