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 transaction
Settings registryPoints to legitimate protocol componentsRewired to attacker helper
unbondCommission callerReal rewards distributor onlyHelper passes after slot overwrite
withdrawARequest callerReal staking manager onlySame helper passes after slot overwrite
SQT payout pathLegitimate protocol-controlled unbond flowImmediate attacker-controlled withdrawal
Settings state after executionStableRestored by attacker to reduce visibility

Attack Execution

High-Level Flow

  1. The attacker EOA deploys an orchestrator contract.
  2. The orchestrator reads the Staking contract’s SQT balance and fetches the current StakingManager and RewardsDistributor values from Settings.
  3. The orchestrator deploys a helper contract.
  4. The orchestrator calls setBatchAddress to replace slots 2 and 8 with the helper.
  5. The helper calls unbondCommission on Staking for the full liquid SQT balance, creating an unbond request for itself.
  6. The helper immediately calls withdrawARequest, causing Staking to emit the unbond events and transfer SQT out.
  7. Treasury receives the standard unbond fee, and the helper receives the remaining SQT.
  8. The orchestrator restores the original Settings values.
  9. 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 Delta
0x7a68b10eb116a8b71a9b6f77b32b47eb591b6dedVictim - Staking contract-218,288,766.801976152143142451 SQT
0xd043807a0f41ee95fd66a523a93016a53456e79bTreasury fee recipient+218,288.766801976152143142 SQT
0x910175f3fee798add5fabd3e9cbb63d0a785482cAttacker 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.