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.

Ahalya: The Petrified Memory

**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:

Analysis

Frame format in archive.nvm. Every frame is 235 bytes:

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.

hex dump of the sealed frame showing PNKPMEM1 magic, lid, epoch, txn and PNKSEAL1

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:

hex view of archive.log header and the first few LREC records

Solution

Recovery algorithm:

  1. Start from the checkpoint. All 5000 map entries point at logical pba 12,514.
  2. Replay the log in order.
  3. Drop the 3,501-write batch and the 2,500 abort records. They are losers.
  4. 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.
  5. 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.
  6. For each live lid, keep the highest epoch that is both committed and valid.

For lid 3155 the versions are:

So the real latest-committed record is epoch 3, even though epochs 4 to 6 look newer.

recovery script output showing the four versions of lid 3155 and which one is chosen

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

Read this post on CyberHx →