Platform: Code4rena
Start Date: 12/07/2023
Pot Size: $80,000 USDC
Total HM: 11
Participants: 47
Period: 9 days
Judge: berndartmueller
Total Solo HM: 1
Id: 260
League: ETH
Rank: 4/47
Findings: 4
Award: $5,976.24
🌟 Selected for report: 3
🚀 Solo Findings: 0
🌟 Selected for report: Jeiwan
Also found by: Toshii, immeas, pcarranzav
660.4741 USDC - $660.47
In interchainTransferFrom, the msg.sender specifies a sender address from where tokens will be pulled. The tokens are then sent to a different chain using the TokenManager's tokenManager.transmitInterchainTransfer
function, which will specify the token sender as the refund address for the Axelar GMP gas. But in this scenario, the fee (in native token msg.value) is provided by the msg.sender, not the token sender, so the refund will be sent to the wrong address. Moreover, the token sender may be a contract that can only handle the transferred token and has no way to transfer native tokens from the source chain, so the refunded value may be lost forever.
Consider the following scenario: On Ethereum mainnet, we have a simple token storage contract that only allows handling SOME_TOKEN (that is an InterchainToken), but not ETH:
contract TokenStorage { address public constant SOME_TOKEN_ADDRESS = <SOME_TOKEN's address goes here>; address public owner; constructor() { owner = msg.sender; } function approve(address beneficiary, uint256 amount) public { require(msg.sender == owner, "Only owner can call"); IERC20(SOME_TOKEN_ADDRESS).approve(beneficiary, amount); } }
interchainTransferFrom()
and specifying the TokenStorage as sendertokenManager.transmitInterchainTransfer
specifying the TokenStorage address as sender (https://github.com/code-423n4/2023-07-axelar/blob/9f642fe854eb11ad9f18fe028e5a8353c258e926/contracts/its/interchain-token/InterchainToken.sol#L104)receive()
function, the ETH would be received by TokenStorage and then stuck there).Visual inspection.
Consider setting the refund address to be the msg.sender. This might require some modifications to TokenManager.transmitInterchainTransfer
and InterchainTokenService.transmitSendToken
so that they accept a refund address parameter, so if adding this is problematic, consider documenting this behavior for interchainTransferFrom in a way that is clear to its users.
ETH-Transfer
#0 - c4-pre-sort
2023-07-29T00:21:49Z
0xSorryNotSorry marked the issue as duplicate of #316
#1 - c4-judge
2023-09-01T10:36:13Z
berndartmueller marked the issue as satisfactory
🌟 Selected for report: pcarranzav
Also found by: immeas
2120.0403 USDC - $2,120.04
https://github.com/code-423n4/2023-07-axelar/blob/9f642fe854eb11ad9f18fe028e5a8353c258e926/contracts/its/remote-address-validator/RemoteAddressValidator.sol#L72-L74 https://github.com/code-423n4/2023-07-axelar/blob/9f642fe854eb11ad9f18fe028e5a8353c258e926/contracts/its/remote-address-validator/RemoteAddressValidator.sol#L83
The InterchainTokenService is deployed using create3: https://github.com/code-423n4/2023-07-axelar/blob/9f642fe854eb11ad9f18fe028e5a8353c258e926/scripts/deploy.js#L60-L64 - this means that its address depends on the address of the deployer wallet, the address of the Create3Deployer contract, and the salt that is extracted from a deployment key.
It should be feasible, then, for the same deployer wallet to deploy a Create3Deployer with the same address on a new chain, and use the same deployment key to deploy a contract with the same address as the InterchainTokenService but arbitrary implementation. A team member has confirmed on Discord that "the address will be the interchainTokenServiceAddress which is constant across EVM chains.". However, at deployment time, only a subset of all the possible EVM chains will be supported, and more may be added in the future. When that happens, it is possible that the original deployer wallet is compromised or no longer available.
On the other hand, the addTrustedAddress
function validates that the sender is the owner of the RemoteAddressValidator contract. This owner is originally the deployer wallet, but ownership may be transferred (and it would be a good practice to transfer it to a more secure governance multisig immediately after deployment). After ownership transfer, the previous owner should not be allowed to add trusted addresses.
However, the validateSender
function will treat any address that is the same as the current chain's InterchainTokenService as valid. A malicious contract deployed by the deployer wallet after the ownership transfer could be treated as valid and would have the ability to deploy token managers and standardized tokens, or perform arbitrary token transfers. This is a form of centralization risk but is also a serious privilege escalation, as it should be possible to strip the deployer of the ability to perform these actions, and this gives them virtually unlimited power even after an ownership transfer.
RemoteAddressValidator
and all other contracts is transferred to governance multisigs. After this point, the deployer should have no ability to add trusted addresses.function callContract( string calldata destinationChain, bytes memory payload, uint256 gasValue, address refundTo ) external onlyOwner { _callContract(destinationChain, payload, gasValue, refundTo); }
validateSender
on the RemoteAddressValidator on chain A, this check will pass and the address will be treated as valid:if (sourceAddressHash == interchainTokenServiceAddressHash) { return true; }
Visual inspection.
The assumption that the InterchainTokenService address is valid in any chain is dangerous, because of how the contract is created and the possibility that new EVM chains may exist in the future. A deployer EOA should not have this amount of permission for an indefinite time. I would recommend breaking that assumption and requiring that all addresses are added as trusted addresses explicitly.
A check can therefore be added to only treat the interchain token service's address as valid if the source chain is also the same chain where the RemoteAddressValidator is deployed. The following diff shows a way to do this:
diff --git a/contracts/its/remote-address-validator/RemoteAddressValidator.sol b/contracts/its/remote-address-validator/RemoteAddressValidator.sol index bb101e5..c2e5382 100644 --- a/contracts/its/remote-address-validator/RemoteAddressValidator.sol +++ b/contracts/its/remote-address-validator/RemoteAddressValidator.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.0; import { IRemoteAddressValidator } from '../interfaces/IRemoteAddressValidator.sol'; import { AddressToString } from '../../gmp-sdk/util/AddressString.sol'; import { Upgradable } from '../../gmp-sdk/upgradable/Upgradable.sol'; +import { StringToBytes32 } from '../../gmp-sdk/util/Bytes32String.sol'; /** * @title RemoteAddressValidator @@ -11,23 +12,25 @@ import { Upgradable } from '../../gmp-sdk/upgradable/Upgradable.sol'; */ contract RemoteAddressValidator is IRemoteAddressValidator, Upgradable { using AddressToString for address; + using StringToBytes32 for string; mapping(string => bytes32) public remoteAddressHashes; mapping(string => string) public remoteAddresses; address public immutable interchainTokenServiceAddress; bytes32 public immutable interchainTokenServiceAddressHash; mapping(string => bool) public supportedByGateway; - + bytes32 public immutable currentChainName; bytes32 private constant CONTRACT_ID = keccak256('remote-address-validator'); /** * @dev Constructs the RemoteAddressValidator contract, both array parameters must be equal in length * @param _interchainTokenServiceAddress Address of the interchain token service */ - constructor(address _interchainTokenServiceAddress) { + constructor(address _interchainTokenServiceAddress, string memory _chainName) { if (_interchainTokenServiceAddress == address(0)) revert ZeroAddress(); interchainTokenServiceAddress = _interchainTokenServiceAddress; interchainTokenServiceAddressHash = keccak256(bytes(_lowerCase(interchainTokenServiceAddress.toString()))); + currentChainName = _chainName.toBytes32(); } /** @@ -69,7 +72,7 @@ contract RemoteAddressValidator is IRemoteAddressValidator, Upgradable { function validateSender(string calldata sourceChain, string calldata sourceAddress) external view returns (bool) { string memory sourceAddressLC = _lowerCase(sourceAddress); bytes32 sourceAddressHash = keccak256(bytes(sourceAddressLC)); - if (sourceAddressHash == interchainTokenServiceAddressHash) { + if (sourceAddressHash == interchainTokenServiceAddressHash && sourceChain.toBytes32() == currentChainName) { return true; } return sourceAddressHash == remoteAddressHashes[sourceChain];
Access Control
#0 - c4-pre-sort
2023-07-29T00:37:08Z
0xSorryNotSorry marked the issue as duplicate of #348
#1 - c4-judge
2023-09-08T16:29:11Z
berndartmueller marked the issue as unsatisfactory: Invalid
#2 - pcarranzav
2023-09-08T19:31:28Z
Hi again @berndartmueller - I'd also like to point out this one, which was marked as duplicate of https://github.com/code-423n4/2023-07-axelar-findings/issues/348 which is marked as invalid. The argumentation in my submission is different as it is framed as a privilege escalation of the deployer wallet, which persists after ownership transfer and in the hypothetical of a new EVM chain being added to the Axelar network.
This modifies the trust assumptions of the protocol as users can reasonably expect to trust the multisig Owner, but not a deployer EOA, for an indefinite time. In the closed issue, @deanamiel commented that "It would be impossible to deploy a different contract at this address because the address will depend on the deployer address and salt." However, there is a different level of "impossible" when comparing an EOA to a multisig and users now need to trust that nobody with access to that EOA is compromised or that it is disposed of properly.
The probability of this being exploited is low (requires deployer EOA compromise and new chain added to the network), but the impact would be huge which is why I honestly believe it warrants a Medium severity and is a valid finding to surface in the report so that users are aware.
#3 - c4-judge
2023-09-11T11:44:38Z
berndartmueller marked the issue as not a duplicate
#4 - c4-judge
2023-09-11T11:54:31Z
berndartmueller marked the issue as primary issue
#5 - berndartmueller
2023-09-11T12:01:31Z
Hi again @berndartmueller - I'd also like to point out this one, which was marked as duplicate of #348 which is marked as invalid. The argumentation in my submission is different as it is framed as a privilege escalation of the deployer wallet, which persists after ownership transfer and in the hypothetical of a new EVM chain being added to the Axelar network.
This modifies the trust assumptions of the protocol as users can reasonably expect to trust the multisig Owner, but not a deployer EOA, for an indefinite time. In the closed issue, @deanamiel commented that "It would be impossible to deploy a different contract at this address because the address will depend on the deployer address and salt." However, there is a different level of "impossible" when comparing an EOA to a multisig and users now need to trust that nobody with access to that EOA is compromised or that it is disposed of properly.
The probability of this being exploited is low (requires deployer EOA compromise and new chain added to the network), but the impact would be huge which is why I honestly believe it warrants a Medium severity and is a valid finding to surface in the report so that users are aware.
Hey @pcarranzav!
thanks for pointing this out!
I've marked your submission as the primary report due to pointing out the deployer privilege escalation. But I also consider #348 a duplicate, as the recommended fix would also fix the underlying issue.
Overall, I agree with the outlined privilege escalation. Even though the likelihood of this to happen is low, the impact would be critical. Thus, I consider medium severity to be appropriate.
#6 - c4-judge
2023-09-11T12:01:45Z
berndartmueller marked the issue as satisfactory
#7 - c4-judge
2023-09-11T12:02:02Z
berndartmueller marked the issue as selected for report
#8 - c4-sponsor
2023-09-12T21:15:01Z
deanamiel (sponsor) acknowledged
#9 - deanamiel
2023-09-12T21:15:35Z
We acknowledge the severity, and while we’ve considered using a deployer multisig contract which reduces this risk, our operations team is planning on whitelisting explicitly, to minimize the impact of the deployer or a non-whitelisted chain being compromised. Note that RemoteAddressValidator is deployed on the destination chain, so the recommendation to compare the source chain to the chain in remote address validator won’t work.
🌟 Selected for report: pcarranzav
Also found by: immeas
2120.0403 USDC - $2,120.04
https://github.com/code-423n4/2023-07-axelar/blob/aeabaa7086eb35e8614e58b42f0d50728e023881/contracts/cgp/auth/MultisigBase.sol#L44-L77 https://github.com/code-423n4/2023-07-axelar/blob/9f642fe854eb11ad9f18fe028e5a8353c258e926/contracts/cgp/governance/Multisig.sol#L30-L36
In MultisigBase and its use in Multisig, the onlySigners
modifier will reset the vote count and execute the external call when the vote threshold is met. But this means that if many signers send their transactions during the same block, the votes that are executed after the call execution will start a new tally, potentially re-executing the same external call if the votes are enough to meet the threshold again. This is probably low-likelihood for multisigs where the threshold is high relative to the number of signers, but could be quite likely if the threshold is relatively low.
In the general case, users of Multisig may not be aware of this behavior and have no good way of avoiding this other than off-chain coordination. An accidental double-execution could easily lead to loss of funds.
This doesn't affect AxelarServiceGovernance because of the additional requirement of an interchain message, but it might still leave behind unwanted votes, which reduces the overall security of the governance mechanism.
Arguably this unit test is a PoC in itself: https://github.com/code-423n4/2023-07-axelar/blob/aeabaa7086eb35e8614e58b42f0d50728e023881/test/cgp/Multisig.js#L152-L176
But the following example might be a better illustration. The following test passes (when based on the explanation above, it shouldn't), and is a modification of the above test but using threshold 1:
it('executes the same call twice, accidentally', async () => { const targetInterface = new Interface(['function callTarget() external']); const calldata = targetInterface.encodeFunctionData('callTarget'); const nativeValue = 1000; // Set the threshold to 1 await multisig.connect(signer1).rotateSigners(accounts, 1) await multisig.connect(signer2).rotateSigners(accounts, 1) // Say these two signers send their vote at the same time: await expect(multisig.connect(signer1).execute(targetContract.address, calldata, nativeValue, { value: nativeValue })).to.emit( targetContract, 'TargetCalled', ); await expect(multisig.connect(signer2).execute(targetContract.address, calldata, nativeValue, { value: nativeValue })).to.emit( targetContract, 'TargetCalled', ); // The call was executed twice! });
Visual inspection.
Consider adding an incrementing nonce to each topic, so that repeating the call requires using a new nonce. If the intent is to allow arbitrary-order execution, then using a random or unique topic ID in addition to the topic hash could be used instead (like you did with the commandId in AxelarGateway).
Access Control
#0 - c4-pre-sort
2023-07-29T00:41:40Z
0xSorryNotSorry marked the issue as duplicate of #245
#1 - c4-judge
2023-09-04T09:49:15Z
berndartmueller changed the severity to QA (Quality Assurance)
#2 - c4-judge
2023-09-08T11:57:05Z
berndartmueller marked the issue as grade-c
#3 - c4-judge
2023-09-09T20:28:42Z
This previously downgraded issue has been upgraded by berndartmueller
#4 - c4-judge
2023-09-09T20:29:45Z
berndartmueller marked the issue as not a duplicate
#5 - c4-judge
2023-09-12T08:41:49Z
berndartmueller marked the issue as primary issue
#6 - c4-judge
2023-09-12T08:42:27Z
berndartmueller marked the issue as satisfactory
#7 - berndartmueller
2023-09-12T09:09:50Z
Referencing the comment in https://github.com/code-423n4/2023-07-axelar-findings/issues/333#issuecomment-1712145265:
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 #116 and #245 could be considered duplicates - impact descriptions are slightly different but the underlying issue is the same)
After careful consideration, I'd like to change my stance on the validity and severity of the outlined issue of overcounting proposal votes. Let me elaborate on my reasoning:
First of all, there's an underlying trust assumption on the used multisig. All signers, initially chosen by the deployer of the multisig contract (MultisigBase
or the derived Multisig
contract), are trusted. Rotating signers, i.e., adding/removing signers and changing the threshold (quorum), is also voted on by the existing signers. Thus, assuming the new set of signers (and threshold) is reasonable and trustworthy.
However, a multisig is purposefully used to ensure proposals only get executed once the quorum of the signers is reached.
Given the outlined issue in the submission, overvoting by signers can occur. For example, if the signing transactions get executed within the same block. Moreover, it can be assumed that the Multisig.execute
function is intended to be executed multiple times with the same arguments (calldata). For instance, to repeatedly perform certain token transfers on a regular basis. Adding some kind of "nonce" or additional data to the arguments to achieve a new and unique proposal (specifically, a unique topic
) to be voted on is not reasonable as the Multisig.execute
function does not provide such parameters. Contrary to OpenZeppelin's Governor
contract, which allows specifying a (unique) proposal description (see Governor.sol#L268-L289. Additionally, in OZ's Governor
contract, casting votes is only possible on pending proposals and reverts otherwise.
I consider "overvoting" as a bug worth mitigating as it leaves proposals in an inconsistent state, leading to unpredictable outcomes.
Given C4's judging criteria and severity categorization
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.
I'm considering this submission valid and the chosen Medium severity to be appropriate.
#8 - c4-judge
2023-09-12T09:10:11Z
berndartmueller marked the issue as selected for report
#9 - milapsheth
2023-11-08T12:31:10Z
This was an intentional design decision. It's designed for internal use and will have low usage frequency with a coordinated participation such that this issue is Low impact, but we acknowledge the concern for general use.
🌟 Selected for report: pcarranzav
Also found by: Jeiwan, K42, MrPotatoMagic, Sathish9098, libratus, niloy
1075.6905 USDC - $1,075.69
The audited contracts are part of Axelar Network’s interchain general message passing, governance and token transfer services. The assets in scope implement a few new features:
The codebase also includes helpers that allow deploying contracts using the create3 pattern, that produces a deployment address that is solely dependent on the sender address and a chosen salt, not the bytecode.
I spent about 11 hours diving deep into the codebase, and then a couple more cleaning up the reports, thinking through the proofs of concept, and preparing the submissions (including this report). My approach was straightforward: visual inspection of the codebase, cross-checking with unit tests and documentation, and thinking through potential edge cases related to timing, reentrancy, ordering of transactions, etc.
I believe I did a thorough review of the governance-related changes, but a slightly less in-depth review of the interchain token service. The following summarizes my findings and thoughts from what I could gather in this time.
I focused on High/Medium severity issues (though I only found Medium ones) and this Analysis, so it’s likely that I won’t be submitting a QA or gas report. The audit bots seem to have caught some of the usual QA issues, so I will highlight some things that are not covered in bot output, and affect the code in a broader way, in “Other recommendations” below.
Generally the codebase looks clean and the architecture seems sound. Interchain governance and generalized token bridging are massive problems to tackle and the developers have approached them in a straightforward, simple way.
I’ve raised a couple Medium issues that relate to timing and ordering of messages and transactions, on the multisig and the interchain governance contracts. This would prompt me to suggest taking a closer look at potential timing conditions with interchain messages, and the impact of messages getting delayed due to gas. This is one of the trickiest things to get right when doing cross-chain messaging, so I wouldn’t be surprised if there are a few other edge cases where this could cause issues.
A general suggestion I have for the architecture is on the amount of proxy contracts that are defined in the codebase. A lot of different proxies are available, and in some cases there is very little difference between each type. This leads to some duplication, e.g. proxy fallback functions re-implemented in several places, which increases the attack surface and risk of errors. I would suggest, on a future iteration, to attempt to combine some of these into fewer contracts to improve maintainability. For instance, proxies that differ only in the contractId()
could have the ID be defined as an immutable and set at proxy deployment time.
Some of the contracts in scope present upgrade mechanisms but use decentralized governance with timelocks. The cases where multisigs are used are a form of centralization risk (especially if the number of signers is low). AxelarServiceGovernance requiring both an interchain approval and a multisig execution provides some additional security against compromised signers.
In general, token projects using the Axelar gateway token transfer service are trusting the Axelar network and its governance mechanism with minting power for their tokens - this governance is decentralized and this is part of the core trust assumption when using Axelar, but it’s worth noting that the mint limiter is a separate, more centralized role (according to the docs, to be handled through a multisig).
For the interchain token service, an important centralization risk is noted in DESIGN.md
:
Users using Custom Bridges need to trust the deployers as they could easily confiscate the funds of users if they wanted to, same as any
ERC20
distributor could confiscate the funds of users.
This is an important consideration and ideally it should be mentioned in user documentation or user interfaces. Luckily, not all bridges suffer this risk, just like not all ERC20s allow confiscation. In particular canonical tokens, and bridges using standardized tokens where the distributor is the token manager, are less susceptible to the deployer doing arbitrary confiscations or minting. This is something that could be surfaced in documentation or UI as well, so that users can know what trust assumptions they are working with when they bridge with each particular token and bridge combination.
In the interchain token service, Axelar governance is able to add trusted addresses that can perform arbitrary token transfers on the InterchainTokenService, so this should be noted as an important centralization risk that affects both canonical and custom bridges. (I have also raised a Medium severity issue related to this, where the deployer address retains this ability even after the ownership is transferred to a governance account).
As mentioned above, timing and gas are some things that are tricky to handle in any protocol that uses cross-chain messaging, and this additional complexity introduces some risk from the possibility of stalling/deadlocks and also simply from the fact that it’s a bigger attack sufrace.
In general, users of these services that are, for instance, extending a DAO or token with interchain governance or bridging, would be plugging their tokens or DAOs into a multiple-chain, multiple-consensus, multiple-VM super-system, so this should always be done with care. Such a system, going beyond a single chain’s consensus, can suddenly be exposed to unexpected issues like reorgs or consensus problems (e.g. forking) in a separate chain causing an impact in token supply in the token’s “native” chain.
Luckily, it seems the team has considered these risks and at first sight Axelar Network has mechanisms to mitigate them (as mentioned in section 5.2 of the Axelar whitepaper), but I’d need to do a deeper dive into the rest of the codebase to assert this with more confidence that all the potential risks stemming from this are covered.
@notice
on all external/public functions would be a big improvement. This is especially important for things like this: https://github.com/code-423n4/2023-07-axelar/blob/9f642fe854eb11ad9f18fe028e5a8353c258e926/contracts/cgp/AxelarGateway.sol#L291-L292 - this audit note is important enough that including it in the NatSpec for the function would be useful. But in general, the meaning of all parameters and return values is valuable information. Similarly, it is not clear in TokenManagerDeployer and StandardizedTokenDeployer that these are meant to be used through delegatecalls - without some documentation, it looks like someone could try to deploy tokens or managers by calling these directly.commandId := calldataload(4)
in some command processing functions suggests that it would be cleaner to pass the commandId to the internal _execute
functions explicitly to avoid the need to use assembly. This assignment is a sort of “out-of-band” parameter-passing mechanism and could be prone to errors, as it obscures the fact that the params for _execute
are not everything that the function needs from the caller. If there is a concern for backwards compatibility, a separate _executeWithCommandId
or similar could be added to AxelarExecutable
.13 hours
#0 - c4-judge
2023-09-04T10:51:18Z
berndartmueller marked the issue as grade-a
#1 - c4-judge
2023-09-05T10:07:51Z
berndartmueller marked the issue as selected for report
#2 - berndartmueller
2023-09-05T10:08:52Z
Choosing this analysis report as the best report out of all submissions. The report includes a thorough review of the codebase and great recommendations.