Skip to content
All writing
Technical · 10 min

I Tried to Break Into My Own Journaling App on an Unrooted Pixel 9

A weekend attacking my own encrypted journaling app. The only foothold I got was my own debug build, my standard MITM playbook failed completely, and figuring out why taught me the most.

Contents

I build a journaling app called MoodHaven Journal. It is a Tauri v2 app, which means a Rust backend wired to a React and TypeScript frontend, and it ships on desktop and Android. Its whole pitch rests on three security promises: it is zero-knowledge (your encryption key is derived from your password and nothing useful is stored on a server), it is encrypted at rest, and your journal text never leaves the device. Only anonymized metadata is ever sent anywhere, and only if you opt into the AI features.

Those are easy promises to make and harder ones to keep. So I spent a weekend trying to break into my own app the way someone who wanted to read your journal might. White-box, on hardware I own: a Google Pixel 9 running Android 16, unrooted, tethered to my laptop over USB. Kali on the side, kept minimal. The Android build, pulled straight off the device. Two packages were installed: com.moodhaven.app, the current one, and com.moodbloom.app, an older build from when the project had a different name.

Here is the thing that set the tone for the entire exercise. The hardest part was not some exotic crypto attack. It was a single boolean in the manifest.

Phase 1: Recon

The recon phase was unremarkable. adb over USB, pull the APK. First surprise, though a mundane one: it is a chunky 524 MB. That is enormous for what is fundamentally a text app. The reason is Tauri bundles the compiled Rust native libraries (the .so files), and on top of that I ship whisper.cpp speech-to-text models for on-device dictation. Size is not a security finding, but it set my expectations for how much was going on under the hood.

Phase 2: Static analysis

A useful quirk of Tauri apps: the web frontend ships as plain JavaScript inside the APK’s assets/ directory, so I could grep the bundle for secrets without decompiling anything. I went looking for the classic mobile sins: API keys, tokens, anything that looked like a credential.

I found nothing. The only matches were UI placeholder strings. The tauri.conf.json had a tight Content Security Policy (CSP, the rule list that says which origins an app is allowed to talk to): connect-src was limited to self plus https://api.openai.com. That is a good sign for the privacy claim, because the frontend physically cannot phone home anywhere else.

Then I decoded the binary AndroidManifest. Android compiles it into a binary format called AXML, so I parsed it with a small pyaxmlparser script in a throwaway venv rather than guessing. This is where the picture got interesting:

  • android:debuggable="true"
  • android:usesCleartextTraffic="true"
  • WearListenerService exported with no permission guard
  • FileProvider correctly set to exported=false
  • No network_security_config resource at all

These came from the debug build I happened to have installed, which matters a lot, so I want to resolve it up front rather than let it hang over the post. I later checked app/build.gradle.kts and the actual built release manifest: the debug buildType sets isDebuggable=true and usesCleartextTraffic="true", while the release buildType sets neither, so release inherits debuggable=false (the Android Gradle Plugin default) and usesCleartextTraffic="false" (the defaultConfig placeholder). The built arm64Release manifest confirms both. So the two flags above are debug-only; the shipping build is clean on both. The debuggable=true flag still stood out, because it defined how the rest of this engagement played out on the build I had in hand.

Phase 3: Data at rest, and the turning point

Here is why debuggable=true is the whole game. When an app is marked debuggable, Android lets you run adb run-as com.moodhaven.app, which drops your shell into that app’s own user ID and gives you read access to its entire private sandbox under /data/data/. run-as is a small helper that says “become this app and read its files.” No root required. On a release build with debuggable=false, run-as is blocked outright, and reading that sandbox would need a rooted device. So this one flag is the difference between “I need nothing but USB debugging” and “I need root.” As noted above, I confirmed MoodHaven’s release build is debuggable=false, so on the shipping build run-as is blocked and this whole foothold disappears.

The APK I had installed was my own dev build, so run-as worked, and I was standing inside my own app’s private storage with no root and no exploit. The interesting part is what happened next, because getting in was supposed to be the hard part and instead the hard part was everything after.

I read moodhaven.db. If at-rest encryption were broken, the file would start with the literal bytes SQLite format 3. It did not. The first bytes were random, the signature of SQLCipher ciphertext. (SQLCipher is a build of SQLite that transparently encrypts the whole database file.) Next to it, db_state.json confirmed {"encrypted":true, salt:...}. So even with a full foothold in the sandbox, the database was opaque.

The biometric unlock feature was the part I most wanted to break. To skip retyping the password, the app stores the master password in shared preferences (moodbloom_biometric.xml) as AES-GCM ciphertext alongside a separate IV. The obvious move from inside the sandbox is to grab the ciphertext and the wrapping key and decrypt it offline. But the wrapping key does not live in the sandbox. It lives in the AndroidKeyStore, the OS-managed secure key container where the key material is never handed to the app, only used by the OS on the app’s behalf. I confirmed in BiometricPlugin.kt that the key is created with:

.setUserAuthenticationRequired(true)
.setInvalidatedByBiometricEnrollment(true)

The first line means the key cannot be used without a fresh biometric authentication. The second means enrolling a new fingerprint destroys the key. So my run-as foothold let me read the ciphertext and the IV, and that was it. Without my actual fingerprint, the Keystore would not unwrap anything. That is exactly how it is supposed to work, and it was satisfying to watch it hold against a real attempt rather than just trusting the docs.

The one wart was a cleanup nit: a stale moodbloom_securekey.xml, a legacy cloud-token key that the current code no longer writes (the Rust keyring crate is cfg-excluded on Android, and cloud sync is not shipping). Not a live exposure, just leftover clutter from an earlier design.

I expected run-as access to be damaging. Instead it confirmed the at-rest story holds even when an attacker is sitting inside the sandbox.

Phase 4: The MITM playbook that failed

Phase four was supposed to be the easy win, and it is the phase where my instincts were flat wrong. Figuring out why was the most interesting thing I learned all weekend.

There is a standard playbook for inspecting what a mobile app sends. You run an intercepting proxy like mitmproxy (a tool that sits between your phone and the internet so you can read its traffic), point the phone’s proxy at it, and install the proxy’s certificate as a trusted CA on the device so it can decrypt HTTPS. This is MITM, machine-in-the-middle: you insert yourself into the conversation and read both sides. On most apps, you now see every request in plaintext.

I did all of that, confident, and my proxy captured nothing from MoodHaven. The only HTTPS I intercepted was Android’s own autofill service talking to content-autofill.googleapis.com. The AI calls, the weather lookups, the geocoding, the update checks, none of it appeared.

My first instinct was that I had misconfigured the proxy. I had not. The answer is architectural. MoodHaven does not make HTTP calls from the WebView the way a normal web app would. It routes them through tauri-plugin-http, which on the Rust side is the reqwest HTTP client. Two consequences fall out of that:

  1. reqwest ignores the Android global proxy setting. The OS-level “send everything through this proxy” knob that the WebView and most apps respect is simply not consulted, so the traffic never flows through mitmproxy in the first place.
  2. reqwest uses its own bundled trust roots rather than the Android system CA store, so the user certificate I installed is meaningless to it. Even if I had forced the traffic through the proxy, the TLS handshake would reject my cert.

There is also no network_security_config adding a user trust anchor, and since Android 7 the platform stopped trusting user-installed CAs by default, so even WebView traffic would have refused my cert regardless of that cleartext flag. On an unrooted device, I was stuck. Intercepting those payloads would mean rooting the phone, rebuilding the app, or hooking the Rust TLS with something like Frida. None of which is “plug in a USB cable and run a proxy.”

I want to be honest that this was accidental hardening. I did not pick reqwest to defeat proxies. I picked it because Tauri makes Rust-side HTTP the natural path and it bypasses the WebView’s CSP for user-configured URLs. But the security property is real, and it is a good reminder that your dependency choices have consequences you did not design.

So when you cannot break the transport, you read the source. The privacy claim is that journal text never leaves the device, and I could not watch the AI request go out, so I traced how it is built. The request body is assembled from aggregated metadata only: average mood, mood trend and volatility, mood distribution counts, locally-extracted emotion labels, journaling frequency and streak, day of week, gratitude and goal completion percentages, and qualitative health hints. No entry text. No titles. The system prompt itself says, almost verbatim:

Based on the user’s journaling patterns (NOT their actual journal content).

Combined with the OpenAI-only CSP, the privacy claim holds. Could I have proven it harder by capturing the literal bytes? Yes, with root or Frida. I am noting that as a limitation rather than pretending a source read is identical to interception. But it is strong evidence, and I am more confident in the claim now precisely because I had to confirm it two different ways.

Phase 5: The exported service

One component was exported: WearListenerService. It has to be, because a WearableListenerService (the Android base class for receiving events from a paired Wear OS watch through Google Play Services) requires exported=true to function at all. So the export is unavoidable. The real question is whether its handlers validate who is talking to them. They do not: onMessageReceived and onChannelOpened trust any node ID.

I tried to drive it locally with forged intents:

adb shell am startservice -n com.moodhaven.app/... \
  -a com.google.android.gms.wearable.MESSAGE_RECEIVED

The handlers did not fire. Two things stopped me: Android 8+ background-service-start restrictions, and the fact that the Play Services base class only dispatches events that actually arrive through Play Services, not arbitrary intents I throw at it. So this is not trivially abusable by a local app.

The residual gap is defense-in-depth: a malicious or compromised paired wearable could inject fake mood signals or voice-memo drafts. The impact is bounded, because drafts require user review before they become journal entries and nothing gets exfiltrated. The fix is straightforward: validate the source node ID against the known paired node. I am logging it as a real action item, not a crisis.

Scorecard

AreaResultSeverity
Hardcoded secretsNone found (UI placeholders only)Clean
CSPTight: self + api.openai.com onlyClean
debuggable=true on the debug build I hadEnabled run-as sandbox read without rootDebug-only; release verified debuggable=false (resolved)
No network_security_config (cleartext is debug-only; release is false)Minor defense-in-depth nitLow
SQLCipher at restCiphertext confirmed (no plaintext header)Held
Biometric password storageKeystore + fingerprint-gated, not decryptable via run-asHeld
Stale moodbloom_securekey.xmlLegacy pref, no longer writtenInfo, cleanup
Network MITMFailed; reqwest ignores proxy + bundled rootsHeld
Metadata-only AIVerified by source: no entry text leaves deviceHeld
WearListenerService source validationNo node-ID check; not locally abusable, paired-device gapLow

Action items, in priority order:

  1. [Verified] The release buildType sets neither debuggable nor a cleartext override, so the shipping build is debuggable=false and usesCleartextTraffic=false. The foothold existed only on my own debug build.
  2. (Optional) Add a network_security_config for defense-in-depth. Release cleartext is already off, so this is low priority.
  3. Add a source-node allowlist to WearListenerService.
  4. Remove the stale securekey preference.

What I learned

The thing that mattered most was a boring build flag, not exotic cryptography. That keeps being true across the security work I do, and I should stop being surprised by it. Most of what actually protects users is mundane configuration done correctly, and most of what exposes them is mundane configuration done carelessly. debuggable is the hinge of Android app security on an unrooted device: get it wrong and an attacker with USB access reads your whole sandbox, get it right and they need root, which is a much higher bar.

My standard MITM playbook failed outright, and understanding why taught me more than a clean capture would have. The Rust reqwest stack ignoring the system proxy and bundling its own trust roots is a property I got almost by accident, and now I understand the architecture well enough to reason about it instead of just trusting it.

And when you cannot break the transport, read the source. I could not prove the privacy claim by sniffing packets, so I proved it by reading the request builder and the system prompt, and I said exactly where that verification stops.

Testing your own app honestly is uncomfortable in the right way. It would have been easy to declare victory at “the database is encrypted” and stop. Pushing past that surfaced a short list of real, unglamorous items: a debug-build flag I chased down and verified is off in release, an optional manifest hardening, a missing node-ID check, and a stale file to delete. That is what most security work actually looks like, and I would rather find these myself than have someone else find them for me. I am still learning this, and writing it down is part of how.