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: 5/47
Findings: 1
Award: $4,711.20
π Selected for report: 1
π Solo Findings: 1
π Selected for report: 0xTheC0der
4711.2008 USDC - $4,711.20
https://github.com/code-423n4/2023-07-axelar/blob/2f9b234bb8222d5fbe934beafede56bfb4522641/contracts/its/token-manager/TokenManager.sol#L77-L100 https://github.com/code-423n4/2023-07-axelar/blob/2f9b234bb8222d5fbe934beafede56bfb4522641/contracts/its/interchain-token-service/InterchainTokenService.sol#L493-L523 https://github.com/code-423n4/2023-07-axelar/blob/2f9b234bb8222d5fbe934beafede56bfb4522641/contracts/cgp/AxelarGateway.sol#L455-L467
According to the docs, the Axelar network supports cross-chain bridging of external ERC20
tokens as well as their own StandardizedToken
(using lock/unlock, mint/burn or liquidity pools).
StandardizedToken
with the same TokenId
but different decimals on different chains, see PoC.A cross-chain bridging can be performed using the TokenManager.sendToken(...) method which correctly collects the source tokens from the sender and subsequently calls the InterchainTokenService.transmitSendToken(...) method that generates the payload for the remote ContractCall
and also emits the TokenSent
event.
However, this payload as well as the susequently emitted ContractCall
and TokenSent
events, see InterchainTokenService:L512-L514 contain the unscaled source amount with respect to the source token's decimals.
Next, this exact payload (actually its keccak256
hash) gets relayed on the remote chain as it is via AxelarGateway.approveContractCall(...) and the ContractCall
is now approved to be executed with the source amount irrespective of the remote token's decimals.
Therefore, the bridged amounts are off by a factor of 10^abs(sourceDecimals - remoteDecimals)
.
Note that there are also other ways/entry-points to reproduce this issue with the current codebase.
This leads to loss of funds for user/protocol/pool when source token decimals are lower/higher than remote token decimals, because the token amount is just passed through instead of being scaled accordingly.
The first part of the PoC demonstrates the following:
StandardizedToken
with the same TokenId
but different decimals on different chains. In this example, 18 decimals on source chain and 16 decimals on remote chains.sendToken
method creates the aforementioned payload (to be relayed) and the respective ContractCall
/TokenSent
events with the unscaled source amount.Just apply the diff below and run the test with npm run test test/its/tokenServiceFullFlow.js
:
diff --git a/test/its/tokenServiceFullFlow.js b/test/its/tokenServiceFullFlow.js index c1d72a2..3eb873b 100644 --- a/test/its/tokenServiceFullFlow.js +++ b/test/its/tokenServiceFullFlow.js @@ -31,6 +31,7 @@ describe('Interchain Token Service', () => { const name = 'tokenName'; const symbol = 'tokenSymbol'; const decimals = 18; + const otherDecimals = 16; before(async () => { const wallets = await ethers.getSigners(); @@ -151,13 +152,13 @@ describe('Interchain Token Service', () => { }); }); - describe('Full standardized token registration, remote deployment and token send', async () => { + describe.only('Full standardized token registration, remote deployment and token send', async () => { let token; let tokenId; const salt = getRandomBytes32(); const otherChains = ['chain 1', 'chain 2']; const gasValues = [1234, 5678]; - const tokenCap = BigInt(1e18); + const tokenCap = BigInt(10000e18); before(async () => { tokenId = await service.getCustomTokenId(wallet.address, salt); @@ -184,7 +185,7 @@ describe('Interchain Token Service', () => { salt, name, symbol, - decimals, + otherDecimals, // use other decimals on remote chains '0x', wallet.address, otherChains[i], @@ -197,19 +198,19 @@ describe('Interchain Token Service', () => { const params = defaultAbiCoder.encode(['bytes', 'address'], [wallet.address, token.address]); const payload = defaultAbiCoder.encode( ['uint256', 'bytes32', 'string', 'string', 'uint8', 'bytes', 'bytes'], - [SELECTOR_DEPLOY_AND_REGISTER_STANDARDIZED_TOKEN, tokenId, name, symbol, decimals, '0x', wallet.address], + [SELECTOR_DEPLOY_AND_REGISTER_STANDARDIZED_TOKEN, tokenId, name, symbol, otherDecimals, '0x', wallet.address], ); await expect(service.multicall(data, { value })) .to.emit(service, 'TokenManagerDeployed') .withArgs(tokenId, LOCK_UNLOCK, params) .and.to.emit(service, 'RemoteStandardizedTokenAndManagerDeploymentInitialized') - .withArgs(tokenId, name, symbol, decimals, '0x', wallet.address, otherChains[0], gasValues[0]) + .withArgs(tokenId, name, symbol, otherDecimals, '0x', wallet.address, otherChains[0], gasValues[0]) .and.to.emit(gasService, 'NativeGasPaidForContractCall') .withArgs(service.address, otherChains[0], service.address.toLowerCase(), keccak256(payload), gasValues[0], wallet.address) .and.to.emit(gateway, 'ContractCall') .withArgs(service.address, otherChains[0], service.address.toLowerCase(), keccak256(payload), payload) .and.to.emit(service, 'RemoteStandardizedTokenAndManagerDeploymentInitialized') - .withArgs(tokenId, name, symbol, decimals, '0x', wallet.address, otherChains[1], gasValues[1]) + .withArgs(tokenId, name, symbol, otherDecimals, '0x', wallet.address, otherChains[1], gasValues[1]) .and.to.emit(gasService, 'NativeGasPaidForContractCall') .withArgs(service.address, otherChains[1], service.address.toLowerCase(), keccak256(payload), gasValues[1], wallet.address) .and.to.emit(gateway, 'ContractCall') @@ -217,30 +218,32 @@ describe('Interchain Token Service', () => { }); it('Should send some token to another chain', async () => { - const amount = 1234; + const amountSrc = BigInt(1234e18); // same amount on source and destination chain + const amountDst = BigInt(1234e16); // just scaled according to decimals const destAddress = '0x1234'; const destChain = otherChains[0]; const gasValue = 6789; const payload = defaultAbiCoder.encode( ['uint256', 'bytes32', 'bytes', 'uint256'], - [SELECTOR_SEND_TOKEN, tokenId, destAddress, amount], + [SELECTOR_SEND_TOKEN, tokenId, destAddress, amountDst], // expect scaled amount according to remote decimals ); const payloadHash = keccak256(payload); - await expect(token.approve(tokenManager.address, amount)) + await expect(token.approve(tokenManager.address, amountSrc)) .to.emit(token, 'Approval') - .withArgs(wallet.address, tokenManager.address, amount); - - await expect(tokenManager.sendToken(destChain, destAddress, amount, '0x', { value: gasValue })) + .withArgs(wallet.address, tokenManager.address, amountSrc); + + // call succeeds but doesn't take into account remote decimals + await expect(tokenManager.sendToken(destChain, destAddress, amountSrc, '0x', { value: gasValue })) .and.to.emit(token, 'Transfer') - .withArgs(wallet.address, tokenManager.address, amount) + .withArgs(wallet.address, tokenManager.address, amountSrc) .and.to.emit(gateway, 'ContractCall') - .withArgs(service.address, destChain, service.address.toLowerCase(), payloadHash, payload) + .withArgs(service.address, destChain, service.address.toLowerCase(), payloadHash, payload) // should fail .and.to.emit(gasService, 'NativeGasPaidForContractCall') - .withArgs(service.address, destChain, service.address.toLowerCase(), payloadHash, gasValue, wallet.address) + .withArgs(service.address, destChain, service.address.toLowerCase(), payloadHash, gasValue, wallet.address) // should fail .to.emit(service, 'TokenSent') - .withArgs(tokenId, destChain, destAddress, amount); + .withArgs(tokenId, destChain, destAddress, amountDst); // should fail }); // For this test the token must be a standardized token (or a distributable token in general)
The second part of the PoC demonstrates that the aforementioned payload is relayed/approved as it is to the remote chain and the source token amount is received on the remote chain irrespective of the remote token's decimals.
Just apply the diff below and run the test with npm run test test/its/tokenService.js
:
diff --git a/test/its/tokenService.js b/test/its/tokenService.js index f9843c1..161ac8a 100644 --- a/test/its/tokenService.js +++ b/test/its/tokenService.js @@ -797,10 +797,10 @@ describe('Interchain Token Service', () => { } }); - describe('Receive Remote Tokens', () => { + describe.only('Receive Remote Tokens', () => { const sourceChain = 'source chain'; let sourceAddress; - const amount = 1234; + const amount = 1234; // this unscaled source amount gets processed with respect to remote decimals let destAddress; before(async () => { sourceAddress = service.address.toLowerCase(); @@ -813,7 +813,7 @@ describe('Interchain Token Service', () => { const payload = defaultAbiCoder.encode( ['uint256', 'bytes32', 'bytes', 'uint256'], - [SELECTOR_SEND_TOKEN, tokenId, destAddress, amount], + [SELECTOR_SEND_TOKEN, tokenId, destAddress, amount], // amount should have been scaled according to remote decimals before relaying ); const commandId = await approveContractCall(gateway, sourceChain, sourceAddress, service.address, payload); @@ -825,11 +825,11 @@ describe('Interchain Token Service', () => { }); it('Should be able to receive mint/burn token', async () => { - const [token, , tokenId] = await deployFunctions.mintBurn(`Test Token Mint Burn`, 'TT', 12, 0); + const [token, , tokenId] = await deployFunctions.mintBurn(`Test Token Mint Burn`, 'TT', 14, 0); const payload = defaultAbiCoder.encode( ['uint256', 'bytes32', 'bytes', 'uint256'], - [SELECTOR_SEND_TOKEN, tokenId, destAddress, amount], + [SELECTOR_SEND_TOKEN, tokenId, destAddress, amount], // amount should have been scaled according to remote decimals before relaying ); const commandId = await approveContractCall(gateway, sourceChain, sourceAddress, service.address, payload); @@ -841,11 +841,11 @@ describe('Interchain Token Service', () => { }); it('Should be able to receive liquidity pool token', async () => { - const [token, , tokenId] = await deployFunctions.liquidityPool(`Test Token Liquidity Pool`, 'TTLP', 12, amount); + const [token, , tokenId] = await deployFunctions.liquidityPool(`Test Token Liquidity Pool`, 'TTLP', 16, amount); (await await token.transfer(liquidityPool.address, amount)).wait(); const payload = defaultAbiCoder.encode( ['uint256', 'bytes32', 'bytes', 'uint256'], - [SELECTOR_SEND_TOKEN, tokenId, destAddress, amount], + [SELECTOR_SEND_TOKEN, tokenId, destAddress, amount], // amount should have been scaled according to remote decimals before relaying ); const commandId = await approveContractCall(gateway, sourceChain, sourceAddress, service.address, payload);
VS Code, Hardhat
This issue cannot be solved easily since the remote chain doesn't know about the token decimals on the source chain and vice versa. I suggest the following options:
ContractCall
/TokenSent
events at all instances. De-normalize token amounts on the remote chain accordingly.Decimal
#0 - 0xSorryNotSorry
2023-07-28T23:07:01Z
The issue is well demonstrated, properly formatted, contains a coded POC. Marking as HQ.
#1 - c4-pre-sort
2023-07-28T23:07:05Z
0xSorryNotSorry marked the issue as high quality report
#2 - c4-pre-sort
2023-07-29T00:47:47Z
0xSorryNotSorry marked the issue as primary issue
#3 - c4-sponsor
2023-08-27T01:46:30Z
deanamiel marked the issue as disagree with severity
#4 - deanamiel
2023-08-27T01:47:25Z
Corrected Severity: QA We might account for this if a better approach is found than rounding off decimals. For pre-existing tokens with different decimals a wrapper can be made by the deployer that does the decimal handling, which is the current intended use for such tokens.
#5 - berndartmueller
2023-09-05T09:43:18Z
This is a very well-written submission!
However, I agree with the sponsor that wrapper tokens are supposed to be used in case of different decimals across chains. For instance, see the wrapper ERC-20 token for USDC on BNB chain, Axelar Wrapped USDC (axlUSDC)
, https://bscscan.com/address/0x4268B8F0B87b6Eae5d897996E6b845ddbD99Adf3#readContract.
Thus, I'm downgrading to QA (Low).
#6 - c4-judge
2023-09-05T09:43:28Z
berndartmueller changed the severity to QA (Quality Assurance)
#7 - c4-judge
2023-09-05T10:22:44Z
berndartmueller marked the issue as grade-c
#8 - MarioPoneder
2023-09-08T20:27:12Z
@berndartmueller Please be aware that the sponor's comment and severity suggestion is only about the first part of the issue (pre-existing tokens) and therefore not conclusive for the whole report.
StandardizedToken
(not mentioned by the sponsor):StandardizedToken
with the same TokenId
but different decimals on different chains, see first part of PoC about (remote) deployment and InterchainTokenService.getCustomTokenId(...).
This should not be possible in the first place as long as the transfer amount does not get scaled according to decimals automatically, i.e. it's a bug which is even more severe than the above issue also leading to loss of funds.TokenId
but different decimals already registered on another chain.I am looking forward to more fact-based opinions about this and fair judgement considering the proven impacts, see also second part of PoC that demonstrates loss of funds (wrong transfer of funds by factor 100).
Have a nice day, everyone!
#9 - berndartmueller
2023-09-11T10:02:02Z
Hey @MarioPoneder!
According to the Interchain token service docs, there are two types of bridges:
Canonical bridges are used to enable bridging pre-existing ERC-20 tokens across chains. Such bridges are deployed via InterchainTokenService.registerCanonicalToken
on the source chain and InterchainTokenService.deployRemoteCanonicalToken
for the remote (destination) chains. Here, the trust assumption is that anyone can create canonical bridges - the resulting StandardizedToken
on the remote chains will have matching decimals with the pre-existing ERC-20 token (see InterchainTokenService.sol#L334. Thus, there is no such issue with non-matching decimals for canonical bridges.
In regards to custom bridges, the trust assumption is different (docs):
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.
In the submission's PoC, the described issue is demonstrated by deploying a StandardizedToken
with 18 decimals on the source chain and deploying corresponding StandardizedToken
tokens with 16 decimals on remote chains (via deployAndRegisterRemoteStandardizedToken
. In this case, it truly shows a mismatch of token decimals between the source and remote chain, leading to a loss of bridged funds.
Even though the docs mention that the deployer of such a custom bridge has to be trusted, implementing the warden's second mitigation recommendation (normalizing token transfer amounts to, e.g., 18 decimals) certainly helps mitigate this issue and reduces the surface for potential errors (malicious or non-malicious) when deploying custom bridges. For example, the Wormhole bridge does this as well by normalizing the token transfer amounts to 8 decimals.
Ultimately, this leads me to acknowledge the outlined issue and medium severity chosen by the warden.
Kindly invite the sponsor's feedback before changing the severity back to medium. @deanamiel
#10 - c4-judge
2023-09-12T08:36:08Z
This previously downgraded issue has been upgraded by berndartmueller
#11 - c4-judge
2023-09-12T08:36:23Z
berndartmueller marked the issue as satisfactory
#12 - c4-judge
2023-09-12T08:36:29Z
berndartmueller marked the issue as selected for report
#13 - deanamiel
2023-09-12T15:37:54Z
The standardized token scenario would still not lead to loss of funds. One could still bridge tokens back and get the same amount, the amounts seen would be mismatched (when divided by 10^ decimals) but this is only a cosmetic issue. We still believe this is a QA level issue.
#14 - berndartmueller
2023-09-12T16:25:24Z
The standardized token scenario would still not lead to loss of funds. One could still bridge tokens back and get the same amount, the amounts seen would be mismatched (when divided by 10^ decimals) but this is only a cosmetic issue. We still believe this is a QA level issue.
Loss of funds may not be permanent, indeed. Still, it presents an issue for users bridging StandardizedToken
tokens and the token pools ( itself. Such tokens with mismatching decimals on the source- and destination chains can also be purposefully bridged to steal funds.
#15 - milapsheth
2023-11-08T12:51:56Z
We consider this QA or Low severity. As mentioned before, there is no actual loss of funds since you can always bridge the same amount back. Furthermore, Standardized tokens still require trusting the deployer, unlike Canonical tokens. Tokens deployed and whitelisted via the UI will make sure the same decimals are used for deployments. Users are not recommended to interact with arbitrary Standardized tokens given that the deployer can still be malicious in various ways.
The ERC20 spec does not require the presence of name
, symbol
, decimals
. While commonly supported, these are still optional fields, so we left it flexible in the main protocol.
You can also build deployer contracts on top of this that do enforce various checks such as using the on-chain name, symbol, decimals before deploying.