Handover

FORENSICS CF26  ·  2026-09-29  ·  Chrome Local Storage / LevelDB overwritten-value recovery
CF26{c0ldch41n_dr4y4g3_0v3r_th3_d0cks}

We are handed a grabkit collection — a triage pull of the Chrome profile belonging to NORTHVANE\p.raman. Somewhere in it is a database password that an operator typed into an internal "add datasource" wizard and then, in a later save, took back out. The current state of the profile no longer contains it. Our job is to prove it was there and recover it.

On paper this is "find a string in a browser profile." In practice the value is buried under two independent layers of compression and only survives as a superseded database record that no normal LevelDB client will ever hand you. The whole challenge is knowing that "deleted from the UI" is not the same as "gone from disk," and being willing to parse the on-disk format yourself.

Table of Contents

  1. Where browser secrets live
  2. The decoy field
  3. Why grep and strings find nothing
  4. “Removed” is not “gone”
  5. Recovering overwritten values
  6. Decompressing the value
  7. The flag
  8. Pitfalls
  9. Key Lessons

1 Where Browser Secrets Live

A Chromium profile keeps per-site web-app state in Local Storage, which on disk is a LevelDB database at Local Storage/leveldb/. That directory is not a single file — it is a set of immutable sorted-string tables (*.ldb), an append-only write log (*.log), a MANIFEST and a CURRENT pointer.

The wizard we care about autosaves its draft there:

FieldValue
StoreLocal Storage/leveldb
Originhttps://atlas.northvane.internal
Keyatlas.setup.autosave (Atlas “add datasource” draft)

Local Storage keys on disk are framed as _<origin>\x00\x01<scriptkey>, and each value carries a one-byte type prefix: 0x00 means the payload is UTF-16LE, 0x01 means Latin-1/UTF-8. Hold onto that 0x00 — it is the first clue that the value is not plain text.

Background: Local Storage and LevelDB Local Storage is the browser API a web page uses to store key/value pairs, per origin, that persist across sessions — drafts, tokens, UI state. Chromium backs it with LevelDB, Google's embedded key/value store: a log-structured on-disk database made of immutable sorted tables (*.ldb) plus a write-ahead log (*.log).

2 The Decoy Field

Before anything else: the profile is stuffed with realistic-but-fake secrets, and some of them even rotate between snapshots to bait a diff. None are the flag.

ghp_… GitHub token · Bearer nv_ro_… · nvk_live_… Figma key · a JWT · Notion secret_… · SMTP password Kq9v-tarn-5528
Decode first, then check the format. Only a value in genuine CF26{…} format counts. A plausible-looking token is not a flag. This field is an explicit trap for analysts (and AI agents) who report the first high-entropy string they see.

3 Why grep and strings Find Nothing

The reflex move — grep -a CF26 / strings over the .ldb files — comes back empty. Even a UTF-16 grep finds nothing. That is because the value is hidden twice over:

.ldb sstable → data block (Snappy-compressed) → value bytes (lz-string compressToUTF16) → JSON with the DSN

4 “Removed” Is Not “Gone”

LevelDB is log-structured: an update to a key does not overwrite the old bytes, it writes a new version with a higher sequence number. The old version lingers in its original sstable until a compaction happens to rewrite that table. The autosave draft went through several saves:

SaveLocationContent of atlas.setup.autosave
step 1000138.ldb seq 1217{…"host":"db01…","user":"svc_wh"} — no secret yet
step 3000144.ldb seq 1570"dsn":"postgres://svc_wh:CF26{…}@db01…/analytics" — the flag
step 5000145.log seq 1778 (live)password stripped → "connectionRef":"nv-conn-7741"
A normal reader lies to you. plyvel — or any LevelDB client — returns only the newest value per key, i.e. the sanitized step-5 draft. The flagged step-3 version still exists physically inside 000144.ldb as an overwritten record. You must enumerate all sequence numbers, not just whatever a client resolves.

5 Recovering Overwritten Values

Instead of using a LevelDB client, you must use a forensic LevelDB raw-record reader — one that iterates every physical record across the .ldb tables and the .log instead of resolving a key to its latest value. Two standard ones are ccl_leveldb (from Alex Caithness's ccl_chromium_reader) and Google's dfindexeddb; both Snappy-decompress the blocks for you and return superseded and deleted versions:

import ccl_leveldb
db = ccl_leveldb.RawLevelDb("Local Storage/leveldb")
for rec in db.iterate_records_raw():
    if b"atlas.setup.autosave" in rec.user_key:
        print(rec.origin_file, rec.seq, rec.state, rec.value[:40])
# the SAME key comes back at seq 1217 (step 1), 1570 (step 3), 1778 (live) —
# every version, not just the one a merged read returns.
Under the hood Those readers just walk the sstable. If you want to understand them — or need zero dependencies — the layout is small:
# sstable structure, bottom-up:
footer        # fixed 48 bytes at EOF; points to the index block
 → index block   # one entry per data block (last-key + BlockHandle)
   → data block   # prefix-compressed key/value entries
      # trailer after block_size bytes: 1 type byte (0=none, 1=snappy, 2=zstd) + 4 CRC
      # type==1  ->  cramjam.snappy.decompress_raw(block)

# each entry's internal key = user_key + 8-byte trailer
# trailer = (seq << 8) | type   -> this is how you see EVERY version

Walk every data block, Snappy-decompressing the ones whose trailer byte is 1, then compare the same user-key across 000138.ldb, 000144.ldb and the .log. The diff between step 1 and step 3 is where the credential appears.

Something like...

import cramjam

def records(ldb):                      # yield (user_key, seq, value) from one .ldb
    buf = open(ldb, "rb").read()
    idx_off, idx_size = parse_footer(buf)          # footer = last 48 bytes → index BlockHandle
    for b_off, b_size in index_handles(buf, idx_off, idx_size):
        block = buf[b_off : b_off + b_size]
        if buf[b_off + b_size] == 1:                   # the 1 trailer byte after the block
            block = cramjam.snappy.decompress_raw(block)
        for ikey, val in entries(block):               # prefix-compressed entries
            yield ikey[:-8], int.from_bytes(ikey[-8:], "little") >> 8, val
            # internal key = user_key + 8-byte trailer; trailer = (seq<<8)|type

target = b"_https://atlas.northvane.internal\x00\x01atlas.setup.autosave"
for f in ("000138.ldb", "000144.ldb"):      # .log is the WAL (different framing; the readers handle it too)
    for ukey, seq, val in records(f):
        if ukey == target:
            print(f, seq, val[:40])             # diff versions → 000144.ldb / seq 1570 carries the DSN

parse_footer / index_handles / entries are just the varint decoders for the footer, index block and data block.

6 Decompressing the Value

Now unwrap Layer A. A value with the 0x00 (UTF-16) type prefix whose first code unit is large (here 0x1BE1) is an lz-string compressToUTF16 blob. The quick path is CyberChef's LZString Decompress operation — paste the value and it decodes. If you script it in Python instead, two gotchas bite:

# lzstring==1.0.4 decompressFromUTF16() is BROKEN (it does char-32 on a str).
# Call the internal _decompress directly:
from lzstring import LZString
s = # the raw UCS-2 code units of the value (see below)
out = LZString()._decompress(len(s), 16384, lambda i: ord(s[i]) - 32)

# Rebuild the JS string as raw UCS-2 units so lone surrogates survive;
# NEVER value.decode('utf-16-le','replace') (it mangles them):
s = ''.join(chr(u) for (u,) in struct.iter_unpack('<H', value_bytes))

Decompress every candidate value, then grep CF26 over the decompressed output. The hit is in atlas.setup.autosave, seq 1570, inside 000144.ldb.

7 The Flag

The recovered step-3 draft holds the full connection string with the password still in it:

"dsn": "postgres://svc_wh:CF26{c0ldch41n_dr4y4g3_0v3r_th3_d0cks}@db01.northvane.internal:5432/analytics"
CF26{c0ldch41n_dr4y4g3_0v3r_th3_d0cks}

Pitfalls

Key Lessons

Recover overwritten LevelDB values with a raw-record reader — or by parsing the sstable directly. footer → index block → each data block (Snappy-decompress when the trailer byte is 1) → walk the prefix-compressed entries; the 8-byte internal-key trailer (seq<<8)|type exposes every historical version of a key. This is the general recipe for “it was deleted” browser-storage cases.

Tools: ccl_leveldb / dfindexeddb (forensic raw-record readers — recover superseded versions, Snappy handled), plyvel (quick live-value / origin enumeration), cramjam.snappy + a small sstable walker (zero-dependency alternative), CyberChef LZString Decompress or lzstring via internal _decompress (compressToUTF16), grep/strings (triage — deliberately failed, which pointed to the decompression path).