Learn · Mobile forensics

WAL & deleted chats

In WAL mode SQLite never changes a page in place. It appends a new copy of the page to the -wal file. Older copies stay there until the log is reused, and they can include messages that have since been deleted.

1 How the write-ahead log works

Every transaction appends one frame per changed page to chat.db-wal. A frame is a 24-byte header plus a complete copy of the page. The last frame of a transaction is marked as a commit frame. The main file is not touched.

WAL header32 bytes
Frame 124-byte header + page image
Frame 224-byte header + page image
Frame 3 …24-byte header + page image
frame N starts at 32 + (N − 1) × (24 + page size) its page image at frame start + 24

To read page P, SQLite looks for the newest valid frame that holds page P (the -shm file is an index that makes this fast) and uses the main file only if there is none. A checkpoint copies the newest version of each page back into the main file. After that the WAL is either truncated, or restarted: new frames are written from the beginning again, over the old ones. Frames that are not overwritten stay in the file and are recognised as old by their salts.

3 Frames and which ones count

SQLite accepts a frame only if three things hold:

valid frame = salt-1 and salt-2 equal the WAL header's AND its checksum continues the chain from the header AND a commit frame follows (or it is one) before the chain breaks

The table below applies these rules to the sample WAL. Click a frame number to jump to its hex view.

Frame 1 is a commit frame for page 2 (the messages table): its commit size says the database has 4 pages after this transaction.

Frame 2 is another version of page 2, written by a later transaction. It is the newest valid frame for page 2, so this is the page a normal query sees.

Frame 3 holds page 3 (contacts), but its salt-1 is one lower than the header's: it was written before the last restart and survived because the new generation has only two frames so far. SQLite ignores it. Its content still matters to you: a stale frame can hold an older version of a page than anything else in the file. Here it happens to match the main file, because that version was checkpointed.

4 Recovering the deleted message

Put the three versions of page 2 side by side:

WhereRowids in the cellsMessage 9 ("Meet me at the north entrance …")
Main file, page 2 (last checkpoint)1, 2, 4, 5, 7absent; never checkpointed
WAL frame 1 (older version)1, 2, 4, 5, 7, 8, 9live row, with sender and timestamp
WAL frame 2 (current version)1, 2, 4, 5, 7, 8deleted, text left in the unallocated space

Sam sent two messages a minute apart. The second was deleted before any checkpoint, so the main file never saw it, and the current page only has its remains. Frame 1 still holds the complete row: contact, direction, timestamp and text. A normal query returns only message 8:

SELECT on the main file only vs. with the WAL (builder output)
WAL frame list (builder output)

Try this: in frame 1's page image, find cell 7 and read its sent_at value. Then find the same text in frame 2: which field of the page header tells you that those bytes are no longer part of any cell? And why does the deleted cell sit in the unallocated space rather than in a freeblock?

Report it carefully: "the WAL contains an earlier version of page 2 in which a message with rowid 9 exists" is a finding. "The user deleted message 9" is an interpretation: the app could also have removed it, e.g. when a message expired. Keep the two apart, and quote frame number and offset.

5 Pitfalls

6 Try it yourself

Answer from the hex views and the frame table above. Numbers can be typed in decimal or hex (0x…).

At which byte offset of the WAL file does the header of frame 3 start?

Take the page size from the WAL header (offset 8, 4 bytes, big-endian) and use the formula in section 1.

WAL header bytes 8–11: 00 00 04 00 = 1,024. Each frame is 24 + 1,024 = 1,048 bytes.

32 + (3 − 1) × 1,048 = 2,128 = 0x850. Frame 3's hex view starts there.

Which 4 bytes does frame 3 store as its salt-1? Type them in file order.

Salt-1 is at offset 8 of the 24-byte frame header, after page number and commit size.

Frame 3 header at 0x850: 00 00 00 03 (page 3) | 00 00 00 04 (commit size) | 5A 17 00 01 (salt-1) | 0F 1E 2D 3C (salt-2) | two checksums.

The WAL header has salt-1 5A 17 00 02: one higher, because the log was restarted once after frame 3 was written.

In frame 2's page image, at which page offset does the cell content area begin?

The b-tree page header of a leaf page: type (1 byte), first freeblock (2), cell count (2), cell content start (2), fragmented bytes (1). The page image begins 24 bytes after the frame header.

Frame 2's page image starts 0D 02 F1 00 06 02 73 00. Cell content start = 02 73 = 627 (0x273).

In frame 1 the same field is 02 12 = 530: the cell of message 9 lay at page offsets 530–626. In frame 2 those bytes are below the content start, i.e. unallocated, which is why the text is still there but belongs to no cell.

Why does SQLite ignore frame 3?

Compare frame 3's commit size and salts with the WAL header, and check the "Frame status" row of its hex view.

Frame 3's commit size is 00 00 00 04, so it is a commit frame, and the database has 4 pages. But its salts are 5A 17 00 01 / 0F 1E 2D 3C, while the header has 5A 17 00 02 / 4B 5A 69 78. The frame belongs to an earlier generation of the log, so SQLite stops before it.

These are the 24 bytes of frame 2's header (file offset 0x438). Edit them and watch the decoded header below:

  • Change the last byte of salt-1 (at 0x443) from 02 to 01. The frame now looks stale. If this were the real file, which version of page 2 would SQLite read, and would message 9 be visible?
  • Reset, then set the commit size (last byte at 0x43F) from 04 to 00. The frame is no longer a commit frame, and both checksums fail.
  • The salt edit left the checksums alone, the commit size edit broke them. Why? Which bytes of a frame does the checksum cover?