Skip to content
All writing
Technical · 13 min

So We Rooted the Phone and Went Deeper

The unrooted phone told us our encryption held. Root let us check whether we were telling ourselves the truth. We weren't, entirely — and that was the point.

Contents

A while back I wrote up an attempt to break into our own app on a real phone. That was an unrooted Pixel 9 — a stock device, the threat model most users actually live in. The encryption held. The network surface looked clean. It was a good post and an honest one.

But it left a splinter in my brain, because an unrooted phone is fighting with one hand behind its back. I couldn’t read the app’s process memory. I couldn’t get under the storage layer. I couldn’t see what the app actually does the moment it’s unlocked, only what it presents. “We couldn’t break in” is a weaker claim when the attacker isn’t allowed to use their best tools.

So I rooted a phone and went looking for the things the first test couldn’t see. This is the long, messy version — the one for people who like the parts where it doesn’t work yet.

The short version of where it lands: the cryptography is genuinely good, better than I expected when I started poking. And I found two real bugs that I’m glad I went looking for, both of which are exactly the kind of thing you only catch when you stop trusting your own settings screen.

Why root, and what I was actually testing

MoodHaven is a local-first journaling app — Tauri v2, so a Rust core with a React/TypeScript frontend running in the system WebView. Everything lives on-device in an encrypted SQLite database. The whole pitch is “your journal never leaves your hardware and nobody, including us, can read it.” That’s a strong claim. Strong claims deserve adversarial pressure.

One honest framing note before the fun part: this is white-box testing of my own app — I have the source, and I know the password. It isn’t a black-box break-in by a stranger who has neither; that’s a harder, different claim I’m not making here. What this is: me taking the attacker’s seat with every advantage I could hand myself, and checking whether the design holds anyway. The interesting findings come from that mindset — assume you’ve already won a layer, and see what the next one costs you — not from guessing a password I was handed.

Root gives an attacker (or an honest tester pretending to be one) three capabilities the stock device denies:

  1. Pull the database off disk and try to decrypt it offline, at leisure, with no rate limiting.
  2. Install a system-level CA and try to man-in-the-middle the app’s traffic.
  3. Attach a debugger to the live process (frida) and read the key material straight out of memory.

Each of those maps to a layer of the security model: at-rest, in-transit, in-use. I wanted to hit all three.

The test device was a Pixel 4a — old, cheap, fully supported by the unlock-and-flash path, and not something I’d cry over if I bricked it. Which, spoiler, I nearly did.

Part one: rooting a Pixel 4a, and the two things that actually went wrong

If you’ve never rooted a modern Pixel, the happy-path is genuinely four steps:

  1. Unlock the bootloader (fastboot flashing unlock).
  2. Download the matching factory image and extract boot.img.
  3. Patch boot.img with Magisk on the device itself.
  4. Flash the patched boot image back (fastboot flash boot magisk_patched.img).

That’s it. That’s the whole thing, when it works. It did not, at first, work, and the two reasons it didn’t are the genuinely instructive part of this section, because neither of them is about Android at all.

fastboot devices would show the phone. I’d kick off a flash. About two seconds in, the transfer would just… stop. The device would drop off the bus. Re-plug, retry, same thing. Two seconds, gone.

The kernel log told the real story — the fastboot interface enumerating and then vanishing a beat later:

usb 1-1: new high-speed USB device number 38 using xhci_hcd
usb 1-1: New USB device found, idVendor=18d1, idProduct=4ee0   # fastboot mode
usb 1-1: USB disconnect, device number 38                      # ...gone ~2s later

The instinct is to blame the cable, then the port, then fastboot, then the moon. The actual culprit was my laptop’s xHCI USB controller doing autosuspend — Linux power management aggressively putting the USB device into a low-power state when it decided the link was “idle.” A bootloader flash has bursty traffic with brief quiet gaps, and the kernel read those gaps as “nobody’s using this, power it down.” Mid-flash. On the boot partition. Which is the single worst moment for the link to vanish.

The fix, once I’d stopped suspecting the cable:

# check what's enabled (2 = suspend after 2 seconds idle — there's your timer)
$ cat /sys/module/usbcore/parameters/autosuspend
2

# disable it for the session
$ echo -1 | sudo tee /sys/module/usbcore/parameters/autosuspend
-1

(or usbcore.autosuspend=-1 on the kernel command line if you want it to stick). With autosuspend off, the controller stopped helpfully sabotaging me and the flash ran clean to completion.

The lesson I keep relearning: when a transfer dies at a suspiciously consistent time interval, stop debugging the protocol and start debugging the power state. Consistent timing is a clock somewhere, and a clock you didn’t set is usually power management or a watchdog.

Obstacle two: the power-button trap

This one is dumber and I love it more.

The bootloader/fastboot screen on a Pixel is navigated with volume keys, and the power button is “select.” The default highlighted option is Start — which boots normally into Android.

So my reflexive flow was: get the phone into the bootloader, glance at the screen, press power to “wake it up / confirm I’m here”… and immediately select Start, booting straight back into Android and dropping me out of fastboot. Then I’d plug in, see no fastboot device, and get confused about why the phone keeps leaving the bootloader on its own. It wasn’t leaving on its own. I was telling it to leave, every single time, with the muscle-memory “press power to do something” habit from twenty years of phones.

The fix was nothing more than discipline: get it into the bootloader, then hands off the device entirely. No confirming, no waking, no touching. Drive everything from the laptop. The flow that finally worked was a tight poll-then-flash from the host so there was no window for me to fat-finger anything:

# wait for the device to enumerate, then flash the instant it does —
# no human in the loop to press the wrong button
until fastboot devices | grep -q fastboot; do sleep 0.2; done
fastboot flash boot magisk_patched.img

Two obstacles, zero of them about cryptography or Android internals. One was the host OS being too clever about power; one was me being a primate with a thumb. Most of the friction in security work is like this — environmental, dumb, and completely opaque until you name it.

Anyway. Magisk-patched boot flashed, device booted, su worked. Now the actual test.

Part two: at-rest — trying to read the journal off the disk

First target: the database. With root I pulled moodhaven.db straight off the device — along with its db_state.json sidecar, which records whether the on-disk encryption is active.

The app uses two layers, and I wanted to defeat both, not just admire them:

  • Layer 1: the whole SQLite file is encrypted with SQLCipher.
  • Layer 2: each journal entry’s text is also individually encrypted with AES-256-GCM, with a key derived from the user’s password, before it’s ever written into the database.

Layer one confirmed itself immediately. file doesn’t recognize it as a SQLite database, the first 16 bytes are high-entropy noise rather than the telltale SQLite format 3\0 magic, and db_state.json says the encryption is on:

$ cat db_state.json
{"encrypted":true,"salt":"+nETLuWX5w9+6/kpRXVPyA=="}

$ file moodhaven.db
moodhaven.db: data                 # NOT "SQLite format 3"

$ head -c 16 moodhaven.db | xxd
00000000: f5aa afc8 77c8 5520 b22f 79db 7ad1 3f5a  ....w.U ./y.z.?Z

Before I’d written a real entry I’d seeded the journal with a canary phrase — PURPLE-ELEPHANT-CANARY-7788 — so I’d know instantly if any layer leaked it. strings moodhaven.db | grep PURPLE came up empty. Good, but unsurprising: the whole file is encrypted, so nothing is greppable. That’s the encrypted-at-rest claim holding.

Then I did the thing that actually matters — I assumed the attacker has won layer one. I know the test password, so I derived the SQLCipher key offline (PBKDF2-HMAC-SHA256, 600,000 iterations, salt straight from that db_state.json) and opened the file with sqlcipher3 using the raw-key pragma the app itself uses:

import sqlcipher3
conn = sqlcipher3.connect("moodhaven.db")
conn.execute(f"PRAGMA key = \"x'{raw_key_hex}'\"")   # raw 256-bit key, no SQLCipher KDF

conn.execute("SELECT count(*) FROM sqlite_master").fetchone()
# (72,)   -- it opens. the schema is right there.

conn.execute("SELECT encrypted_content FROM journal_entries LIMIT 1").fetchone()
# ('{"ciphertext":"bVs1lKuzJtl5ssd3F7Mbp86sjHXCQyw...","iv":"h...","salt":"..."}',)

It opens. Seventy-two schema objects, the whole structure laid bare. And the encrypted_content column is… still ciphertext — a per-entry AES-256-GCM envelope with its own IV and salt. I re-ran the canary grep against the fully decrypted database and it was still absent, because the phrase lives inside that inner envelope, not in any column SQLCipher protects. Defeating SQLCipher gets you the structure of the journal — how many entries, when, mood scores, tags — but not a single word of what anyone wrote.

I want to be precise about why this matters, because “we encrypt twice” sounds like security theater when you say it fast. It isn’t, here, because the two layers fail independently. A bug in the SQLCipher integration (and there was one historically — a pragma mismatch, hexkey where it should have been a raw key, that left the on-disk layer inert from v1.7.0 until v1.8.0, which we found and fixed and wrote up honestly) doesn’t expose entry text, because entry text was never relying on SQLCipher in the first place. Two locks on the same door is theater. Two locks where the inner door is a different door entirely is defense in depth. This was the latter, and on a rooted device with the outer key in hand, the entries stayed dark.

That’s the strongest single result of the whole exercise, and it’s the one I was most prepared to be embarrassed by. I wasn’t.

Part three: in-transit — the MITM that decrypted Google but not us

Second target: the network. The app does its HTTP from the Rust side (reqwest), and I wanted to see it on the wire.

The classic root-enabled MITM is: install your own CA into the system trust store, point the device at a proxy, and watch TLS dissolve into readable plaintext. On a rooted phone you genuinely can install a system CA. To prove the rig actually worked, I watched it decrypt Google’s own traffic — my proxy sitting in the middle of the phone’s connections and reading inside their TLS:

total flows: 44  (https decrypted: 36)
    13  youtubei.googleapis.com
     6  play.googleapis.com
     5  content-autofill.googleapis.com
     4  connectivitycheck.gstatic.com
     ...

So the interception was real and functional — it was reading Google. Then I drove the app (validating an Oura health token, one of its reqwest calls) and watched for it. Nothing. The app’s traffic went straight through the rig, untouched. Two reasons, both consequences of how the Rust HTTP client is built:

1. It ignores the Android system proxy. I scoped a kernel log to the app’s UID and watched every outbound connection it made. It went straight to Oura’s edge on 443 — and never once to my proxy:

# every outbound connection from MoodHaven's uid, logged by the kernel:
DST=108.138.94.5   PROTO=TCP  DPT=443    (x18)   # straight to Oura's CloudFront edge
DST=224.0.0.251    PROTO=UDP  DPT=5353           # mDNS (LAN peer discovery)
proxy hits (DST=<laptop>:8080): 0                # never touched the proxy

2. It rejects the system CA. So I forced the issue: an iptables DNAT rule that redirected the app’s “direct” 443 traffic into a reverse-proxy impersonating Oura with my CA-signed certificate. The TCP connection landed, the TLS ClientHello arrived… and reqwest aborted the handshake before sending a single byte:

# DNAT redirect: the app's 443 now lands on my MITM
DNAT  tcp  --  owner UID match 10235  dpt:443  to:<laptop>:8443   (1 packet matched)

captured flows: 0            # the ClientHello reached us; nothing got past it
app UI: "failed to connect"  # reqwest refused our cert and gave up

The reason is a deliberate build choice: reqwest here is compiled with rustls-tls and bundled webpki roots (rustls-native-certs is nowhere in the dependency tree), so it trusts its own baked-in list of authorities and never consults the OS trust store. The exact CA that was happily decrypting Google simply isn’t in the set the app trusts. The app would rather not talk than talk to a stranger wearing a system-issued badge.

This is the kind of thing that’s invisible on an unrooted phone (you can’t install the CA in the first place) and quietly excellent once you can. A rogue or coerced system CA — the actual nightmare scenario for “an attacker who fully owns the device’s trust store” — couldn’t MITM this app, because the HTTP client was built to trust its own roots and ignore the device’s opinions.

(One honest caveat I want on the record: this MITM-resistance is a property of the Rust reqwest client. The optional, opt-in cloud transcript-formatting for voice notes goes out through the WebView’s fetch, which does use the system trust store — so if you turn that feature on, that one path is not protected the same way. It’s off by default and requires explicit consent plus your own API key, but it’s a real exception and I’d rather name it than let “we can’t be intercepted” read as absolute.)

Part four: in-use — frida, and the memory that lingered

Third target, and the one I knew would draw blood: live memory.

Here’s the uncomfortable truth about every local-first app with client-side encryption: while it’s unlocked, the keys are in memory. They have to be — you can’t decrypt the journal without the decryption key being somewhere the CPU can reach it. Root plus a debugger plus an unlocked app will always find key material. That’s not a MoodHaven bug; it’s physics. So the interesting question was never “is the key in memory while unlocked” (yes, obviously). It was “what happens when you lock?”

I attached frida and scanned the process’s read/write memory for two things: the exact 32-byte key I’d derived earlier, and the master password as a string.

While unlocked, both were there:

KEY        HIT @ 0x736a143616
KEY        HIT @ 0x736a1503d8
KEY        HIT @ 0x736a1506d8
KEY        TOTAL=3          # the exact SQLCipher key — 3 copies (Rust state, the live
                            # SQLCipher connection, a derivation buffer)
PW_ascii   TOTAL=1
PW_utf16   TOTAL=1          # the master password, as a JS (UTF-16) string + an ASCII copy

Expected. Unavoidable. Fine — as long as locking cleans it up. So I locked the app (tapped lock, leaving the process alive) and re-scanned the same PID:

KEY        TOTAL=0          # SQLCipher key ZEROIZED — gone, scrubbed, verified live
PW_ascii   HIT @ 0x12cae3f8
PW_ascii   HIT @ 0x12e40095
PW_ascii   HIT @ 0x12e4016e
PW_ascii   TOTAL=4          # ...but the password is STILL here. 4 plaintext copies.

Two different outcomes in one scan. The Rust-side SQLCipher key was correctly zeroized — that’s the zeroize crate doing exactly what it claims, and seeing it work in a live memory dump rather than trusting a code comment was genuinely satisfying. But the master password was still there, four plaintext copies lingering in the WebView/JS heap. Locking didn’t get rid of them. Only fully closing the app did — a fresh process sitting at the lock screen scanned clean:

# after fully closing + reopening (fresh process, lock screen):
KEY TOTAL=0    PW_ascii TOTAL=0    PW_utf16 TOTAL=0      # nothing

And here’s the bind: JavaScript can’t scrub its own memory. Strings are immutable, the garbage collector moves things around and copies them whenever it likes, and there’s no memset you can call. The Rust side can zeroize because Rust gives you a mutable byte buffer you own and can overwrite. The JS side fundamentally can’t — by the time you “clear” a string variable, the runtime may have made copies you’ll never get a handle on. Which is exactly what frida found: the references get dropped on lock, but stale copies survive in the heap, untouchable.

So the password lingers in the WebView heap on lock, and only a full process teardown clears it. On its own, that’s an annoyance — an attacker with root and a live debugger on an unlocked-then-locked phone could still fish out the password. But it gets worse when you combine it with the next finding, which is where this stops being theoretical.

Part five: the two real bugs

Finding #1 — the auto-lock that never locks

MoodHaven has an auto-lock setting. autoLockTimeout and clearClipboardOnLock both persist correctly, the UI reads and writes them, the settings screen honors your choice and shows it back to you. Everything looks wired up. I even confirmed the save landed by pulling and decrypting the settings after changing it:

# app_settings, decrypted out of the DB after I set auto-lock to 1 minute:
autoLockTimeout      = 1        # the setting saved correctly...
clearClipboardOnLock = true

But there’s no idle timer. There’s no app-lifecycle listener. Nothing actually calls lock() when the timeout elapses.

I confirmed it the dumb, definitive way: set auto-lock to 1 minute, unlocked the app, put the phone down, and waited two minutes. Still unlocked. The setting is a label on a switch that isn’t connected to anything. You can set it to one minute or one hour; the app never auto-locks regardless.

This is the worst kind of bug because it’s worse than not having the feature. A user who sets “auto-lock after 1 minute” makes downstream decisions trusting that promise — they’ll leave the phone on the desk, hand it to someone to show a photo, set it down at a café — believing the journal re-locks behind them. It doesn’t. The setting actively lies.

The fix is conceptually small: wire a real idle timer (reset on user interaction) and an app-lifecycle listener (lock on background/blur) to actually invoke the existing lock() path. The lock machinery exists and works — I watched it zeroize the SQLCipher key. It’s just that nothing is pulling the trigger automatically. The plumbing’s there; the float switch was never connected to the valve. (Same goes for clearClipboardOnLock — it persists, but nothing reads it to actually clear the clipboard. The fix should honor it on every lock path, manual and automatic.)

Finding #2 — the password that lingers, made dangerous by #1

On its own, “password lingers in the JS heap after lock” is a footnote with a known root cause and no clean fix. Tied to Finding #1, it becomes the real exposure:

Because the app rarely locks — only on explicit manual lock or full close — the in-memory password and key sit exposed indefinitely on an unattended, unlocked phone. The auto-lock that was supposed to bound that exposure window to one minute doesn’t run, so the window is “until the user remembers to lock manually or kills the app,” which in practice is hours, or never.

So the two findings compound. #2 is “lock doesn’t fully scrub the password (and can’t, in JS).” #1 is “lock barely ever happens automatically.” Together: a found phone, unlocked, with a debugger, yields the master password long after the user assumed the app had secured itself.

Fixing #1 doesn’t fully fix #2 — the JS heap still can’t be scrubbed — but it dramatically shrinks the exposure window and removes the false promise. And there’s a real mitigation for the residual: minimize how long the password lives in JS at all (derive the key in Rust, hand the password across the IPC boundary as briefly as possible, never hold it as a long-lived JS string — the app actually already keeps a duplicate copy in two places, which is one too many), so that even though you can’t scrub what’s there, there’s far less there to find. That’s the direction the fix should go.

(There was also a non-security UX bug worth noting because it shows the test surface was real and not cherry-picked: on Android I couldn’t navigate back from Settings to the main UI without force-closing. Logging, separately, was clean — no secrets, no journal content, nothing sensitive in logcat. I went looking specifically and came up empty, which is its own kind of result.)

What I actually take away from this

I think there’s a temptation, when you test your own work, to test it the way you’d demo it — to walk the happy path with a flashlight and announce that everything you pointed the flashlight at was fine. That’s not testing, that’s reassurance.

The unrooted post was honest but limited; it could only conclude “we couldn’t get in with these tools.” Rooting the device removed the excuse. With root I could pull the database, MITM the network, and read live memory — the full kit — and the cryptography stood up to all of it. The entries stayed encrypted under a key I held. The network refused a CA that was decrypting Google. The Rust key zeroized on lock, live, verified.

And then the same level of access that vindicated the crypto found two bugs that the settings screen had been quietly hiding from me. The auto-lock was a switch wired to nothing. The password lingered where JS can’t reach to clean it. Neither of those shows up if you trust the UI. Both of them show up the instant you treat your own app as hostile and check whether it does what it claims rather than what it displays.

That’s the whole discipline, I think: test the claim, not the UI. The settings screen said it auto-locked. The only way to know was to set one minute, walk away, and come back to a phone that should have been locked and wasn’t. Everything else — the rooting, the frida scripts, the proxy — was just buying myself the access to ask that question honestly.

The crypto held. The promises didn’t, quite. Both of those are findings, and I’d rather have found them than not.

Next up: wiring the idle timer, the lifecycle listener, and shrinking the password’s lifetime in JS to as close to zero as the runtime allows. I’ll write up whether the fix survives the same frida scan that caught it — because the only test I trust now is the one that already drew blood once.