90408 bc medium quantus s safe rpc exposes genesisbuilder with unbounded derivation paths letting unauthenticated callers pin node cpu cores
Submitted on Aug 24th 2026 at 21:20:46 UTC by @tinnohofficial for Audit Comp | Quantus
Report ID: #90408
Report Type: Blockchain/DLT
Report severity: Medium
Target: https://github.com/immunefi-team/audit-comp-quantus-chain
Impacts:
Increasing network processing node resource consumption by at least 30% without brute force actions, compared to the preceding 24 hours
Description
Brief/Intro
Quantus exports GenesisBuilder_build_state from its production runtime. An unauthenticated client can invoke it through the standard state_call RPC even when the node uses --rpc-methods safe.
The caller controls devAccounts = (count, balance, derivation_template). Although Quantus caps the account count at 1,000, it does not bound the template's length, placeholder count, or derivation-path junctions. The runtime reparses that attacker-selected path for every account, making the work approximately account count * junction count.
An 8,739-byte request with 1,000 accounts and 1,000 short junctions occupied one core for about 6.7 seconds. Four clients raised the frozen node from approximately 100% to 500% process CPU while sending only about 5.2 KB/s. An 80,743-byte request ran for more than 60 seconds, and the work continued after the client disconnected.
The attack requires no account, signature, transaction fee, or high request rate and directly exceeds the selected 30% Medium node-resource threshold.
Vulnerability Details
state_call exposes the GenesisBuilder under the safe RPC policy
Quantus's production runtime exports the complete GenesisBuilder API:
See runtime/src/apis.rs:323-340.
The standard state RPC declares state_call as a blocking method that accepts an arbitrary runtime method name:
See sc-rpc-api-0.54.0/src/state/mod.rs:34-39.
Its implementation forwards directly to the runtime executor. Unlike state_getPairs immediately below it, state_call does not invoke check_if_safe:
See sc-rpc-50.0.0/src/state/mod.rs:203-216. FullState::call then executes the selected export at the best block using CallContext::Offchain: sc-rpc-50.0.0/src/state/state_full.rs:197-210.
The node executes this through sc_executor::WasmExecutor<sp_io::SubstrateHostFunctions> (node/src/service.rs:556-560). The overlay is discarded afterward, but the JSON parsing, key derivations, and storage writes have already consumed CPU.
The PoC invokes GenesisBuilder_build_state successfully on a publicly bound node using --rpc-methods safe; each call returns "result":"0x00", the SCALE encoding of Ok(()). The default RPC configuration allows 15 MB requests and 100 connections, while per-connection rate limiting is disabled. The measured attack needed only four concurrent calls.
The count cap still permits seconds of work from a 763-byte request
The existing mitigation caps only the account count:
MAX_DEV_ACCOUNTS is 1,000 (pallets/balances/src/lib.rs:200-208,1360-1376). Even an ordinary 763-byte request at that limit took 1.70 seconds in the production Wasm executor. The PoC stays within this cap and increases work through the still-unbounded derivation template.
The derivation template has unbounded multiplicative complexity
The genesis field contains an unrestricted String:
See pallets/balances/src/lib.rs:564-574.
Validation requires only that this string contain at least one {}:
For every one of the accepted 1,000 accounts, the runtime copies/replaces the whole string, reparses every path junction, performs the requested sr25519 derivations, and writes an account:
See pallets/balances/src/lib.rs:1384-1416.
A template made from "//{}" * n is valid. Each repetition is parsed as another hard derivation junction, and every placeholder is replaced before parsing. Work is therefore approximately proportional to:
There is no bound on junction count, placeholder count, or template length other than the generic 15 MB outer RPC request limit. The PoC needs only 8,739 bytes per request.
Measured scaling on frozen HEAD:
Request
Outer RPC bytes
Live build_state duration
1,000 accounts, ordinary template
763
1.70 s
1,000 accounts, 1,000 short junctions
8,739
6.58-6.81 s across repeated runs
1,000 accounts, 10,000 short junctions
80,743
more than 60 s
1,000 accounts, one 7 MB junction
14,000,755
22.43 s
The 14 MB request is accepted under the compiled 15 MB default. More importantly, the short-junction variant gives minute-scale work from only about 80 KB.
Closing the client does not cancel the blocking runtime job
The 10,000-junction request exceeded the client's 60-second read timeout. Immediately after the client raised Net::ReadTimeout and closed its socket:
The same node's normal local-mining baseline was approximately 100%. The extra core remained occupied after disconnect and disappeared only when the runtime call completed. A lightweight state_call("Core_version", "0x") still returned during this interval.
Consequently, an attacker does not need to retain long-lived response connections. Once the blocking work has been admitted, abandoning the HTTP request does not free the victim's CPU.
Why this is not a duplicate
Quantus PR #616 disclosed an unbounded account count and introduced MAX_DEV_ACCOUNTS = 1_000. This PoC never exceeds that limit. It exploits a separate unbounded input inside every accepted account: the derivation template's length and junction count. Limiting the number of loop iterations does not limit the work performed by each iteration.
The vulnerable combination is also Quantus-specific: its production runtime exports GenesisBuilder_build_state, routes it through prepare_genesis_build_input, and uses the custom balances devAccounts derivation loop.
Impact Details
The attack targets any Quantus node exposing RPC, including nodes using --rpc-methods safe. It requires no account, authentication, signature, transaction fee, on-chain state, or mining power.
On an Apple M4 Pro host with 12 logical CPUs, frozen quantus-node 0.8.0-exodus-3b243b870a5 showed:
Four clients completed 12 requests in 20.275 seconds. Each request was 8,739 bytes and took 6.688-6.813 seconds, for approximately 5.2 KB/s of request payload. The measured increase therefore does not depend on brute-force traffic. Increasing the junction count lengthens each admitted job, and disconnecting does not cancel it.
The PoC did not shut down the whole network, and lightweight RPC calls could still be scheduled on the 12-core test host. For that reason this report selects the exact measured Medium - Increasing network processing node resource consumption by at least 30% without brute force actions impact rather than claiming High or Critical availability loss.
Recommended Fix
Remove GenesisBuilder_ exports from the remotely callable state_call namespace. Chain-spec tooling should use an internal client path rather than exposing genesis construction on a running node.
As defense in depth:
References
runtime/src/apis.rs:323-340- productionGenesisBuilderruntime export.runtime/src/genesis_config_presets.rs:458-495- custom genesis-input parser.pallets/balances/src/lib.rs:200-208- account-count cap.pallets/balances/src/lib.rs:564-620- attacker-controlled genesis field and call into derivation.pallets/balances/src/lib.rs:1360-1418- count-only cap and multiplicative derivation loop.node/src/service.rs:556-560- production Wasm executor.client/cli/src/config.rs:49-58- 15 MB request and 100-connection defaults.client/cli/src/params/rpc_params.rs:47-85- public binding, safe method policy, and disabled-by-default rate limiting.sc-rpc-api-0.54.0/src/state/mod.rs:34-39- arbitrary blockingstate_calldeclaration.sc-rpc-50.0.0/src/state/mod.rs:203-216- no safe-method check before forwarding the call.sc-rpc-50.0.0/src/state/state_full.rs:197-210- runtime executor invocation.
Proof of Concept
Environment
I reproduced these measurements on macOS; the same node and Python PoC run on Linux, where top or pidstat can be used to observe process CPU consumption.
No source modification is required.
Run the client
Observed client output:
Observed node samples while those four workers were active:
Immediately after the clients completed, the same process returned to its local-mining baseline:
All 12 unauthenticated runtime calls returned success. The only attacker traffic was 12 bodies of 8,739 bytes over approximately 20 seconds.
Long-running call and disconnect persistence
One worker with 10,000 junctions creates an 80,743-byte request:
The client times out after 60 seconds. Monitoring the node after the exception shows it still using one extra full CPU core until GenesisBuilder_build_state finishes. This demonstrates that a client can abandon admitted work rather than paying for a long-lived connection.
Stop the local node after testing:
Was this helpful?