MetaMask Demonic Mobile: seed phrase survives autolock on Android
A MetaMask Android user enters their password, the wallet unlocks, they put the phone down. Thirty seconds later the autolock fires and the password screen returns. MetaMask’s UI indicates the keys are no longer reachable. The password the user just typed is in the process heap. So is the 12-word BIP39 mnemonic — in indexed form after every unlock, in contiguous plaintext from any session in which the user has viewed their seed phrase. MetaMask’s onboarding requires every new wallet to view and confirm it.
Contents
This is the same vulnerability class as Halborn’s 2022 Demonic finding — CVE-2022-32969 — in the MetaMask browser extension. Halborn’s disclosure described the extension bug’s attacker model as “an attacker with equal or greater permissions than the user may be able to retrieve the mnemonic phrase directly from memory” — a local-memory threat on the browser’s disk surface, not requiring root. MetaMask patched it in extension v10.11.3. The Android client does not meet that 2022 bar.
From the source code
MetaMask/metamask-mobile is an open-source repository on GitHub. The mobile app’s authentication module contains a function named wipeSensitiveData, called at ten sites to clear password and seed variables after use. The function, with the comment above it, at app/core/Authentication/Authentication.ts:295-303 — reformatted here for column width:
/**
* This method is used for password memory obfuscation
* It simply returns an empty string so we can reset all the sensitive
* params like passwords and SRPs. Since we cannot control memory in JS
* the best we can do is remove the pointer to sensitive information in
* memory
* - see this thread for more details:
* https://security.stackexchange.com/questions/192387/how-to-securely-erase-javascript-parameters-after-use
* [Future improvement] to fully remove these values from memory we can
* convert these params to Buffers or UInt8Array as is done in extension
* - see: https://github.com/MetaMask/metamask-extension/commit/98f187c301176152a7f697e62e2ba6d78b018b68
*/
private wipeSensitiveData = () => '';
The function does one thing: return an empty string. In JavaScript, a variable holds a pointer to data in memory — not the data itself. Assigning a new value moves the pointer; the original bytes remain exactly where they were. There is no API to zero a string’s underlying bytes; only the runtime decides when to reclaim them. The comment names the fix: represent the seed phrase as a Uint8Array instead of a string. Typed arrays can be explicitly zeroed with .fill(0) — the same approach the extension commit implements — provided the engine does not elide the fill as a dead store; a read-after-write or a named secureZero wrapper makes the intent explicit and optimizer-resistant.
The lock orchestration in LockManagerService.#lockApp calls KeyringController.setLocked() and dispatches a Redux action. No buffer wipe.
Runtime evidence
Three places in memory hold key material in plaintext or near-plaintext until those heap regions are reallocated:
| Allocation | Form | Trigger | Lifetime |
|---|---|---|---|
RN bridge onChange JSON | {"text":"..."}, one per keystroke, cumulative | Every password keystroke | Until heap reallocation |
KeyringController…keyrings[i].mnemonic | Uint8Array of BIP39 word indices | submitPassword decryption on every unlock | Until heap reallocation |
HdKeyring.serialize intermediates | space-separated plaintext String + TextEncoder bytes + Array copy | Vault encrypt, or Reveal Secret Recovery Phrase | Until heap reallocation |
Reaching the space-separated plaintext form requires HdKeyring.serialize to fire, which happens on Reveal SRP or vault encryption. MetaMask’s onboarding requires every new user to view and confirm their seed phrase to proceed past wallet creation, so every install has triggered this allocation at least once.
Reproduction: Android 14 emulator, io.metamask v7.76.0 from Google Play, adb root. Wallet unlocked, Settings → Security & Privacy → Reveal Secret Recovery Phrase opened, seed phrase displayed. Heap scan via Frida’s Memory.scanSync or a static NDK binary reading /proc/<pid>/mem — either works.
Result, wallet unlocked:
[FOUND] mnemonic at 0x7b23459f30
7b23459f30 70 79 72 61 6d 69 64 20 73 75 72 67 65 20 72 69 pyramid surge ri
7b23459f40 67 68 74 20 76 61 70 6f 72 20 63 68 69 63 6b 65 ght vapor chicke
7b23459f50 6e 20 72 65 61 64 79 20 73 6f 6c 64 69 65 72 20 n ready soldier
7b23459f60 77 61 6c 6c 20 77 68 61 74 20 66 69 74 20 73 79 wall what fit sy
7b23459f70 6d 62 6f 6c 20 62 69 74 74 65 72 00 00 00 00 00 mbol bitter.....
Background the app, wait 30 seconds for autolock, verify the password-entry screen is showing. Re-scan the same PID:
[FOUND] mnemonic at 0x7b23459f30
7b23459f30 70 79 72 61 6d 69 64 20 73 75 72 67 65 20 72 69 pyramid surge ri
...
setLocked ran; the UI shows the locked screen; the mnemonic sits at the same address, unchanged. In a separate test the mnemonic persisted for ten minutes with the app backgrounded — zero bytes changed across the entire window. Re-unlocking allocates a fresh copy at a new address without zeroing the prior one:
[FOUND] mnemonic (2 hit(s))
0x788e025330 ← new copy, current unlock
0x788e08ee10 ← old copy, prior unlock — never zeroed
The leak is cumulative. Each unlock/lock cycle adds another plaintext copy. adb shell am force-stop io.metamask followed by re-scan returns zero hits — heap residue, not disk persistence. The vulnerability window opens at unlock and closes on heap reallocation or process termination.
The password also reaches memory through a separate path. The React Native bridge serializes every TextInput onChange event as JSON containing the full password text typed so far:
[FOUND] password event at 0x34328a9055
0034328a9045 75 6e 74 22 3a 33 34 2c 22 74 65 78 74 22 3a 22 unt":34,"text":"
0034328a9055 68 65 6c 6c 6f 64 61 77 67 22 7d 28 00 00 1a 00 hellodawg"}(....
Each keystroke generates a separate bridge event. None are zeroed. The password traverses the bridge as a series of plaintext copies. Closing this path requires bypassing onChangeText entirely — secureTextEntry={true} masks the UI but does not prevent the bridge from serializing each keystroke as a plaintext JS String.
droidsaw audit of io.metamask v7.76.0 confirms zero secure-erase primitives — secureZero, secureWipe, zeroize, memzero, secureClear — across the 245K-string bundle, including the bundled @metamask/keyring-controller, @metamask/eth-hd-keyring, and hdkey dependencies. The bundled hdkey does define HDKey.prototype.wipePrivateData, which zeroes private-key bytes correctly via buffer.fill(0); no caller from the lock path invokes it. The shipping APK behaviour matches the public source.
Demonic vs Demonic Mobile
Same vulnerability class — key material persisting beyond its intended lifecycle — in a different surface and a different medium.
| Demonic (CVE-2022-32969) | Demonic Mobile (this finding) | |
|---|---|---|
| Surface | MetaMask browser extension | MetaMask Android, io.metamask v7.76.0 |
| Medium | Browser session-restore (disk) | Hermes heap + RN bridge events (memory) |
| Persistence | Across browser restart, survived uninstall | Unlock to heap reallocation; cumulative per unlock cycle |
| Fix | Buffer/integer-array SRP representation, extension v10.11.3, 2021-07-30 | None; mobile-app comment labels it “[Future improvement]” |
| MetaMask statement | “The following does not impact MetaMask Mobile users.” — June 2022 | — |
| Disclosure outcome | Accepted, patched, public CVE | Closed Not Applicable |
The 2023 mobile-app source comment names the 2021 extension commit as the solution — same engineering organisation, same npm dependency tree, same documented fix. The mobile app has not adopted it.
In the 2022 disclosure MetaMask co-founder Dan Finlay cited Jeffrey Goldberg (1Password, Principal Security Architect) from a 2019 interview on ISE’s Windows password manager research:
“This is a well-known issue that’s been publicly discussed many times before, but any plausible cure may be worse than the disease. Fixing this particular problem introduces new, greater security risks, and so we have chosen to stick with the security afforded by high-level memory management, even if it means that we cannot clear memory instantly.”
Finlay quoted the excerpt above. The interview continued:
“Long term, we may not need to make such a tradeoff. But given the tools and technologies at our disposal, we have had to make a decision as to how best to keep our users secure.”
“The realistic threat from this issue is limited. An attacker who is in a position to exploit this information in memory is already in a very powerful position. No password manager (or anything else) can promise to run securely on a compromised computer.”
Goldberg was describing 1Password7 on Windows in 2019. MetaMask’s extension fix had shipped eleven months before the disclosure. 1Password’s latest Android client (v8.12.12) is native Kotlin; its OpaqueString type wraps every sensitive field with byte-level control.
Threat model
| Vector | Access needed |
|---|---|
| Stolen device, USB debugging on | adb shell |
| Co-installed app + Android RCE-to-root chain | Prior exploit |
| Forensic seizure (LE, border control) | Physical device, lab tooling |
| Crash report / analytics SDK heap snippet | None additional — vendor-side capture |
| Memory pressure → page swap → flash (devices with swap enabled) | Disk image of swap-backed pages |
None require both root and Frida. For the forensic-seizure row in particular: 1Password Android uses a dedicated native type (OpaqueString) for sensitive strings — masterPassword, secretKey, paperKey — with byte-level control at lock. MetaMask’s UI documents the same defence at the lock screen. No code implements it.
Disclosure surface
This bug was reported to MetaMask through HackerOne because that is the only reporting channel MetaMask offers. The org-level Security Policy that GitHub renders on every MetaMask repository — including MetaMask/metamask-mobile — states the rule in one sentence: “Report the security issue on our MetaMask HackerOne program.” No alternate email, no PGP key, no CERT designation. Neither metamask.io/security.txt nor /.well-known/security.txt — the discoverable paths RFC 9116 defines for security contact, policy, and acknowledgments — is implemented. The GitHub Security Advisory channel that MetaMask has used in the past for at least one disclosure is not offered as an inbound reporting path. Single channel by design.
HackerOne is a bug bounty platform; its triage applies bounty-program rules. The report was closed as Not Applicable under MetaMask’s Attacks Requiring a Compromised Device exclusion clause. The PoC’s adb root reproduction path gave the exclusion a clean landing; the crash-report and analytics-SDK vector in the threat model requires no compromised device and survives it. That close-out applies to the bounty program’s payment terms. Under MetaMask’s HackerOne program rules, reports that highlight a risk the team commits to addressing are closed Informative — immediately added to the security backlog. Not Applicable carries no such commitment. Report #3652547 was closed Not Applicable.
Bug bounty programs are payment mechanisms, not disclosure frameworks. RFPolicy, a precursor to modern coordinated disclosure, establishes that when a vendor closes the reporting channel without a remediation plan, full disclosure follows. For a vulnerability affecting every install of io.metamask v7.76.0, the single-channel design leaves users with no path to remediation.
Timeline
| Date | Event |
|---|---|
| 2021-07-30 | Extension fix 98f187c30117 lands: SRP converted to integer-array representation for proper buffer wipe. |
| 2022-06-15 | MetaMask/Halborn public disclosure of the extension issue. MetaMask states: “The following does not impact MetaMask Mobile users.” |
| 2023-02-24 | Mobile-app wipeSensitiveData = () => '' stub and accompanying [Future improvement] comment introduced in PR #5374 (“Authentication refactor”). |
| 2026-04-06 | Report #3652547 submitted to MetaMask via HackerOne. Full PoC, no-Frida reproduction path, Demonic precedent cited. Weakness classified as Insufficiently Protected Credentials. |
| 2026-04-30 | Researcher follow-up: 24 days since submission, requesting a concrete plan by 2026-05-06. |
| 2026-05-07 | Report closed Not Applicable. Reason cited: “requires physical access… root access and Frida instrumentation”; excluded as “rooted device conditions.” |
| 2026-05-16 | Public disclosure. Source comment, lock path, and shipping APK behaviour re-verified on the current Google Play release (v7.76.0) and the current main branch of MetaMask/metamask-mobile. |
Mitigations
For MetaMask Android users on v7.76.0: the autolock is not a security boundary against an attacker with /proc/pid/mem access. Force-stop the app (adb shell am force-stop io.metamask or Settings → Apps → MetaMask → Force Stop) when lock state matters. Disable USB debugging when not actively using it. Move significant balances to a hardware wallet; treat the mobile wallet as hot.
For MetaMask: the source comment names the fix; the linked extension commit implements it. Apply the Buffer / Uint8Array representation in wipeSensitiveData. Iterate the keyrings on lock and call the wipePrivateData code that already ships in the dependency tree. Handle the password field at the native layer and bypass onChangeText — secureTextEntry={true} masks the UI but each keystroke still traverses the RN bridge as a plaintext JS String.
MetaMask patched the browser extension on 2021-07-30 and publicly disclosed on 2022-06-15 that the same vulnerability did not affect Mobile users. The Android app’s source labelled the equivalent fix [Future improvement] on 2023-02-24. The HackerOne report was closed Not Applicable on 2026-05-07. The stub private wipeSensitiveData = () => ''; is in main today. Passwords are in the heap.