Home > Blog > Onapookkalam / ഓണപ്പൂക്കളം - DFIR report
Onapookkalam / ഓണപ്പൂക്കളം - DFIR report
Posted: 2025-01-04 | Author: Siddharth | Category: CTF
Date: 2025-01-04 | Tags:

Onapookkalam Challenge Writeup

Challenge Overview

While preparing for Onapookkalam, notes were recorded on a mobile device. The phone was suspected to be tampered with, potentially having data modified or deleted.

Objectives: - Note Retrieval: Extract specific information from saved notes (flagPart1) - Database Analysis: Identify a specific string that was modified and deleted from Realm DB (flagPart2)

Required Output Format: flag{flagPart1_flagPart2}

---

Investigation Process

Initial File Extraction

After extraction, numerous files were discovered:

Extracted Files

Notes Recovery

The challenge focused on recovering notes. During the search for notes files, one file was found with a base64-encoded name:

Base64 Named File

This directory contained the following files:

Directory Contents

Flutter App Analysis

Further exploration revealed this was about recovering data from a Flutter app:

Flutter App Evidence 1
Flutter App Evidence 2

Source Code Discovery

Source Code

Since Flutter uses Python (via Flet framework), i searched for Python files containing the APK logic. The main application file was found:

Python File Location

Encryption Implementation

The discovered Python code revealed the notes app's encryption scheme:

import flet as ft
import datetime, random, os, string

def key_scheduling(key):
    sched = [i for i in range(0, 256)]
    i = 0
    for j in range(0, 256):
        i = (i + sched[j] + key[j % len(key)]) % 256
        tmp = sched[j]
        sched[j] = sched[i]
        sched[i] = tmp
    return sched

def stream_generation(sched):
    stream = []
    i = 0
    j = 0
    while True:
        i = (1 + i) % 256
        j = (sched[i] + j) % 256
        tmp = sched[j]
        sched[j] = sched[i]
        sched[i] = tmp
        yield sched[(sched[i] + sched[j]) % 256]

def encrypt(text, key):
    text = [ord(char) for char in text]
    key = [ord(char) for char in key]
    sched = key_scheduling(key)
    key_stream = stream_generation(sched)
    ciphertext = ""
    for char in text:
        enc = str(hex(char ^ next(key_stream))).lower()
        ciphertext += enc
    return ciphertext

def storeData(page, content):
    app_data_path = os.getenv("FLET_APP_STORAGE_DATA")
    fileName = f"{datetime.datetime.now().strftime("%d%m%Y%H%M%S%f")}"
    my_file_path = os.path.join(app_data_path, fileName)
    key = "".join(
        random.choice(string.ascii_letters + string.digits) for _ in range(16)
    )
    encText = encrypt(content, key)
    page.client_storage.set(fileName, key)
    with open(my_file_path, "w") as f:
        f.write(encText)
    page.open(ft.SnackBar(ft.Text(f"File saved to App Data Storage!")))

def main(page: ft.Page):
    def saveNote(e):
        data = inputBox.value
        if data != "":
            storeData(page, data)
        else:
            page.open(ft.SnackBar(ft.Text("Empty content!")))
    
    appBar = ft.AppBar(title=ft.Text("Notes App"))
    inputBox = ft.TextField(hint_text="Enter some text...", multiline=True, min_lines=3)
    page.appbar = appBar
    page.add(inputBox)
    page.add(
        ft.ElevatedButton(text="Save Note", on_click=saveNote, style=ft.ButtonStyle())
    )

ft.app(main)

the application allows users to create and save encrypted notes with the following workflow: When a user enters text and clicks "Save Note", the application generates a unique filename based on the current timestamp (format: DDMMYYYYHHMMSSμs). It then creates a random 16-character encryption key consisting of letters and digits. The note content is encrypted using a custom implementation of the RC4 stream cipher algorithm, which involves two main phases: key scheduling (which initializes a 256-byte state array based on the encryption key) and stream generation (which produces a pseudo-random keystream). The encryption process XORs each character of the plaintext with the corresponding keystream byte and converts the result to hexadecimal format. The encrypted text is stored in a file within the app's data directory, while the encryption key is separately stored in the client storage using the filename as the identifier. This design means that to decrypt any saved note, you need both the encrypted file and its corresponding encryption key from the client storage, making the encryption key essential for data recovery.

so I wrote a Python script that: 1. Implemented the RC4 decryption algorithm (mirroring the encryption code) 2. Parsed the hex-encoded ciphertext 3. XORed each byte with the keystream to recover plaintext

#!/usr/bin/env python3
"""Simple decryption script for the notes challenge"""

import os

def key_scheduling(key):
    sched = [i for i in range(0, 256)]
    i = 0
    for j in range(0, 256):
        i = (i + sched[j] + key[j % len(key)]) % 256
        tmp = sched[j]
        sched[j] = sched[i]
        sched[i] = tmp
    return sched

def stream_generation(sched):
    i = 0
    j = 0
    while True:
        i = (1 + i) % 256
        j = (sched[i] + j) % 256
        tmp = sched[j]
        sched[j] = sched[i]
        sched[i] = tmp
        yield sched[(sched[i] + sched[j]) % 256]

def decrypt(ciphertext, key):
    key = [ord(char) for char in key]
    sched = key_scheduling(key)
    key_stream = stream_generation(sched)
    
    # Parse hex values from format: 0xXX0xYY0xZZ...
    hex_values = []
    i = 0
    while i < len(ciphertext):
        if ciphertext[i:i+2] == '0x':
            j = i + 2
            while j < len(ciphertext) and ciphertext[j:j+2] != '0x':
                j += 1
            hex_val = ciphertext[i:j]
            hex_values.append(int(hex_val, 16))
            i = j
        else:
            i += 1
    
    plaintext = ""
    for encrypted_byte in hex_values:
        decrypted_byte = encrypted_byte ^ next(key_stream)
        plaintext += chr(decrypted_byte)
    
    return plaintext

# Keys from client storage
KEYS = {
    "15052025175732777833": "1OmIyq5YT50YlWB0",
    "15052025175747121993": "oIdeaSz9iySlAmKJ",
    "15052025175936114230": "YKnQqnrzfTIM9HLu",
    "15052025180002742685": "RhjZrO2JGKQLamST",
    "15052025175724299736": "SkZFksurgEq3Tdhe",
    "15052025175950593733": "M52JUgdj9r6kkVg4",
    "15052025175944264611": "lunMORQQjKhX9u5H",
}

print("\n" + "="*70)
print("DECRYPTING ALL NOTES")
print("="*70 + "\n")

for filename, key in KEYS.items():
    if os.path.exists(filename):
        print(f"\n{'='*70}")
        print(f"File: {filename}")
        print(f"Key: {key}")
        print(f"{'='*70}")
        
        with open(filename, 'r') as f:
            ciphertext = f.read()
        
        try:
            plaintext = decrypt(ciphertext, key)
            print(f"Decrypted content:\n{plaintext}")
        except Exception as e:
            print(f"Error decrypting: {e}")
        
        print(f"{'='*70}\n")
    else:
        print(f"File not found: {filename}")

print("\n✓ Decryption complete!\n")
15052025175732777833: "well this was easy"
15052025175747121993: "i'll give you the first part of the flag :)"
15052025175936114230: "first part of the flag: w311_7h47_p4r7_w45_345y"
15052025180002742685: "f4k3_f14g"
15052025175724299736: "hello there"
15052025175950593733: "boop"
15052025175944264611: "hehehe"

After an hour of going through all the files i found this snapshots folder ( i tried organising the files by images , txt , png etc and came across this title 0

Snapshots Folder
App Screenshot

After analyzing the Android filesystem dump, we identified two key applications in the /data/app/ directory: 1. com.sp3p3x.notesapp - A notes application (already analyzed for flagPart1) 2. com.example.accessmydata - The target application for flagPart2

The challenge description hinted at finding deleted data from a "realm db", and a screenshot found in /data/system_ce/0/snapshots/ confirmed the existence of an app that downloads and decrypts a realm file

The Access My Data APK was found at:

/data/app/~~hvxKjg-YJ69UXXtnoCmWcw==/com.example.accessmydata-rkCVbU6Yb-jHyAaRmU8b0Q==/base.apk

Terminal output:

$ find /data/app -name "*accessmydata*" -name "*.apk"
/data/app/~~hvxKjg-YJ69UXXtnoCmWcw==/com.example.accessmydata-rkCVbU6Yb-jHyAaRmU8b0Q==/base.apk

$ ls -lh base.apk
-rw-r--r-- 1 muffin muffin 21M May 15  2025 base.apk

flutter applications are unique in the Android ecosystem because they don't use traditional Java/Kotlin code. Instead, they compile Dart code to native machine code using Ahead-Of-Time (AOT) compilation. This results in two important native libraries:

1. libflutter.so - The Flutter engine (shared across all Flutter apps) 2. libapp.so - The application-specific code compiled from Dart

The Dart code is embedded within libapp.so as a snapshot, which contains: - Compiled Dart VM bytecode - AOT-compiled native code - An Object Pool with string literals and constants - Type information and metadata

This compilation method makes traditional APK analysis tools (like jadx or dex2jar) useless, as there's no DEX bytecode to decompile:(

We extracted the APK to access the native libraries:

$ mkdir apk_extracted
$ unzip -q base.apk -d apk_extracted/
$ ls -lh apk_extracted/lib/arm64-v8a/
total 16M
-rw-r--r-- 1 muffin muffin 4.7M Jan  1  1981 libapp.so
-rw-r--r-- 1 muffin muffin  11M Jan  1  1981 libflutter.so

The libapp.so file (4.7 MB) contains all the application logic we need to reverse engineer.

blutter, a portmanteau of "B(l)utter" that plays on both buttering and Flutter, is a reverse engineering tool built specifically with Flutter applications in mind. Unlike traditional disassemblers that treat Flutter binaries as opaque native code, Blutter understands the internal structure of the Dart VM snapshot embedded inside libapp.so. It is able to parse this snapshot, extract the object pool that contains constants, strings, and type information, and use that data to reconstruct readable pseudo-Dart code complete with symbols and function names. In addition to this higher-level reconstruction, Blutter can also generate annotated assembly listings , and even produce Frida scripts to assist with dynamic analysis and runtime instrumentation.(TODO for me to check this out one day )

When we run Blutter, it performs several automated steps:

$ python3 blutter.py /path/to/apk_extracted/lib/arm64-v8a /path/to/blutter_output

Blutter's first move is reading libflutter.so to figure out exactly which Dart SDK version compiled the app cuz the Dart VM snapshot format isn't stable across versions and we parse it with the wrong layout and you get garbage. Here it pegs the app at Dart 3.7.2.

if Blutter doesn't already have an analysis backend for that version, it builds one on the spot ,it pulls the Dart SDK source at that exact tag, compiles the VM runtime into a static library for the target arch, then links its own analysis components against it,expect several minutes of build output,it's compiling a chunk of the Dart VM from scratch

with that done, it digs into libapp.so. It locates the embedded snapshot, pulls the object pool (constants and string literals), reconstructs the class hierarchy, and maps out function entry points. the output is annotated assembly, reconstructed class and function definitions, inline constants, and control flow per Dart library,it also spits out a Frida script if you want to instrument things at runtime.

Examining the Output Structure

$ ls -la blutter_output/
total 1524
drwxr-xr-x 1 muffin muffin     86 Dec 30 18:56 .
drwxr-x--x 1 muffin muffin    894 Dec 30 18:56 ..
drwxr-xr-x 1 muffin muffin    478 Dec 30 18:56 asm/              # Assembly listings
-rw-r--r-- 1 muffin muffin 221632 Dec 30 18:56 blutter_frida.js  # Frida script template
drwxr-xr-x 1 muffin muffin     56 Dec 30 18:56 ida_script/       # IDA Pro integration
-rw-r--r-- 1 muffin muffin 376330 Dec 30 18:56 objs.txt          # Nested object dump
-rw-r--r-- 1 muffin muffin 957372 Dec 30 18:56 pp.txt            # Complete Object Pool

$ ls blutter_output/asm/ | head -10
accessmydata/
async/
characters/
collection/
.dart_tool/
dio/
encrypt/
flutter/
...

The asm/ directory contains subdirectories for each Dart package used in the app. Our target is accessmydata/main.dart.

Locating the Main Application Code

$ ls -lh blutter_output/asm/accessmydata/
total 52
-rw-r--r-- 1 muffin muffin 51467 Dec 30 18:56 main.dart

The main.dart file contains 51 KB of annotated assembly code representing the application's core logic.

Blutter generates assembly listings that interleave ARM64 assembly with Dart-level semantic comments. Here's an example of the format:

_ _decryptData(/* No info */) {
  // addr: 0x2db4c0, size: 0x174
  // 0x2db4c0: EnterFrame
      0x2db4c0: stp             fp, lr, [SP, #-0x10]!
      0x2db4c4: mov             fp, SP
  // 0x2db4c8: AllocStack(0x30)
      0x2db4c8: sub             SP, SP, #0x30

Explanation: - Function signature: _ _decryptData(/ No info /) - Dart function name - Address and size: addr: 0x2db4c0, size: 0x174 (372 bytes) - Comments: // 0x2db4c0: EnterFrame - high-level operation - Assembly: 0x2db4c0: stp fp, lr, [SP, #-0x10]! - actual ARM64 instruction - PP references: [PP, #0x90] - references to the Object Pool

Extracting the Encryption Keys

the download URL:

    // 0x304ed8: r16 = "https://github.com/chicken-jockeyy/confidentialdb/raw/refs/heads/main/enc.bin"
    //     0x304ed8: add             x16, PP, #0xd, lsl #12  ; [pp+0xd348] "https://github..."
    /     0x304edc: ldr             x16, [x16, #0x348]

these literal strings are stored in the Object Pool (PP) and loaded into registers for use.

    // Load key part 1 into x16
    // 0x2db4f8: r16 = "04e0d32be85f3b42"
    //     0x2db4f8: add             x16, PP, #0xb, lsl #12
    /     0x2db4fc: ldr             x16, [x16, #0x5a0]

    // Load key part 2 into x30 (lr)
    // 0x2db500: r30 = "7173047a9d6574e5"
    //     0x2db500: add             lr, PP, #0xb, lsl #12
    /     0x2db504: ldr             lr, [lr, #0x5a8]

    // Push both parts onto stack
    // 0x2db508: stp             lr, x16, [SP]

    // 0x2db50c: r0 = +()
    //     Concatenate the two strings: "04e0d32be85f3b427173047a9d6574e5"

Then the code creates a reversed iterable:

    // 0x2db540: r0 = ReversedListIterable()
    //     0x2db540: bl              #0x2bee38  ; AllocateReversedListIterableStub
    // 0x2db558: r0 = join()
    //     0x2db558: bl              #0x28e4f8  ; [dart:_internal] ListIterable::join

This reverses the concatenated key string character by character: - Original: 04e0d32be85f3b427173047a9d6574e5 - Reversed: 5e4756d9a740371724b3f58eb23d0e40

    // 0x2db564: r0 = Key()
    //     Create an encryption key object

    // 0x2db578: r0 = AES()
    //     0x2db578: bl              #0x304864  ; AllocateAESStub -> AES

    // 0x2db588: r0 = AES()
    //     0x2db588: bl              #0x2db870  ; [package:encrypt/encrypt.dart] AES::AES
    //     Initialize AES cipher with the reversed key

To find which AES mode is used, we examined the Object Pool dump:

$ grep -i "ecb\|AESMode" blutter_output/pp.txt
[pp+0xb5d8] Obj!AESMode@5032b1 : {
    off_10: "ecb"
}
[pp+0xb5f0] Map<AESMode, String>(8) {
  Obj!AESMode@503291: "CBC",
  Obj!AESMode@503271: "CFB-64",
  Obj!AESMode@503251: "CTR",
  Obj!AESMode@5032b1: "ECB",
  Obj!AESMode@503231: "OFB-64/GCTR",
  Obj!AESMode@503211: "OFB-64",
  ...
}

Cross-referencing with the assembly:

    // 0x2db880: r4 = Instance_AESMode
    //     0x2db880: add             x4, PP, #0xb, lsl #12  ; [pp+0xb5d8] Obj!AESMode@5032b1
    /     0x2db884: ldr             x4, [x4, #0x5d8]

The constant at pp+0xb5d8 is Obj!AESMode@5032b1, which corresponds to "ECB" mode in the enum map.

#### Step 4: Decryption and Base64 Decode

    // 0x2db5b8: r0 = decrypt()
    //     0x2db5b8: bl              #0x2db6a0  ; [package:encrypt/encrypt.dart] Encrypter::decrypt
    //     Decrypt the encrypted data using AES-ECB

    // 0x2db5c0: r0 = base64Decode()
    //     0x2db5c0: bl              #0x2db634  ; [dart:convert] ::base64Decode
    //     Decode the decrypted Base64 string to binary

1. Key Preparation:

   key_part1 = "04e0d32be85f3b42"
   key_part2 = "7173047a9d6574e5"
   combined = "04e0d32be85f3b427173047a9d6574e5"  (32 characters)
   reversed = "5e4756d9a740371724b3f58eb23d0e40"  (32 characters → 16 bytes UTF-8)

2. Download encrypted file:

   URL: https://github.com/chicken-jockeyy/confidentialdb/raw/refs/heads/main/enc.bin
   Size: 12,582,928 bytes

3. AES-ECB Decryption:

   Cipher: AES-128 (128-bit key = 16 bytes)
   Mode: ECB (Electronic Codebook)
   Key: "5e4756d9a7403717" + "24b3f58eb23d0e40" (16 ASCII characters)
   Input: enc.bin (raw binary data)
   Output: Base64-encoded string

4. Base64 Decoding:

   Input: Base64 string (from AES decryption)
   Output: decrypted.realm (9,437,184 bytes)

---

Part 6: Implementing the Decryptor

Downloading the Encrypted File

$ curl -L -o enc.bin "https://github.com/chicken-jockeyy/confidentialdb/raw/refs/heads/main/enc.bin"
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 12288k  100 12288k   0     0  1988k      0   0:00:06  0:00:06 --:--:--  3087k

$ ls -lh enc.bin
-rw-r--r-- 1 muffin muffin 13M Dec 30 18:50 enc.bin

$ file enc.bin
enc.bin: data

The file is 12 MB of raw binary data (AES-encrypted).

Creating Cargo.toml for Rust Dependencies

[package]
name = "decrypt_realm"
version = "0.1.0"
edition = "2021"

[dependencies]
aes = "0.8"
base64 = "0.21"

Required crates: - aes: Provides AES block cipher implementation - base64: Handles Base64 encoding/decoding

Running the Rust Decryptor

$ cargo run --release
   Compiling aes v0.8.3
   Compiling base64 v0.21.5
   Compiling decrypt_realm v0.1.0
    Finished release [optimized] target(s) in 3.42s
     Running `target/release/decrypt_realm`



Key part 1: 04e0d32be85f3b42
Key part 2: 7173047a9d6574e5
Combined key: 04e0d32be85f3b427173047a9d6574e5
Reversed key: 5e4756d9a740371724b3f58eb23d0e40

Encrypted data size: 12582928 bytes
 AES ECB decryption complete
 Base64 decoding complete

Realm database saved as 'decrypted.realm'
 File size: 9437184 bytes (9 MB)
 File appears to be a valid Realm database

Verifying the Decrypted File

$ ls -lh decrypted.realm
-rw-r--r-- 1 muffin muffin 9.0M Dec 30 19:15 decrypted.realm

$ file decrypted.realm
decrypted.realm: data

$ hexdump -C decrypted.realm | head -20
00000000  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00000020  54 4c 44 42 00 00 00 00  0f 72 65 61 6c 6d 5f 76  |TLDB.....realm_v|
00000030  65 72 73 69 6f 6e 00 00  14 00 00 00 01 00 00 00  |ersion..........|

Why the Key is Reversed

Looking at the Dart code pattern:

String keyPart1 = "04e0d32be85f3b42";
String keyPart2 = "7173047a9d6574e5";
String combined = keyPart1 + keyPart2;
String reversed = combined.split('').reversed.join('');

obfuscation, mostly. if someone pulls the APK and dumps strings, they won't immediately see an encryption key sitting in plaintext

The reversed key 5e4756d9a740371724b3f58eb23d0e40 is exactly 32 ASCII characters. As UTF-8 bytes, that's lowkey a 128-bit AES key

with our Python decryption script complete ,lemme try to decrypt it

$ python3 decrypt_realm.py
Realm database saved as 'decrypted.realm'
File size: 9437184 bytes

Verification of the decrypted file:

$ ls -lh decrypted.realm
-rw-r--r-- 1 muffin muffin 9.0M Dec 30 19:21 decrypted.realm

$ file decrypted.realm
decrypted.realm: data

$ hexdump -C decrypted.realm | head -10
00000000  78 96 88 00 00 00 00 00  c8 36 88 00 00 00 00 00  |x........6......|
00000010  54 2d 44 42 09 09 00 00  41 41 41 41 0e 00 00 05  |T-DB....AAAA....|
00000020  70 6b 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |pk..............|
00000030  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 1d  |................|
00000040  6d 65 74 61 64 61 74 61  00 00 00 00 00 00 00 00  |metadata........|
00000050  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 17  |................|
00000060  63 6c 61 73 73 5f 52 65  61 6c 6d 54 65 73 74 43  |class_RealmTestC|
00000070  6c 61 73 73 30 00 00 00  00 00 00 00 00 00 00 0a  |lass0...........|
00000080  63 6c 61 73 73 5f 52 65  61 6c 6d 54 65 73 74 43  |class_RealmTestC|
00000090  6c 61 73 73 31 00 00 00  00 00 00 00 00 00 00 0a  |lass1...........|

few things to note from the hex dump:

  • Bytes 0x10–0x13: 54 2d 44 42 = "T-DB" — the Realm magic header, confirming decryption succeeded
  • Bytes 0x14–0x17: 41 41 41 41 = "AAAA" — marks unused/free regions
  • Offset 0x40: "metadata" — the database metadata block
  • Offset 0x60: "class_RealmTestClass0" — first schema class
  • Offset 0x80: "class_RealmTestClass1" — second schema class

before going ballsdeep , a little intro to realeam database working internals , that i got to know after reading some chinneese papers on forensics research (never reccomend)

Realm database architecture

Realm is a mobile-first database built for iOS and Android, it stores data as objects directly in a custom binary format. A few properties that matter for forensics

  • Object-based model: records are objects, not table rows
  • Custom binary format: no SQL layer, parsed with knowledge of the spec
  • MVCC (Multi-Version Concurrency Control): multiple versions of data coexist
  • Zero-copy architecture: the database file is memory-mapped directly from disk

Muffin why doess deleted data sticks around :pepe shock ?

When you delete a record in Realm, it doesn't get wiped the database just stops pointing to it. A new version of the state is written, and the old bytes sit in what's now "unused" space, waiting to be overwritten. If garbage collection hasn't run (and it often hasn't), those bytes are still fully intact on disk.Phew and good for us

To pull deleted records out, we use realm_recover. The prior research section below goes deeper, but the short version is: Realm doesn't erase deleted data, it just stops referencing it. realm_recover knows the binary layout well enough to scan those unreferenced regions and reconstruct objects from raw bytes the same technique used in Android forensics to recover data i think?

References

  • Smart Techniques to Extract the Deleted Data From the Android Application

https://www.sciencedirect.com/science/article/abs/pii/S2666281722000221

  • Stack Overflow discussion on restoring deleted Realm objects

https://stackoverflow.com/questions/52384845/how-to-restore-an-item-deleted-from-realm

  • Smart Techniques to Extract the Deleted Data From the Android Application (Academia.edu)

https://www.academia.edu/77798036/Smart_Techniques_to_Extract_the_Deleted_Data_Form_the_Android_Application

$ cd /tmp
$ git clone https://github.com/hyuunnn/realm_recover.git
Cloning into 'realm_recover'...
remote: Enumerating objects: 84, done.
remote: Total 84 (delta 0), reused 0 (delta 0), pack-reused 84
Receiving objects: 100% (84/84), 27.13 KiB | 1.51 MiB/s, done.
Resolving deltas: 100% (42/42), done.

$ ls /tmp/realm_recover/
LICENSE  objects.py  README.md  realm_recover.py  sample  util.py

The repo has four files worth knowing:

  • realm_recover.py — the main script you run
  • objects.py — handles Realm object parsing
  • util.py — binary analysis helpers
  • sample/ — a test database to try it on before touching your evidence

Running the Realm Recovery Tool

Execute the recovery tool on our decrypted realm database:

$ python3 /tmp/realm_recover/realm_recover.py --file decrypted.realm
[Tool runs silently, generating output files]

The tool doesn't produce console output but generates several analysis files:

$ ls -lh *.txt
-rw-r--r-- 1 muffin muffin    1705 Dec 30 19:23 compare_objects.txt
-rw-r--r-- 1 muffin muffin 1033382 Dec 30 19:23 data_storages.txt
-rw-r--r-- 1 muffin muffin 1798075 Dec 30 19:23 scan_all_objects.txt
-rw-r--r-- 1 muffin muffin 1117764 Dec 30 19:23 scan_unused_objects.txt

Understanding the output files

Four files got generated

  • scan_all_objects.txt (1.8 MB) every object in the database, active or deleted, sorted by offset and type
  • scan_unused_objects.txt (1.1 MB) objects no longer referenced by any active tree root, i.e. the deleted/orphaned ones. This is what we want gng
  • data_storages.txt (1.0 MB) raw binary dumps of storage regions
  • compare_objects.txt (1.7 KB) diff between TreeRootOffset01 and TreeRootOffset02, showing what changed between versions

The challenge description states the flag is in a "string which was modified and then deleted from the realm db". This means we need to search scan_unused_objects.txt.

Search for UUID-pattern strings (the typical format for test data in Realm databases):

$ grep -E "[0-9A-F]{8}-[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{12}" scan_unused_objects.txt

The pattern matches standard UUID format (8-4-4-4-12). Note that [0-9A-Z] is intentionally broader than pure hex the flag parts use characters outside the normal hex range, so a strict [0-9A-F] would miss them.

Result (truncated for clarity):

Offset: 0x800000, Type: 0x11, Count: 534043, Object: [
  ...
  b'$97990266-4B4D-4306-A1EB-0FA8B4025844\x06\x04\x02',
  b'$48E1F6A5-C826-4244-B1CF-008E451606F3\r\x01\x01\x01',
  ...
  b'$5P0BF5BC-5AA1-4790-A05F-A2RDCBALDB49\x06\x04\x02\x03',
  ...
]
Deleted Data

Among hundreds of valid UUIDs, one stands out:

5P0BF5BC-5AA1-4790-A05F-A2RDCBALDB49

This is flagPart2.

so why did this work? well think about what mvcc actually does actually,instead of overwriting a record in place, realm writes a new version and leaves the old one sitting in what's now "unreferenced" space. The active tree no longer points to it,but the bytes are still there on disk. realm_recover scans those unreferenced regions, and because it knows Realm's binary object layout, it can parse those leftover structures and pull out meaningful records.

in this case the deleted UUID was sitting at offset 0x800000 in a region marked unused. The object had been cut from the active tree, but nothing had overwritten it yet — so its binary representation was completely intact. No garbage collection, no overwrite, clean recovery.

Complete Flag:

bi0sctf{w311_7h47_p4r7_w45_345y_5P0BF5BC-5AA1-4790-A05F-A2RDCBALDB49}

References and further reading

Start
Blog Reading
12:00 PM