Official Cheater CATF 2026 DFIR Writeup
A full reconstruction of an anonymous ChatGPT chat from a Windows memory image: this is a writeup for my DFIR challenge in CAT CTF

CATF 2026
Try the challenge →Hello everyone,
This time I am writing more of a methodology than a writeup. I wanted to make a simple and fun challenge after I was inspired by a research paper, so I present Cheater, my DFIR challenge for CATF 2026.
Description:
A university receives an unusually good assignment from a student whose earlier work was always incomplete. The student denies getting unauthorized help, so the laptop is confiscated.
Can we tell whether he is lying?
The challenge files are available from the primary download and Google Drive mirror.
| Challenge | Details |
|---|---|
| Name | Cheater |
| Category | DFIR / Windows memory forensics |
| Author | Immortal_ibr |
| Evidence | dump.mem |
| Flag format | CATF{Flag} |
Process triage: the first tempting mistake
I started with the active process list and then asked windows.cmdline about the
interesting children.
python3 vol.py -f dump.mem windows.pslist.PsList
python3 vol.py -f dump.mem windows.cmdline.CmdLine --pid 4080
python3 vol.py -f dump.mem windows.cmdline.CmdLine --pid 6612
python3 vol.py -f dump.mem windows.cmdline.CmdLine --pid 1472

| PID | Process | Recovered role | Start time (UTC) |
|---|---|---|---|
| 3064 | chrome.exe |
Chrome browser process | 10:50:05 |
| 1196 | chrome.exe |
network utility process | — |
| 6612 | chrome.exe |
renderer, client ID 129 | 16:11:04 |
| 1472 | chrome.exe |
renderer, client ID 133 | 16:11:13 |
| 3740 | Discord.exe |
Discord/Electron main process | 10:52:26 |
| 4080 | Discord.exe |
renderer, client ID 6 | 10:52:38 |
Looking at the running processes, I had two obvious hypotheses of how he could have cheated. The student could have asked a friend for the solution. That would require a communication channel, and Discord is the clearest candidate in this process list.
The other possibility is that he used an AI assistant or searched the web for help. I could look for a dedicated ChatGPT or Claude client, or for an AI CLI running in a terminal, but there is no reason the activity had to happen through either of them, it could all be inside a browser tab. That makes Chrome the second candidate.
At this stage, Discord and Chrome are the leads.
The command lines expose the renderer client IDs and Discord 1.0.9256. These details matter because
the client-side formats parsed below can change.
Chrome 3064 and Discord 3740 are the main processes, but the web documents live in their renderers.
I dumped all three candidate renderers:
python3 vol.py -f dump.mem -o "$OUT/renderer-6612" windows.memmap.Memmap --pid 6612 --dump
python3 vol.py -f dump.mem -o "$OUT/renderer-1472" windows.memmap.Memmap --pid 1472 --dump
python3 vol.py -f dump.mem -o "$OUT/discord-renderer-4080" windows.memmap.Memmap --pid 4080 --dump
With --dump, windows.memmap concatenates resident mappings into a derived
file. Without it, the table retains each virtual address, physical address,
size, and derived-file offset.
Do not choose a renderer by PID or age
Chrome renderer 1472 started nine seconds after 6612, but the newest renderer is not necessarily the tab that completed the request. It may still hold copied draft state or cached strings from another page.
This is just a way to try to have an indicator on which one is the right one, so don’t read too much into it (unless you want to)
The comparison script memory-maps both dumps and locates four schema anchors:
the composer key, terminal-received, data-assistant-stream-block, and the
unauthenticated conversation route. A match is only a lead. Composer candidates
must survive the Mojo pointer and array-header checks explained later, HTML
candidates must parse as one bounded element and routes must parse to the
expected host, path, and query.
For anyone who wants the format details, see the
Web Storage standard,
Blink’s DOM Storage KeyValue definition,
and Chromium’s Mojom wire-format specification.
The script here accepts a completed turn only when a
committed assistant block, its resume token, assistant-content-started, and a
terminal control repeat the same conversation, operation, final-message, and
pending-message IDs.
| Validated evidence | PID 1472 | PID 6612 |
|---|---|---|
| raw composer-key occurrences (leads only) | 3 | 10 |
validated storage KeyValue records |
0 | 2 |
| terminal controls | 0 | 1 |
| assistant blocks | 0 | 3 (1 committed) |
| ID-linked completed turns | 0 | 1 |
PID 1472 is a convincing wrong choice because its three key copies even sit near prompt fragments. None resolves as the Mojo pair used later, however, and no terminal or stream record survives validation. This is stale residue, not a completed exchange.
PID 6612 contains two valid composer revisions: a 12-character partial value at
0x466440 and the complete 233-character value at 0x467440. It also contains
three assistant blocks, but two are four-character pending fragments. Only the
40-character block at 0x2658000 is committed, and its IDs join it to the
terminal control at 0x265360c. That single relationship selects PID 6612, not
its age, and not because 10 is greater than 3.
The renderer-comparison script
The script takes PID=path arguments and writes its JSON report to standard
output. It uses field names as anchors, then validates the surrounding formats,
none of the constants contains the recovered prompt or response. The same
tested file is available as
compare_renderers.py.
python3 compare_renderers.py "1472=renderer-1472/pid.1472.dmp" "6612=renderer-6612/pid.6612.dmp"
#!/usr/bin/env python3
"""Compare raw Chrome renderer dumps without searching for the prompt or answer."""
import argparse
import json
import mmap
import re
import struct
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import parse_qs, urlsplit
from uuid import UUID
COMPOSER_KEY = b"oai/apps/lightweight-web/composerDraft/v1"
TERMINAL = b'data-conversation-control="terminal-received"'
RESUME = b'data-conversation-control="resume-token"'
STARTED = b'data-conversation-control="assistant-content-started"'
STREAM = b'data-assistant-stream-block=""'
NOAUTH_ROUTE = b"chatgpt.com/unauth-mweb/conversation?lightweight_authenticated=0"
SUBMISSION_OWNER = b"conversationRetryOwner="
UUID_TEXT = rb"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
PENDING_FOR = re.compile(
rb'for="assistant-pending-(' + UUID_TEXT + rb')-(committed-tail|pending)"'
)
TURN_STATE = re.compile(
rb'\x22\x12conversationTurnId\x22\x24(' + UUID_TEXT +
rb')\x22\x04kind\x22\x1cweb-mobile-conversation-turn'
rb'\x22\x09streaming([FT])'
)
class FirstStartTag(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.name = None
self.attrs = {}
def handle_starttag(self, tag, attrs):
if self.name is None:
self.name = tag
self.attrs = dict(attrs)
def offsets(data, needle):
at = 0
while (at := data.find(needle, at)) != -1:
yield at
at += 1
def canonical_uuid(value):
try:
parsed = str(UUID(value))
except (ValueError, AttributeError):
return None
return parsed if parsed == value.lower() else None
def parse_start_tag(data, anchor, name, look_back=512, look_ahead=2048):
start = data.rfind(b"<" + name.encode(), max(0, anchor - look_back), anchor + 1)
end = data.find(b">", anchor, min(len(data), anchor + look_ahead))
if start == -1 or end == -1:
return None
parser = FirstStartTag()
try:
parser.feed(data[start : end + 1].decode("latin-1"))
except Exception:
return None
if parser.name != name:
return None
return start, end + 1, parser.attrs
def decode_storage_array(data, start):
if start < 0 or start + 9 > len(data) or start % 8:
return None
num_bytes, num_elements = struct.unpack_from("<II", data, start)
if num_elements < 1 or num_bytes != 8 + num_elements:
return None
end = start + num_bytes
if end > len(data):
return None
payload = data[start + 8 : end]
marker, raw = payload[0], payload[1:]
try:
if marker == 1:
return raw.decode("latin-1"), start + 9
if marker == 0 and len(raw) % 2 == 0:
return raw.decode("utf-16le"), start + 9
except UnicodeDecodeError:
pass
return None
def composer_pairs(data):
hits = list(offsets(data, COMPOSER_KEY))
pairs = []
for hit in hits:
key_array = hit - 9 # 8-byte array header + 1-byte encoding marker
decoded_key = decode_storage_array(data, key_array)
if not decoded_key or decoded_key[0].encode("latin-1") != COMPOSER_KEY:
continue
first_slot = max(8, key_array - 0x80)
first_slot += (-first_slot) % 8
for key_slot in range(first_slot, key_array, 8):
key_relative = struct.unpack_from("<Q", data, key_slot)[0]
if key_slot + key_relative != key_array:
continue
record = key_slot - 8
num_bytes, version = struct.unpack_from("<II", data, record)
if version != 0 or num_bytes < 0x18 or num_bytes % 8:
continue
if record + num_bytes != key_array:
continue
value_slot = key_slot + 8
value_relative = struct.unpack_from("<Q", data, value_slot)[0]
value_array = value_slot + value_relative
decoded_value = decode_storage_array(data, value_array)
if not decoded_value:
continue
value, value_offset = decoded_value
pairs.append(
{
"record_offset": hex(record),
"key_offset": hex(hit),
"value_offset": hex(value_offset),
"value_characters": len(value),
"value": value,
}
)
break
return hits, pairs
def printable_allocation(data, anchor, maximum=0x10000):
"""Bound one printable ASCII run around an anchor in raw memory."""
start = anchor
floor = max(0, anchor - maximum)
while start > floor and 0x20 <= data[start - 1] <= 0x7E:
start -= 1
end = anchor
ceiling = min(len(data), anchor + maximum)
while end < ceiling and 0x20 <= data[end] <= 0x7E:
end += 1
return start, end
def submission_records(data):
"""Decode form fragments containing complete owner and message-ID fields."""
grouped = {}
for anchor in offsets(data, SUBMISSION_OWNER):
start, end = printable_allocation(data, anchor)
try:
fields = parse_qs(
data[start:end].decode("ascii"),
keep_blank_values=True,
strict_parsing=False,
)
owner_text = fields["conversationRetryOwner"][-1]
assistant_text = fields["assistantMessageId"][-1]
user_text = fields["userMessageId"][-1]
owner = json.loads(owner_text)
except (UnicodeDecodeError, KeyError, IndexError, json.JSONDecodeError):
continue
if not assistant_text.startswith("pending-"):
continue
pending_id = canonical_uuid(assistant_text.removeprefix("pending-"))
user_message_id = canonical_uuid(user_text)
if pending_id is None or user_message_id is None or not isinstance(owner, dict):
continue
if owner.get("mode") != "anonymous" or owner.get("sessionEpoch") is not None:
continue
key = (pending_id, user_message_id, json.dumps(owner, sort_keys=True))
record = grouped.setdefault(
key,
{
"allocation_offset": hex(start),
"allocation_end": hex(end),
"owner_field_offset": hex(anchor),
"owner": owner,
"pending_assistant_id": pending_id,
"user_message_id": user_message_id,
"copy_offsets": [],
},
)
record["copy_offsets"].append(hex(anchor))
return list(grouped.values())
def history_url_arrays(data, conversation_id):
"""Validate exact /uc/ URLs as length-delimited Mojo arrays."""
if conversation_id is None:
return []
encoded = f"https://chatgpt.com/uc/{conversation_id}".encode("ascii")
records = []
for url_offset in offsets(data, encoded):
header = url_offset - 8
if header < 0 or header % 8:
continue
num_bytes, num_elements = struct.unpack_from("<II", data, header)
if num_bytes != 8 + len(encoded) or num_elements != len(encoded):
continue
records.append(
{
"array_header_offset": hex(header),
"url_offset": hex(url_offset),
"conversation_id_offset": hex(url_offset + encoded.index(conversation_id.encode("ascii"))),
"num_bytes": num_bytes,
"num_elements": num_elements,
"url": encoded.decode("ascii"),
}
)
return records
def conversation_turn_states(data):
"""Parse serialized navigation-state records keyed by user message UUID."""
grouped = {}
for match in TURN_STATE.finditer(data):
conversation_turn_id = canonical_uuid(match.group(1).decode("ascii"))
if conversation_turn_id is None:
continue
state = "true" if match.group(2) == b"T" else "false"
record = grouped.setdefault(
conversation_turn_id,
{
"conversation_turn_id": conversation_turn_id,
"kind": "web-mobile-conversation-turn",
"streaming_true_offsets": [],
"streaming_false_offsets": [],
},
)
record[f"streaming_{state}_offsets"].append(hex(match.start()))
return list(grouped.values())
def storage_page_links(url_arrays, pairs):
"""Keep only pages containing both the pivot URL and a valid composer pair."""
links = []
for url_record in url_arrays:
page = int(url_record["array_header_offset"], 16) & ~0xFFF
page_pairs = [
pair
for pair in pairs
if page <= int(pair["record_offset"], 16) < page + 0x1000
]
if page_pairs:
links.append(
{
"page": hex(page),
"url_array_header": url_record["array_header_offset"],
"composer_records": page_pairs,
}
)
return links
def controls(data, needle, kind, required):
parsed = []
for anchor in offsets(data, needle):
tag = parse_start_tag(data, anchor, "span")
if not tag:
continue
start, end, attrs = tag
if attrs.get("data-conversation-control") != kind:
continue
values = {}
valid = True
for attr in required:
value = canonical_uuid(attrs.get(attr))
if value is None:
valid = False
break
values[attr.removeprefix("data-").replace("-", "_")] = value
if valid:
values.update({"anchor": anchor, "tag_start": start, "tag_end": end})
parsed.append(values)
return parsed
def stream_blocks(data):
blocks = []
for attr_offset in offsets(data, STREAM):
tag = parse_start_tag(data, attr_offset, "p", 256, 1024)
if not tag:
continue
start, open_end, attrs = tag
try:
index = int(attrs["data-assistant-stream-block-index"])
except (KeyError, ValueError):
continue
close = data.find(b"</p>", open_end, min(len(data), open_end + 4096))
if close == -1:
continue
body = data[open_end:close].split(b"<", 1)[0]
try:
text = body.decode("utf-8")
except UnicodeDecodeError:
continue
# The allocation boundary can cut off the beginning of <template>, so
# use the last complete `for=` attribute before the paragraph.
context_start = max(0, start - 2048)
matches = list(PENDING_FOR.finditer(data[context_start:start]))
if not matches:
continue
match = matches[-1]
pending_id = canonical_uuid(match.group(1).decode("ascii"))
state = match.group(2).decode("ascii")
blocks.append(
{
"anchor": attr_offset,
"end": close + 4,
"block_index": index,
"text": text,
"pending_id": pending_id,
"committed": state == "committed-tail",
}
)
return blocks
def pending_target_after(data, control):
end = min(len(data), control["tag_end"] + 4096)
match = PENDING_FOR.search(data[control["tag_end"] : end])
if not match or match.group(2) != b"pending":
return None
return canonical_uuid(match.group(1).decode("ascii"))
def linked_turns(data, terminals, blocks, resumes, started):
turns = []
for block in (item for item in blocks if item["committed"]):
nearby_resumes = [r for r in resumes if 0 < r["anchor"] - block["end"] < 0x8000]
for resume in nearby_resumes:
nearby_started = [
s for s in started if 0 < s["anchor"] - resume["anchor"] < 0x8000
]
for began in nearby_started:
if pending_target_after(data, began) != block["pending_id"]:
continue
for terminal in terminals:
if not (0 < block["anchor"] - terminal["anchor"] < 0x10000):
continue
if not (
terminal["conversation_id"] == resume["conversation_id"]
and terminal["operation_id"] == resume["operation_id"]
and terminal["operation_id"] == began["operation_id"]
and terminal["message_id"] == began["message_id"]
):
continue
turns.append(
{
"terminal_control": hex(terminal["anchor"]),
"committed_stream_block": hex(block["anchor"]),
"conversation_id": terminal["conversation_id"],
"pending_assistant_id": block["pending_id"],
"final_assistant_id": terminal["message_id"],
"operation_id": terminal["operation_id"],
"response_text": block["text"],
}
)
return turns
def attach_submissions(turns, submissions):
for turn in turns:
matches = [
record
for record in submissions
if record["pending_assistant_id"] == turn["pending_assistant_id"]
]
if len(matches) == 1:
turn["submission"] = matches[0]
def attach_turn_states(submissions, states):
by_id = {state["conversation_turn_id"]: state for state in states}
for submission in submissions:
state = by_id.get(submission["user_message_id"])
if state is not None:
submission["navigation_state"] = state
def inspect_dump(pid, path, conversation_id=None):
with path.open("rb") as stream, mmap.mmap(
stream.fileno(), 0, access=mmap.ACCESS_READ
) as data:
key_hits, pairs = composer_pairs(data)
terminals = controls(
data,
TERMINAL,
"terminal-received",
("data-conversation-id", "data-message-id", "data-operation-id"),
)
resumes = controls(
data, RESUME, "resume-token", ("data-conversation-id", "data-operation-id")
)
started = controls(
data, STARTED, "assistant-content-started", ("data-message-id", "data-operation-id")
)
blocks = stream_blocks(data)
turns = linked_turns(data, terminals, blocks, resumes, started)
submissions = submission_records(data)
turn_states = conversation_turn_states(data)
attach_turn_states(submissions, turn_states)
attach_submissions(turns, submissions)
url_arrays = history_url_arrays(data, conversation_id)
page_links = storage_page_links(url_arrays, pairs)
route_offsets = []
for at in offsets(data, NOAUTH_ROUTE):
route = urlsplit("https://" + NOAUTH_ROUTE.decode("ascii"))
if (
route.hostname == "chatgpt.com"
and route.path == "/unauth-mweb/conversation"
and parse_qs(route.query) == {"lightweight_authenticated": ["0"]}
):
route_offsets.append(hex(at))
matching_turns = [
turn
for turn in turns
if conversation_id is None or turn["conversation_id"] == conversation_id
]
return {
"pid": pid,
"history_pivot": {
"conversation_id": conversation_id,
"validated_url_arrays": url_arrays,
"storage_page_links": page_links,
"matching_completed_turn_count": len(matching_turns),
},
"composer_key_candidates": {
"count": len(key_hits),
"offsets": [hex(item) for item in key_hits],
},
"valid_mojo_composer_pairs": pairs,
"terminal_controls": [
{
"anchor_offset": hex(item["anchor"]),
"conversation_id": item["conversation_id"],
"message_id": item["message_id"],
"operation_id": item["operation_id"],
}
for item in terminals
],
"assistant_stream_blocks": [
{
"anchor_offset": hex(item["anchor"]),
"text_characters": len(item["text"]),
"committed": item["committed"],
"pending_assistant_id": item["pending_id"],
"text": item["text"],
}
for item in blocks
],
"submission_records": submissions,
"conversation_turn_states": turn_states,
"valid_noauth_route_literals": route_offsets,
"linked_completed_turns": turns,
"complete_turn": bool(matching_turns),
}
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"--conversation-id",
help="conversation UUID recovered from Chrome History",
)
parser.add_argument("dump", nargs="+", metavar="PID=PATH")
args = parser.parse_args()
conversation_id = None
if args.conversation_id is not None:
conversation_id = canonical_uuid(args.conversation_id)
if conversation_id is None:
parser.error("--conversation-id must be a canonical UUID")
renderers = []
for item in args.dump:
pid_text, separator, path_text = item.partition("=")
if not separator:
parser.error(f"expected PID=PATH, got {item!r}")
renderers.append(
inspect_dump(int(pid_text), Path(path_text), conversation_id)
)
completed = [r["pid"] for r in renderers if r["complete_turn"]]
report = {
"report": "Cheater Chrome renderer comparison",
"source": "Volatility windows.memmap --dump output",
"method": {
"history_pivot": "Validate the supplied History conversation UUID in exact /uc/ URL arrays and typed terminal controls.",
"response_join": "Join terminal, committed stream, resume-token, and assistant-content-started records by their named UUID fields.",
"submission_join": "Match the committed stream's pending assistant UUID to assistantMessageId in a decoded form allocation.",
"prompt_join": "Require the validated /uc/ URL array and a valid Mojo composer KeyValue record to occupy the same 4 KiB storage page.",
},
"history_conversation_id": conversation_id,
"renderers": renderers,
"selected_pid": completed[0] if len(completed) == 1 else None,
}
print(json.dumps(report, indent=2))
if __name__ == "__main__":
main()
Reconstructing Discord
PID 4080 is the Discord renderer, but that does not make every Discord-looking
string inside it a message. The interesting question is narrower: was the word
solution actually sent, or did it only exist in the composer?
I wanted one parser to answer that without being handed the answer first. Its only input is the renderer dump, no channel ID, username, message text, or magic offset.
Run the reconstruction
Download parse_discord.py, put it
beside pid.4080.dmp, and run:
python3 parse_discord.py pid.4080.dmp -o discord-investigation.json
It prints:
Recovered 28 Gateway dispatches, 5 messages, and 13 draft revisions for channel 1546915046091923606.
The complete, already generated JSON report keeps the source offsets and the fields used for every join. Its first draft-to-message comparison is the part that was very interesting:
{
"message_id": "1546915339453857823",
"final_draft": "did you finish the ECE 54700 hw 1 solution",
"sent_content": "did you finish the ECE 54700 hw 1",
"removed_words": ["solution"],
"draft_to_send_ms": 8754
}
That is still not yet the proof. The rest of this section shows how the script reached it if you’re interested.
Show the complete Discord reconstruction script
#!/usr/bin/env python3
"""Reconstruct Discord evidence from a dumped Chromium renderer.
The parser deliberately starts without a channel ID, username, message text, or
known offset. It validates complete Discord Gateway ETF dispatches, correlates
the recovered DM channel with Discord's cached selected-channel state, and then
extracts DraftStore revisions for that same channel.
"""
from __future__ import annotations
import argparse
import collections
import datetime as dt
import json
import mmap
import struct
from pathlib import Path
from typing import Any, Iterator
DISCORD_EPOCH_MS = 1_420_070_400_000
MAX_TERM_BYTES = 8 * 1024 * 1024
MAX_COLLECTION_ITEMS = 100_000
MAX_DEPTH = 64
class ETFError(Exception):
"""A candidate is not a complete, supported ETF term."""
class ETFReader:
def __init__(self, data: mmap.mmap, pos: int, limit: int):
self.data = data
self.pos = pos
self.limit = min(limit, len(data))
def take(self, size: int) -> bytes:
if size < 0 or self.pos + size > self.limit:
raise ETFError("term runs outside the candidate bounds")
value = self.data[self.pos : self.pos + size]
self.pos += size
return value
def u8(self) -> int:
return self.take(1)[0]
def u16(self) -> int:
return struct.unpack(">H", self.take(2))[0]
def u32(self) -> int:
return struct.unpack(">I", self.take(4))[0]
def checked_count(self, count: int) -> int:
if count > MAX_COLLECTION_ITEMS:
raise ETFError(f"unreasonable collection length: {count}")
return count
def atom(self, size: int) -> Any:
value = self.take(size).decode("utf-8", errors="strict")
return {"true": True, "false": False, "nil": None}.get(value, value)
def binary(self, size: int) -> Any:
value = self.take(size)
try:
return value.decode("utf-8", errors="strict")
except UnicodeDecodeError:
return {"$binary_hex": value.hex()}
@staticmethod
def map_key(value: Any) -> Any:
if isinstance(value, (str, int, float, bool, bytes, type(None))):
return value
return json.dumps(value, ensure_ascii=False, sort_keys=True)
def term(self, depth: int = 0) -> Any:
if depth > MAX_DEPTH:
raise ETFError("maximum ETF nesting depth exceeded")
tag = self.u8()
if tag == 70: # NEW_FLOAT_EXT
return struct.unpack(">d", self.take(8))[0]
if tag == 97: # SMALL_INTEGER_EXT
return self.u8()
if tag == 98: # INTEGER_EXT
return struct.unpack(">i", self.take(4))[0]
if tag == 99: # FLOAT_EXT (legacy, 31-byte string)
raw = self.take(31).split(b"\x00", 1)[0]
try:
return float(raw.decode("ascii"))
except ValueError as exc:
raise ETFError("invalid FLOAT_EXT") from exc
if tag in (100, 118): # ATOM_EXT / ATOM_UTF8_EXT
return self.atom(self.u16())
if tag in (115, 119): # SMALL_ATOM_EXT / SMALL_ATOM_UTF8_EXT
return self.atom(self.u8())
if tag == 104: # SMALL_TUPLE_EXT
return [self.term(depth + 1) for _ in range(self.checked_count(self.u8()))]
if tag == 105: # LARGE_TUPLE_EXT
return [self.term(depth + 1) for _ in range(self.checked_count(self.u32()))]
if tag == 106: # NIL_EXT
return []
if tag == 107: # STRING_EXT
return self.take(self.u16()).decode("latin-1")
if tag == 108: # LIST_EXT
count = self.checked_count(self.u32())
values = [self.term(depth + 1) for _ in range(count)]
tail = self.term(depth + 1)
if tail != []:
return {"items": values, "tail": tail}
return values
if tag == 109: # BINARY_EXT
return self.binary(self.u32())
if tag == 77: # BIT_BINARY_EXT
size = self.u32()
significant_bits = self.u8()
value = self.binary(size)
return {"bits_in_last_byte": significant_bits, "value": value}
if tag in (110, 111): # SMALL_BIG_EXT / LARGE_BIG_EXT
size = self.u8() if tag == 110 else self.checked_count(self.u32())
sign = self.u8()
magnitude = int.from_bytes(self.take(size), "little")
return -magnitude if sign else magnitude
if tag == 116: # MAP_EXT
count = self.checked_count(self.u32())
result = {}
for _ in range(count):
key = self.map_key(self.term(depth + 1))
result[key] = self.term(depth + 1)
return result
raise ETFError(f"unsupported ETF tag {tag}")
def scan_gateway_events(data: mmap.mmap) -> list[dict[str, Any]]:
"""Find and validate uncompressed ETF maps beginning with version 131."""
events: list[dict[str, Any]] = []
seen: set[str] = set()
cursor = 0
while True:
offset = data.find(b"\x83\x74", cursor)
if offset == -1:
break
cursor = offset + 1
reader = ETFReader(data, offset + 1, offset + MAX_TERM_BYTES)
try:
event = reader.term()
except (ETFError, UnicodeDecodeError, struct.error):
continue
if not (
isinstance(event, dict)
and event.get("op") == 0
and isinstance(event.get("t"), str)
and "d" in event
and "s" in event
):
continue
fingerprint = json.dumps(event, ensure_ascii=False, sort_keys=True)
if fingerprint in seen:
continue
seen.add(fingerprint)
events.append(
{
"offset": offset,
"end_offset": reader.pos,
"event": event,
}
)
return events
def bounded_json_object(data: mmap.mmap, start: int, maximum: int = 1024 * 1024) -> tuple[Any, int]:
"""Return one balanced UTF-8 JSON object and its exclusive end offset."""
if start < 0 or start >= len(data) or data[start] != ord("{"):
raise ValueError("JSON object does not start with an opening brace")
depth = 0
in_string = False
escaped = False
end_limit = min(len(data), start + maximum)
for pos in range(start, end_limit):
byte = data[pos]
if in_string:
if escaped:
escaped = False
elif byte == ord("\\"):
escaped = True
elif byte == ord('"'):
in_string = False
continue
if byte == ord('"'):
in_string = True
elif byte == ord("{"):
depth += 1
elif byte == ord("}"):
depth -= 1
if depth == 0:
raw = data[start : pos + 1]
return json.loads(raw.decode("utf-8")), pos + 1
raise ValueError("unterminated JSON object")
def scan_selected_channels(data: mmap.mmap) -> list[dict[str, Any]]:
marker = b'{"selectedChannelId"'
records: list[dict[str, Any]] = []
seen: set[str] = set()
cursor = 0
while True:
json_offset = data.find(marker, cursor)
if json_offset == -1:
break
cursor = json_offset + 1
if json_offset < 8:
continue
length, flag = struct.unpack("<II", data[json_offset - 8 : json_offset])
if flag != 1 or length < len(marker) or length > 1024 * 1024:
continue
try:
raw = data[json_offset : json_offset + length]
value = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
continue
if not isinstance(value, dict) or "selectedChannelId" not in value:
continue
fingerprint = json.dumps(value, ensure_ascii=False, sort_keys=True)
if fingerprint in seen:
continue
seen.add(fingerprint)
records.append(
{
"record_offset": json_offset - 8,
"json_offset": json_offset,
"length": length,
"string_flag": flag,
"value": value,
}
)
return records
def walk_draft_state(value: Any, path: tuple[str, ...] = ()) -> Iterator[dict[str, Any]]:
if isinstance(value, dict):
if isinstance(value.get("timestamp"), int) and isinstance(value.get("draft"), str):
yield {
"path": list(path),
"timestamp_ms": value["timestamp"],
"draft": value["draft"],
}
for key, child in value.items():
yield from walk_draft_state(child, path + (str(key),))
elif isinstance(value, list):
for index, child in enumerate(value):
yield from walk_draft_state(child, path + (str(index),))
def scan_draft_store(data: mmap.mmap) -> list[dict[str, Any]]:
marker = b"DraftStore"
drafts: list[dict[str, Any]] = []
seen: set[tuple[str, str, int, str]] = set()
cursor = 0
while True:
anchor = data.find(marker, cursor)
if anchor == -1:
break
cursor = anchor + 1
json_offset = data.find(b"{", anchor + len(marker), anchor + len(marker) + 32)
if json_offset == -1:
continue
try:
value, _ = bounded_json_object(data, json_offset)
except (ValueError, UnicodeDecodeError, json.JSONDecodeError):
continue
if not isinstance(value, dict) or not isinstance(value.get("_state"), dict):
continue
for leaf in walk_draft_state(value["_state"]):
path = leaf.pop("path")
if len(path) < 2:
continue
user_id, channel_id = path[0], path[1]
key = (user_id, channel_id, leaf["timestamp_ms"], leaf["draft"])
if key in seen:
continue
seen.add(key)
drafts.append(
{
"record_offset": anchor,
"json_offset": json_offset,
"user_id": user_id,
"channel_id": channel_id,
**leaf,
}
)
drafts.sort(key=lambda item: (item["timestamp_ms"], item["record_offset"]))
return drafts
def iso_from_ms(timestamp_ms: int) -> str:
value = dt.datetime.fromtimestamp(timestamp_ms / 1000, tz=dt.timezone.utc)
return value.isoformat(timespec="milliseconds").replace("+00:00", "Z")
def snowflake_ms(value: str | int) -> int:
return (int(value) >> 22) + DISCORD_EPOCH_MS
def event_id(entry: dict[str, Any]) -> str | None:
payload = entry["event"].get("d")
if isinstance(payload, dict) and payload.get("id") is not None:
return str(payload["id"])
return None
def choose_dm_channel(
events: list[dict[str, Any]],
selected: list[dict[str, Any]],
drafts: list[dict[str, Any]],
) -> dict[str, Any]:
messages_per_channel: collections.Counter[str] = collections.Counter()
for entry in events:
event = entry["event"]
payload = event.get("d")
if event.get("t") == "MESSAGE_CREATE" and isinstance(payload, dict):
if payload.get("channel_id") is not None:
messages_per_channel[str(payload["channel_id"])] += 1
selected_ids = {
str(record["value"].get("selectedChannelId"))
for record in selected
if record["value"].get("selectedChannelId") is not None
}
draft_counts: collections.Counter[str] = collections.Counter(
draft["channel_id"] for draft in drafts
)
candidates = []
for entry in events:
event = entry["event"]
payload = event.get("d")
if event.get("t") != "CHANNEL_CREATE" or not isinstance(payload, dict):
continue
if payload.get("type") != 1 or payload.get("id") is None:
continue
channel_id = str(payload["id"])
candidates.append(
{
"entry": entry,
"channel_id": channel_id,
"message_count": messages_per_channel[channel_id],
"selected": channel_id in selected_ids,
"draft_count": draft_counts[channel_id],
}
)
if not candidates:
raise RuntimeError("no validated type-1 CHANNEL_CREATE dispatch was recovered")
corroborated = [
candidate
for candidate in candidates
if candidate["message_count"] > 0
and candidate["selected"]
and candidate["draft_count"] > 0
]
if len(corroborated) != 1:
details = ", ".join(
f"{candidate['channel_id']} "
f"(messages={candidate['message_count']}, selected={candidate['selected']}, "
f"drafts={candidate['draft_count']})"
for candidate in candidates
)
raise RuntimeError(f"expected one fully corroborated DM channel; recovered: {details}")
return corroborated[0]["entry"]
def compact_channel(payload: dict[str, Any]) -> dict[str, Any]:
recipients = []
for recipient in payload.get("recipients", []):
if isinstance(recipient, dict):
recipients.append(
{
key: recipient.get(key)
for key in ("id", "username", "global_name")
if recipient.get(key) is not None
}
)
return {"id": str(payload["id"]), "type": payload.get("type"), "recipients": recipients}
def compact_message(entry: dict[str, Any]) -> dict[str, Any]:
payload = entry["event"]["d"]
author = payload.get("author") if isinstance(payload.get("author"), dict) else {}
identifier = str(payload["id"])
derived_ms = snowflake_ms(identifier)
supplied = str(payload.get("timestamp", ""))
supplied_ms = int(dt.datetime.fromisoformat(supplied.replace("Z", "+00:00")).timestamp() * 1000)
return {
"event_offset": f"0x{entry['offset']:x}",
"id": identifier,
"channel_id": str(payload.get("channel_id")),
"timestamp": supplied,
"snowflake_timestamp": iso_from_ms(derived_ms),
"snowflake_delta_ms": derived_ms - supplied_ms,
"author": {
key: author.get(key)
for key in ("id", "username", "global_name")
if author.get(key) is not None
},
"content": payload.get("content"),
}
def final_draft_before(
drafts: list[dict[str, Any]], timestamp_ms: int, user_id: str
) -> dict[str, Any] | None:
candidates = [
draft
for draft in drafts
if draft["user_id"] == user_id and draft["timestamp_ms"] <= timestamp_ms
]
if not candidates:
return None
candidate = max(candidates, key=lambda item: item["timestamp_ms"])
if timestamp_ms - candidate["timestamp_ms"] > 60_000:
return None
return candidate
def build_report(path: Path, data: mmap.mmap) -> dict[str, Any]:
events = scan_gateway_events(data)
selected = scan_selected_channels(data)
drafts = scan_draft_store(data)
channel_entry = choose_dm_channel(events, selected, drafts)
channel = compact_channel(channel_entry["event"]["d"])
channel_id = channel["id"]
channel_messages = [
compact_message(entry)
for entry in events
if entry["event"].get("t") == "MESSAGE_CREATE"
and isinstance(entry["event"].get("d"), dict)
and str(entry["event"]["d"].get("channel_id")) == channel_id
]
channel_messages.sort(key=lambda item: item["timestamp"])
selected_matches = [
record for record in selected if str(record["value"].get("selectedChannelId")) == channel_id
]
relevant_drafts = [draft for draft in drafts if draft["channel_id"] == channel_id]
draft_user_counts = collections.Counter(draft["user_id"] for draft in relevant_drafts)
message_author_ids = {str(item["author"].get("id")) for item in channel_messages}
local_user_id = next(
(user_id for user_id, _ in draft_user_counts.most_common() if user_id in message_author_ids),
None,
)
message_comparisons = []
for message in channel_messages:
if local_user_id is None or str(message["author"].get("id")) != local_user_id:
continue
message_ms = int(
dt.datetime.fromisoformat(message["timestamp"].replace("Z", "+00:00")).timestamp() * 1000
)
draft = final_draft_before(relevant_drafts, message_ms, local_user_id)
if draft is None:
continue
draft_words = draft["draft"].split()
sent_words = str(message["content"]).split()
removed = [word for word in draft_words if word not in sent_words]
message_comparisons.append(
{
"message_id": message["id"],
"final_draft": draft["draft"],
"sent_content": message["content"],
"removed_words": removed,
"draft_to_send_ms": message_ms - draft["timestamp_ms"],
}
)
event_counts = collections.Counter(entry["event"]["t"] for entry in events)
recipient_ids = [str(item["id"]) for item in channel["recipients"] if item.get("id")]
return {
"report": "Cheater Discord renderer reconstruction",
"source": {"file": path.name, "size_bytes": path.stat().st_size},
"method": {
"gateway": "complete ETF terms beginning with VERSION_MAGIC + MAP_EXT",
"selected_channel": "complete little-endian length-prefixed JSON records",
"drafts": "complete DraftStore JSON states",
"deduplication": "identical decoded Gateway events and identical draft revisions",
},
"gateway": {
"validated_dispatch_count": len(events),
"event_type_counts": dict(sorted(event_counts.items())),
"dm_channel": {
"event_offset": f"0x{channel_entry['offset']:x}",
**channel,
},
"messages": channel_messages,
},
"client_state": {
"selected_channel_records": [
{
"record_offset": f"0x{record['record_offset']:x}",
"json_offset": f"0x{record['json_offset']:x}",
"length": record["length"],
"string_flag": record["string_flag"],
"value": record["value"],
}
for record in selected_matches
],
"draft_revisions": [
{
"record_offset": f"0x{draft['record_offset']:x}",
"user_id": draft["user_id"],
"channel_id": draft["channel_id"],
"timestamp": iso_from_ms(draft["timestamp_ms"]),
"timestamp_ms": draft["timestamp_ms"],
"draft": draft["draft"],
}
for draft in relevant_drafts
],
},
"correlation": {
"channel_id": channel_id,
"local_user_id": local_user_id,
"recipient_ids": recipient_ids,
"message_count": len(channel_messages),
"all_messages_match_channel": all(
message["channel_id"] == channel_id for message in channel_messages
),
"all_snowflake_checks_zero_delta": all(
message["snowflake_delta_ms"] == 0 for message in channel_messages
),
"draft_to_sent": message_comparisons,
},
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("renderer", type=Path, help="renderer dump, for example pid.4080.dmp")
parser.add_argument("-o", "--output", type=Path, help="write the JSON report here")
return parser.parse_args()
def main() -> None:
args = parse_args()
with args.renderer.open("rb") as stream:
data = mmap.mmap(stream.fileno(), 0, access=mmap.ACCESS_READ)
try:
report = build_report(args.renderer, data)
finally:
data.close()
rendered = json.dumps(report, indent=2, ensure_ascii=False) + "\n"
if args.output:
args.output.write_text(rendered, encoding="utf-8")
else:
print(rendered, end="")
print(
f"Recovered {report['gateway']['validated_dispatch_count']} Gateway dispatches, "
f"{report['correlation']['message_count']} messages, and "
f"{len(report['client_state']['draft_revisions'])} draft revisions "
f"for channel {report['correlation']['channel_id']}."
)
if __name__ == "__main__":
main()
What the parser accepts as a Discord event
Discord’s Gateway may encode payloads as JSON or binary Erlang External Term Format (ETF). In this renderer the complete cached dispatches begin like this:
83 74 00 00 00 04
According to the official
ETF grammar,
0x83 is the version byte, 0x74 is MAP_EXT, and the next four
big-endian bytes say that the map has four pairs. Those pairs are the Gateway
envelope:
op operation code d event data
s sequence number t dispatch event name

The scanner uses 83 74 only to propose an offset. A recursive decoder then
has to consume the whole term: BINARY_EXT supplies an exact byte length,
LIST_EXT supplies an element count and tail, MAP_EXT supplies a pair
count, and SMALL_BIG_EXT supplies the byte width and sign of a large integer.
Every read is bounds-checked, collections are capped, and nesting stops at 64
levels. A false hit, unsupported tag, impossible length, or truncated object is
discarded.
Even a successfully decoded map is ignored unless op == 0, t is a
string, and both d and s exist. Identical decoded objects are deduplicated
because the same event can survive in more than one allocation. Only then does
the script inspect t.
The result is not just five convenient messages. It is 28 complete dispatches:
11 SESSIONS_REPLACE, five MESSAGE_CREATE, two MESSAGE_ACK, two
RELATIONSHIP_ADD, two TYPING_START, and six other single events. That
background traffic is a useful sanity check. A parser that returned only the
sentence I hoped to find would make me suspicious.
The conversation ID is an output, not an input
After decoding every dispatch, the script looks for CHANNEL_CREATE events.
There is one type-1 channel whose ID is also present in three independently
parsed structures: sent messages, the selected-channel record, and DraftStore.
If that join were ambiguous, the script would stop instead of quietly choosing
a channel.
The mapping is:
| What it identifies | Structural field | Recovered value |
|---|---|---|
| DM conversation | CHANNEL_CREATE.d.id |
1546915046091923606 |
| Why it is a DM | CHANNEL_CREATE.d.type |
1 |
| Other participant | CHANNEL_CREATE.d.recipients[0].id |
541465360080044032 |
| Other participant’s handle | CHANNEL_CREATE.d.recipients[0].username |
tarek_ibr |
| Local account | outer user key in DraftStore._state, also the author of three messages |
1546836880941777006 |
| Conversation on each sent record | MESSAGE_CREATE.d.channel_id |
1546915046091923606 |
| Conversation open in the UI | selectedChannelId |
1546915046091923606 |
| Conversation owning each draft | second key below the local user in DraftStore._state |
1546915046091923606 |
Discord’s Channel resource
defines type 1 as a DM and gives it a recipients array. That is why the
recipient ID means more than a username found near a message.
The selected-channel evidence is a different structure. At 0xa9aa814 the
renderer contains:
99 00 00 00 01 00 00 00 { ...153 JSON bytes... }
^ length=153 ^ string flag ^ JSON begins at 0xa9aa81c
The script reads both four-byte little-endian fields, takes exactly 153 bytes,
and only accepts the record if json.loads() consumes a complete object:
{
"selectedChannelId": "1546915046091923606",
"selectedChannelIds": {"null": "1546915046091923606"},
"mostRecentSelectedTextChannelIds": {},
"knownThreadIds": []
}
This cache schema is version-specific and is not documented as part of
Discord’s public API, so I do not use it alone. It agrees with the official DM
object, every message’s channel_id, and every draft’s nested channel key.
That is how conversation 1546915046091923606 was recovered.
Rebuilding the transcript
Only after the channel is established does the script select
t == "MESSAGE_CREATE" and match d.channel_id. The five records are:
| UTC | Message ID | Author (user ID) | Sent content |
|---|---|---|---|
| 16:08:17.987 | 1546915091000328343 |
immortal_xxxx (1546836880941777006) |
hey |
| 16:08:20.567 | 1546915101821370449 |
tarek_ibr (541465360080044032) |
hey |
| 16:09:17.223 | 1546915339453857823 |
immortal_xxxx (1546836880941777006) |
did you finish the ECE 54700 hw 1 |
| 16:09:55.418 | 1546915499655303199 |
immortal_xxxx (1546836880941777006) |
the deadline is approaching and I can't do it |
| 16:10:17.181 | 1546915590935945417 |
tarek_ibr (541465360080044032) |
bro I didn't even start doing it |
The local user is not guessed from the display name. The outer DraftStore user
key is 1546836880941777006, and that same immutable ID appears in
author.id on three messages. The DM recipient ID appears in author.id on
the two replies.

The draft changes the meaning
DraftStore is client state, not a Gateway event. After each DraftStore
anchor, the parser finds the nearby JSON object, balances braces while respecting
quoted strings and escapes, parses the complete object, and walks this shape:
_state
└── 1546836880941777006 local user ID
└── 1546915046091923606 channel ID
└── 0
├── timestamp
└── draft
Thirteen distinct revisions survive. The first important message develops like this:
| UTC | Recovered composer state |
|---|---|
| 16:08:29.828 | just wanted t |
| 16:08:31.209 | just wanted to ask |
| 16:08:40.788 | did you finish |
| 16:08:51.446 | did you finish the assignment |
| 16:08:56.050 | did you finish the e |
| 16:09:03.308 | did you finish the ECE 54700: Intro to Computer Communication Networks hw 1 solution |
| 16:09:08.469 | did you finish the ECE 54700 hw 1 solution |
| 16:09:17.223 | sent: did you finish the ECE 54700 hw 1 |
For each message authored by the local account, the script takes the newest
draft for the same user and channel within the preceding 60 seconds. Here that
draft was saved 8.754 seconds before the matching message event. The second
long draft matches its sent message exactly, the first does not. solution
existed in the composer and was removed before Discord created the message.
This distinction is the trap: DraftStore answers “what was typed?” while
MESSAGE_CREATE answers “what was sent?” Treating both as chat fragments would
turn two different facts into a false transcript.
A second clock inside every message ID
Discord message IDs are Snowflakes. Their high bits encode milliseconds since Discord’s epoch:
timestamp_ms = (int(message_id) >> 22) + 1420070400000
For 1546915339453857823 this yields
2026-09-08T16:09:17.223Z.
At 16:10:17 the friend says he has not even started. Forty-seven seconds later, a new Chrome renderer appears. Now the browser activity is becoming more interesting as his friend didn’t give him the assignment.
Reconstructing Chrome’s History database from RAM
To get a better idea of what really happened let’s try reconstructing the chrome history. Chrome’s browser process had the profile’s
SQLite History file mapped, so I located its Windows file objects:
python3 vol.py -f dump.mem --filters 'Name,History' windows.filescan.FileScan
The two relevant rows were:
0x978fd8a95500 \Users\Tarok\AppData\Local\Google\Chrome\User Data\Default\History
0x978fd8a95820 \Users\Tarok\AppData\Local\Google\Chrome\User Data\Default\History-journal
now we dump them
python3 vol.py -f dump.mem -o "$HISTORY_OUT" windows.dumpfiles.DumpFiles --virtaddr 0x978fd8a95500
python3 vol.py -f dump.mem -o "$HISTORY_OUT" windows.dumpfiles.DumpFiles --virtaddr 0x978fd8a95820
Volatility’s DumpFiles implementation
can recover separate DataSectionObject and SharedCacheMap views. They are
cache layers, not guaranteed clean disk copies. Here, History is 188,416
bytes with a valid SQLite format 3 header. When trying to open it in DB Browser for
SQLite. The header is recognized, but the GUI’s integrity check reports
database disk image is malformed and the normal schema cannot be browsed.
The journal views cannot repair it: one is mostly zero and the other contains a
stale, unrelated page. Treating every dump with History-journal in its name as
a replayable transaction would mix cache residue into the database.
SQLite’s documented .recover command
reads usable pages directly instead of stopping at the broken schema.
The input is the 188,416-byte DataSectionObject dumped from the main History
file at 0x978fd8a95500:
file.0x978fd8a95500.0x978fd58f1c30.DataSectionObject.History.dat
--ignore-freelist prevents deleted rows from being silently reintroduced:
sqlite3 "$HISTORY_OUT/file.0x978fd8a95500.0x978fd58f1c30.DataSectionObject.History.dat" ".recover --ignore-freelist" > "$HISTORY_OUT/History-recovered.sql"
History-recovered.sql is a text SQL dump, not yet a database. In DB Browser
for SQLite, choose File → Import → Database from SQL file…, select that SQL
file, and save the new database. now we can view the recovered table and browse it.
This imported database is the one I use in the screenshots.

Database beautification
now we have the database you can choose to take what you want from here or to clean the database more for better view🤓
Only lost_and_found survived because the schema could not be associated with
its b-trees. That does not make the rows anonymous. The recovery output keeps
the root page, rowid, number of fields, and each decoded field. Root page 4 has
seven-field URL records, root page 6 has eighteen-field visit records. Chromium’s
own urls implementation
defines id, url, title, visit_count, typed_count, last_visit_time, hidden, and
its visits implementation
starts with id, url, visit_time, from_visit, external_referrer_url, transition.
The recovered shapes and types match those definitions.
c0 appears null because both tables declare id INTEGER PRIMARY KEY. In an
ordinary SQLite rowid table, that column aliases the b-tree rowid and is stored
as null inside the record, SQLite uses the b-tree key instead. This is specified
in the official database file format.
Therefore the join is visits.c1 = urls.id, not visits.c0 = urls.c0.
I run that query in the GUI’s Execute SQL tab.
SELECT v.id AS visit_id, v.c2 AS chrome_time, v.c3 AS from_visit,
v.c4 AS external_referrer, v.c5 AS transition, u.c1 AS url
FROM lost_and_found AS v
JOIN lost_and_found AS u
ON u.rootpgno = 4 AND u.id = CAST(v.c1 AS INTEGER)
WHERE v.rootpgno = 6 AND v.nfield = 18
ORDER BY CAST(v.c2 AS INTEGER), v.id;
| UTC | Visit(s) | Recovered navigation |
|---|---|---|
| 14:51:02.249567 | 38 | Google search for ECE 54700 ... hw 1 solution |
| 16:11:06.966950 | 46 | Google search for chatgpt, generated from the address bar |
| 16:11:13.701106 | 47 → 48 | Google goto URL → https://chatgpt.com/, server-redirect chain |
| 16:12:43.363572 | 49 | https://chatgpt.com/ |
| 16:12:45.456248, 16:12:46.359363 | 50, 51 | /uc/6aa033fb-3d68-83ea-b1c2-a333a240813c |
| 16:13:04.274016 | 52 | /?noauth_recent_chat_failed=1, from visit 51 |
| 16:13:06.463614 | 53 | back to https://chatgpt.com/, also from visit 51 |

History establishes what was visited and when. It even discloses the anonymous conversation UUID, but it cannot tell us the prompt or answer. For that we must parse the renderer state.
The research that inspired this challenge and why its tool rejects our file
One inspiration for Cheater was Jihun Joun’s paper, “Forensic analysis from generative AI web application memory: A ChatGPT case study”, published in Forensic Science International: Digital Investigation. Its core idea is important: browser memory should be treated as structured application state, and conversation objects can remain recoverable even when they are no longer visible in the active interface.
The accompanying ChatGPT V8 Heap Forensics tool
does this properly for Chrome DevTools .heapsnapshot files. A heap snapshot is
not an arbitrary slice of process memory. It is a UTF-8 JSON document with a
schema in snapshot.meta, flattened nodes and edges arrays, and a string
table. The tool rebuilds the directed object graph, finds candidate objects with
properties such as id, parentId, children, and message, and then follows
the path message → content → parts → elements.
I tried the tool against renderer 6612 because “Chrome V8 memory” sounds like the right input. It failed immediately:
$ python heap_forensics.py pid.6612.dmp
Error: 'utf-8' codec can't decode byte 0xa0 in position 6: invalid start byte
That is an expected and useful failure. Our Volatility file is sparse raw virtual-memory pages from a process. It has no heap-snapshot JSON envelope, node table, edge table, or string table. The program is not broken, we just violated its input contract.

this would have been possible if for example we managed to acquire a DevTools heap snapshot. An offline full-RAM investigation cannot manufacture graph metadata that was never captured.
The paper still gives us the right methodological instinct: recover relationships, not isolated words. With this evidence, however, those relationships must come from self-describing records that survived inside the raw renderer pages: Erlang ETF, JSON, URL-encoded form data, HTML attributes, and application telemetry.
Reconstructing the anonymous ChatGPT turn
The History database has already handed us the best lead in the renderer:
https://chatgpt.com/uc/6aa033fb-3d68-83ea-b1c2-a333a240813c
└──────────────────────────────────┘
conversation UUID
Visits 50 and 51 contain that URL. I take the final path component, validate it as a UUID, and use it as a pivot. I do not begin by searching for the prompt or decoding every Base64-looking string.
I go back to the same compare_renderers.py used during renderer triage and
save a new report. This time I give its optional --conversation-id argument
the UUID recovered from History:
python3 compare_renderers.py --conversation-id 6aa033fb-3d68-83ea-b1c2-a333a240813c "1472=renderer-1472/pid.1472.dmp" "6612=renderer-6612/pid.6612.dmp" > renderer-comparison.json
The tested script and its
JSON report are included
with the writeup. The value after --conversation-id is copied directly from
the recovered /uc/ URL. In the report, PID 6612 has one
history_pivot.matching_completed_turn_count PID 1472 has zero, which we expected.
There are only two branches to follow from here. One reaches the submitted request and response. The other reaches the saved prompt:
History /uc/ URL
└── conversation UUID 6aa033fb-...
├── terminal and resume controls
│ ├── final assistant ID + operation ID
│ └── committed assistant block
│ ├── response text
│ └── pending assistant ID
│ └── submission form → anonymous owner + user-message ID
└── length-delimited URL array on the storage page
└── validated composer KeyValue → exact prompt
Each arrow is an equality between a named field in two parsed records. Proximity only tells the parser which bounded container to try next.
1. Confirm the History UUID inside a terminal control
The UUID occurs many times in raw V8 memory. Most copies are useless on their
own. The useful one begins at 0x2653682 because it is the value of
data-conversation-id inside one complete HTML start tag:
<span data-conversation-control="terminal-received"
data-conversation-id="6aa033fb-3d68-83ea-b1c2-a333a240813c"
data-message-id="32b7301b-6a8d-492e-9ff5-519f961d2944"
data-operation-id="d8d9a168-c0cc-498c-a7c6-7fa0c14c900e">
The parser works backward to <span, forward to the closing >, parses one
tag, and requires all three data-*
attributes
to contain canonical UUIDs. The fields now have distinct jobs:
data-conversation-idmatches the UUID recovered from Historydata-message-idnames the final assistant messagedata-operation-idfollows this response through the streamed update
That typed container is why PID 6612 survives. PID 1472 still has cached /uc/
URL arrays for this UUID, but no terminal control for it and no linked committed
assistant block.
2. Follow the same IDs into the response
At 0x2657f10 a streamed HTML update targets the pending assistant message.
The important parts occur in one bounded update:
<template for="assistant-pending-23bfd3ad-3695-44bc-8b47-99ff1186dcf5-committed-tail">
<p data-assistant-stream-block=""
data-assistant-stream-block-index="0">VGVhX2Jsb29tc19zb2Z0bHlfd2FybV9xdWlldA==</p>
</template>
<span data-conversation-control="resume-token"
data-conversation-id="6aa033fb-3d68-83ea-b1c2-a333a240813c"
data-operation-id="d8d9a168-c0cc-498c-a7c6-7fa0c14c900e">
<span data-conversation-control="assistant-content-started"
data-message-id="32b7301b-6a8d-492e-9ff5-519f961d2944"
data-operation-id="d8d9a168-c0cc-498c-a7c6-7fa0c14c900e">
The resume-token repeats the History conversation UUID and the terminal’s
operation ID. The following assistant-content-started control repeats that
operation ID and the terminal’s final message ID. The template target adds a
new identifier: pending assistant message
23bfd3ad-3695-44bc-8b47-99ff1186dcf5.
The response candidate is the text node at 0x2658045. The parser accepts it
because it is inside block index 0 of the committed-tail for that pending
message not because it happens to resemble Base64.
3. Use the pending ID to recover the submission
The pending assistant UUID leads backward to the request state. A matching V8
allocation around 0x2aa3400 contains a complete
URL-encoded
form. The parser finds the field boundaries, percent-decodes every value, parses
conversationRetryOwner as JSON, and validates both message IDs as UUIDs:
conversationRetryOwner={"mode":"anonymous","sessionEpoch":null}
assistantMessageId=pending-23bfd3ad-3695-44bc-8b47-99ff1186dcf5
userMessageId=8408d033-4102-48db-a549-77f18e6b15eb
The equality is now explicit: assistantMessageId contains the same pending
UUID carried by the committed response template. The form also introduces the
user-message UUID, 8408d033-4102-48db-a549-77f18e6b15eb.
That user-message UUID later appears in a serialized navigation-state record as
conversationTurnId. The parser requires the two UUIDs to match, so the form is
tied to the same browser turn rather than only to a pending UI element.
Two independent records describe a guest request:
- the route is
/unauth-mweb/conversation?lightweight_authenticated=0 conversationRetryOwner.modeisanonymousandsessionEpochis null
There is no ChatGPT account user ID to recover here. userMessageId identifies
the guest’s message, not a user account.
All of this you can refrence from the JSON report
4. Use the History UUID again to reach the prompt
A second useful copy of the conversation UUID begins at 0x4675d7. This time it
is inside a length-delimited URL array:
| Offset | Parsed field |
|---|---|
0x4675b8 |
num_bytes=0x43, num_elements=0x3b |
0x4675c0 |
start of the 59-byte https://chatgpt.com/uc/... URL |
0x4675d7 |
start of the 36-byte conversation UUID |
0x43 is 67, exactly the eight-byte array header plus 0x3b, or 59,
URL bytes. This validates the URL object and narrows the next pass to the
0x467000–0x467fff storage page. It does not make nearby printable text a
prompt. The report records this correlation under
history_pivot.storage_page_links: only that page contains both a validated
copy of the History URL and a valid composer record.
On that page I enumerate Chromium DOM-storage KeyValue records. Blink defines
a pair as two
array<uint8> fields.
In the Mojo wire
format,
each pointer is a relative offset from its own slot and each array begins with
little-endian num_bytes and num_elements values.
The walker resolves the two pointers:
target = pointer_slot + read_u64le(pointer_slot)
num_bytes, count = unpack_from("<II", dump, target)
require(target % 8 == 0)
require(num_bytes == 8 + count)
payload = dump[target + 8 : target + num_bytes]
For local storage, the first payload byte is the string encoding marker.
Chromium’s storage
implementation
uses 0 for UTF-16LE and 1 for Latin-1. The complete pair is:
| Location | Parsed value |
|---|---|
0x467440 |
struct header: num_bytes=0x20, version=0 |
0x467448 |
key pointer: relative 0x18 → 0x467460 |
0x467460 |
key array: num_bytes=0x32, num_elements=0x2a |
0x467468 |
encoding 01 + oai/apps/lightweight-web/composerDraft/v1 |
0x467450 |
value pointer: relative 0x48 → 0x467498 |
0x467498 |
value array: num_bytes=0xf2, num_elements=0xea |
0x4674a1 |
decoded 233-character value |
The application-owned key says what the value represents, while the Mojo headers, pointers, lengths, and encoding say where it ends. Together they recover the exact prompt:
I want you to help me with this assignment to solve
write it in human written way
make no mistakes
make me a sentence of five words sperated with underscore talking about tea make it unique
respond only with a base64 of this sentence
An earlier valid pair at 0x466440 contains only I want you t. That is a
saved revision, not a second prompt.
The recovered prompt finally gives us constraints for the still-encoded response: it must decode to five underscore-separated words about tea and contain nothing else.
5. Check the sequence against two clocks
The recovered application telemetry gives a tight sequence:
| UTC | Event |
|---|---|
| 16:12:43.324 | first action / intent |
| 16:12:43.337 | conversation submitted |
| 16:12:43.345 | request started and sentinel completed |
| 16:12:43.360 | dispatch |
| 16:12:44.682 | response started |
| 16:12:45.965 | first response content observed |
| 16:13:06.468 | navigation away from the conversation URL |
Chrome History independently records the root visit at 16:12:43.363572,
26.572 ms after the submit event, and the two /uc/ visits around the first
response content. Afterward, History records the failed guest page at
16:13:04.274016 and the committed root URL at 16:13:06.463614. Renderer
telemetry records that final
navigation only
4.386 ms later.
The renderer’s final navigation record keeps the URL roles separate:
referrer = https://chatgpt.com/uc/6aa033fb-3d68-83ea-b1c2-a333a240813c
entryUrl = https://chatgpt.com/?noauth_recent_chat_failed=1
url = https://chatgpt.com/
A later client-state fragment has messages: [], userMessageCount: 1, and
parentMessageId equal to final assistant message
32b7301b-6a8d-492e-9ff5-519f961d2944. I describe that as the conversation
being cleared or dismissed from the active UI state. It does not prove secure
deletion or say what the remote service retained.
If the /uc/ rows had not survived in History, the format-first parser from
renderer triage would still enumerate valid terminal, stream, form, and Mojo
records. Here, however, ignoring the UUID that History gave us would make the
investigation harder to follow for no benefit.
The shortcut, honestly
Once a player has already found the prompt and knows that the response must be a five-word Base64 string, a broad ASCII scan followed by a tiny Base64 filter can reach the encoded token quickly. That is a valid CTF shortcut, but skill issue?
Flag
The assistant returned:
VGVhX2Jsb29tc19zb2Z0bHlfd2FybV9xdWlldA==

Applying the challenge’s wrapper, the flag is:
CATF{Tea_blooms_softly_warm_quiet}
Closing thoughts
The lesson is not merely “AI text stays in RAM.” Modern desktop applications carry parallel representations of one action: Discord had drafts and sent Gateway events, ChatGPT had composer, submission, terminal, stream, telemetry, and navigation state. Windows gave each record a route back to physical memory.
Now what I wanted to show here is: When a specialized tool accepts your evidence format, use it. When it fails, read the format it expected and the format you actually captured. That gap is often where the real forensic work begins.
I hope you enjoyed the challenge and learned something useful from the deeper path. Cya
