90220 bc medium dilithium signature verification is undercharged by stale base extrinsic weight
Submitted on Aug 23rd 2026 at 19:48:30 UTC by @abarbatei for Audit Comp | Quantus
Report ID: #90220
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 accepts signed transactions authenticated with ML-DSA Dilithium signatures, but the checked-in base extrinsic weight is still a generic Substrate no-op benchmark value of 108,157 ns. A valid ML-DSA-87 signed transaction performs account derivation and signature verification before dispatch while block-weight and fee accounting reserve only the stale base amount plus normal call and length components. The runnable proof measured the runtime-like ML-DSA-87 authentication cost at 264,126 ns, so the base component undercharges this mandatory work by 59.05% of the correct cost.
Vulnerability Details
The intended behavior is that each signed extrinsic reserves and pays for the compute needed to authenticate it. Otherwise a block can contain more verification work than the block weight model says it contains, and transaction fees no longer reflect the actual per-transaction cost.
Quantus changes the runtime signature type from the usual Substrate signature model to Dilithium. The runtime aliases at lib.rs#L111-L183 make DilithiumSignatureScheme the Signature, derive AccountId from that signature scheme, include CheckWeight in TxExtension, and use those types for UncheckedExtrinsic and SignedPayload. The accepted signature enum includes both ML-DSA-87 and ML-DSA-65 variants at types.rs#L51-L74.
Signature verification is not a cheap constant check. DilithiumSignatureScheme::verify derives the expected account from the public key carried in the signature and then calls the selected ML-DSA verifier.
DilithiumSignatureScheme::verify lines 160-183
The account derivation used by that check hashes the Dilithium public key at DilithiumSigner::into_account lines 194-199, so the authentication path includes both account derivation and the selected ML-DSA verification.
The same check is reached before dispatch during block import. Executive::do_apply_extrinsic decodes the unchecked extrinsic, verifies the signature through check, then obtains dispatch info and applies the already checked extrinsic.
Executive::do_apply_extrinsic lines 883-899
Transaction-pool validation also performs the signed check first at Executive::validate_transaction lines 983-1004. This makes the work attacker reachable through ordinary valid signed transactions.
The block-weight path does not add a Dilithium-specific verification charge. CheckWeight accounts for the dispatch info, the configured base_extrinsic, and encoded length as proof size.
calculate_consumed_weight lines 162-166
That configured base is the problem. The checked-in value was generated by a 2024 generic Substrate overhead benchmark and sets the no-op extrinsic overhead to 108,157 ns.
BlockWeights::builder installs constants::ExtrinsicBaseWeight::get() as each non-mandatory class's base_extrinsic at BlockWeights::builder lines 343-355, and the Quantus runtime uses BlockWeights::with_sensible_defaults at configs/mod.rs#L80-L95. Fees use the same base value through T::BlockWeights::get().get(class).base_extrinsic at Pallet::compute_fee_raw lines 668-680, with a 1:1 compute-weight fee mapping configured at configs/mod.rs#L477-L486.
A focused timing proof measured the runtime-like ML-DSA-87 authentication component at 264,126 ns. Compared with the configured 108,157 ns base, the uncharged portion is 155,969 ns. Using the correct component as the denominator, the undercharge is 155,969 / 264,126 = 59.05%. This already exceeds the program's 50% threshold before including any remaining non-signature extrinsic overhead.
There is useful counterevidence: the current benchmark builder creates Dilithium87 signed system.remark extrinsics at RemarkBuilder::build lines 42-53 and create_benchmark_extrinsic lines 99-161. Regenerating and checking in the overhead artifact may therefore be enough to fix this. The audited commit still ships the stale 108,157 ns artifact, so the live configured accounting is under the measured ML-DSA-87 authentication cost.
Impact Details
An untrusted user can create valid ML-DSA-87 signed low-dispatch-cost extrinsics and submit many of them. Each included extrinsic forces importing nodes to perform Dilithium public-key account derivation and ML-DSA-87 signature verification, but the chain only accounts for the stale generic base, normal dispatch weight, and length component. This causes block-weight and transaction-fee under-accounting for each such transaction.
The impact is 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 measured ML-DSA-87 runtime-like authentication component is undercharged by 59.05% of its correct cost.
This should not be overstated as proven network shutdown. Block length is capped and the available evidence proves the weight and fee inaccuracy, not a separate total liveness failure.
Mitigation
Regenerate ExtrinsicBaseWeight for the Quantus runtime and checked-in node at the audited configuration, using the Dilithium87 signed no-op extrinsic path. Check in the regenerated frame/support/src/weights/extrinsic_weights.rs value and make the overhead benchmark part of release CI whenever the runtime signature type, transaction extension set, or signature verification implementation changes.
If the base extrinsic weight is not the desired accounting location, add an explicit signed-extrinsic authentication weight that covers the worst accepted signature variant, including public-key account derivation and ML-DSA verification, and make sure both block-weight admission and transaction-payment fees use it.
References
Audited chain commit 3b243b870a5442a6e5f443056f67bbba87d472f5.
Runtime signed extrinsic aliases:
lib.rs#L111-L183.Accepted Dilithium signature variants:
types.rs#L51-L74.Dilithium verification path:
DilithiumSignatureScheme::verify.Block import signed check:
Executive::do_apply_extrinsic.Transaction-pool signed check:
Executive::validate_transaction.Block-weight accounting formula: calculate_consumed_weight.
Checked-in base extrinsic artifact:
extrinsic_weights.rs#L18-L56.Fee base uses the same
base_extrinsic:Pallet::compute_fee_rawlines 668-680.Current benchmark builder uses Dilithium87 signed remarks: create_benchmark_extrinsic.
Proof of Concept
Setup Assumptions
The audited chain repository is checked out at commit 3b243b870a5442a6e5f443056f67bbba87d472f5.
The proof files are installed into
runtime/tests/poc_dilithium_base_weight_undercharge/for the run, withoffline-vendor/adjacent toCargo.toml.The proof uses local path dependencies for the in-scope
audit-comp-quantus-qp-poseidonandaudit-comp-quantus-qp-rusty-crystalsrepositories. The offline run below uses the vendored registry patches inoffline-vendor/so Cargo does not need network access.
Execution Command
Proof Manifest
Proof Source
Observed Proof Output
The final assertion first confirms that the checked-in base-extrinsic artifact is the stale 108_157 ns value, then asserts that the valid ML-DSA-87 authentication path is undercharged by more than 50% of the measured correct cost. In the recorded run, the measured authentication cost was 264_126 ns; the uncharged portion was 155_969 ns, which is 59.05% of the measured cost.
Was this helpful?