88529 bc medium incorrect base extrinsic weight for ml dsa 87 undercharges transaction verification by 70
Submitted on Aug 13th 2026 at 16:31:32 UTC by @Pig46940 for Audit Comp | Quantus
- Report ID: #88529
- 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 uses ML-DSA-87 signatures for ordinary signed extrinsics, but its runtime still uses an ExtrinsicBaseWeight generated by the generic Substrate Development runtime. As a result, Quantus accounts only 108,157 ns of base work per extrinsic even though ML-DSA-87 signature verification alone took an average of 394,756 ns in a release-mode test. This is a ~70% under-accounting of the measured verification cost and allows substantially more CPU work into a block than the configured weight limit represents.
Vulnerability Details
Quantus configures its block weights with BlockWeights::with_sensible_defaults:
runtime/src/configs/mod.rs
pub RuntimeBlockWeights: BlockWeights = BlockWeights::with_sensible_defaults(
Weight::from_parts(6u64 * WEIGHT_REF_TIME_PER_SECOND, u64::MAX),
NORMAL_DISPATCH_RATIO,
);
with_sensible_defaults does not benchmark or derive a Quantus-specific base-extrinsic weight. It calls BlockWeights::builder(), whose default for every non-mandatory dispatch class is the framework constant constants::ExtrinsicBaseWeight:
pallets/frame-system/src/limits.rs
pub fn builder() -> BlockWeightsBuilder {
BlockWeightsBuilder {
weights: BlockWeights {
base_block: constants::BlockExecutionWeight::get(),
max_block: Weight::zero(),
per_class: PerDispatchClass::new(|class| {
let initial =
if class == DispatchClass::Mandatory { None } else { Some(Weight::zero()) };
WeightsPerClass {
base_extrinsic: constants::ExtrinsicBaseWeight::get(),
max_extrinsic: None,
max_total: initial,
reserved: initial,
}
}),
},
init_cost: None,
}
}
The metadata in the active ExtrinsicBaseWeight file shows that the value was generated by substrate-node benchmark overhead for the Substrate Development runtime:
frame/support/src/weights/extrinsic_weights.rs
// Executed Command:
// ./target/production/substrate-node
// benchmark
// overhead
// --chain=dev
// --wasm-execution=compiled
// --weight-path=./substrate/frame/support/src/weights/
// --header=./substrate/HEADER-APACHE2
// --warmup=10
// --repeat=100
pub const ExtrinsicBaseWeight: Weight =
Weight::from_parts(WEIGHT_REF_TIME_PER_NANOS.saturating_mul(108_157), 0);
That benchmark does not represent the signature path used by Quantus. The Quantus runtime defines its extrinsics with the runtime Signature type, and FRAME Executive verifies the signature before dispatch:
runtime/src/lib.rs
pub type UncheckedExtrinsic =
generic::UncheckedExtrinsic<Address, RuntimeCall, Signature, TxExtension>;
runtime/src/lib.rs
// Verify that the signature is good.
let xt = check(uxt, &Context::default())?;
For an ML-DSA-87 signature, this reaches Quantus's verify_ml_dsa_87 implementation:
primitives/dilithium-crypto/src/traits.rs
Self::Dilithium87(sig_public) => {
let account = sig_public.public().clone().into_account();
if account != *signer {
return false;
}
crate::verify_ml_dsa_87(
sig_public.public().as_ref(),
msg.get(),
sig_public.signature().as_ref(),
)
}
There is no separate weight charged for this verification. The inherited base-extrinsic value is added to the extrinsic's recorded weight, and it is also used to calculate the base transaction fee.
The repository already contains the machinery needed to generate the correct value. Its benchmark overhead command uses Quantus's RemarkBuilder, which constructs and signs a real Signature::Dilithium87 extrinsic. However, the committed runtime configuration does not use output generated by that builder; it continues to inherit the generic Substrate value.
The root cause is therefore not a defect in upstream Substrate. It is a Quantus runtime configuration error: Quantus changed the transaction signature scheme to ML-DSA-87 without replacing the base-extrinsic benchmark inherited from the generic Substrate runtime.
Impact Details
Selected the following in-scope impact:
Inaccuracies to the weight (similar to gas) accounting system that under or overcharge by more than 50% of the correct amount.
Release-mode PoC in my environment produced the following result:
configured base extrinsic: 108157 ns
average ML-DSA-87 verification: 394756 ns
verification/base-weight ratio: 3.65x
The percentage under-accounting, measured against the actual verification cost, is:
(394,756 - 108,157) / 394,756 * 100 = 72.6%
This is a conservative comparison. The measured 394,756 ns is only the ML-DSA-87 verification operation, whereas the configured 108,157 ns base weight is expected to cover the complete baseline cost of processing an extrinsic, including decoding, signed-extension processing, and signature verification.
Because the base weight is charged to every signed extrinsic, a user can trigger the discrepancy with ordinary valid transactions. No miner privileges, node credentials, leaked keys, malicious genesis state, governance action, or access to the trusted miner-to-node network is required. A sustained stream of valid signed transactions therefore makes nodes perform materially more computation than the block-weight accounting records. It also makes that computation cheaper than intended because the same base weight feeds into the base transaction fee.
References
- Quantus runtime inherits
with_sensible_defaults BlockWeights::builderimportsExtrinsicBaseWeight- The committed 108,157 ns value was generated for Substrate's Development runtime
- Quantus signed extrinsic type
- FRAME Executive verifies the signature before dispatch
- ML-DSA-87 verification path
- Base weight is included in recorded extrinsic weight
- Base weight is used to calculate the base fee
- Quantus ML-DSA overhead benchmark builder
benchmark overheadis wired to the Quantus builder
Proof of Concept
The following test reads the base-extrinsic weight from the actual Quantus runtime configuration and compares it with the average time required to verify an ML-DSA-87 signature. It deliberately measures signature verification alone, making the comparison conservative.
Add the test module
Add this line to runtime/tests/transactions/mod.rs:
pub mod ml_dsa_weight_poc;
Add the PoC test
Create runtime/tests/transactions/ml_dsa_weight_poc.rs with the following content:
//! PoC: the configured base-extrinsic weight was benchmarked for Substrate's classical
//! signature path and does not cover Quantus ML-DSA-87 verification.
use frame_support::{
dispatch::DispatchClass,
weights::constants::WEIGHT_REF_TIME_PER_NANOS,
};
use qp_dilithium_crypto::{Dilithium87Pair, DilithiumSignatureScheme};
use quantus_runtime::Runtime;
use sp_core::Pair;
use sp_runtime::traits::{IdentifyAccount, Verify};
use std::{hint::black_box, time::Instant};
#[test]
#[ignore = "manual timing PoC; run with --release and --ignored --nocapture"]
fn ml_dsa_87_verification_exceeds_base_extrinsic_weight_by_more_than_50_percent() {
const WARMUPS: usize = 10;
const ITERATIONS: usize = 100;
let pair = Dilithium87Pair::from_seed_slice(&[42u8; 32]).expect("valid ML-DSA seed");
let account = pair.public().into_account();
let message = b"Quantus ML-DSA-87 extrinsic-weight PoC";
let signature = DilithiumSignatureScheme::Dilithium87(pair.sign(message));
for _ in 0..WARMUPS {
assert!(black_box(&signature).verify(black_box(message.as_slice()), black_box(&account)));
}
let started = Instant::now();
for _ in 0..ITERATIONS {
assert!(black_box(&signature).verify(black_box(message.as_slice()), black_box(&account)));
}
let elapsed = started.elapsed();
let average_ns = elapsed.as_nanos() / ITERATIONS as u128;
let base_ref_time = <Runtime as frame_system::Config>::BlockWeights::get()
.get(DispatchClass::Normal)
.base_extrinsic
.ref_time();
let base_ns = base_ref_time / WEIGHT_REF_TIME_PER_NANOS;
let ratio = average_ns as f64 / base_ns as f64;
println!("configured base extrinsic: {base_ns} ns");
println!("average ML-DSA-87 verification: {average_ns} ns");
println!("verification/base-weight ratio: {ratio:.2}x");
// If actual cost A is more than 2C, where C is the charged amount, then
// (A - C) / A > 50%. This comparison is conservative: A contains signature
// verification only, while C is supposed to cover the entire extrinsic baseline.
assert!(
average_ns > u128::from(base_ns) * 2,
"ML-DSA verification did not exceed twice the configured base weight on this machine"
);
}
Run the PoC
From the repository root at commit 3b243b870a5442a6e5f443056f67bbba87d472f5, run:
cargo test -p quantus-runtime --release \
ml_dsa_87_verification_exceeds_base_extrinsic_weight_by_more_than_50_percent \
-- --ignored --nocapture --test-threads=1
Observe the result
On my test system, the test passed with:
running 1 test
test transactions::ml_dsa_weight_poc::ml_dsa_87_verification_exceeds_base_extrinsic_weight_by_more_than_50_percent ... configured base extrinsic: 108157 ns
average ML-DSA-87 verification: 394756 ns
verification/base-weight ratio: 3.65x
ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 44 filtered out
The assertion requires the measured ML-DSA-87 verification time to exceed twice the configured value. This directly tests the program's greater-than-50% threshold because, for actual cost A and charged cost C:
(A - C) / A > 50% if and only if A > 2C
Here, 394,756 > 2 * 108,157, so the assertion succeeds.