# 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 via `DELEGATECALL`.
– **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**:

StepNormal protocol behaviorAttack transactionSettings registryPoints to legitimate protocol componentsRewired to attacker helperReal rewards distributor onlyHelper passes after slot overwriteReal staking manager onlySame helper passes after slot overwriteSQT payout pathLegitimate protocol-controlled unbond flowImmediate attacker-controlled withdrawalSettings state after executionStableRestored by attacker to reduce visibility

## Attack Execution

### High-Level Flow

01. The attacker EOA deploys an orchestrator contract.
02. The orchestrator reads the Staking contract’s SQT balance and fetches the current `StakingManager` and `RewardsDistributor` values from Settings.
03. The orchestrator deploys a helper contract.
04. The orchestrator calls `setBatchAddress` to replace slots `2` and `8` with the helper.
05. The helper calls `unbondCommission` on Staking for the full liquid SQT balance, creating an unbond request for itself.
06. The helper immediately calls `withdrawARequest`, causing Staking to emit the unbond events and transfer SQT out.
07. Treasury receives the standard unbond fee, and the helper receives the remaining SQT.
08. The orchestrator restores the original Settings values.
09. The helper transfers the stolen SQT to the attacker EOA.
10. 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

AddressRoleSQT DeltaVictim – Staking contract**-218,288,766.801976152143142451 SQT**Treasury fee recipient**+218,288.766801976152143142 SQT**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.sol` contains both vulnerable setters with no access-control guard.
– `contracts/Staking.sol` authorizes `withdrawARequest` and `unbondCommission` through 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`, one `UnbondWithdrawn`, and three SQT `Transfer` events matching the fee, helper payout, and attacker sweep.
– `funds_flow.json` reconciles exactly with the receipt and the trace totals.