\n
Skip to contentRealm is an object database for mobile applications. It stores objects directly rather than rows in tables, which is why developers chose it, and it is why a SQLite tool opens a Realm file and shows nothing.
MongoDB acquired Realm in 2019 and later wound down the hosted synchronisation service. That did not remove Realm from devices. The local database is still open source, applications built on it are still installed, and the files are still being written. Imgur and Life360 are two you are likely to meet.
Realm is also the format where a deleted object has the best chance of coming back, because of a design decision the developers made for performance and never intended as a forensic gift.
A Realm database is a single .realm file, usually accompanied by .realm.lock, a .realm.management folder and sometimes a .realm.note. The data is in the .realm file. The others are coordination between processes.
The file opens with a 24 byte header, and the identifying bytes are not at the start. They sit at offset 16.
00 top_ref[0] 8 bytes, little-endian 08 top_ref[1] 8 bytes, little-endian 10 54 2D 44 42 "T-DB" the magic 14 format_version[0] 1 byte 15 format_version[1] 1 byte 16 reserved 1 byte 17 flags bit 0 selects the active top_ref
Everything after the header is a tree of arrays, and every array announces itself with the same four bytes. When Realm flushes an array to disk it replaces the in-memory allocator capacity with a fixed marker, so the file is full of literal capital A characters.
41 41 41 41 "AAAA" the on-disk marker FF flags: inner node, has refs, width ?? ?? ?? element count, 24-bit BIG-endian
Two details there catch people out. The element count is big-endian while the header's own pointers are little-endian. And arrays always begin on an 8 byte boundary, which means a scan for these markers can step eight bytes at a time rather than one.
Look at the header again. There are two root pointers, not one, and a flag that says which is active.
This is how Realm commits safely. It never modifies existing data. It writes the new state into free space, then flips one bit in the header to point at it. If power fails mid-write, the old pointer is still the active one and the database is intact. The technique is called copy-on-write, and the two slots alternate.
The forensic consequence is that the inactive slot still points at a complete, valid, previous committed state of the entire database. Not a fragment. A whole earlier version, reachable by following one pointer the running application no longer uses.
How much earlier depends on write activity. On a busy database it is the state one commit ago. On a database that has been quiet since the events you care about, it can be considerably older.
flags & 0x01 == 0 → top_ref[0] is live top_ref[1] is the previous state flags & 0x01 == 1 → top_ref[1] is live top_ref[0] is the previous state
You can compare two committed states of the same database from a single file, with no backup, no shadow copy and no second acquisition. Differences between them are changes the application made, and they are readable rather than inferred.
The same principle appears in SQLite as the write-ahead log, where superseded page images survive alongside the current ones. See Write-Ahead Log Viewer.
Not as a row. Realm stores each class as a cluster tree, and the leaves of that tree are where object data lives. A leaf holds at most 256 objects. Larger classes grow an inner tree above them.
Inside a leaf, the layout is the part that matters for recovery. Slot 0 holds the object keys. Every slot after it is a pointer to a separate array holding one column for every object in that leaf.
slot 0 → object keys (or an inline count) slot 1 → column 1 array all objects' first field slot 2 → column 2 array all objects' second field slot 3 → column 3 array …in schema order
An object is therefore not stored in one place. It is spread across as many arrays as it has fields, and it is assembled by reading the same position out of each. That is column-major storage, and it is the reason recovery behaves the way section five describes.
Realm has eighteen column types. Most are familiar: Int, Bool, String, Binary, Float, Double, Decimal128, Timestamp, UUID, ObjectId and Mixed. Four are not, and they are the ones worth knowing.
| Type | What it holds | Why it matters |
|---|---|---|
Link | A direct pointer to another object | A real relationship, not a foreign key you have to join |
LinkList | An ordered list of pointers | Membership and order are both recorded |
BackLink | The reverse of a link, maintained automatically | Shows what pointed at an object, including from classes you were not looking at |
TypedLink | A pointer that also records the target class | Resolvable without knowing the schema in advance |
Links are the reason a Realm file can answer questions a flat export cannot. A message object points at its conversation and at its sender. Follow the backlinks from a person and you get everything that referenced them, without writing a join or guessing at a naming convention.
Found by walking from the active root pointer. This is the live database.
The allocator itself marked this space as released. Higher confidence.
A valid array header that nothing points at. A previous version, or a deletion.
A later write reused the space. These are gone, and saying so is part of the job.
For the same reason the previous version survives. Realm does not edit in place, so deleting an object does not clear its bytes. It removes the object from the new version of the tree and releases the space it occupied.
Released is not erased. The bytes stay exactly where they were, still carrying their array markers, until some later write happens to need that space and reuses it.
Finding them is a scan, not a carve in the traditional sense. Walk every array reachable from the active root pointer and record where you went. Then scan the whole file on eight byte boundaries for array markers. Anything the scan finds that the walk did not reach is a candidate, and these are structured arrays with intact headers rather than fragments pulled out of slack.
The free list makes the result sharper. Arrays the allocator explicitly released are stronger candidates than orphans found by scanning alone, because an orphan can also be a leftover from an older file format version or an unreferenced stale snapshot.
This is where column-major storage stops being a design note and starts deciding your result. Because each column is its own array, the columns of a deleted object are released as separate blocks of different sizes, and the allocator reuses those sizes at different rates.
| Column type | Survival | Why |
|---|---|---|
Int Double Bool Timestamp | Good | Fixed width arrays. The released block lands in a size class that later small allocations rarely match, so the bytes sit untouched across many commits. |
String | Poor | Realm packs strings densely, and the released block is usually the same size as the next small string the application writes. Reuse is often within two or three commits. |
Binary | Mixed | Depends entirely on size. A large blob occupies a block nothing else fits into. |
The practical effect is that a recovered object frequently comes back with its numbers and its times intact and some of its text missing. Coordinates survive. Timestamps survive. The label describing what happened there is often the field that was reused.
A partial reconstruction is the honest result. The fields that decoded are evidence. The fields that did not are unavailable because a later write reused their space, and that is a statement you can make and defend. A tool that fills those gaps with plausible values is doing something else.
Recovering bytes is the easy half. Saying which object they belonged to is where a Realm analysis goes wrong, and the failure is quiet enough that nobody notices until the report is challenged.
Object keys in a cluster leaf are stored relative to a base value that comes from the leaf's parent in the tree. An orphaned leaf has lost its parent. Its keys are therefore relative to a base you cannot determine.
Read the recovered keys as if they were absolute and compare them against the live objects. Key 8 in the orphan is matched to key 8 in the live database.
Every comparison is against the wrong object. The tool now reports that a field changed, or that an object was deleted, and names a live object that had nothing to do with it.
The output looks entirely reasonable. Real keys, real field names, real values, a plausible history. Nothing signals that the two sides were never the same object.
Anchor on application level identity instead. Use the primary key value when the class has one, full content matching when it does not, and report the row as an unattributed fragment when neither settles it.
Before claiming a recovered object was deleted, require more than its primary key to decode. A reused block can produce a clean, plausible key that matches nothing live, which looks exactly like a deletion and is not one. One readable field is not enough to support the claim.
There is a related trap in the values themselves. If a recovered object's column array is still reachable from the live root pointer, the value you decode cannot be attributed to the recovered object as independent evidence. Either the two share an unchanged array through copy-on-write, or a newer object reused it. Both cases mean the value belongs to the live database, not to the deleted one.
Firefly reads the Realm file directly rather than through the Realm SDK, which is what makes the previous version and the orphaned arrays visible at all. An SDK shows you the live database, because that is its purpose.
Recovery runs in three passes. Walk everything reachable from the active root pointer. Collect the allocator's free list. Then scan the file on eight byte boundaries for array markers and treat what the walk never reached as candidates.
Candidates are matched against the live schema by shape, checking that each slot holds a leaf consistent with the type that column should be. Above a match threshold, the same decoder that reads the live grid reads the candidate, so a recovered value and a live value are produced by identical code.
Each result is then classified, and the fourth classification is the one that matters.
Deleted the object is absent from the live database Modified it exists live, with different field values Snapshot an earlier version of a live object Fragment bytes decoded, identity not established
When the recovered bytes cannot be tied to a specific object, Firefly says so and stops. It does not compare raw keys against live objects, because that fabricates matches and produces confident claims about the wrong record.
The active root pointer and the previous one, from the same file.
Allocator-released arrays are distinguished from orphans found by scanning.
Fields whose arrays were reused decode as unavailable, never as a guess.
Binary columns and orphan blobs are labelled by signature: PNG, JPEG, HEIC, PDF and more.
Related: SQLite Forensic Analysis, LevelDB and IndexedDB and Apple Biome and SEGB.
Realm is a different format, not a different discipline. Read the header, find the root, learn what marks a release, and know the difference between a value you recovered and a value you can attribute. Those habits come from working a format you can check your answer against, which is why the training is built on SQLite.
The foundation the rest of it stands on. Record headers, serial types, varints, recovery and validation, taught on real training files you work on in your own tools.
A shorter course on transaction files. Realm's two root pointers and SQLite's write-ahead log solve the same problem, and both leave a superseded state behind.
Certified SQLite Forensics also runs live online and on-site by arrangement. See the formats.
Last updated 7 September 2026