Skip to content
All writing
Technical · 11 min

The Bugs I Found Attacking My Own Journaling App — and the Bugs My Fixes Created

The confirmed vulnerabilities from a ten-round self-pentest of MoodHaven Journal: a readable database, silently lost edits, keys leaking over the LAN — and the critical bugs my own fixes introduced. Part 2 of a four-part series.


Part 2 of a four-part series on the ten-round penetration test of MoodHaven Journal. Part 1 covered why I attacked my own app and how the lab worked. This part is the findings themselves — what broke, and what my fixes broke in turn.


The headline findings (in plain language)

You don’t need to read code to understand what was at stake. Here are the most significant confirmed issues, described for a general reader, with the technical detail tucked into a toggle for anyone who wants it.

Your data was encrypted — but the story around it wasn’t

The journal entries themselves were always properly encrypted. But when I copied the app’s database file off the victim machine and opened it with a standard database tool, I could still read a lot: which days you wrote, your daily mood scores, and — most sensitively — your tag names. Tags like “therapy,” “anxiety,” or a person’s name are themselves revealing, even if the entry text is scrambled. Someone who stole the database could reconstruct a detailed behavioral profile without decrypting a single word.

There’s a sting in the tail here, and it earns its own section — the opening of Part 3 of this series. Several rounds later, the eighth round of testing discovered that this very fix — the database encryption everyone, including earlier pentest rounds, believed was working — had never actually engaged (and has since been fixed and verified).

For the technically inclined — what sat in the plaintext SQLite file, and the offline-cracking path it opened

Entry content was AES-256-GCM ciphertext, but the surrounding metadata sat in a plain, readable SQLite file:

  • books table: names, colors, emojis, creation dates
  • tags table: all tag names (can reveal sensitive categories like “therapy,” “anxiety”)
  • entry_tags: which entries carry which tags
  • settings table: all preference keys and values
  • journal_entries: mood score (1–5), creation/update timestamps, privacy mode, book assignment, pin status

Worse, the settings table held the password_hash and password_salt rows in plaintext. Those are readable with nothing more than file access, and they enable an offline dictionary attack: the hash is PBKDF2-HMAC-SHA256 with 600,000 iterations — slow by design, impractical against a strong unique password, but crackable against common or short passphrases with a GPU and hashcat. The hash parameters are stored alongside the hash, so a targeted attack needs no guessing about the KDF.

Fix: full database encryption with SQLCipher, keyed from the user’s password. With the whole file encrypted at rest, the metadata leak and the offline-cracking path close at once. Shipped in v1.7.1. (Read on for the sting: years of rounds later, this fix turned out never to have engaged.)

A single root cause hiding behind three “separate” problems

Three of the findings looked like three independent tasks: the readable database, the exposed password hash, and a way to reset the app’s brute-force lockout by deleting a file. But all three had one root cause — the database wasn’t encrypted at rest. Fixing that one thing resolved all three.

For the technically inclined — why the lockout-bypass finding is less critical than it looks

The lockout (5 failed password attempts → 30-second ban) is persisted in pw_lockout.json in the same AppData directory as the database. Deleting the file resets the counter — confirmed by writing a fake lockout valid until 2099 and then removing it in one command.

But this finding is less critical than it looks in isolation, and the calibration matters. The lockout only protects against online brute force — guessing passwords through the running app. An attacker with file access doesn’t need the app at all: they take the plaintext password hash and crack it offline, on their own machine, with no rate limit possible no matter what the lockout file says. Deleting pw_lockout.json adds nothing to an attacker who already holds the database.

So all three findings collapse into one: encrypt the database (SQLCipher), and the hash is no longer readable, which kills both the offline-crack path and the relevance of the lockout file. The priority list that looked like three urgent tasks was one. Always ask which findings share a root cause before scheduling them as independent work.

Edits that silently disappeared

The sync feature decides which version of an edited entry “wins” by comparing timestamps. The comparison treated the timestamp as plain text — and in text, "9999-12-31" sorts as larger than any real date. A compromised device could stamp an entry with a far-future date so that every future edit you made would silently lose and never survive a sync. The app looked fine; your changes just wouldn’t stick.

For the technically inclined — lexicographic compare on updated_at, and the future-timestamp guard

Last-write-wins used updated_at > local.as_str() — a lexicographic string compare, not a date compare (sync.rs, conflict.rs). "9999-12-31" beats "2026-06-04T10:00:00Z" because '9' > '2'. A compromised trusted peer (not an arbitrary attacker) sends an entry with updated_at: "9999-12-31"; every later edit loses permanently. Impact: silent, permanent data loss.

Fix: parse updated_at as RFC 3339 before comparing, and reject any value more than MAX_FUTURE_SECS ahead of local time — which also closes the future-date poisoning path. Shipped in commit 3cd3a60.

Secrets leaking over the local network

This one I’d never have caught by reading code — I only saw it by capturing the actual network traffic with Wireshark. The device-discovery feature was broadcasting each device’s full public encryption key across the local network every 30 seconds. Because the sync encryption key is derived from both devices’ public keys, anyone passively listening on the same network could collect both keys and decrypt all the sync traffic — with no pairing and no password.

Exhibit 1 — The public key is off the wire, both at discovery and in the sync stream. Two captures from the fixed build: the mDNS service record it broadcasts on the LAN (only a device name and type — no public key, no key hint, where the vulnerable build carried a pubkey_hint= field and the UDP fallback broadcast the full Ed25519 key), and a live capture of an actual sync connection showing the plaintext handshake giving way to opaque AES-256-GCM frames the instant the ECDH key is established. (Anonymized: device IDs and IPs redacted — real LAN IPs rewritten to RFC 5737 documentation addresses.)

View raw records — mDNS TXT (fixed vs. vulnerable) + on-wire handshake→ciphertext
# FIXED build (current):
_moodhaven._tcp.local  TXT
    device_id=d2be…1240f  device_name="Study PC"  device_type=desktop  version=1.8.0
    # no public_key, no pubkey_hint

# VULNERABLE build (pre-PT3, for contrast):
_moodhaven._tcp.local  TXT
    device_id=d2be…1240f  pubkey_hint=<first 8 chars of pubkey>   # ← public-key prefix leaked

And here is the other half of “watching the traffic” — a capture of the fixed build’s actual sync connection, so you can see exactly where plaintext stops and ciphertext begins:

# Live sync capture, fixed v1.8.0 build. attacker 192.0.2.10 ↔ victim 192.0.2.20, TCP port 44950.
# 17 packets, ~42 KB total. Frames shown by role, not raw bytes.

# ── plaintext handshake (by design — no secret in it) ──
→  Hello { did: d2be…1240f, eph_pub: <X25519 ephemeral pub> }
←  Ok    { name: <redacted>, eph_pub: <X25519 ephemeral pub>, challenge: <32-byte challenge> }
→  Auth  { signature: <Ed25519 sig over "moodhaven-hello-auth-v1:"||challenge> }
   # device IDs + per-connection ephemeral X25519 keys are visible here, and nothing else;
   # the static public key is NOT on the wire (that was the leak this section is about)

# ── after the ECDH key is established, every frame is opaque ──
←  [4-byte len][12-byte nonce][AES-256-GCM ciphertext]   # manifest, entries, etc.
→  [4-byte len][12-byte nonce][AES-256-GCM ciphertext]
   ... (all remaining frames identical in shape; payload unreadable without the session key)

A passive listener now sees the handshake metadata and a stream of indistinguishable ciphertext blocks — and, the point of this whole section, never the static public key needed to derive the session key. (The raw pcap was captured on the attacker box, which is a party to the connection, then run through the anonymization pipeline below.)

For the technically inclined — the UDP broadcast, the mDNS hint, and the QR/PIN coupling

The UDP discovery fallback (run_udp_discovery) broadcast to 255.255.255.255:4243 every 30 seconds with the full Ed25519 public key in the payload. The sync transport key is SHA-256("moodhaven-sync-v1:" + sorted(pubKeyA, pubKeyB)) — both keys are required, and both were now on the wire. Any LAN host that captured one probe from each device could precompute the transport key and decrypt all sync traffic, with no pairing and no authentication.

The mDNS service TXT record made it even easier: it carried pubkey_hint= — the first 8 characters of the base64url public key — passively delivered to every device on the LAN. The documented security model explicitly said the public key is only shared during pairing; the discovery path violated that invariant.

A related finding from the same round: the pairing QR code embedded the PIN, defeating the “read the PIN off the other screen separately” out-of-band design — a screenshot would capture both at once.

Fixes: strip public_key and pubkey_hint from all discovery payloads (now only device_id, device_name, device_type, version — enough for detection, insufficient for key derivation), and remove the PIN from the QR so it must still be typed by hand. Shipped in PR #122.

The “fixed it, then broke it” problem — twice critical

This is the most important pattern in the whole campaign, so it gets its own spotlight.

  • When I shipped the database encryption fix above, the next round found that the encryption migration could fail on Windows in a way that left the app silently running on the old, unencrypted database — so a user who upgraded believing their data was now encrypted might not actually be. A critical bug, living inside the fix for an earlier critical bug.
  • The recovery code I then added to handle that failure had its own critical bug: under a precise crash timing, it could lock the user out of their database entirely, requiring a factory reset and losing data. A critical bug inside the fix for the fix.

Both were eventually caught — and the second one only surfaced when I installed and ran the actual build and force-killed it at the wrong millisecond. No amount of code reading would have reliably found it. That’s the whole argument for round-after-round testing on real machines, in one example.

For the technically inclined — the Windows handle quirk and the null-salt recovery regression

The migration failure (PT3). encrypt_in_place exported the DB to an encrypted temp file (moodhaven_enc.db), then renamed it over the original. On Windows, SQLite in WAL mode can retain the SHM file handle after the Connection is dropped, so the rename failed: moodhaven.db kept its plaintext SQLite magic bytes, moodhaven_enc.db existed but was never renamed, and no db_state.json was written. Same migration worked on Linux. Fix: retry loops (5×50 ms) for SHM/WAL removal and for the rename. Shipped in PR #122.

The orphaned-file gap (PT4). If a crash hit between the export step and writing db_state.json, the encrypted file existed but the state file didn’t — and the startup recovery check was gated on db_state.json saying encrypted=true, so it never fired. Every launch silently opened the plaintext DB and ignored the encrypted copy forever. Fix: check for moodhaven_enc.db unconditionally, before the state-file gate; write db_state.json atomically (.tmp then rename()); complete the rename.

The recovery regression (QA pass). The orphan-recovery path then promoted the encrypted file but wrote {encrypted: true, salt: null} — it had no salt to record. The next unlock called db_salt()None → “Database encryption record is missing,” a permanent unlock failure requiring factory reset. Surfaced only by installing the build and force-killing it between export and salt-write. Fix: encrypt_in_place now pre-writes {encrypted: false, salt: Some(salt)} before creating the encrypted file; recovery branches on salt.is_some() — complete the migration if the salt is known, discard the orphan otherwise. Shipped in PR #127.

Locked, but not really locked

In later rounds, the testing fanned out across the app’s full command surface and found a class of access-control gaps: a number of commands could be triggered while the app was still locked. Depending on the command, that meant a locked-session attacker with access to the app could enumerate voice memos, start or accept a device pairing, list trusted devices, or even — in the browser version of the app — read and write journal data and analytics while the lock screen was up.

For the technically inclined — the missing require_unlocked guards and the default-allow browser gate

Roughly six voice-memo commands (list_voice_memos, get_voice_memo, delete_voice_memo, patch_voice_memo_transcription, transcribe_voice_memo, link_voice_memo_to_entry), four peer-pairing commands (peer_generate_pairing_token, peer_accept_pairing, peer_get_trusted, peer_revoke_device), and two sync helpers (upsert_entry_from_sync, get_entry_timestamps) lacked the require_unlocked guard. store_voice_memo is intentionally pre-auth — the Wear OS watch needs to deliver audio while the app is locked — but the review-path commands in the same module had never been audited separately.

The browser/PWA build was worse. Its lock gate was default-allow: a LOCK_GATED_COMMANDS set that listed only seven commands, so every other IndexedDB-backed command (all journal reads/writes, settings, books, analytics, export, time capsule, StillHaven) ran regardless of lock state — roughly 40 data commands leaking while locked. Content stayed AES-encrypted, but metadata, analytics, and writes did not.

Fixes: add lock guards to every sensitive native command; expand the browser shim’s gate to the full data surface — with a dozen new tests asserting each category refuses to run while locked. The deeper lesson is that a default-allow gate is the wrong shape; the consistent fix this release was to enumerate the data commands rather than invert to default-deny mid-release. Shipped in PR #133.

The encryption key, written out in plaintext on every unlock

The recurring theme across the last several rounds was memory hygiene: making sure secret keys are actively wiped from memory after use, not just dropped and left for the system to maybe-overwrite-eventually. Round after round found one more place this was missed. The deepest one: the database key was protected in its original form, but the code built a SQL string containing that key in full and never wiped the string. So on every unlock, the single most sensitive secret in the app — the key to the entire database — was left sitting in memory.

For the technically inclinedZeroizing on the key but not the string built from it

The key bytes were wrapped in Zeroizing, but format!("PRAGMA hexkey = '{}'", hex) produced a fresh, unzeroized String holding the full key in hex. Four sites, all now Zeroizing. This was the same class of bug found one layer shallower in earlier rounds: PT4 wrapped verify_password and unlock_app; PT5 swept the rest of the PBKDF2 call sites and caught three more (TOTP secret encryption in two_factor.rs, export payloads in data_management.rs, and media key derivation in media.rs), changing derive_key’s return type from [u8; 32] to Zeroizing<[u8; 32]>. The broader lesson: protecting the key isn’t enough — you have to protect every string you build out of it. Shipped across PRs #124, #125, and #133.

Exhibit pending — the one empirical check still owed here. The live memory-dump test against the latest build (dump the just-locked process and grep for key-shaped material to confirm the Zeroizing wipes actually clear it at runtime) is the single remaining check from this round, and it isn’t done — I was away from the lab machine when the rest of this was captured. So I’m not showing a dump here, and I’m not claiming one. The zeroization itself is code-verified, not yet runtime-verified: every key site above is wrapped in Zeroizing, with tests over the call sites, and earlier rounds’ dumps (PT4/PT5) came back clean. The fresh live dump is a deferred, GUI-required residual — I’ll add the capture when it exists rather than imply it already does.

The full-database restore that anyone with an old key could trigger

The new-device setup flow lets a fresh install pull your entire database from an existing device over your home network. The receiving side asked; the serving side just… served. There was no prompt and no approval on the device that held the data — so a device that had been paired once, then lost or stolen, could quietly pull your whole journal whenever your real device was running.

Exhibit 2 — The restore consent gate, probed both ways. A still-trusted attack client completes the full handshake and sends a RestoreRequest. Unarmed, the gate blocks it before any database bytes leave disk. Armed (a human flips the one-shot window in Settings → Devices), the server does start streaming — and the probe aborts on the first chunk by design, exfiltrating nothing — then a re-probe is rejected again because the arm is one-shot. Full-database transfer now requires a present human, not just possession of an old key. (Anonymized: device IDs and IPs redacted; the probe never writes the stream to disk. The field extract below is hand-built from the run; the underlying raw capture — e3_armed.pcap — still has to go through the anonymization pipeline before the binary itself is ever published.)

View raw capture — restore gate: unarmed reject, armed allow (probe aborts), re-probe reject (anonymized)
# Probe = the custom v2 sync-client emulator running with a still-trusted Ed25519 key
# (the same e3 tool that surfaced the non-blocking-socket bug). attacker 192.0.2.10 → victim 192.0.2.20.

# ── Run 1: source device NOT armed ──────────────────────────────────────────
→  Hello { did: d2be…1240f, eph_pub: <X25519 ephemeral pub> }
←  Ok    { name: <redacted>, eph_pub: <X25519 ephemeral pub>, challenge: <32-byte challenge> }
→  Auth  { signature: <Ed25519 sig over "moodhaven-hello-auth-v1:"||challenge> }  # valid — key still trusted
   # handshake completes: Ed25519 identity proven + X25519 ECDH session key derived
→  RestoreRequest { }                              # sent inside the AES-256-GCM frame
←  [GATE BLOCKED] Restore not authorized          # → UNARMED-REJECT
   <connection closed; zero database bytes left disk>

# ── Run 2: user armed restore via Settings → Devices (one-shot, 5-min window) ─
   <handshake identical to Run 1 — same trusted key, same ECDH>
→  RestoreRequest { }
←  [GATE OPEN] server began streaming DB: seq=0 total_bytes=249856   # → ARMED-ALLOW
   -> ABORTING (not exfiltrating)                  # probe aborts on the first chunk by design
   <nothing exfiltrated; the emulator never writes the stream to disk>

# ── Run 3: re-probe immediately after Run 2 (arm is one-shot) ────────────────
→  RestoreRequest { }
←  [GATE BLOCKED] Restore not authorized          # → UNARMED-REJECT (window already consumed)
For the technically inclined — trust-at-pairing is not authorization-to-exfiltrate

After the Ed25519 handshake, if the first message was a RestoreRequest, the server read the whole SQLCipher file off disk and streamed it — no prompt, no approval. The problem: a device paired once still holds a valid trusted keypair, so a lost/stolen/compromised peer can complete the handshake on its own and pull the full database whenever the source device’s sync server is up. Trust established at pairing time is not authorization to exfiltrate everything later.

Fix: mirror the pairing model — the serving device must explicitly arm restore (Settings → Devices → “Set up a new device”) for a single 5-minute, one-shot window. Unarmed RestoreRequests are rejected. Full-DB exfiltration now requires a live, present human on the source device, not just possession of an old key. Shipped in PR #133.


The thread running through all of this

Every finding above shares a shape: a fix is new code, and new code hasn’t been attacked yet. Twice that produced a critical bug living inside an earlier fix. But the sharpest example was still ahead — and it’s the one I least wanted to write.

Part 3 opens with it: the flagship “encrypted at rest” feature that, several rounds after I shipped it and everyone signed off, turned out to have never actually engaged on a single install. Then the rounds where my own attack tool started finding bugs by itself.

Share LinkedInXBlueskyReddit