Ahalya: The Petrified Memory
By Team Cryptonite · 4 min read
About the CTF: Pinaka CTF
Given a raw NVM image, a damaged write-ahead log and a logical-to-physical map checkpoint, recover the last committed version of a sealed record and attest to the whole archive. The record is logical id 3155.
**Pinaka CTF 2026** · Reverse engineering · Team Cryptonite · All 44 Pinaka write-ups
Category: forensics / persistent-memory recovery
Flag: PINAKA{ahalyaawakenswhenherlastcommittedmemoryisreadbackwhole}
Overview
Given a raw NVM image, a damaged write-ahead log and a logical-to-physical map checkpoint, recover the last committed version of a sealed record and attest to the whole archive. The record is logical id 3155.
Files:
archive.nvm: 11 MB raw NVM image, 47,189 fixed 235-byte physical framesarchive.log: 1.8 MB damaged write-ahead redo/undo logl2p.map: logical-to-physical map checkpoint, geometry, relocation table, sealed lid
Analysis
Frame format in archive.nvm. Every frame is 235 bytes:
- offset 0: 8 bytes -> magic
PNKPMEM1 - offset 8: 2 bytes -> lid, little endian
- offset 10: 2 bytes -> epoch, little endian
- offset 12: 2 bytes -> pad
- offset 14: 4 bytes -> txn, little endian
- offset 18: 1 byte -> valid / flag byte
- offset 19: 4 bytes -> CRC32 over bytes 0..18
- offset 23: 10 bytes -> duplicate lid, epoch, txn block
- offset 23: 200 bytes -> record body
- offset 223: 4 bytes -> trailer
- offset 227: 8 bytes ->
PNKSEAL1persistence seal
The frame transaction number is pba + 1. The record body has its own 9-byte prefix:
body[0:2] lid (u16 LE)
body[2:6] epoch (u32 LE)
body[6:10] txn (u32 LE)
body[10:] ASCII payload, padded with '.' to 200 bytes
For the sealed record the body begins 53 0c 03 00 00 00 a4 8e 00 00, which is lid 0x0c53 = 3155, epoch 3, txn 0x00008ea4 = 36516, followed by AHALYA-STONE:3155:v3:scribe36516.

l2p.map and wear leveling. The map starts with a 34-byte header (PNKL2PM1, slot count 47189, record count 5000, sealed lid 3155). Then come 5000 entries of 18 bytes (lid u16, epoch u32, pbastart u32, pbaend u32, crc32), then 16 relocation entries of 12 bytes (slot u32, pba u32, crc32).
Wear leveling maps a logical pba to a medium slot with:
slot = (18791 * pba + 1112) % 47189
The 16 relocation entries override this for specific pbas. For the recovered record, pba 36515 gives (18791 * 36515 + 1112) % 47189 == 26417, which matches physical_slot.
WAL in archive.log. The log starts with an 18-byte header (PNKWALOG, version, expected record count 53536, CRC32), followed by LREC records:
0x23: 45 bytes -> write record0x0d: 23 bytes -> decision record, where byte 2 is the decision (2commit,3abort)

Solution
Recovery algorithm:
- Start from the checkpoint. All 5000 map entries point at logical pba 12,514.
- Replay the log in order.
- Drop the 3,501-write batch and the 2,500 abort records. They are losers.
- For committed transactions, accept only frames whose valid flag is set. 2,001 commit-marked frames in pba 34,515 to 36,514 and pba 38,016 have flag
0, so they are invalid or torn and get skipped. - Reject the 31 frames at pba 41,018 to 41,048. They sit past the last log record and are the torn tail from the power loss.
- For each live lid, keep the highest epoch that is both committed and valid.
For lid 3155 the versions are:
- epoch 3: pba 36,515 -> committed and valid
- epoch 4: pba 38,016 -> commit-marked but flag 0, invalid
- epoch 5: pba 39,517 -> no matching commit
- epoch 6: pba 41,018 -> torn tail
So the real latest-committed record is epoch 3, even though epochs 4 to 6 look newer.

Digests. The record digest is SHA-256 over the exact 200-byte body:
body = nvm[26417*235 + 23 : 26417*235 + 223]
record_digest = hashlib.sha256(body).hexdigest()
# b3ef6fd95676c067ef3b46df05a0601d27ee27978ced72842771616ff13e92a8
The archive digest is built from every live record (5000 of them):
parts = []
for lid in sorted(live):
payload_hash = hashlib.sha256(live[lid].payload).hexdigest()
parts.append(f"{lid}:{live[lid].epoch}:{payload_hash}")
archive_digest = hashlib.sha256(";".join(parts).encode()).hexdigest()
# ea05aa9e0f487c43f680e51f4f06758149b345f3822dbed55ec080b1c37cc6bc
Submission
{
"target_lid": 3155,
"committed_epoch": 3,
"committed_txn": 36516,
"physical_slot": 26417,
"record_digest": "b3ef6fd95676c067ef3b46df05a0601d27ee27978ced72842771616ff13e92a8",
"committed_record_count": 5000,
"archive_digest": "ea05aa9e0f487c43f680e51f4f06758149b345f3822dbed55ec080b1c37cc6bc"
}
The validator returned {"status":"memory-restored"}. The recovered record's 200-byte payload starts with AHALYA-STONE:3155:v3:scribe36516 and is padded with dots.
Notes
- The visible ASCII prefix isn't the whole payload. The digest is over the full 200-byte body, including the 9-byte record prefix and the dot padding.
- The duplicate lid/epoch/txn block at offset 23 starts the body, so don't treat it as a separate header when hashing.
- The wear-leveling indirection must be inverted. A frame whose lid and epoch look right can still be in the wrong physical place.
- Commit-marked does not mean valid. The flag byte decides whether the write survived.
- Frames after the last log record are the torn write. They aren't committed even if their headers look newer.
- A higher epoch can be a loser. Epoch 6 is newer than epoch 3 but is torn.