Skip to main content

Reentrancy Guard By Aspect

Intro​

This sample Aspect can prevent hacks similar to the reentrant attack happened to Curve.fi on 2023.07.

Attack Scenario:​

  1. The attacked Curve.fi pool was written in Vyper (a smart contract language compiled into EVM bytecode).
  2. The Vyper version used (0.2.15) had a known reentrant lock issue in the compiler.
  3. The attacker bypassed the reentrancy lock by calling add_liquidity from remove_liquidity through the fallback function, exploiting the vulnerability.

To delve into the attack details, you can check out the attack transaction here.

Learn more from our blog:

How does Aspect Programming prevent reentrancy attacks through on-chain runtime protection ?.

Pre-requisites​

To reproduce the attack, install solc and a specific version of vyper with the reentrant lock bug:

pip install vyper==0.2.16

1. Init Aspect dApp​

   npm install -g @artela/aspect-tool

mkdir reentrancy-aspect && cd reentrancy-aspect

aspect-tool init

npm install

2. Create Blockchain Accounts (optional).​

Execute the following command under project folder to create two accounts, if you don't already have one.

npm run account:create -- --skfile ./curve_accounts.txt
npm run account:create -- --skfile ./attack_accounts.txt

If you don't have a test token in your account, please join our discard ,require testnet faucet.

If you lack test tokens, request some in our Discord testnet-faucet channel.

3. Create Smart Contracts​

3.1 contracts/curve.vy​

This is a simplified version of Curve smart contract implemented with vyper. Similar to the Curve pool, it has two methods: add_liquidity and remove_liquidity. Both are guarded by the same reentrant lock. AddLiquidity and RemoveLiquidity events will be emitted when corresponding method are called.

event AddLiquidity:
executed: uint256

event RemoveLiquidity:
executed: uint256

deployer: address

@external
def __init__():
self.deployer = msg.sender

@external
@view
def isOwner(user: address) -> bool:
return user == self.deployer

@external
@nonreentrant('lock')
def add_liquidity():
log AddLiquidity(1)

@external
@nonreentrant('lock')
def remove_liquidity():
raw_call(msg.sender, b"")
log RemoveLiquidity(1)

3.2 contracts/attack.sol​

This is a simplified version of attack contract implemented in solidity. It will try to start a reentrant attack through the remove_liquidity with its fallback function.

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.2 <0.9.0;

interface CurveContract {
function add_liquidity() external;

function remove_liquidity() external;
}

contract Attack {
CurveContract public curve;

constructor(address _curveContract) {
curve = CurveContract(_curveContract);
}

function attack() external payable {
curve.remove_liquidity();
}

fallback() external {
curve.add_liquidity();
}
}

3.3 Deploy Curve Contract​


mkdir -p ./build/contract

## build curve contract
vyper -f abi ./contracts/curve.vy > build/contract/CurveContract.abi && vyper ./contracts/curve.vy > build/contract/CurveContract.bin

## deploy contract
npm run contract:deploy -- --abi ./build/contract/CurveContract.abi --bytecode ./build/contract/CurveContract.bin --skfile ./curve_accounts.txt

The result of the execution can be obtained from the contract address, for example

...
--contractAccount 0xaa19F4957C890518b577205c41C706F1c07fa0cc --contractAddress 0xFe4b65F17554B45eF7D146B86E030da7A4e250bb

3.4 Deploy Attack Contract​

When deploying an attack contract, replace '{curveAddress}' with the real Curve contract address


## build attack contract
npm run contract:build

## deploy contract
npm run contract:deploy -- --abi ./build/contract/Attack.abi --bytecode ./build/contract/Attack.bin --args {curveAddress} --skfile ./attack_accounts.txt

The result of the execution can be obtained from the contract address, for example:

...
--contractAccount 0xaa19F4957C890518b577205c41C706F1c07fa0bb --contractAddress 0xFe4b65F17554B45eF7D146B86E030da7A4e250ee

3.5 Attack​

npm run contract:send -- --contract {attack-address}  --abi ./build/contract/Attack.abi   --skfile ./attack_accounts.txt  --method attack  --gas 200000
  • Replace the placeholder {owner-account} with the real address. like: 0x08D721275c6DbB33bc688B62ef199bbd709154c9

If the reentrant attack succeeded, you will see both AddLiquidity and RemoveLiquidity events logged.

4. Create Aspect​

Next, we deploy an Aspect code to stop the attack happening.

4.1 add intercept logic in 'aspect/index.ts' method 'preContractCall':​


import {
allocate,
entryPoint,
execute,
IPreContractCallJP,
PreContractCallInput, ethereum, CallTreeQuery, sys, EthCallTree,
} from "@artela/aspect-libs";
import {Protobuf} from "as-proto/assembly/Protobuf";

/**
* Please describe what functionality this aspect needs to implement.
*
* About the concept of Aspect @see [join-point](https://docs.artela.network/develop/core-concepts/join-point)
* How to develop an Aspect @see [Aspect Structure](https://docs.artela.network/develop/reference/aspect-lib/aspect-structure)
*/
class Aspect implements IPreContractCallJP {
preContractCall(input: PreContractCallInput): void {
// Get the method of currently called contract.
const currentCallMethod = ethereum.parseMethodSig(input.call!.data);

// Define functions that are not allowed to be reentered.
const noReentrantMethods: Array<string> = [
ethereum.computeMethodSig('add_liquidity()'),
ethereum.computeMethodSig('remove_liquidity()')
];

// Verify if the current method is within the scope of functions that are not susceptible to reentrancy.
if (noReentrantMethods.includes(currentCallMethod)) {
const callTreeQuery = new CallTreeQuery(-1);

const queryCallTree = sys.hostApi.trace.queryCallTree(callTreeQuery);
const ethCallTree = Protobuf.decode<EthCallTree>(queryCallTree, EthCallTree.decode);
var size = ethCallTree.calls.size;
var arrayKeys = ethCallTree.calls.keys();

for (let i = 0; i < size; i++) {
var key = arrayKeys[i];
var parentCall = ethCallTree.calls.get(key);
const parentCallMethod = ethereum.parseMethodSig(parentCall.data);
if (noReentrantMethods.includes(parentCallMethod)) {
// If yes, revert the transaction.
sys.revert(`illegal transaction: method reentered from ${currentCallMethod} to ${parentCallMethod}`);
}
}
}
}

/**
* isOwner is the governance account implemented by the Aspect, when any of the governance operation
* (including upgrade, config, destroy) is made, isOwner method will be invoked to check
* against the initiator's account to make sure it has the permission.
*
* @param sender address of the transaction
* @return true if check success, false if check fail
*/
isOwner(sender: Uint8Array): bool {
return true;
}

}

// 2.register aspect Instance
const aspect = new Aspect()
entryPoint.setAspect(aspect)

// 3.must export it
export {execute, allocate}

4.2 Compile the Aspect​

Build your Aspect:

npm run aspect:build

The resulting release.wasm in the build folder contains the necessary WASM bytecode.

4.3 Deploy the Aspect​

Deploy your compiled Aspect:

 npm run aspect:deploy -- --wasm ./build/release.wasm  \
--joinPoints PreContractCall \
--skfile ./curve_accounts.txt

βœ… Upon successful execution, the terminal will display the Aspect address. It is essential to make a note of this address as it will be useful later on.

πŸ’‘

For more detailed usage information about this command, please refer to the deploy-aspect command documentation.

4.4 Bind the Curve Contract and Aspect​

Deploying the Aspect doesn't automatically activate it. To make it functional, bind it to a smart contract:

   npm run contract:bind -- --skfile ./curve_accounts.txt \
--contract {curveAddress} \
--abi ./build/contract/CurveContract.abi \
--aspectId {aspect-Id} \
--gas 200000

you will see == aspect bind success ==

5. Attack Test​

Execute the re-entrant attack on the simplified Curve contract with Aspect protection, and watch the output.

 npm run contract:send -- --contract {attackAddress} \
--abi ./build/contract/Attack.abi \
--skfile ./attack_accounts.txt \
--method attack \
--gas 200000

If the protection succeeded, you will see the transaction reverted.

img.svg