SubQuery Network: Missing Access Control in Settings Enables Staking Drain
On April 12, 2026, SubQuery Network, a staking protocol on Base, (block 44,590,469) suffered an access-control exploit that drained approximately 218.29M SQT (about $131.2K) from the protocol’s Staking contract. The attacker deployed two ephemeral contracts, abused the absence of any owner or role guard on Settings.setBatchAddress and Settings.setContractAddress, and temporarily rewired the protocol’s StakingManager and RewardsDistributor entries to an attacker-controlled helper. With those privileged dependency slots poisoned, the helper contract was able to call unbondCommission to create an unbond request for the full liquid SQT balance and then immediately call withdrawARequest to pull the funds out. The attacker restored the original Settings values before transaction end and swept 218,070,478.035174175990999309 SQT to the EOA, while treasury received the standard unbond fee.
Root Cause
Vulnerable Contract
- Name: Settings (EIP-1967 proxy + implementation)
- Proxy address:
0x1d1e8c85a2c99575fcb95903c9ad9ae2adea54fc - Implementation address:
0xf282737992da4217bf5f8b6ae621181e84d7d3b9 - Pattern: EIP-1967 proxy. At block 44,590,469, the implementation slot resolves to
0xf282737992da4217bf5f8b6ae621181e84d7d3b9, and the trace shows the proxy forwarding the attacker calls viaDELEGATECALL. - Source type: verified
Secondary affected contract:
- Name: Staking (EIP-1967 proxy + implementation)
- Proxy address:
0x7a68b10eb116a8b71a9b6f77b32b47eb591b6ded - Implementation address:
0xf6c913c506881d7eb37ce52af4dc8e59fd61694d - Role: trusts
Settings.getContractAddress(...)for authorization in the exploited flows
Vulnerable Function
- Primary function:
setBatchAddress(uint8[],address[]) - Selector:
0x7fb7f426 - File:
contracts/Settings.sol
Related unprotected function:
- Function:
setContractAddress(uint8,address) - File:
contracts/Settings.sol - Issue: equally writable by arbitrary callers, even though it mutates the same privileged settings registry
Vulnerable Code
// Settings.sol
function setContractAddress(SQContracts sq, address _address) public { // <-- VULNERABILITY: no onlyOwner or role check
contractAddresses[sq] = _address; // <-- VULNERABILITY: arbitrary caller can rewrite any privileged dependency slot
}
function getContractAddress(SQContracts sq) public view returns (address) {
return contractAddresses[sq];
}
function setBatchAddress(SQContracts[] calldata _sq, address[] calldata _address) external { // <-- VULNERABILITY: arbitrary caller can batch-overwrite trusted protocol roles
require(_sq.length == _address.length, 'ST001');
for (uint256 i = 0; i < _sq.length; i++) {
contractAddresses[_sq[i]] = _address[i]; // <-- VULNERABILITY: attacker rewrote slots 2 and 8 to its helper
}
}
// Staking.sol
modifier onlyStakingManager() {
require(msg.sender == settings.getContractAddress(SQContracts.StakingManager), 'G007');
_;
}
function withdrawARequest(address _source, uint256 _index) external onlyStakingManager {
...
}
function unbondCommission(address _runner, uint256 _amount) external {
require(msg.sender == settings.getContractAddress(SQContracts.RewardsDistributor), 'G003');
lockedAmount[_runner] += _amount;
this.startUnbond(_runner, _runner, _amount, UnbondType.Commission);
}
Why It’s Vulnerable
Expected behavior: Because Settings inherits OwnableUpgradeable, any mutation of contractAddresses should be restricted to the protocol owner or another explicitly authorized administrator. In particular, critical entries such as StakingManager and RewardsDistributor should never be writable by arbitrary users, because the Staking contract depends on them for authorization.
Actual behavior: Both settings mutators are externally reachable without onlyOwner, any custom modifier, or any msg.sender validation. As a result, the attacker could overwrite the settings registry and make the Staking contract trust an attacker-controlled helper as both the staking manager and the rewards distributor.
Why this matters: The Staking implementation does not maintain an internal immutable allowlist for those roles. Instead, it checks settings.getContractAddress(SQContracts.StakingManager) in onlyStakingManager, checks settings.getContractAddress(SQContracts.RewardsDistributor) inside unbondCommission, and checks the staking manager value again inside startUnbond. Once the helper is inserted into Settings, all of those authorization checks pass.
Missing protection: There is no owner check, no role check, no timelock, and no two-step admin update around the Settings registry. The exploit path is simply: overwrite privileged slots -> call privileged staking functions -> withdraw tokens -> restore slots.
Normal flow vs Attack flow:
| Step | Normal protocol behavior | Attack transaction |
|---|---|---|
| Settings registry | Points to legitimate protocol components | Rewired to attacker helper |
unbondCommission caller | Real rewards distributor only | Helper passes after slot overwrite |
withdrawARequest caller | Real staking manager only | Same helper passes after slot overwrite |
| SQT payout path | Legitimate protocol-controlled unbond flow | Immediate attacker-controlled withdrawal |
| Settings state after execution | Stable | Restored by attacker to reduce visibility |
Attack Execution
High-Level Flow
- The attacker EOA deploys an orchestrator contract.
- The orchestrator reads the Staking contract’s SQT balance and fetches the current
StakingManagerandRewardsDistributorvalues from Settings. - The orchestrator deploys a helper contract.
- The orchestrator calls
setBatchAddressto replace slots2and8with the helper. - The helper calls
unbondCommissionon Staking for the full liquid SQT balance, creating an unbond request for itself. - The helper immediately calls
withdrawARequest, causing Staking to emit the unbond events and transfer SQT out. - Treasury receives the standard unbond fee, and the helper receives the remaining SQT.
- The orchestrator restores the original Settings values.
- The helper transfers the stolen SQT to the attacker EOA.
- Both attacker contracts self-destruct at the end of the transaction.
Detailed Call Trace
[depth 0] Attacker EOA -> CREATE Orchestrator (`0x51952ec8dcd8c9345d8d0df299e63983e0b3f55a`)
[1] Orchestrator -> SQT STATICCALL balanceOf(Staking)
- Observes `218,288,766.801976152143142451` SQT in Staking
[2] Orchestrator -> SettingsProxy STATICCALL getContractAddress(2)
[3] SettingsProxy -> SettingsImpl DELEGATECALL getContractAddress(2)
- Returns original `StakingManager`
[4] Orchestrator -> SettingsProxy STATICCALL getContractAddress(8)
[5] SettingsProxy -> SettingsImpl DELEGATECALL getContractAddress(8)
- Returns original `RewardsDistributor`
[6] Orchestrator -> CREATE Helper (`0xf5d3c18416f364342d8aad69afc13e490d05a7af`)
[7] Orchestrator -> SettingsProxy CALL setBatchAddress([2,8],[Helper,Helper])
[8] SettingsProxy -> SettingsImpl DELEGATECALL setBatchAddress(...)
- Overwrites `StakingManager` and `RewardsDistributor`
[9] Orchestrator -> Helper CALL execute()
[10] Helper -> StakingProxy CALL unbondCommission(Helper, fullBalance)
[11] StakingProxy -> StakingImpl DELEGATECALL unbondCommission(...)
[12] StakingImpl -> SettingsProxy STATICCALL getContractAddress(8)
[13] SettingsProxy -> SettingsImpl DELEGATECALL getContractAddress(8)
- Returns Helper, so `G003` passes
[14] StakingProxy -> StakingProxy CALL startUnbond(Helper, Helper, fullBalance, Commission)
[15] StakingProxy -> StakingImpl DELEGATECALL startUnbond(...)
[16] StakingImpl -> SettingsProxy STATICCALL getContractAddress(2)
[17] SettingsProxy -> SettingsImpl DELEGATECALL getContractAddress(2)
- Returns Helper, so `G008` passes
- Emits `UnbondRequested`
[18] Helper -> StakingProxy CALL withdrawARequest(Helper, 0)
[19] StakingProxy -> StakingImpl DELEGATECALL withdrawARequest(...)
[20] StakingImpl -> SettingsProxy STATICCALL getContractAddress(2)
[21] SettingsProxy -> SettingsImpl DELEGATECALL getContractAddress(2)
- Returns Helper, so `G007` passes
[22] StakingImpl -> SettingsProxy STATICCALL getContractAddress(0)
[23] SettingsProxy -> SettingsImpl DELEGATECALL getContractAddress(0)
- Resolves SQT token
[24] StakingImpl -> SettingsProxy STATICCALL getContractAddress(18)
[25] SettingsProxy -> SettingsImpl DELEGATECALL getContractAddress(18)
- Resolves Treasury
[26] StakingProxy -> SQT CALL transfer(Treasury, fee)
[27] StakingProxy -> SQT CALL transfer(Helper, netAmount)
- Emits `UnbondWithdrawn`
[28] Helper -> SQT STATICCALL balanceOf(Helper)
- Observes stolen balance
[29] Orchestrator -> SettingsProxy CALL setBatchAddress([2,8],[originalManager,originalDistributor])
[30] SettingsProxy -> SettingsImpl DELEGATECALL setBatchAddress(...)
- Restores original Settings state
[31] Orchestrator -> Helper CALL unresolved sweep selector
[32] Helper -> SQT STATICCALL balanceOf(Helper)
[33] Helper -> SQT CALL transfer(AttackerEOA, stolenAmount)
[34] Helper SELFDESTRUCT -> AttackerEOA
[35] Orchestrator SELFDESTRUCT -> AttackerEOA
Financial Impact
| Address | Role | SQT Delta |
|---|---|---|
0x7a68b10eb116a8b71a9b6f77b32b47eb591b6ded | Victim - Staking contract | -218,288,766.801976152143142451 SQT |
0xd043807a0f41ee95fd66a523a93016a53456e79b | Treasury fee recipient | +218,288.766801976152143142 SQT |
0x910175f3fee798add5fabd3e9cbb63d0a785482c | Attacker EOA | +218,070,478.035174175990999309 SQT |
Breakdown from funds_flow.json and the trace:
- Total drained from Staking: 218,288,766.801976152143142451 SQT
- Treasury fee skim: 218,288.766801976152143142 SQT
- Net attacker proceeds: 218,070,478.035174175990999309 SQT
- Reference valuation at $0.000601 / SQT:
- Gross protocol outflow: $131,191.55
- Net attacker proceeds: $131,060.36
The attack emptied the Staking contract’s liquid SQT balance observed at the beginning of the transaction. No flash loan or repayment leg appears anywhere in the trace; this was a pure authorization failure leading directly to token loss.
Evidence
On-chain transaction: 0xd063b3848a6b8c67f46990ab166665d454147855819acb60c083c0aea0180b2d
Block: 44,590,469 on Base (timestamp 2026-04-12 05:04:45 UTC)
Key evidence points:
contracts/Settings.solcontains both vulnerable setters with no access-control guard.contracts/Staking.solauthorizeswithdrawARequestandunbondCommissionthrough Settings lookups rather than immutable role bindings.- The proxy implementation slot for Settings resolves to the verified implementation used in the report.
- The call trace shows the attacker reading the original settings values, overwriting them, using the helper to pass authorization, restoring the original values, and sweeping the stolen tokens.
- Receipt logs contain one
UnbondRequested, oneUnbondWithdrawn, and three SQTTransferevents matching the fee, helper payout, and attacker sweep. funds_flow.jsonreconciles exactly with the receipt and the trace totals.