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
.