Aurora Withdrawal Logic Error Bugfix Review

Immunefi
Immunefi
Published in
8 min readSep 20, 2022

Summary

On June 16, an anonymous whitehat submitted a critical vulnerability to Aurora via Immunefi, which consisted of a withdrawal logic error. At the time of the submission, on block 14970303, 50550.9 ETH was on the vulnerable contract. Given that the average price for ETH that day was ~$1,245, the funds at risk amounted to $62,935,870.

The Aurora team quickly fixed the issue, and no user funds were lost.

Aurora then paid out a bounty of $1,000,000 in Aurora tokens, streamed linearly over a year.

Immunefi is pleased to have facilitated this responsible disclosure with our platform. Our goal is to make Web3 safer by incentivizing hackers to responsibly disclose bugs and receive clean money and reputation in exchange.

Before we go any further, it’s important to note that this bugfix review was written by Michal of Halborn, a top-tier DeFi security auditing firm. If you’d like to write bugfix reviews for Immunefi and get your firm’s name out there, send us an email at team@immunefi.com.

Aurora Introduction

There is no denying the impact of Ethereum on the blockchain world — it was the first blockchain to introduce smart contracts. The way Ethereum smart contracts work is that they are written in Solidity and compiled for the Ethereum Virtual Machine (EVM). This virtual machine ensures that contracts compiled on any machine can work the same way across the whole blockchain. Furthermore, Solidity is a language with relatively simple syntax, which is easy for most developers to understand. So naturally, many projects have been developed using Solidity. Are there any drawbacks associated with using Ethereum? Yes, and that is performance. Ethereum is very slow and expensive to use.

Developers could use other blockchains, which have better performance, such as the NEAR blockchain. The challenge is that NEAR blockchain (among many others) uses Rust or AssemblyScript for its smart contracts. Porting the whole code base to another language would be a hassle. Along the way, it might turn out that it is impossible to port some of the functionalities directly.

Here’s where Aurora comes in. Aurora is an EVM (yes, a fully operational environment for Solidity smart contract execution) existing on the NEAR blockchain. How is that possible? Since NEAR’s smart contracts are written in Turing-complete programming languages, it is not a problem to write an emulator for EVM. From NEAR’s perspective, Aurora is just another smart contract on the NEAR blockchain. However, from Aurora’s perspective, it is an L2 network that allows using assets and contracts as though they were on Ethereum. And that’s with leveraging the high throughput and low fees of NEAR blockchain.

Bridges

It’s important to remember that although Aurora can be looked at as an Ethereum-like blockchain, it is not Ethereum and exists on NEAR, a completely separate network.

To transfer assets from one blockchain to another, there has to be a bridge. In the case of NEAR and Aurora, that bridge is called the Rainbow Bridge. It allows the transfer of tokens between Ethereum, NEAR, and Aurora in a trustless manner — it is implemented as smart contracts.

Knowing how the Rainbow Bridge works is crucial to understanding the bug described in this article, primarily how the transfer to Ethereum works.

Technical background on Aurora and Rainbow Bridge

The core idea relies on implementing two light clients, which will store blockchain headers. One is implemented in Solidity and deployed to the Ethereum blockchain. The other is implemented in Rust and exists in the NEAR blockchain. Both are smart contracts. The contract deployed to NEAR will hold Ethereum’s header data and vice versa. Those light clients are ultimately responsible for verifying if appropriate transactions occurred on the other blockchain.

Then, you also need smart contracts responsible for handling the token transfers between blockchains. The way to do this is via the EthCustodian contract.

EthCustodian holds the ETH deposited if you transfer it to NEAR/Aurora. It will also transfer ETH to your Ethereum address if you bridge it from NEAR/Aurora to Ethereum.

Let’s focus on the transfer from NEAR/Aurora to Ethereum, as that was the vulnerable component in this bug submission. The function responsible for payouts is the withdraw function:

It requires a proofData, which is basically just information on the transaction from the NEAR side encoded using Borsh. It parses the proof, i.e., makes sure it is not too old and also valid, and then decodes it. The following function is responsible for that:

The proveOutcome function ensures that the block hash and Merkle proof are valid. The important bit here is the fourth require statement. It verifies a so-called executor_id is equal to the nearProofProducerAccount_. This nearProofProducerAccount is the Aurora contract on the NEAR blockchain. This check makes sure that the Aurora contract executed the transaction. It is related to the following function in the Aurora engine:

As you can see in the code above, the Aurora engine calls the executor_params.make_executor(self) function to set itself as the executor in this context.

Coming back to the withdraw function. After initial verification is complete, the code decodes the result into a BurnResult struct, which is defined as:

Solidity
struct BurnResult {
uint128 amount;
address recipient;
address ethCustodian;
}

This result contains information about the amount of ETH to send, the recipient of this ETH, and the address of the ethCustodian that should do it. As you can see, the withdraw function verifies if this ethCustodian address is equal to its own address. If that is the case, the contract executes the transfer.

Only one question remains: what happens on the NEAR side? The Aurora contract defines its own struct, reflecting Solidity’s BurnResult. It is called WithdrawResult and is defined as follows:

Rust
/// withdraw result for eth-connector
#[derive(BorshSerialize)]
#[cfg_attr(not(target_arch = “wasm32”), derive(BorshDeserialize))]
pub struct WithdrawResult {
pub amount: NEP141Wei,
pub recipient_id: Address,
pub eth_custodian_address: Address,
}

This WithdrawResult is used in the withdraw_eth_from_near function as the return type:

withdraw_eth_from_near function, in turn, is used directly in withdraw, like so:

It is worth noting that although the function is called withdraw_eth_from_near, it is a burn function because ETH is not transferred anywhere. Here is the internal function used by withdraw_eth_from_near, which actually manipulates the balance:

To summarize what we learned, we need Aurora to execute a burn function on the NEAR blockchain. We then need to wait for the light client on the Ethereum side to receive information about it, and only then can we call the withdraw function.

Vulnerability

In fact, we don’t need Aurora to execute the burn. We only need to make the transaction look like it did. However, the Ethereum side is processing the proof. It expects the executor_id to match the Aurora contract. We cannot bypass that. But the Aurora contract also sets itself as an executor in other functions. For instance, let’s look at the view function (which executes the view functions of contracts deployed to Aurora):

Rust
#[no_mangle]
pub extern “C” fn view() {
let mut io = Runtime;
let env = ViewEnv;
let args: ViewCallArgs = io.read_input_borsh().sdk_unwrap();
let current_account_id = io.current_account_id();
let engine = Engine::new(args.sender, current_account_id, io, &env).sdk_unwrap();
let result = Engine::view_with_args(&engine, args).sdk_unwrap();
io.return_output(&result.try_to_vec().sdk_expect(“ERR_SERIALIZE”));
}

It internally calls the Engine::view_with_args which in turn calls the Engine::view function:

As you can see, Aurora also sets itself as an executor for view. Suppose we deploy the following contract to Aurora:

Solidity
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract Echo {
function echo(bytes memory payload) public pure {
assembly {
let pos := mload(0x40)
mstore(pos, mload(add(payload, 0x20)))
mstore(add(pos, 0x20), mload(add(payload, 0x40)))
return(pos, 51)
}
}}

All it does is output the 51 bytes of whatever payload was sent to it (skipping the first 5 bytes of input) prepended with 4 bytes of length.

Before Aurora had patched the bug, it was possible to use the Echo contract to steal the funds from the EthCustodian contract without burning any tokens on the Aurora side. How? First, an attacker had to create a malicious payload, which would be correctly deserialized to the BurnResult struct. Since the expected structure is Borsh serialized, then in this case, the payload is:

  • An amount to withdraw written in little-endian notation, for example, 0x0000000000f06381960a000000000000
  • Address of the receiver on Ethereum blockchain, for example, 0x1111111122222222333333334444444455555555
  • EthCustodian address (the one which will be processing withdraw on the Ethereum side), for example, 0x6666666677777777888888889999999911111111

All those values need to be provided as one lump of bytes. So for the examples above, the payload would be:

0x0000000000f06381960a00000000000011111111222222223333333344444444555555556666666677777777888888889999999911111111

Then, all a malicious user needs to do is call the view function in the Aurora, which will call the Echo contract with the above payload. By doing so, the NEAR blockchain will record a valid and successful transaction containing the payload decodable by EthCustodian. It will not be the same as the payload submitted to the Echo contract since an attacker does not control the first 5 bytes of the payload. Those first 5 bytes will be populated by a Borsh serialized information about the transaction status. For the payload above, the whole result will be:

0x0033000000f06381960a00000000000011111111222222223333333344444444555555556666666677777777888888889999999911111111

At this point, the attacker follows the usual procedure associated with bridging tokens to Ethereum. The attacker would need to wait until the block with the malicious transaction is propagated to the Light Client on Ethereum. Then, the attacker would need to extract the proof, which will be done almost automatically — NEAR has an API endpoint that will do it.

All that is left is for the attacker to send a transaction to the withdraw function in the EthCustodian contract. The transaction will be successful, as all requirements are satisfied:

  • Proof data is associated with the transaction that did happen on the NEAR blockchain
  • The executor_id is the Aurora contract
  • Data from the proof will be correctly deserialized to the BurnResult struct
  • The deserialized data contains the EthCustodian contract’s address

Vulnerability Fix

The function responsible for returning data in the Aurora contract was modified to check specifically for this exploitation process. It now verifies if the bytes corresponding to the EthCustodian address in the malicious result are actually equal to EthCustodian’s address for every output which is longer than 55 bytes. If that is the case, then Aurora throws an error with the ERR_ILLEGAL_RETURN message. The Aurora team is currently working on a longer-term fix by changing the custodian to look at state proofs instead of execution proofs.

Acknowledgements

We would like to thank the anonymous whitehat for doing an amazing job and responsibly disclosing such an important bug. Big props also to the Aurora team who responded quickly to the report and patched it.

We’d like to give further thanks to Michal of Halborn for writing this bugfix review.

If you’re a Web2 or Web3 developer who is finally thinking about a bug-hunting career in Web3, we got you. Check out our ultimate blockchain hacking guide, and start taking home some of the $132m in rewards available on Immunefi — the leading bug bounty platform for Web3.

And if you’re feeling good about your skillset and want to see if you will find bugs in the code, check out the bug bounty program from Aurora.

--

--

Immunefi
Immunefi

Immunefi is the premier bug bounty platform for smart contracts, where hackers review code, disclose vulnerabilities, get paid, and make crypto safer.