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

88221 w a medium unvalidated amount in the pay deep link freezes and crashes the wallet via unbounded bigint materialisation on the ui isolate

Submitted on Aug 12th 2026 at 09:54:40 UTC by @Lun3x for Audit Comp | Quantus

  • Report ID: #88221

  • Report Type: Websites & Apps

  • Report severity: Medium

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

  • Impacts:

    • Taking down the application/website

Description

Brief/Intro

The Quantus wallet registers https://www.quantus.com/pay as an auto-verified Android App Link and iOS Universal Link, and the amount query parameter of that link is never validated beyond a non-empty check before it is parsed. Because the parser accepts scientific notation and then materialises the value as an arbitrary-precision integer on the UI isolate inside initState, a 10-character amount such as 1e10000000 freezes the wallet for 42-53 seconds on a modern arm64 machine, and a slightly larger exponent freezes it indefinitely. Any web page, chat message, or printed QR code can trigger this against any user with the wallet installed, taking the application down with no attacker infrastructure and no control of quantus.com.

Vulnerability Details

The deep link is registered with autoVerify, so an <a href> on an arbitrary website opens the wallet directly:

mobile-app/android/app/src/main/AndroidManifest.xml:44,51

<intent-filter android:autoVerify="true">
  ...
  <data android:scheme="https" android:host="www.quantus.com" android:pathPrefix="/pay" />

iOS registers the same association via applinks:www.quantus.com in mobile-app/ios/Runner/Runner.entitlements.

The /pay handler validates the recipient and nothing else:

mobile-app/lib/services/deep_link_service.dart:59-68

amount is carried as a raw String and is checked only for emptiness:

mobile-app/lib/providers/route_intent_providers.dart:56-62

The home screen drains the intent and pushes the amount screen in pay mode:

mobile-app/lib/v2/screens/home/home_screen.dart:135-143

The attacker-supplied string is then parsed synchronously inside initState, on the UI isolate, with no try/catch at that level:

mobile-app/lib/v2/screens/send/input_amount_screen.dart:78-87

Normalisation strips grouping separators and swaps the decimal separator. It does not reject or strip exponent notation, so 1e10000000 survives verbatim:

quantus_sdk/lib/src/services/locale_number_config.dart:65-82

The cost is not in Decimal.tryParse, which is lazy and returns in 0 ms even for a billion-digit exponent. It is in the scaling step that follows, which forces the value into an actual BigInt:

quantus_sdk/lib/src/services/number_formatting_service.dart:131-149

scaleFactorDecimal is 10^12 (the chain's decimals). Multiplying a lazily-held 1e10000000 by 10^12 and calling toBigInt() forces rational 2.2.3 to build the full integer — 33,219,321 bits — via repeated big-integer multiplication. The try/catch above is ineffective: this is a hang, not an exception. Cost is superlinear in the exponent, so the attacker chooses the freeze duration by typing more digits, and there is no upper bound on the exponent because nothing validates the string's length or shape.

Three separate guards that exist elsewhere in this flow do not apply here: isValidSS58Address covers only to; the numeric keypad that constrains manually typed amounts is bypassed because the value arrives pre-filled; and the try/catch in parseAmount catches only thrown exceptions.

Impact Details

This maps to the in-scope Critical impact for Websites and Applications, quoted verbatim from the program page:

Taking down the application/website

The wallet becomes unusable for 42-53 seconds per tap on a 2026-era arm64 machine, measured across repeated runs (the spread is machine load; the control stays at 5 ms in every run). Because the cost grows superlinearly with the exponent and the exponent is unbounded, amount=1e99999999 requires roughly a 415 MB integer and does not complete in any practical time — on a phone, with far less RAM and a slower core than the machine above, that is an out-of-memory kill of the wallet process. The freeze happens on the UI isolate during initState, so the app is fully unresponsive for its duration: no cancel, no back gesture, no error dialog, and on Android the system offers only the ANR "close app" dialog.

To be precise about persistence, since it bears on severity: the PaymentIntent lives in an in-memory provider and is cleared when consumed, so it is not written to disk. Force-killing the app and relaunching from the icon gives a working wallet. The denial is therefore per-tap rather than permanent — but it is a complete crash or hang of the application each time, it is reachable from any web page or QR code with no attacker infrastructure, and it costs the attacker nothing to repeat.

The delivery surface is the worst case for a wallet:

  • No attacker infrastructure and no control of quantus.com is needed. The App Link is autoVerify'd, so an ordinary <a href="https://www.quantus.com/pay?to=…&amount=1e10000000"> on any website, in any chat app, or encoded in a printed QR code opens the victim's wallet and triggers the parse.

  • It is one tap on a link that looks like a legitimate payment request, inside the app's normal payment workflow — not an unprompted or unusual in-app action.

  • It is unauthenticated and untargeted. It can be posted publicly and hit every installed wallet that follows the link.

  • Repeating it is free, so a user who taps a link in a group chat can be denied access to their funds for as long as they keep opening it, including during time-sensitive operations.

No funds are stolen or frozen on-chain, so I am not claiming a funds-at-risk figure. The loss is availability of the wallet client, which is the Critical impact quoted above.

References

  • mobile-app/android/app/src/main/AndroidManifest.xml:44,51 — auto-verified /pay App Link

  • mobile-app/ios/Runner/Runner.entitlements — applinks:www.quantus.com

  • mobile-app/lib/services/deep_link_service.dart:59-68 — only to is validated

  • mobile-app/lib/providers/route_intent_providers.dart:56-62 — amount checked only for emptiness

  • mobile-app/lib/v2/screens/home/home_screen.dart:135-143 — pushes InputAmountScreen(isPayMode: true)

  • mobile-app/lib/v2/screens/send/input_amount_screen.dart:78-87 — synchronous parse in initState

  • quantus_sdk/lib/src/services/locale_number_config.dart:65-82 — normalize() keeps exponent notation

  • quantus_sdk/lib/src/services/number_formatting_service.dart:131-149 — * 10^12 then toBigInt()

  • Pinned dependencies (mobile-app/pubspec.lock): decimal 3.2.4, rational 2.2.3

  • Asset revision: audit-comp-quantus-apps @ d5fc4d6572ebc2244e95009a33f5da5048935867, branch audit-comp-ready

Proof of Concept

This is a Websites-and-Applications asset (the Flutter wallet and its Dart SDK), not a smart contract, so the "fork mainnet with Hardhat/Foundry" instruction does not apply — there is no EVM and no contract state involved. Nothing here touches mainnet or any public testnet: the harness runs entirely offline against the two arbitrary-precision libraries pinned in the app's own pubspec.lock, exercising the exact expression the wallet evaluates. It is also not a unit test of app code — it is a standalone executable that reproduces the wallet's parse pipeline end to end and measures wall-clock blocking time, with a legitimate-amount control in the same run.

The attack

A single link on any web page, in any chat message, or encoded in a QR code. <VALID_SS58> is any well-formed Quantus address (prefix 189) — the recipient is validated, the amount is not, so any address works:

The victim taps it. The autoVerify'd App Link opens the wallet, PaymentIntent.tryParseUrl accepts the amount because it is non-empty, home_screen pushes InputAmountScreen(initialAmount: "1e10000000", isPayMode: true), and initState parses it synchronously on the UI isolate. The app is frozen from that moment.

Reproducing the freeze

Exact dependencies, config and commands. No env vars, no network at run time.

1

Set up the Dart SDK and project

2

Create pubspec.yaml

Pins the exact versions from the app's mobile-app/pubspec.lock:

3

Create bin/final.dart

4

Run the harness

Measured output

Dart SDK 3.13.0 stable, decimal 3.2.4, rational 2.2.3, arm64 macOS:

A second run of the same file gave 42,193 ms for the hostile input and 5 ms for the control. The absolute figure moves with machine load; the control is stable and the result size is identical (33,219,321 bits) in every run, so the blocking time is caused by the attacker's input and not by harness overhead. Treat 42-53 s as the range on this hardware, and note that a phone is considerably slower.

The cost is attacker-tunable and unbounded

Same harness, varying only the exponent, showing the growth is superlinear so the attacker picks the freeze duration by typing more digits:

Isolating the underlying primitive that rational reaches for (BigInt.from(10).pow(exp)) confirms the shape — roughly 91x cost for 10x the exponent:

Nothing in the wallet caps the exponent, because nothing validates the amount string's length or shape at all. amount=1e99999999 demands roughly a 415 MB integer, which on a mobile device is an out-of-memory kill or a hang that never completes.

Why the existing controls do not stop it

  • deep_link_service.dart:63 validates only payment.to via isValidSS58Address. There is no equivalent check on payment.amount.

  • route_intent_providers.dart:58-62 accepts any non-empty amount string.

  • normalize() (locale_number_config.dart:65-82) removes grouping separators and swaps the decimal separator. It does not reject exponents, non-numeric shapes, or over-long input.

  • The try/catch in parseAmount catches thrown exceptions only. The failure mode here is CPU and memory exhaustion, which throws nothing until the process dies.

  • The on-screen numeric keypad that constrains manually typed amounts is bypassed entirely, because the value arrives pre-filled through initialAmount.

Suggested fix

Validate amount where to is already validated, before it reaches any parser. A bounded, anchored numeric check rejects the entire class:

Independently, LocaleNumberConfig.parseDecimal should reject exponent notation rather than forwarding it to Decimal.tryParse, and the pre-fill parse in input_amount_screen.dart:78-87 should not run synchronously in initState.

Regression test

A test that pins both the rejection and the bound:

Was this helpful?