88249 bc medium extrinsicbaseweight is still upstream s sr25519 measurement after the switch to ml dsa so every signed extrinsic is charged 12 of what it costs
Submitted on Aug 12th 2026 at 11:32:06 UTC by @skydev for Audit Comp | Quantus
Report ID: #88249
Report Type: Blockchain/DLT
Report severity: Medium
Target: https://github.com/immunefi-team/audit-comp-quantus-chain
Impacts:
Inaccuracies to the weight (similar to gas) accounting system that under or overcharge by more than 50% of the correct amount (and are not intentional as indicated by the program's terms)
Description
Brief/Intro
Quantus replaced the signature scheme wholesale — runtime/src/lib.rs:113, pub type Signature = DilithiumSignatureScheme — so every signed extrinsic now performs an ML-DSA verification inside WASM. The constant that prices per-extrinsic work, ExtrinsicBaseWeight, was never re-derived: it is byte-identical to upstream, where it was benchmarked on a node that signs with sr25519. Running the project's own benchmark overhead against its own runtime measures 871,694 ns per extrinsic against the 108,157 ns the runtime declares. The runtime charges 12.4% of the correct amount — an undercharge of 87.6%, against the programme's 50% threshold for this impact class. That is the smallest of three runs on three machines; the other two give 91.6% and 94.2%.
Vulnerability Details
The constant prices sr25519 work
runtime/src/configs/mod.rs:86-89 builds the block weights from the FRAME default:
pub RuntimeBlockWeights: BlockWeights = BlockWeights::with_sensible_defaults(
Weight::from_parts(6u64 * WEIGHT_REF_TIME_PER_SECOND, u64::MAX),
NORMAL_DISPATCH_RATIO,
);with_sensible_defaults (pallets/frame-system/src/limits.rs:323-335) delegates to BlockWeights::builder(), which sets the per-class base at limits.rs:350:
so the constant is added to the weight of every extrinsic in every dispatch class.
Its generated header records what it measured and where:
frame/support/src/weights/extrinsic_weights.rs diffs empty against polkadot-stable2512-2, and there is no runtime/src/weights/ directory anywhere in the repository. The constant was inherited and never regenerated.
The published Eiger review of 2026-05-13 quotes this same parameter_types! block, at runtime/src/configs/mod.rs:69-82, under EQ-QNT-RUNTIME-O-02 — a finding about the proof-size dimension being left uncapped and unpriced. That finding assesses the limits the block sets. The per-extrinsic base constant those limits meter against is not part of it, and ExtrinsicBaseWeight appears nowhere in that review or in the other three published audits.
The same command signs with a different scheme on each side
benchmark overhead measures a no-op signed extrinsic, so the signature check is inside the number. What changed is which signature.
Upstream, substrate/bin/node/cli/src/benchmarking.rs:
Quantus, node/src/benchmarking.rs:
Same tool, same subcommand, same --chain=dev: upstream's 108,157 ns is the cost of verifying an sr25519 signature, and it is the number the runtime still declares. Quantus's own harness signs ML-DSA-87. The constant was carried across that change unmodified.
DilithiumSignatureScheme is a two-variant enum (primitives/dilithium-crypto/src/types.rs:71-74): the runtime accepts both ML-DSA-87 and ML-DSA-65. The harness signs the 87 variant, so that is what the measurement below prices, and it is what a regenerated constant would encode. A single base_extrinsic cannot price both variants correctly in any case.
The tool's own output corroborates the size of what it signed. It reports Extrinsics per block: 541, and the Normal-class length limit is 3,932,160 bytes (runtime/src/configs/mod.rs:95, 5 MiB at 75%) — 7,268 bytes per extrinsic. An ML-DSA-87 signature is 4,627 bytes and its public key 2,592, so the extrinsics it packed carried a full post-quantum signature envelope, not a 64-byte sr25519 one.
For scale, on the same machine as the runs below: an ML-DSA-87 verification measures 314.5 µs and the Poseidon2 hash over the 2,592-byte public key used for account derivation about 100 µs, both natively (criterion point estimates) — against a declared budget of 108.157 µs for the whole extrinsic. Those come from the criterion benches shipped in qp-rusty-crystals-dilithium 4.1.0 and qp-poseidon-core 3.1.0, run from their own repositories. They are context for the magnitude, not the measurement this report rests on.
benchmark overhead does not run as shipped
node/Cargo.toml:133-141 propagates runtime-benchmarks into quantus-runtime, so the runtime WASM built with that feature imports ext_benchmarking_* host functions — nine of them are named in the error below. Three of the four benchmark subcommands build a client through service::new_partial — Block (node/src/command.rs:511), Storage (:517) and Overhead (:523) — and that executor registers only sp_io::SubstrateHostFunctions (node/src/service.rs:559, :599). All three abort while constructing the runtime:
BenchmarkCmd::Pallet is unaffected: node/src/command.rs:506-509 routes it to run_with_spec, which builds its own executor. Per-pallet benchmarking works; overhead, the subcommand that generates this constant, does not.
This is why the constant could not have been regenerated in place: the process at scripts/regenerate_weights.sh uses benchmark pallet, which works, and the constant is produced by benchmark overhead, which does not. Reproducing the measurement requires a three-line executor fix, attached as enable-benchmarks.patch.
Measured
Run on three machines. Each generated file carries its own hostname and CPU in the header the tool stamps in; all three pairs are attached.
Xeon E5-2687W v4 @ 3.00 GHz (2016, server)
release
1,865,545 ns
94.2%
66,830 ns
Ryzen 7 5800X (2020, desktop)
release
1,288,206 ns
91.6%
156,497 ns
Ryzen 7 5800X, same machine
production
1,205,508 ns
91.0%
7,770 ns
Core i9-14900K (2023, desktop)
release
871,694 ns
87.6%
13,961 ns
declared by the runtime
production
108,157 ns
—
—
undercharge is the impact class's own metric — the shortfall as a fraction of the correct amount, (measured − declared) / measured. On the last row that is (871,694 − 108,157) / 871,694 = 87.6%, i.e. the runtime charges 12.4% of what the work costs. The threshold is 50%, and by construction the figure cannot exceed 100%, which would mean charging nothing.
Upstream generated 108,157 ns from ./target/production/substrate-node, and [profile.production] in this workspace adds lto = true and codegen-units = 1 over release. The third row is that like-for-like control, on the same machine as the second: the profile accounts for 6.4%, and it also produces a far tighter measurement — standard deviation 7,770 ns against 156,497. The two rows measured on release are therefore marginally overstated in the same direction, and remain far above the threshold either way.
The declared constant is fixed while the measurement is not, so the ratio is a property of the pair, not of the runtime alone: it varies 2.1x across these three machines. What does not vary is the sign and the floor — the smallest gap measured, on the newest desktop part, is still 87.6%. The three order monotonically by single-thread performance. The figures quoted in this report are the smallest of the three; the Xeon is the closest match to the Intel(R) Xeon(R) CPU @ 2.60GHz in the inherited constant's header and measures 94.2%.
What the measured runtime is
The attached patch changes the node only. The runtime WASM it executes is nevertheless built with runtime-benchmarks, because node/Cargo.toml:138 propagates the feature and the benchmark subcommands are gated behind it. Two things bound what that can explain:
The measured hot path is unchanged by the feature:
grep -rn 'runtime-benchmarks' pallets/frame-system/src/extensions/returns nothing, soCheckWeightand the extension pipeline compile identically.The only
--chain=devgenesis difference under the feature (runtime/src/genesis_config_presets.rs:366versus:415) is one additionalinitial_high_security_accountsentry.
Neither accounts for an eight-fold gap, and the direction of the finding does not depend on the exact figure: the constant prices an sr25519 signature and the runtime verifies ML-DSA.
Target revision
Measured on Quantus-Network/chain at v0.9.0-endless-sky, one automated version-bump commit after the listed asset revision 3b243b870a5442a6e5f443056f67bbba87d472f5 ("ci: Automate version bump to v0.9.0-endless-sky (#658)"). git diff 3b243b87 v0.9.0-endless-sky --stat returns four paths and five changed lines: Cargo.lock, node/Cargo.toml and runtime/Cargo.toml (package version 0.8.0-exodus → 0.9.0-endless-sky), and runtime/src/lib.rs (spec_version: 143 → 144). frame/support/src/weights/extrinsic_weights.rs, runtime/src/configs/mod.rs, runtime/src/lib.rs:113, node/src/benchmarking.rs and node/src/service.rs are byte-identical between the two, so every line reference and every figure here holds on the asset revision.
Impact Details
Impact (Medium): "Inaccuracies to the weight (similar to gas) accounting system that under or overcharge by more than 50% of the correct amount (and are not intentional as indicated by the program's terms)".
The runtime charges 108,157 ns where the measurement gives 871,694 ns — 12.4% of the correct amount, an undercharge of 87.6% on the most favourable of three hosts and 94.2% on the least. The threshold is 50%.
The error applies to every extrinsic, not one call.
base_extrinsicis added to each extrinsic's weight by the block-weights configuration. It is a fixed 763,537 ns shortfall per extrinsic, so it dominates cheap calls and is proportionally smaller against heavy ones.Block accounting is wrong in the same proportion.
benchmark overheadbuilt blocks of 541 extrinsics; at the declared constant those book 59 ms against 472 ms of measured per-extrinsic overhead.Fees are wrong in the same proportion, since the length-and-weight fee model prices
base_extrinsicinto every transaction.Nothing in the repository reports the discrepancy. There is no
runtime/src/weights/directory, andbenchmark overheaddoes not run without the executor fix.
Not intentional per the programme's terms
The impact class excludes inaccuracies "intentional as indicated by the program's terms". The project does document weight decisions where it makes them, and does regenerate weights — which is what places this constant outside that set.
runtime/src/configs/mod.rs:78-84 carries a rationale for the block-weight limits it sets deliberately, ref_time at 6 s and proof_size uncapped. It says nothing about the per-extrinsic base.
scripts/regenerate_weights.sh, checked into the asset, is the project's weight-regeneration process. It runs benchmark pallet (:50) over eight named pallets and writes their weights.rs. It never runs benchmark overhead, and the strings overhead, ExtrinsicBaseWeight and extrinsic_weights do not appear in it. PR #647 states that "Official weights must be regenerated on the reference benchmark machine before release" — that is the output of this script, which does not include the base constants.
So the constant is not covered by the documented rationale, not covered by the regeneration process, and the subcommand that would produce it does not run. The file carries upstream's generated header verbatim, naming upstream's host, upstream's runtime and upstream's substrate-node command.
Remediation: apply the executor fix so benchmark overhead runs, then regenerate the constant and check the result in under runtime/src/weights/, as FRAME expects.
References
chain/runtime/src/lib.rs:113—Signature = DilithiumSignatureSchemechain/runtime/src/configs/mod.rs:86-89—with_sensible_defaultschain/pallets/frame-system/src/limits.rs:323-335and:350—base_extrinsictaken fromExtrinsicBaseWeightchain/frame/support/src/weights/extrinsic_weights.rs— the inherited constant and its upstream headerchain/node/src/benchmarking.rs:15,43,101,154,159— the harness signs ML-DSA-87Upstream
polkadot-stable2512-2,substrate/bin/node/cli/src/benchmarking.rs:31,60— the same harness signs sr25519chain/node/src/command.rs:506-509(Pallet, unaffected) and:511,517,523(Block,Storage,Overhead)chain/node/src/service.rs:559,599— executor registers onlySubstrateHostFunctionschain/node/Cargo.toml:133-141, propagation at:138—runtime-benchmarksinto the runtimechain/scripts/regenerate_weights.sh:50— the project's regeneration process runsbenchmark palletonlychain/runtime/src/configs/mod.rs:78-84— documented rationale, coveringref_timeandproof_sizechain/runtime/src/configs/mod.rs:95— Normal-class length limit, 5 MiB at 75%
Link to Proof of Concept
https://gist.github.com/Skydev0h/4143db37bc4e4857397024f6564089d9
Proof of Concept
The measurement is made by the project's own benchmarking command, quantus-node benchmark overhead, against the project's own runtime, and compared against the constant that same runtime declares.
One prerequisite: the benchmarking tooling does not run as shipped
node/Cargo.toml:133-141 propagates runtime-benchmarks into quantus-runtime, so the runtime WASM imports ext_benchmarking_* host functions, but node/src/service.rs:559,599 register only sp_io::SubstrateHostFunctions. The three subcommands that build a client through service::new_partial — Block, Storage and Overhead — abort while constructing the runtime (BenchmarkCmd::Pallet is routed to run_with_spec at node/src/command.rs:506-509 and is unaffected):
enable-benchmarks.patch (attached, 40 lines) adds frame_benchmarking::benchmarking::HostFunctions to the executor and the corresponding dependency. It touches node/src/service.rs and node/Cargo.toml only. The runtime WASM it then executes is built with runtime-benchmarks, because that feature is what gates the benchmark subcommands; see the Description for why that cannot account for the gap.
overhead is the subcommand that generates this constant.
Steps
The key file is provided in the following section and in the linked gist:
run.sh resolves the patch relative to its own location, so the gist can be cloned anywhere.
Observed output
The comparison
Core i9-14900K (2023, desktop)
release
108,157 ns
871,694 ns
87.6%
Ryzen 7 5800X (2020, desktop)
release
108,157 ns
1,288,206 ns
91.6%
Ryzen 7 5800X, same machine
production
108,157 ns
1,205,508 ns
91.0%
Xeon E5-2687W v4 (2016, server)
release
108,157 ns
1,865,545 ns
94.2%
undercharge = (measured − declared) / measured, the shortfall as a fraction of the correct amount, matching the impact class wording. The threshold is 50%.
The console block above is the Ryzen release run — the second row. The third is the same machine rebuilt with --profile production, matching the profile upstream used for 108,157 ns; it isolates the build profile at 6.4%. The other two are in the attached generated files, each stamped by the tool with its own hostname and CPU. The percentages are of the correct amount, matching the impact class wording; the threshold is 50%.
Diff the generated files against the checked-in frame/support/src/weights/extrinsic_weights.rs, which is byte-identical to polkadot-stable2512-2 and carries upstream's header naming upstream's host, runtime and substrate-node command.
Where the time goes
On the same host, the criterion benches shipped with the two crypto crates give an ML-DSA-87 verification at 314.5 µs (min 200.8, max 453.0) and a Poseidon2 hash at 79.5 µs for 2,048 bytes and 163.2 µs for 4,096, bracketing the 2,592-byte public key used for account derivation at roughly 100 µs. Both were run from the crates' own repositories at the versions this runtime consumes — qp-rusty-crystals-dilithium 4.1.0 and qp-poseidon-core 3.1.0 — not from this checkout, where they are registry dependencies. Verification alone is about three times the entire 108.157 µs the runtime budgets for an extrinsic, natively, before the WASM penalty.
Scope check
The constant itself is unmodified upstream: frame/support/src/weights/extrinsic_weights.rs diffs empty against polkadot-stable2512-2. The defect is not in that file.
The line that pulls it into this runtime is Quantus's own, and they edited it. Upstream's solochain template reads:
runtime/src/configs/mod.rs:86-89 reads 6u64, changed in commit 3a65e8b9 ("Config Update + SigHandler (#133)"). So the block-weight configuration was tuned for this chain — the compute budget was tripled — while the per-extrinsic base it seeds was left at upstream's sr25519 measurement, alongside runtime/src/lib.rs:113 replacing the signature scheme entirely. The composition of those two decisions is Quantus's.
Additional supplementary files
The tool wrote these itself via --weight-path. The same command also emits a block_weights.rs per run; those are in the gist for completeness and this report makes no claim about BlockExecutionWeight. Each carries the header its generator stamps in — CLI version, hostname and CPU, runtime name, warmup and repeat counts, and the exact command — so the provenance of the measurement is machine-written, and lines up directly against the header on the checked-in frame/support/src/weights/{extrinsic,block}_weights.rs, which names upstream's host, upstream's runtime and upstream's substrate-node command. They also carry the full distribution rather than just the average quoted above: median, standard deviation, and the 99th/95th/75th percentiles. Being in the canonical generated format, they are the remediation as well — checking them in under runtime/src/weights/ is the fix.
Was this helpful?