Axelar Network contest - CertoraInc's results

Decentralized interoperability network.

General Information

Platform: Code4rena

Start Date: 07/04/2022

Pot Size: $50,000 USDC

Total HM: 5

Participants: 19

Period: 5 days

Judge: 0xean

Total Solo HM: 4

Id: 109

League: COSMOS

Axelar Network

Findings Distribution

Researcher Performance

Rank: 1/19

Findings: 4

Award: $12,859.15

🌟 Selected for report: 2

πŸš€ Solo Findings: 2

Findings Information

🌟 Selected for report: CertoraInc

Labels

bug
2 (Med Risk)
disagree with severity
sponsor confirmed

Awards

6071.4286 USDC - $6,071.43

External Links

Lines of code

https://github.com/code-423n4/2022-04-axelar/blob/dee2f2d352e8f20f20027977d511b19bfcca23a3/src/AxelarGateway.sol#L545-L548 https://github.com/code-423n4/2022-04-axelar/blob/dee2f2d352e8f20f20027977d511b19bfcca23a3/src/AxelarGatewayProxy.sol#L16-L24

Vulnerability details

Impact

As written in the solidity documentation, 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.

Proof of Concept

The low-level functions call and delegatecall are used in some places in the code and it can be problematic. For example, in the _callERC20Token of the AxelarGateway contract there is a low level call in order to call the ERC20 functions, but if the given tokenAddress doesn't exist success will be equal to true and the function will return true and the code execution will be continued like the call was successful.

function _callERC20Token(address tokenAddress, bytes memory callData) internal returns (bool) {
    (bool success, bytes memory returnData) = tokenAddress.call(callData);
    return success && (returnData.length == uint256(0) || abi.decode(returnData, (bool)));
}

Another place that this can happen is in AxelarGatewayProxy's constructor

constructor(address gatewayImplementation, bytes memory params) {
    _setAddress(KEY_IMPLEMENTATION, gatewayImplementation);

   (bool success, ) = gatewayImplementation.delegatecall(
       abi.encodeWithSelector(IAxelarGateway.setup.selector, params)
   );

    if (!success) revert SetupFailed();
}

If the gatewayImplementation address doesn't exist, the delegate call will return true and the function won't revert.

Tools Used

Remix, VS Code

Check before any low-level call that the address actually exists, for example before the low level call in the callERC20 function you can check that the address is a contract by checking its code size.

#0 - deluca-mike

2022-04-13T23:14:43Z

_callERC20Token is only called with a token address that has been set and was already validated by the gateway, and thus they do already exist.

See:

As for gatewayImplementation.delegatecall, this is a valid find, but there isn't much risk because it would just mean that the gateway deployed would be a dud, and it would need to be redeployed correctly before the Axelar network can start interacting with it. In any case, we will fix this by checking that there is code at that address.

#1 - 0xean

2022-04-23T17:15:37Z

2 β€” Med: Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements.

Given that this would affect the protocols availability (the gateway being a "dud") the finding does qualify as med risk.

Findings Information

🌟 Selected for report: CertoraInc

Labels

bug
2 (Med Risk)
sponsor acknowledged

Awards

6071.4286 USDC - $6,071.43

External Links

Lines of code

https://github.com/code-423n4/2022-04-axelar/blob/dee2f2d352e8f20f20027977d511b19bfcca23a3/src/AxelarGateway.sol#L384-L389

Vulnerability details

Impact

When transferring tokens to other chain, the tokens in the source chain are burned - if they are external they will be transferred to the AxelarGateway, otherwise they will be burned. In the target chain the same amount of tokens will be minted for the user - if it is external it will be transferred to him from the AxelarGateway, otherwise it will be minted to him. But there is a problem - if the AxelarGateway doesn't have the needed amount of token for some reason, the _callERC20Token with the transfer function selector will fail and return false, which will make the _mintToken function revert. Because it reverted, the user won't get his funds on the destination chain, although he payed the needed amount in the source chain.

Tools Used

VS Code and Remix

Instead of reverting when the transfer is not successful, simply call the callContractWithToken with the source chain as the destination chain in order to return the user his funds.

#0 - deluca-mike

2022-04-13T23:24:37Z

While true, the only way the destination gateway cannot mint or transfer token, in response to a burn or transferFrom on the source chain, is foul play on the validators' part. Once we assume foul play, then really there is no protection here, since even with the recommended mitigation steps, you'd still need cooperation from the validators to sign the mint/transfer refund command for the source gateway.

Because of this, while the issue is acknowledged, it's not really something that can be solved.

Further, well-behaving validators can still sign a refund mint/transfer for the source gateway i\f they see that a mint/transfer on the destination gateway failed, without needing to do what is suggested in the Recommended Mitigation Steps.

Findings Information

🌟 Selected for report: IllIllI

Also found by: CertoraInc, Dravee, Funen, cccz, delfin454000, dirk_y, ilan, rayn, rishabh

Labels

bug
QA (Quality Assurance)
sponsor acknowledged

Awards

451.0248 USDC - $451.02

External Links

Low and Non-critical Bugs

  • Force receiving ether in the AxelarGatewayProxy might be unwanted The receive function of the `` contract is implemented so ETH can't be transferred to the contract.

    receive() external payable {
        revert('NO_ETHER');
    }

    However, ETH can still be transferred to the contract by using selfdestruct. selfdestruct sends all remaining Ether stored in the contract to a designated address. So ETH can still be transferred to the contract.

  • Forgot a TODO comment in the burnFrom function of the MintableCappedERC20 contract

  • Anyone can destroy the DepositHandler contract and receive its balance It could be better to use access controls in order to prevent malicious function calls (in the current implementation there's no problem because the contract is destructed in the same transaction it is created, but it is a problematic and dangerous implementation).

  • Use accept ownership to avoid transferring ownership to a non-existing address In the current implementation, if the transferOwnership function is called with an invalid address, the ownership will be transferred to this address and it can DoS the system. It will be better to make the new owner call a function before actually transferring the ownership, in order to validate that the new owner's address is valid and reachable.

    function transferOwnership(address newOwner) public virtual onlyOwner {
        require(newOwner != address(0), 'ZERO_ADDR');
    
        emit OwnershipTransferred(owner, newOwner);
        owner = newOwner;
    }

#0 - deluca-mike

2022-04-13T23:00:53Z

  • "ETH can still be transferred to the contract by using selfdestruct" Technically, true (and actually miners can mine ETH to this contract as well, as another way to force it to take ETH), but we disagree that this is an issue since the reverting on the receive() function is purely to prevent mistakes by users.

  • "Forgot a TODO comment in the burnFrom function of the MintableCappedERC20 contract" This is a good catch, as there was a discussion we've been meaning to have about this.

  • "Anyone can destroy the DepositHandler contract and receive its balance" Disputed, see #3.

  • "Use accept ownership to avoid transferring ownership to a non-existing address" While valid, and we acknowledge, contracts with this functionality are only ever owned by the gateway, which itself does not even call the transferOwnership function, and when it is eventually upgraded to cal it (if ever) it will perform the needed checks.

Findings Information

🌟 Selected for report: IllIllI

Also found by: 0v3rf10w, 0xNazgul, 0xkatana, CertoraInc, Chom, Dravee, Funen, Hawkeye, Tomio, ilan, nahnah, rayn, rfa

Labels

bug
G (Gas Optimization)

Awards

265.2749 USDC - $265.27

External Links

Gas Optimizations

  • For loop in AdminMultisigBase, AxelarGatewayMultisig

  • Loop optimizations Loops can be optimized in several ways. Let's take for example the loop in the _areValidOwnersInEpoch function in AxelarGatewayMultisig.

    for (uint256 i; i < accounts.length; i++) {
        if (_isOwner(epoch, accounts[i]) && ++validSignerCount >= threshold) return true;
    }

    We can do multiple things here:

    1. Use ++i instead of i++ to save some gas spent in every iteration.
    2. Save the array length in a local variable before the loop instead of accessing it in every iteration.

    There are more loops in the code that can be optimized the same way, most of them in the AxelarGatewayMultisig and some more in the AdminMultisigBase and AxelarGateway contracts.

  • Use unchecked in the ERC20 contract In several functions in the ERC20 contract unchecked can be used. For example, in the _mint function unchecked can be used on the addition to the balance of the user, because if it will overflow, the totalSupply will overflow too. new code:

    function _mint(address account, uint256 amount) internal virtual {
        require(account != address(0), 'ZERO_ADDR');
    
        _beforeTokenTransfer(address(0), account, amount);
    
        totalSupply += amount;
        unchecked {
            balanceOf[account] += amount;
        }
        emit Transfer(address(0), account, amount);
    }

    In the _burn function, unchecked can be used when subtracting from the totalSupply, because if it will underflow the balance of the user will underflow too. new code:

    function _burn(address account, uint256 amount) internal virtual {
        require(account != address(0), 'ZERO_ADDR');
    
        _beforeTokenTransfer(account, address(0), amount);
    
        balanceOf[account] -= amount;
        unchecked {
            totalSupply -= amount;
        }
        emit Transfer(account, address(0), amount);
    }

    In the _transfer function we can use unchecked when adding to the recipient balance, because the totalSupply doesn't change and it promises us that the balance of the user will be less than or equal to the totalSupply, so it won't overflow. new code:

    function _transfer(
        address sender,
        address recipient,
        uint256 amount
    ) internal virtual {
        require(sender != address(0), 'ZERO_ADDR');
        require(recipient != address(0), 'ZERO_ADDR');
    
        _beforeTokenTransfer(sender, recipient, amount);
    
        balanceOf[sender] -= amount;
        unchecked {
            balanceOf[recipient] += amount;
        }
        emit Transfer(sender, recipient, amount);
    }
  • Don't cast numbers to uint256, it does that automatically and costs more gas if you do (for example in the AxelarGateway contract you are using uint256(0) instead of simply 0, which costs more gas).

  • Use 1 and 2 as the mutex values in DepositHandler to save gas. Changing a value of a uint variable from non-zero value to a non-zero value costs less gas than changing it from zero to a non-zero value or from a non-zero value ot zero.

  • Calculate hash values pre-deployment to save gas on the constructor. For example in AdminMultisigBase there are multiple hashes that can be calculated pre-deployment instead of being calculated when deploying and waste gas

    bytes32 internal constant KEY_OWNER_EPOCH = keccak256('owner-epoch');
    
    bytes32 internal constant PREFIX_OWNER = keccak256('owner');
    bytes32 internal constant PREFIX_OWNER_COUNT = keccak256('owner-count');
    bytes32 internal constant PREFIX_OWNER_THRESHOLD = keccak256('owner-threshold');
    bytes32 internal constant PREFIX_IS_OWNER = keccak256('is-owner');
    
    bytes32 internal constant KEY_OPERATOR_EPOCH = keccak256('operator-epoch');
    
    bytes32 internal constant PREFIX_OPERATOR = keccak256('operator');
    bytes32 internal constant PREFIX_OPERATOR_COUNT = keccak256('operator-count');
    bytes32 internal constant PREFIX_OPERATOR_THRESHOLD = keccak256('operator-threshold');
    bytes32 internal constant PREFIX_IS_OPERATOR = keccak256('is-operator');
  • Use deleteBool instead of _setVoteCount(adminEpoch, topic, uint256(0)); in AdminMultisigBase to get gas refunds for deleting a storage value

  • Use one require instead of two in _transfer

    require(sender != address(0) && recipient != address(0), 'ZERO_ADDR');

    this can be done also in the _approve function in ERC20 and _beforeTokenTransfer function in BurnableMintableCappedERC20

  • Save return value of _msgSender() in decreaseAllowance, increaseAllowance, transferFrom instead of calling it multiple times (or simply use msg.sender to avoid a function call)

#0 - deluca-mike

2022-04-13T21:43:56Z

Most we do not agree with, but a few are valid.

  • "Use ++i instead of i++ to save some gas spent in every iteration." Confirmed.

  • "Save the array length in a local variable before the loop instead of accessing it in every iteration." Actually, this is no longer needed since recent version of solidity (at least 0.8.x) already do this since accounts is not a dynamic array. This used to be a valid gas-saving technique in older versions of Solidity.

  • "Use unchecked" Yes, while valid, the decision was made for code clarity without additional indentation and bulk caused by unchecked blocks. If and when the code is reduced and more manageable, we may introduce unchecked.

  • "Don't cast numbers to uint256" They are not casts, but rather explicitly typing literals. There is no gas cost/waste here.

  • "Use 1 and 2 as the mutex values in DepositHandler to save gas." Confirmed.

  • "Calculate hash values pre-deployment to save gas on the constructor" No longer the case since https://github.com/ethereum/solidity/issues/4024 (also, see https://github.com/code-423n4/2021-12-mellow-findings/issues/80), thus, disagree.

  • "Use deleteBool instead of _setVoteCount(adminEpoch, topic, uint256(0)); in AdminMultisigBase to get gas refunds for deleting a storage value Setting a value to zero or false is the same as deleting it. Also, I tried converting

_setVoteCount(adminEpoch, topic, uint256(0));

to

_deleteUint(_getAdminVoteCountsKey(adminEpoch, topic));

and converting

_setHasVoted(adminEpoch, topic, _getAdmin(adminEpoch, i), false);

to

_deleteBool(_getAdminVotedKey(adminEpoch, topic, _getAdmin(adminEpoch, i)));

and the result was slight drops in gas for some functions (on average), likely due to less jumps, and even smaller increases for others (on average), but more gas costs in terms of deploy due to the duplication of code. This suggesting is disputed.

  • "Use one require instead of two" Valid for requires in _transfer, _approve, and _beforeTokenTransfer. We will do this.

  • "Save return value of _msgSender()" Confirmed, and instead we will be getting rid of Context altogether and just use msg.sender everywhere.

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