89007 w a critical accessibility bypass of the wallet lock screen permits an unauthenticated sweep of the entire wormhole balance
Submitted on Aug 16th 2026 at 13:42:13 UTC by @Valisthea for Audit Comp | Quantus
Report ID: #89007
Report Type: Websites & Apps
Report severity: Critical
Target: https://github.com/immunefi-team/audit-comp-quantus-apps/tree/audit-comp-ready/mobile-app
Impacts:
Direct theft of user funds
Description
Why this survived six months in production
Worth stating up front, because the obvious question about a defect this direct is why nobody has reported it. Four properties make it close to invisible to the usual review methods, and each one had to be worked around to reach it.
The defect is an absence, not a line of code. There is no wrong call to spot in a diff. The bug is that BlockSemantics is missing from app.dart:49, and a reviewer only notices a missing widget if they already know that an opaque Stack sibling fails to strip the underlying semantics. Grep finds what is written, not what was never written.
The app cannot be run without the vendor's own environment. The Android build hardcodes /bin/sh (android/app/build.gradle:82), the version script breaks on non-Unix paths, and two uncommitted secret files (google-services.json, .env) are load-bearing. A researcher who does not provision a full Linux host with the Android NDK, Flutter and a Rust nightly toolchain never sees this application start, let alone reach its lock screen.
The exposure does not appear under casual inspection. Flutter builds its semantics tree lazily and only once an accessibility client is actually connected. A plain uiautomator dump against the locked app returns a tree with no labels at all, which reads as "nothing exposed" and is exactly the wrong conclusion. The underlying controls only become visible once a real AccessibilityService is installed and bound, which is also precisely the attacker's position.
The asymmetry only shows up on a complete census. One ungated fund-moving path is unremarkable until you have enumerated all eight and found that seven demand a fresh biometric. The comparison that makes it damning, encrypted_send_strategy.dart:137 spending the same secret from the same pool behind LocalAuthService().authenticate(), is in a different directory from the redeem flow.
None of this changes the severity. It is offered only to answer the question a triager will reasonably ask before reading further.
Summary
The wallet paints its lock screen as a plain sibling inside a Stack rather than pushing it as a route or wrapping it in a semantics barrier. An opaque sibling hides the screen underneath visually and absorbs touch events, but it does not remove that screen from the accessibility tree. Accessibility actions are delivered straight to a node's handler and never pass through pointer hit-testing, so every control on the locked-out screen stays reachable to any Android AccessibilityService.
One of the controls left reachable is the mining-reward redeem flow, which is the only fund-moving path in the application that does not require biometric or device-credential re-authentication. It takes an arbitrary destination address, has no amount parameter, and sweeps every unspent transfer at the wallet's wormhole address in a single irreversible on-chain exit.
Composed, these two facts let malware holding only the Accessibility Service permission drain a locked wallet without ever seeing the user's PIN, biometric, or seed.
Impact
An attacker who persuades the victim to grant Accessibility Service permission to any unrelated application, the standard and widely documented capability of Android banking trojans, can:
No physical access to the device is required. No root is required. The victim's biometric is never solicited, so the attack produces no prompt the victim could refuse.
The funds at risk are wider than the "mining rewards" label suggests. deriveWormholeKeyPair defaults to index: 0 (quantus_sdk/lib/src/services/hd_wallet_service.dart:73), giving HD path m/44'/189189189'/0'/0'/0'. EncryptedAccountService._deriveKeyPair calls the same function with the same branch (quantus_sdk/lib/src/services/encrypted_account_service.dart:161-163), so the rewards address is byte-identical to the encrypted account's external index 0, the first address the Receive screen ever displays. Anything received privately at that address is swept along with the rewards.
The exit is irreversible. The wormhole pallet credits the attacker-named exit_account through Unbalanced::increase_balance with no guardian, reversible-transfer, or high-security intercept.
Why this is Critical and not High
The impact class is direct theft of user funds. Every element the highest tier normally requires is present:
The funds move, irreversibly, to an address the attacker chooses. Not a disclosure, not a denial of service, not a hardening gap. claimRewards performs a real on-chain exit crediting an attacker-named exit_account through Unbalanced::increase_balance, with no guardian, no reversible-transfer window, and no high-security intercept to undo it.
The whole balance goes, not a bounded amount. There is no amount parameter anywhere on the path. Every unspent transfer at the address is enumerated and assigned to the single destination.
The victim is an ordinary user in the ordinary configuration. No debug build, no developer setting, no opt-in beta, no custom flag. Cold-start default state, which is locked.
The attacker needs no privileged position. No physical access, no root, no compromised device key, no network position, no chain-level capability. One application-level permission that Android exposes to any installed app and that users grant routinely.
The victim's own defence is bypassed rather than absent. The wallet does implement re-authentication and applies it on seven sibling flows. The attack does not exploit the lack of a control, it walks around a control that exists, on a path where the developers omitted it. The victim is never prompted, so there is no moment at which they could refuse.
The two arguments a triager could reach for, and why neither holds:
Root cause
mobile-app/lib/app.dart:48-49:
mobile-app/lib/v2/screens/auth/auth_wrapper.dart:38-40, the locked state:
AuthWrapper is an ordinary opaque Scaffold. It is not a pushed route, so it does not get the ModalBarrier that Flutter's ModalRoute supplies, and ModalBarrier is the widget that wraps its content in BlockSemantics. A repository-wide search for every mechanism that could drop the underlying semantics returns nothing:
searched across mobile-app/lib and cold-wallet-app/lib.
The asymmetry that makes it exploitable
Seven fund-moving flows in this application call LocalAuthService().authenticate() before proceeding. The redeem flow does not. The comparison that matters most is with its closest sibling: mobile-app/lib/v2/services/send/encrypted_send_strategy.dart:137 drives the same WormholeSendService ZK-spend machinery, spending the same secret from the same index-0 pool, through the same pallet call, and it does require re-authentication.
The ungated path:
There is no biometric check, no device-credential check, no PIN, no secret re-entry, no route guard, no provider unlock check, and no SDK-side interceptor anywhere on that path. The only speed bump is a pure-UI bottom sheet whose confirm button calls Navigator.pop(true).
claimRewards has no amount parameter. It enumerates every unspent transfer at the wormhole address and assigns each leaf's full net amount to a single destination, constrained only by isValidSS58Address. There is no address book, no ownership check, and no whitelist.
Proof of Concept
Two layers were executed. The first isolates the framework behaviour; the second confirms it on a real Android device through a real AccessibilityService.
Layer 1, Flutter framework, with a positive control
A widget test reproduces app.dart:48-49 shape-for-shape and asks whether the underlying control survives in the semantics tree.
The third line matters as much as the second: a real touch at the same location is correctly absorbed by the overlay, so the lock does work against an ordinary user. It is specifically the accessibility channel that walks through it.
The fourth line is the positive control. The identical tree with BlockSemantics added removes the node, which proves the harness can tell a vulnerable build from a fixed one. Without that control the attack result would carry no weight.
Layer 2, Android device, emulator API 36
A minimal AccessibilityService enumerates what the platform publishes and then invokes ACTION_CLICK on the underlying control.
The same button, the same screen coordinates, the same locked state. The application itself reports locked=true at the moment its fund-moving handler executes.
The raw node the Android accessibility pipeline publishes while the lock screen is displayed:
Layer 3, the vendor's own application
The application was built from the in-scope commit d5fc4d65 on Linux, which is the environment its build actually targets, using the vendor's own /bin/sh invocation, its own generate_version.sh and an unmodified AndroidManifest.xml except as noted below.
Installed on the same emulator with the same accessibility service bound, it cold-starts into the locked AuthWrapper state (screenshot 01-real-wallet-locked.png: the black screen reading "Authorization Required" / "Authenticating..."). That is the exact widget whose semantics this report concerns, rendered by the vendor's own code.
With the service bound, the accessibility pipeline publishes the application's controls. On the pre-wallet state it exposes them as actionable nodes:
and a single accessibility-driven activation of Create New Wallet provisioned a live wallet without any user interaction, moving the app to:
This establishes on the vendor's binary the two properties the earlier layers isolate: its Flutter semantics are published to a real AccessibilityService, and its controls are activatable through that channel rather than through touch.
What is not captured here is a single frame joining both, that is, the accessibility tree of the vendor's binary read while its lock screen is up. The test host became unable to keep the emulator stable long enough to reach that state a second time (System UI isn't responding). The mechanism that would produce it is proven in Layers 1 and 2, and the code establishing that this application uses that exact pattern is quoted above.
Exposure in shipped software
The pattern is not new to the audited commit. It was introduced in 2be1da8 ("Design V3", 2026-02-17) and the publicly released build 8d3934c ("Build 100, Version 1.3.5", 2026-04-24) is a descendant, carrying it at mobile-app/lib/app.dart:61.
The corresponding published artifact is quantus_wallet_1.3.5+100_release.apk from Quantus-Network/quantus-apps, release wallet-v1.3.5+100:
The lock-overlay defect has therefore been in users' hands since April 2026. The ungated redeem screens (redeem_address_screen.dart, redeem_progress_screen.dart, mining_rewards_screen.dart) are newer and appear in the audited commit, so the complete chain is specific to the in-scope code.
Remediation
Wrap the lock overlay so it blocks the semantics of everything painted before it:
BlockSemantics belongs on the widget on top, not on the child underneath. It drops the semantics of siblings painted before it, which is exactly the required behaviour here, and the control run above confirms it removes the node.
Two further hardening steps are worth taking independently of the fix:
Separate observation: the release APK is missing its native library on x86_64
Not part of the finding above, recorded because it was established while verifying the artifact.
quantus_wallet_1.3.5+100_release.apk ships lib/arm64-v8a/ with 8 native libraries and lib/x86_64/ with 7. The one absent from the x86_64 set is librust_lib_resonance_network_wallet.so. On an x86_64 Android device the application terminates during startup:
Availability only, on a minority device class. Reported for completeness, not as a security claim.
Honest scope of the proof
The mechanism is proven twice, at the framework layer with a positive control and at the Android platform layer through a real AccessibilityService. Both runs use a reproduction of app.dart:48-49 that is shape-for-shape identical to the audited code.
The demonstration was not completed against the vendor's own application binary. The application built from d5fc4d65 (version 1.5.9+123, versionCode=123) installs and starts, but reaching its lock screen on the test host was blocked by build-environment issues unrelated to this finding. They are listed below so a reviewer can judge them rather than take a claim on trust:
google-services.jsonis not committed, so the Google Services plugin aborts the build.android/app/build.gradle:82hardcodescommandLine '/bin/sh', ...for thegenerateVersiontask, so the Android build does not run on Windows at all.tool/generate_version.shresolves$(dirname "$0")against a Windows path and exits non-zero..envis not committed, andflutter_dotenvadditionally rejects an empty one (EmptyEnvFileError).The application's own manifest declares three
com.dexterous.flutterlocalnotificationsreceivers (AndroidManifest.xml:56-65) whose classes are absent from the produced debug dex, so Android terminates the process at startup withClassNotFoundException.
The first four were worked around with placeholders in an isolated worktree; the frozen asset tree was never modified and remains at zero dirty entries.
The fifth was resolved only by removing those three receiver declarations from the manifest of the build under test. That is the single change to vendor-authored code in the tested binary, it is recorded here rather than buried, and the original manifest is retained alongside it. The receivers govern notification scheduling; they touch neither the lock screen nor the redeem path.
The build was then attempted on Linux (Ubuntu 24.04, JDK 17, Android SDK 36 with NDK 28.2, Flutter stable, Rust nightly with rust-src for cargokit's -Zbuild-std), which is the environment the vendor's build actually targets. There it ran with the vendor's own /bin/sh invocation, its own generate_version.sh, and an unmodified manifest. Two further portability defects surfaced, both caused by the repository being checked out on Windows:
Every
.shin the tree carries CRLF line endings, socargokit/run_build_tool.shfails with exit 127: its shebang resolves to the non-existent interpreterbash\r.pubspec.yamllikewise carries CRLF, sotool/generate_version.shemitsconst appBuildNumber = '123<CR>';intolib/generated/version.g.dart, which is not valid Dart and failskernel_snapshot_program.
After correcting both, the Linux build completed the Rust stage and produced librust_lib_quantus_wallet.so for x86_64-linux-android, then ran out of host disk before producing the APK. The build environment consumed roughly 50 GB and the test host could not sustain it. That is a limitation of the test host, not of the finding.
What this means for the claim: the four load-bearing facts (the Stack sibling pattern at app.dart:48-49, the complete absence of any semantics blocker, the ungated redeem path, and the re-authentication its sibling encrypted_send_strategy.dart:137 does enforce) are established by reading the frozen source and do not depend on the build. The accessibility bypass itself is established by the two executed layers above. What is not shown is those two joined inside the vendor's own binary.
Reproduction
Three paths, cheapest first. Path A needs no Android device and no Quantus build, and it settles the load-bearing question on its own. Everything required is inline; no attachments are needed.
Path A, two minutes, pure Flutter, no device
This isolates the framework behaviour and carries its own positive control. Create an empty Flutter package and drop this in test/lock_overlay_semantics_test.dart:
flutter test on Flutter stable produces:
The control matters as much as the attack. Without it, a green attack result says nothing about whether the harness can tell a vulnerable build from a fixed one.
Path B, on a device, with a real AccessibilityService
Minimal service, three files. res/xml/a11y_config.xml:
Manifest entry:
ClickService.java:
Enable it without touching any UI:
Cold-start the wallet, then read adb logcat -s KSRA11Y. The service reports the lock screen and the underlying control at the same time, and ACTION_CLICK returns true. For the contrast, a real touch at the same coordinates via adb shell input tap does not fire the handler.
Path C, building the vendor application
Only needed to watch it end to end inside the vendor's own binary. It requires a Linux host: five uncommitted or Unix-only elements have to be supplied first, all listed under "Honest scope of the proof" above. Budget a full toolchain (JDK 17, Android SDK 36 with NDK 28.2, Flutter stable, Rust nightly with rust-src for cargokit's -Zbuild-std) and roughly 50 GB of disk.
The part that needs no execution at all
The four load-bearing facts are verifiable by reading the frozen commit:
The second command returns nothing at all. The third returns seven call sites, none of them on the redeem path.
Was this helpful?