> For the complete documentation index, see [llms.txt](https://reports.immunefi.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://reports.immunefi.com/quantus-or-audit-competition/88529-bc-medium-incorrect-base-extrinsic-weight-for-ml-dsa-87-undercharges-transaction-verification.md).

# 88529 bc medium incorrect base extrinsic weight for ml dsa 87 undercharges transaction verification by 70&#x20;

**Submitted on Aug 13th 2026 at 16:31:32 UTC by @Pig46940 for** [**Audit Comp | Quantus**](https://immunefi.com/audit-competition/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`

```rust
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`

```rust
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`

```rust
// 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`

```rust
pub type UncheckedExtrinsic =
	generic::UncheckedExtrinsic<Address, RuntimeCall, Signature, TxExtension>;
```

* `runtime/src/lib.rs`

```rust
// 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`

```rust
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`](https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/runtime/src/configs/mod.rs#L78-L89)
* [`BlockWeights::builder` imports `ExtrinsicBaseWeight`](https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/frame-system/src/limits.rs#L323-L356)
* [The committed 108,157 ns value was generated for Substrate's Development runtime](https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/frame/support/src/weights/extrinsic_weights.rs#L18-L56)
* [Quantus signed extrinsic type](https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/runtime/src/lib.rs#L178-L183)
* [FRAME Executive verifies the signature before dispatch](https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/frame/executive/src/lib.rs#L870-L886)
* [ML-DSA-87 verification path](https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/primitives/dilithium-crypto/src/traits.rs#L152-L171)
* [Base weight is included in recorded extrinsic weight](https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/frame-system/src/lib.rs#L2402-L2412)
* [Base weight is used to calculate the base fee](https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/transaction-payment/src/lib.rs#L668-L681)
* [Quantus ML-DSA overhead benchmark builder](https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/node/src/benchmarking.rs#L19-L53)
* [`benchmark overhead` is wired to the Quantus builder](https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/node/src/command.rs#L522-L533)

## 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.

{% stepper %}
{% step %}

## Add the test module

Add this line to `runtime/tests/transactions/mod.rs`:

```rust
pub mod ml_dsa_weight_poc;
```

{% endstep %}

{% step %}

## Add the PoC test

Create `runtime/tests/transactions/ml_dsa_weight_poc.rs` with the following content:

```rust
//! 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"
	);
}
```

{% endstep %}

{% step %}

## Run the PoC

From the repository root at commit `3b243b870a5442a6e5f443056f67bbba87d472f5`, run:

```bash
cargo test -p quantus-runtime --release \
  ml_dsa_87_verification_exceeds_base_extrinsic_weight_by_more_than_50_percent \
  -- --ignored --nocapture --test-threads=1
```

{% endstep %}

{% step %}

## 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.
{% endstep %}
{% endstepper %}


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://reports.immunefi.com/quantus-or-audit-competition/88529-bc-medium-incorrect-base-extrinsic-weight-for-ml-dsa-87-undercharges-transaction-verification.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
