89008 w a high fail open multisig call decoding in the mobile app hides transfer all and enables theft of the full multisig balance
Submitted on Aug 16th 2026 at 13:48:33 UTC by @sl1de for Audit Comp | Quantus
Report ID: #89008
Report Type: Websites & Apps
Report severity: High
Target: https://github.com/immunefi-team/audit-comp-quantus-apps/tree/audit-comp-ready/mobile-app
Impacts:
Direct theft of user funds
Malicious interactions with an already-connected wallet, such as:
Modifying transaction arguments or parameters
Substituting contract addresses
Submitting malicious transactions
Description
Brief/Intro
A malicious signer who does not have enough approvals to spend from an ordinary multisig can create a proposal that transfers the multisig's full transferable balance to an attacker address. The proposal is a valid Utility.batch, but its second child makes the mobile app's recursive display decoder throw. The app suppresses that error, shows only the generic labels Batch and Utility, omits the amount, recipient, and nested calls, and leaves Yes, Approve enabled. If an honest software-wallet signer approves through the normal Android flow and completes LocalAuth, the app submits the exact hidden proposal bytes. The attacker can then execute the proposal. The transfer runs first, the second child interrupts the non-atomic batch, and the earlier transfer is not reverted. I reproduced this end to end on Android with a local evidence entrypoint that mounts the production multisig widgets, SDK, software signer, LocalAuth, and submission services against a private chain. A 50 QUAN multisig was reduced to zero and the attacker-controlled beneficiary gained exactly 50 QUAN.
Vulnerability Details
Attack scenario
The verified scenario uses a normal 2-of-2 ordinary multisig:
Alice is a legitimate but malicious signer. Alice has one approval and cannot execute alone.
Bob is the honest signer. Bob's account is stored as a software wallet in the mobile app.
Charlie is an address controlled by Alice.
The multisig holds 50 QUAN.
Alice proposes this call:
The exact 80-byte call from the verified final Android run was:
The beneficiary AccountId changes between clean runs, so the 32 bytes after the transfer_all prefix are expected to change. The structure and total length remain the same.
The outer SCALE encoding is valid. The runtime can decode the top-level batch, both batch children, and the Multisig.approve fields. The call field inside Multisig.approve is an opaque Vec<u8>, so 0xfa00 is legal at this layer. The mobile display decoder goes further and tries to decode that opaque value as another RuntimeCall. Variant index 0xfa is invalid, so display decoding throws even though the stored proposal itself is valid.
Root cause and full call path
The failure crosses the SDK's proposal model, chain reader, recursive decoder, and the mobile approval path:
The approval sheet falls back to the already-empty proposal model
_decodedProposalCall catches the resulting exception and falls back to the proposal model. That model has already converted the same failure to null, zero, and an empty recipient: mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart:183-209.
The mobile app submits those exact bytes
TransactionSubmissionService.approveProposal and MultisigService.submitApproveExtrinsic build and submit Multisig.approve with those exact bytes: mobile-app/lib/services/transaction_submission_service.dart:200-230 and quantus_sdk/lib/src/services/multisig_service.dart:439-448.
The critical fail-open behavior is visible in these two short excerpts. First, the SDK converts an undecodable authoritative call into empty transfer fields:
Then the approval sheet logs a decoder exception but does not add it to the blocking state:
The what-you-see-is-not-what-you-sign gap is therefore direct:
What the victim sees
In the verified Android run, the production proposal and confirmation components showed:
Proposal list:
-0 QUAN,1/2, andPROPOSED.Proposal detail: no transfer recipient or amount, one approval from Alice, and an enabled
Approvebutton for Bob.Final confirmation:
Are you sure?,You are about to approve a transfer of,Batch,Utility, a network fee, and an enabledYes, Approvebutton.No beneficiary address, no 50 QUAN amount, no
transfer_all, no nested call list, and no warning that decoding failed.
The vulnerable list row, proposal detail, MultisigActionConfirmSheet, LocalAuth call, software signer, and submission services are production components. The local evidence entrypoint supplies throwaway accounts, a run identifier, and loopback endpoints. The visible Local evidence run c01-final-20260816-07 label is harness provenance and is not presented as production UI.
The local GraphQL fixture supplies only neutral discovery metadata needed to make the proposal available in the app. It is read-only, rejects mutations, does not sign, and does not connect to RPC. It reports the top-level Utility.batch identity with empty call bytes, zero amount, and no recipient. The production SDK independently queries Multisig.Proposals over the local chain RPC and replaces the fixture call with the authoritative 80 bytes before both display and approval. The omission is not caused by the fixture's amount or recipient fields. Once authoritative decoding returns null, _withOnChainCallData overwrites any prior values with an empty recipient and zero amount. The same RPC bytes cause the display failure and are included in Bob's submitted approval.
Verified chain result
The verified final run used Android API 36, runtime spec 136, transaction version 3, and run ID c01-final-20260816-07.
Multisig
qznR3ccVoLuGz2McYD2mr3n1mzyTjSSesp65jicUEzc9KzWFE
Type
Ordinary 2-of-2, nonce 77
Beneficiary
qzntBpmqHZF1jxC8KJKpuxcYuHST892jyXBqRctpAxd1WQ9BL
Payload SHA-256
0xc2ce891665a2481a4d1b36bc475836fb36844c10a5424c6ded18f3484af57180
Starting multisig balance
50000000000000 base units, 50 QUAN
Starting beneficiary balance
100000000000000000 base units
Bob approval extrinsic
0x80c0be91cf01ab260a26b426e48ef2805a67ebc6ee3c6a4c6f1d02de94ab4553
Approval inclusion
Block 35, extrinsic 1, block 0xdde12c018f7c61a49e9fb4cb247bdd453aca065e2f8275188024a8e8ad3c5dd3
Alice execution extrinsic
0x76ffe4aa68a983cfce173e10cc726ab5eb6aa107f204bb3ec1e3853334f44676
Execution inclusion
Block 39, extrinsic 1, block 0x333b71a6f14b8672168a38898a69ac11940c6058606e79010f08f5fbfe82d718
Ending multisig balance
0
Ending beneficiary balance
100050000000000000 base units
Beneficiary gain
50000000000000 base units, exactly 50 QUAN
The attestation decoded Bob's included extrinsic as Multisig.approve, verified Bob as the signer, and verified byte-for-byte equality with the stored 80-byte proposal. Its events included Multisig.SignerApproved, Multisig.ProposalReadyToExecute, and phase-scoped System.ExtrinsicSuccess.
Alice's execution emitted these relevant events in order:
This event order proves that the transfer completed before the poison child failed and that the outer execution still succeeded. The final verifier also confirmed the exact balance delta, proposal removal, unchanged payload, local runtime version, and live chain storage.
Preconditions and boundaries
The target is a funded ordinary multisig.
The attacker is a legitimate signer but controls fewer approvals than the threshold.
The victim uses a locally held software-wallet account and completes the normal approval interaction.
No private key theft, physical device access, malicious RPC, compromised indexer, compromised backend, or attacker-controlled quorum is needed.
The attacker and victim need enough funds for normal proposal, approval, and execution fees.
I do not claim this payload bypasses high-security multisig filtering.
I do not claim a Keystone hardware-wallet display bypass. The verified path is an ordinary multisig approved by a software wallet.
Controls that work but do not stop this exploit
Failure to fetch proposal bytes sets
_callBytesFailedand disables Confirm. This exploit supplies valid authoritative bytes, so the fetch succeeds.Approval byte equality is enforced. This confirms that Bob approved the hidden bytes; it does not confirm that the UI rendered them.
Cancelling Android LocalAuth prevents submission. In the final run, cancelling produced no approval and left the sheet open with
Authentication required. Completing LocalAuth then submitted the hidden approval normally.High-security multisig filtering rejects this batch. The demonstrated target is the ordinary multisig path.
Impact Details
This maps directly to both selected Critical impacts.
First, it enables direct theft of user funds. The verified payload used transfer_all and reduced the funded multisig from 50 QUAN to zero. The technique is not limited to 50 QUAN. It targets the account's full transferable balance at execution time.
Second, it causes a malicious interaction with an already configured wallet. The victim's wallet approves exact attacker-chosen transaction arguments, including the attacker's beneficiary and keep_alive=false, while the confirmation omits those arguments. The attacker starts below threshold and only gains the ability to execute because the honest signer approves through this misleading confirmation.
This does require Bob to perform the normal approval interaction, but that is the interaction the vulnerable confirmation is meant to protect. The program's Critical impact explicitly covers malicious wallet interactions such as modifying transaction parameters and submitting malicious transactions. Here, the app conceals the transaction's only material economic effect before requesting Bob's authentication.
Recommended Remediation
References
Proof of Concept
Safety and environment
This PoC is local and non-destructive. It uses throwaway Alice, Bob, and Charlie accounts, a private spec-136 node, a read-only local GraphQL availability fixture, and an Android API 36 emulator. UID-specific IPv4 and IPv6 firewall rules restrict the app process to the two host loopback services exposed through adb reverse. No public Quantus RPC, indexer, account, key, or funds are used.
Attachment 19-c01-poc-source.zip contains the controller, local fixture, Android entrypoint, build scripts, recorder, verifier, tests, and runtime harness. Generated SDKs, emulator images, build caches, and node databases are excluded. The clean runner deliberately stops before victim approval. Alice's execution is a separate command and is refused unless Bob's included approval has been independently decoded and matched to the exact stored payload.
Reproduction steps
Prepare a fresh recorded run
From the supplied engagement directory, set a throwaway emulator-only PIN in the environment. Do not put it in a file or command log. Run the local preflight and prepare a fresh recorded run:
The clean source worktree remains pinned and unchanged. C01_REQUIRE_CLEAN_WORKTREES=0 is needed only because the isolated application worktree contains the supplied PoC entrypoint and test overlay on top of that exact commit.
prepare creates a fresh private chain, creates Alice and Bob's ordinary 2-of-2 multisig, funds it with 50 QUAN, proves control of Charlie, stores the malicious proposal with only Alice's approval, starts the read-only fixture, cold-boots the emulator, builds the Android x86_64 APK, installs it, and launches the production multisig flow. It then returns at the manual approval gate while the local services and recording remain active. It cannot execute the proposal.
Execute with the separately gated command
On the host, run the separately gated execution command:
Before sending Alice's execution, this command collects the app log, locates the single app-reported approval hash, finds that extrinsic in a local block, recomputes its hash, decodes its signer and call, verifies Bob signed Multisig.approve for the expected multisig and proposal, checks exact equality with the 80 stored bytes, and requires phase-scoped System.ExtrinsicSuccess.
Inspect the generated verification artifacts
Inspect controller-state.json and verification-report.txt in the run directory. The verifier requires all of the following before it passes:
Bob's included approval contains the exact stored 80-byte call.
Alice, not Bob, signs the later execution extrinsic.
The transfer event occurs before
BatchInterrupted(index=1).ProposalExecutedandSystem.ExtrinsicSuccessoccur in the same execution phase.The multisig balance changes from 50 QUAN to zero.
Charlie's balance increases by exactly 50 QUAN.
The proposal is removed after execution.
The raw app log still contains the decoder failure before its one approval submission line.
Verified final-run evidence
The completed evidence run is c01-final-20260816-07. The primary submission video is a continuous 1920x1080 H.264 walkthrough of the Android emulator beside the controller terminal. It runs for 187.85 seconds, contains 3,757 decoded frames, and has no cuts. Plain local captions identify the important UI and chain transitions.
The public walkthrough is derived from the continuous raw host capture. Its only visual redaction is a solid mask over the disposable emulator credential-entry controls from 120.5 through 130.0 seconds. The genuine Android system prompt, the production app, the controller terminal, and the continuous timeline remain visible. The mask prevents transient PIN-character and key-press echoes from appearing in the submission. The derivation metadata records the exact rectangle, time interval, source hash, output hash, duration equality, and synthetic_or_generated_media: false.
The unredacted host capture is retained as a private source and should not be uploaded because it briefly contains the destroyed throwaway emulator PIN. The separate raw Android device recording is safe supporting evidence, but Android blanks the secure credential surface and that recording ends before attacker execution. The public walkthrough is therefore the strongest and safest end-to-end attachment.
Android protects the device-credential surface from adb screenrecord and screenshots. This is why raw full-run screenshot 005 and that portion of the device video are black. The public walkthrough shows the first genuine Authentication required prompt before any credential entry. It also shows the second prompt heading and Authenticate to approve text while only the credential-entry controls are masked. The throwaway credential was destroyed after the run and is not stored in the configuration or evidence JSON.
In the full run, screenshots 001 through 007 are raw 1080x1920 PNGs. Each adjacent JSON file records its capture time, local chain block number and hash, image dimensions, image SHA-256, and successful PNG signature and IHDR CRC checks. Two additional public-safe 1920x1080 PNGs were extracted from the continuous sanitized walkthrough. They show the real Android prompt and the independent final verification.
01-c01-sanitized-walkthrough.mp4
97f828990e97c2233c9625b7c9361c2d5d83b775c96430ac2bdfccc3b6163e2d
02-proposal-list.png
133ea41392a427b4578af9baacae0f6f4098e8fb422aec02fe407ae4fd0346e2
03-proposal-detail.png
5d484c8cb2fec1e2dbd19208e26c7ae77cd58aa46362eba7047bfce26cc8bf7b
04-approve-enabled.png
e2f3d789ba1891ab8e3cd37b1da43a28e5bed6dccfcd12424a7874a37a9ba375
05-hidden-confirmation.png
4655d4ebd0a14c9d778a0a411fa1ef6c6a577dc904f865efb67cf27edfa2713f
06-real-android-auth-prompt.png
4793e28cd1d04d8cc4aeae95193daa844d5dc4b92887c55fe030ccc36f739083
07-final-verification.png
d1e32a306c29ff738592eabd87ccf2984410bde688ff117c5f6da6008b332beb
08-verification-report.txt
2fa897e177b2d58ddbd417cd85ded608c935a6e9e7114901ea22dff2006b1fa4
09-independent-hashes.json
56c9ac50b81a3fbfd0fc7e028dbba506987edea2a058df5a6a558fda210b296d
10-controller-state.json
2f4985db2d1c90e8817e93afe8ed54f8b12f34cb91084a261c4b5cd57d7aef69
11-walkthrough-metadata.json
9f8b0b46081fcc595d85eb20f3fd0e9d41229b110fbc637b79bc9294490e1702
12-app-logcat.txt
ce91a9356106ca628c943a81fcd712c50e2b8d342b7d0393f14f2733ba309026
13-approval-attestation.txt
93c6becb85c1b3bda283ce12f70e8eee1ce0a7c50c29cb280d9dcd8062976571
14-execution-transcript.txt
aff37fee9906a7428831dbf368ed008be61ddc2eeb9e2b326a4499337315b008
15-controller-verification.txt
cc2d7bec38a1ecaff907ded5c9bb25bb19792595251d70508f439fe87771863d
16-network-isolation.json
52a31eeb921bda13fb3091bb1213a1e71036a8189e4b3dad6344444ccc12f1f1
17-raw-media-metadata.json
711860e4171bff5766354ada35299ff72bed4c00516762f4b83798aaad5e76d0
18-full-run-SHA256SUMS.txt
f97517b60719e62a8230c5f3d67ebe06193b858c807621a1d64871febb4c7ac1
19-c01-poc-source.zip
cbfb298db7367b7f8609d9fe190a970aebb79dd1ee41f451f05b52cb10a13341
After the node and fixture were stopped, the privacy-safe walkthrough and two public screenshots were added, and the ephemeral local-node networking identity was excluded, sha256sum -c SHA256SUMS passed for all 106 stable files listed in the manifest. Transient run-control files such as PID, lock, and capture-state files are intentionally excluded from the submission manifest. The independent verification report returned VERDICT: PASS. It re-read the final state and confirmed the exact 80-byte payload, Bob's approval, Alice's execution, transfer-before-interruption event order, exact 50 QUAN balance delta, proposal removal, runtime 136 and transaction version 3, and beneficiary control.
The network evidence is intentionally stated more narrowly. Android's system app-link verifier logged a failed verification for www.quantus.com; this exact IntentFilterIntentOp failure is classified separately in failed_os_app_link_verification_hosts. Outside that failed OS record, the app evidence contains no forbidden Quantus, Supabase, Firebase, Google API, TelemetryDeck, or Senoti service hostname. UID-specific IPv4 and IPv6 firewall rules allowed the app only to reach ports 9944 and 4350 over loopback, the node had zero peers, and the post-run app socket snapshots contained only local endpoints. The final network JSON records forbidden_contact_count: 0 under this explicit definition and passed: true; it is not presented as a packet-capture claim about unrelated Android system services.
The raw app log binds the UI failure to Bob's included approval. The final decoder failure is at line 1679, followed by the single approval submission at line 1728:
The controller then recomputed that extrinsic's Blake2b-256 hash, found it at block 35 index 1, decoded Bob as the signer, decoded Multisig.approve, and matched its call field to the stored payload byte for byte. Alice's execution hash was independently recomputed as 0x76ffe4aa68a983cfce173e10cc726ab5eb6aa107f204bb3ec1e3853334f44676 and matched block 39 index 1.
Was this helpful?