For the complete documentation index, see llms.txt. This page is also available as Markdown.

89987 w a high multiaddress index is decoded at a different width by the sdk and the runtime letting a qr code display a harmless batch while signing a 1000 quan transfer to the attacker

Submitted on Aug 22nd 2026 at 10:26:34 UTC by @Naveen0x for Audit Comp | Quantus

  • Report ID: #89987

  • Report Type: Websites & Apps

  • Report severity: High

  • Target: https://github.com/immunefi-team/audit-comp-quantus-apps/tree/audit-comp-ready/quantus_sdk

  • Impacts:

    • Malicious interactions with an already-connected wallet, such as:

      • Modifying transaction arguments or parameters

      • Substituting contract addresses

      • Submitting malicious transactions

    • Direct theft of user funds

Description

Brief/Intro

The Quantus runtime and the Quantus SDK's Dart decoder disagree about how many bytes the MultiAddress::Index variant occupies. The runtime consumes one byte. The SDK consumes one byte plus a full compact integer.

Once a payload contains an Index destination, the two decoders are reading the remaining bytes from different offsets. Every pallet index, call index, destination and amount after that point means something different to the wallet than it does to the chain. Both decoders still consume the payload exactly, with nothing left over, so every integrity check the signer performs passes and the user sees no warning.

That breaks the property the air-gapped signing flow exists to provide. The parser's own documentation states it plainly: the full signed payload, call plus every signed-extension field, is decoded with nothing left over, so what the signer displays is exactly what it signs. With this bug, it isn't.

I have attached a 128-byte payload that the wallet renders as a zero-value housekeeping transfer plus a text memo, and that the runtime executes as a 1000 QUAN transfer to an account the user never saw.

Affected component

Affected asset: Quantus SDK.

Affected file: quantus_sdk/lib/generated/planck/types/sp_runtime/multiaddress/multi_address.dart

Affected field: Index._decode, lines 155 to 157. It calls CompactBigIntCodec.codec.decode(input) unconditionally, so it reads a real compact integer where the runtime reads nothing.

Contributing file: quantus_sdk/lib/src/quantus_payload_parser.dart

Affected field: _decodeCall, line 158. It delegates the whole call to RuntimeCall.codec.decode(input), which is the generated codec above, with no restriction on which MultiAddress variants may appear.

Contributing file: quantus_sdk/lib/src/chain/call_decoder.dart

Affected field: _addressField, line 824. It renders an Index destination as the text "Account index 0" instead of rejecting it, which is what makes the decoy call look ordinary on screen.

Root cause

The runtime declares its address type as:

In sp_runtime, the variant is declared Index(#[codec(compact)] AccountIndex). With AccountIndex = (), that field is Compact<()>, and parity-scale-codec implements Decode for Compact<()> as a no-op that returns without touching the input:

So the runtime consumes exactly the one-byte variant discriminant and stops.

The generated Dart codec does something else:

There is no conditional there. The generator that produced this file maps every compact type to CompactBigIntCodec and discards the inner type entirely. Its CompactDescriptor is constructed from a bare type id and has no reference to what the compact wraps, so it has no way to know that this particular compact is a unit type that encodes to nothing. The result is that Compact<()> becomes a genuine compact read of between one and 67 bytes, controlled by the attacker.

The encode side has the same asymmetry, which matters for whether the resulting signature is usable. CompactRef<'_, ()> encodes to an empty byte string, so the runtime's reading of this payload re-encodes to exactly the bytes the wallet signed. The node re-encodes the decoded call and extensions when it verifies the signature, and that check passes.

Why the existing defences do not catch it

The parser rejects trailing bytes and validates the genesis hash against an allowlist. Neither helps here.

The trailing-byte rule is satisfied on both sides at once. The signed-extension suffix is anchored to the end of the payload, so the attacker only needs the two readings to re-converge before they get there. A two-byte mortal era on the runtime side is read by the parser as a single 0x00 byte meaning immortal, which absorbs a one-byte drift exactly. From that point the nonce, tip, metadata mode, spec version, transaction version, genesis hash, block hash and the metadata hash option are byte-identical to both decoders. Both finish with zero bytes remaining.

The genesis allowlist passes because the payload declares the real Planck genesis hash, which is on the wallet's list of known networks. The spec version and transaction version also match the bundled values, so the signer's drift check evaluates true and no warning banner appears.

A second gap makes the pivot byte free. The polkadart BoolCodec decodes with input.read() > 0, so it accepts any non-zero byte as true, while parity-scale-codec accepts only 0x00 or 0x01. That lets a single 0x02 byte serve as the parser's keep_alive flag and as the runtime's next pallet index, which is Balances, at the same time.

The payload is 128 bytes. That is under the parser's 8 KiB cap, and under the 256-byte threshold above which a payload would be signed as a hash rather than verbatim, so the raw bytes are what get signed.

Attack scenario

The attacker needs to get one QR code in front of the victim's signer and read the signature QR back. A malicious or compromised companion device, or a web page that presents what claims to be an unsigned transaction, is enough. There is no requirement for key material, device access, or a network position.

Preparation is cheap. The only constraint on the attacker's account is its first byte, because the signer reads that byte as the length prefix of the decoy memo. Grinding a keypair whose account id starts with the required byte is one attempt in 256. The remaining 31 bytes are free.

1

The victim opens the signer and scans the code

2

The signer decodes it and displays a two-call batch

A zero-value transfer_all and a System.remark memo. Nothing on screen looks unusual and there is no warning.

3

The victim approves

The signer signs the raw payload bytes.

4

The attacker broadcasts the extrinsic

The attacker takes the returned signature, assembles the extrinsic from the same bytes, and broadcasts it. The runtime reads those bytes as a 1000 QUAN transfer to the attacker's account.

The first inner call fails on chain, because AccountIdLookup resolves only the Id variant and returns a lookup error for Index. That does not stop the attack. force_batch is chosen precisely because it continues past a failed item: its loop records the error, emits an item-failed event, and moves on to the next call. The second call executes normally.

Impact

An attacker who can show a QR code to a Quantus signer can have the user authorize an arbitrary transfer while the screen shows something harmless. The signature produced is valid over the malicious extrinsic, so the theft needs no further interaction from the victim.

The technique is not limited to the specific decoy in this report. Once the decoders are one byte out of step, the attacker controls how every subsequent byte is interpreted on each side independently, subject only to making the two readings re-converge before the signed-extension suffix. Any call the runtime supports can be substituted behind any display the attacker can construct.

Proof of Concept

Proof of concept

The script below decodes the same 128-byte payload twice, once the way the runtime does and once the way the SDK's generated codec does, and checks the properties that make the attack work. It uses only the Python standard library, touches no network, and does not interact with any chain. Save it as poc.py and run python3 poc.py. It exits 0 when every assertion passes.

The script also rebuilds the payload from its labelled parts and asserts the result matches, so nothing in it is an unexplained blob, and it includes a control showing that an Id destination decodes identically on both sides. The divergence is specific to Index.

Expected output

Reproducing the signer side directly

To confirm the display against the real SDK rather than a model of it, drop this into the SDK's test directory and run:

The parser accepts the payload, reports the network as Planck, reports the era as immortal, reports the spec as matching the bundled version so no drift warning is raised, and renders the two decoy calls. The attacker's destination and the 1000 QUAN amount appear nowhere on screen.

Verification against the chain source

Every pallet index, call index and field layout the proof of concept relies on is taken from the chain source rather than assumed.

The address type is pub type Address = MultiAddress<AccountId, ()> in the runtime crate's root module, which is what makes the inner compact a unit type.

The account lookup is type Lookup = sp_runtime::traits::AccountIdLookup<Self::AccountId, ()> in the runtime's frame-system configuration. AccountIdLookup::lookup matches only MultiAddress::Id and returns a lookup error for everything else, so the Index variant can never resolve to an account in legitimate traffic.

Utility is pallet index 9 and Balances is pallet index 2, both from the runtime's pallet index attributes.

force_batch is call index 4 in the utility pallet. Its body declares a mutable error flag, iterates the calls, sets the flag and emits an item-failed event when a call returns an error, and continues to the next item rather than returning. That is what allows the second call to run after the first fails.

transfer_allow_death is call index 0 in the balances pallet, taking a destination and a compact value. transfer_all is call index 4, taking a destination and a boolean.

The same indices are corroborated independently by the SDK's own bundled metadata, which the Dart test above exercises.

Why this is not intended behaviour, and not already known

The repository already contains the exact restriction the Dart path is missing. Its Rust transaction parser declares the type as a single-variant enum:

Anything other than Id fails to decode there.

More than that, the Dart parser used to have the same check and lost it. The commit that ported the Keystone parser hardening across to the payload parsers, b5d36af from pull request #555, introduced a _decodeMultiAddress helper in the Dart parser that threw on any variant other than Id, with a message saying only Id is accepted. The current parser has no such helper and contains no reference to MultiAddress at all. Call decoding was later moved wholesale onto the generated polkadart codecs, which the parser's own documentation describes as deliberate, so that every call declared by the bundled metadata is supported without an allowlist falling behind the runtime. The variant restriction went away with the hand-written decoder. So this is a regression in a protection the project had already written, not an omission it never considered.

This is also a bypass of the earlier hardening rather than a duplicate of it. The upstream firmware issue that commit was ported from covers recursion bounds and the rejection of unconsumed bytes. It says nothing about MultiAddress, the Index variant, compact encoding, or a divergence between what is displayed and what executes. The payload here satisfies the "reject any unconsumed byte" rule on both sides at once while the two decoders still disagree about what those bytes mean.

The issue does not appear on the program's published list of known issues, and none of the project's completed audit reports covers the applications repository that holds the SDK.

Remediation

Two fixes, and I would apply both. The first closes this instance, the second closes the class.

Reject the variant. Because the runtime's lookup cannot resolve an index to an account, MultiAddress::Index is unreachable in any legitimate transaction, so refusing it costs nothing in functionality. Restore the check the Dart parser used to have and accept only MultiAddress::Id, matching the Rust parser. The same argument applies to Raw, Address32 and Address20.

Add a re-encode equality check. After decoding a call, encode it again and compare against the input slice it came from:

Any width disagreement between the display codec and the runtime codec then becomes a hard rejection instead of a silent reinterpretation. This matters because the trailing-byte rule constrains only the total length of the payload, which is precisely why it did not catch this.

Two smaller changes are worth making alongside those. BoolCodec should reject any byte other than 0x00 and 0x01, which removes the free pivot byte this payload depends on. And the code generator defect should be reported upstream to polkadart, since the generated Index._decode is wrong for any chain whose compact inner type is not a plain integer, and the same class of divergence would reappear on regeneration.

Notes on scope and testing

The chain-side reading is established by decoding with the runtime's own types and pinned codec versions, not by submitting anything to a network. No testnet or mainnet testing was performed. The final dispatch step follows from the decoded call plus the utility pallet's documented force_batch behaviour, both read from the chain source.

On where the defect lives: the immediate cause is a code generator that belongs to a third-party package, but the vulnerable artifact is the generated file committed inside the Quantus SDK, which is the listed asset, and the missing variant restriction in the Dart parser is the project's own code. The Rust parser in the same repository enforces that restriction and the Dart parser previously did too, which shows the current behaviour is unintended rather than an accepted upstream limitation.

The production consumer of parsePayload is the cold wallet signing screen, so the display this report describes is the one a user actually sees before approving.

Was this helpful?