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.
0x1d1e8c85a2c99575fcb95903c9ad9ae2adea54fc0xf282737992da4217bf5f8b6ae621181e84d7d3b90xf282737992da4217bf5f8b6ae621181e84d7d3b9, and the trace shows the proxy forwarding the attacker calls via DELEGATECALL.Secondary affected contract:
0x7a68b10eb116a8b71a9b6f77b32b47eb591b6ded0xf6c913c506881d7eb37ce52af4dc8e59fd61694dSettings.getContractAddress(...) for authorization in the exploited flowssetBatchAddress(uint8[],address[])0x7fb7f426contracts/Settings.solRelated unprotected function:
setContractAddress(uint8,address)contracts/Settings.sol// 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);
}
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 |
StakingManager and RewardsDistributor values from Settings.setBatchAddress to replace slots 2 and 8 with the helper.unbondCommission on Staking for the full liquid SQT balance, creating an unbond request for itself.withdrawARequest, causing Staking to emit the unbond events and transfer SQT out.[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
| 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:
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.
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.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.