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:β
- The attacked Curve.fi pool was written in Vyper (a smart contract language compiled into EVM bytecode).
- The Vyper version used (0.2.15) had a known reentrant lock issue in the compiler.
- The attacker bypassed the reentrancy lock by calling
add_liquidity
fromremove_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.