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
Rank: 1/19
Findings: 4
Award: $12,859.15
π Selected for report: 2
π Solo Findings: 2
π Selected for report: CertoraInc
6071.4286 USDC - $6,071.43
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
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.
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.
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.
π Selected for report: CertoraInc
6071.4286 USDC - $6,071.43
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.
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.
π Selected for report: IllIllI
Also found by: CertoraInc, Dravee, Funen, cccz, delfin454000, dirk_y, ilan, rayn, rishabh
451.0248 USDC - $451.02
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.
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:
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.