Registry Parser
All articles

The nk record: how the registry stores a key (and its last-write time)

12 min read

Every key you have ever seen in a registry tree is, on disk, a single nk record. The key node — that two-byte nk signature plus a fixed header and an inline name — is the spine of the hive. Follow the nk records and their pointers and you have the whole tree. Read one nk correctly and you have the artifact that matters most in registry forensics: the registry key last write time. This post takes the key node apart field by field, then explains why its LastWritten timestamp is both the single most useful per-key field and the one most often misread.

If you have not already, read the regf hive format overview first. This post assumes you know what a cell and an HBIN are. It is part of the broader Windows registry internals series, and its sibling covers the vk value record. Here we stay inside the key node.

The signature and what surrounds it

An nk record lives inside an allocated cell. The cell starts with the usual signed 32-bit size (negative means allocated), and the record begins immediately after. The first two bytes are 0x6E 0x6B — the ASCII letters nk, which read as 0x6B6E when you load them as a little-endian 16-bit word. If those two bytes are not there, you are not looking at a key, and any offset that claims to point at a key but lands on something else is a corruption signal worth chasing.

The header is fixed-length. Everything up to the name is at a known offset; the name is variable and inline at the end. The offsets below are stable across the regf versions you will meet in practice (1.3 through 1.6), and both libregf's format notes and Google Project Zero's writeup agree on them. I'll flag the few fields that are version- or runtime-specific.

Walking the header

OffsetSizeFieldWhat it is
0x002Signaturenk (0x6B6E)
0x022FlagsKey-type bitfield (below)
0x048LastWrittenFILETIME, UTC, 100 ns ticks since 1601
0x0C4(access/spare)Runtime access bits; not forensically load-bearing
0x104ParentCell offset of the parent nk
0x144Subkey countNumber of stable subkeys
0x184Volatile subkey countIn-memory only; effectively zero on disk
0x1C4Subkey-list offsetCell offset of the subkey-list cell
0x204Volatile subkey-list offsetIn-memory only
0x244Value countNumber of values on this key
0x284Value-list offsetCell offset of the value-list cell
0x2C4Security offsetCell offset of the sk cell
0x304Class offsetCell offset of the class-name data
0x344MaxNameLen / flagsLargest subkey name; upper bits hold user/virtualization flags
0x384MaxClassLenLargest subkey class length
0x3C4MaxValueNameLenLargest value name on this key
0x404MaxValueDataLenLargest value data on this key
0x444(work var)Unused since XP; ignore
0x482Name lengthKey name length in bytes
0x4A2Class lengthClass data length in bytes
0x4CvarNameInline key name, ASCII or UTF-16

Every offset field is a cell index — a byte offset measured from the start of the hive's data, which is after the 4096-byte base block, not from the start of the file. Get that base-block adjustment wrong and every pointer in the hive lands 4096 bytes early. The sentinel 0xFFFFFFFF (HCELL_NIL) means "no such cell" and appears in the value-list, subkey-list, and class offsets of keys that have none.

The flags field

The 16-bit flags word at offset 0x02 tells you what kind of key this is. The values both libregf and Project Zero document:

  • 0x0001 KEY_IS_VOLATILE — volatile key. On disk this is essentially never set; volatility is tracked at runtime by the high bit of the cell index.
  • 0x0002 KEY_HIVE_EXIT — a mount point where another hive is grafted in. A memory-only construct; you will not see it in a flushed file.
  • 0x0004 KEY_HIVE_ENTRY — the root key of this hive. Exactly one nk in a clean hive carries this, and it is the one the base block's root-cell offset points at. If the offset and the flag disagree, trust neither and treat the hive as suspect.
  • 0x0008 KEY_NO_DELETE — the key is protected from deletion.
  • 0x0010 KEY_SYM_LINK — a registry symbolic link. The key has a SymbolicLinkValue value holding a target path, and the kernel transparently redirects lookups there. This is the registry's equivalent of a junction, and it is an under-appreciated hiding and redirection primitive: a link under a benign path can point an application's reads at attacker-controlled data elsewhere in the hive. When you see this flag, resolve the target before you trust what the key appears to contain.
  • 0x0020 KEY_COMP_NAME — the name is "compressed" ASCII (one byte per character) rather than UTF-16. More on this below; it is the flag that decides how you read the key name.
  • 0x0040 KEY_PREDEF_HANDLE — a predefined handle key. Deprecated, removed from Windows in 2023; on older hives it meant the "value" was actually a predefined HKEY.
  • 0x0080 / 0x0100 / 0x0200 — registry-virtualization flags (source, target, virtual-store). Relevant when you are untangling per-user virtualized writes to protected locations, not for routine key parsing.

For triage, the two flags worth reading on every key are KEY_HIVE_ENTRY (is this the root?) and KEY_SYM_LINK (is this key lying about where its data lives?).

The LastWritten timestamp

At offset 0x04 sits an 8-byte FILETIME: 100-nanosecond intervals since 1601-01-01 UTC. This is the per-key last write time, and it is the single most forensically important field in the entire hive.

Why it carries so much weight: the registry does not stamp individual values with a time. The vk record has no timestamp. The only temporal information the registry gives you, per key, is this one field on the nk. Whole categories of analysis — when a service was installed, when a USB device was first seen, when a Run key was tampered with, when a user's MRU list last changed — come down to reading the LastWritten time of the right key.

It is also the field most often misread, because what updates it is broader than people assume. The kernel rewrites LastWritten when the key itself is modified, and "modified" includes:

  • Adding or deleting a value on the key.
  • Adding or deleting a subkey.
  • Changing the key's security or class.

What does not reliably move it is changing the data of a value that already exists, in place, without the value being added or removed. So a LastWritten time tells you "something about this key's structure changed at this moment" — not "a specific value was written," and not "the value under this key currently holding X was set at this time." Two caveats follow directly:

  1. It is the key's time, not the value's. If you need to time a single value, the nk timestamp is, at best, an upper-or-equal bound tied to when the value was added — not when its contents last changed. A value updated in place can carry data far newer than the key's LastWritten time suggests.
  2. It moves on any structural change, and only keeps the latest. Adding one unrelated subkey rewrites the parent key's timestamp and overwrites whatever was there. The field is a single slot, not a log. The previous value is gone from the live hive.

That second caveat is why transaction logs and VSS snapshots matter so much for timeline work: replaying .LOG1/.LOG2 and pulling the same key from each snapshot is how you reconstruct a sequence of writes that the live hive has flattened into one timestamp. Same lesson as the volatile LastRun in the UserAssist post: acquire once, at a known time, and treat that as your reference state.

Parent, subkeys, and values

The Parent offset at 0x10 points at the nk of the key one level up. The root key points at something that is not a valid parent — do not recurse upward forever; stop when you hit the KEY_HIVE_ENTRY key. The parent pointer is what lets you reconstruct a full key path from a single recovered cell, which is invaluable when carving deleted keys out of free space.

Subkeys are reached indirectly. Offset 0x14 holds the subkey count and 0x1C holds the offset of a subkey-list cell — not the subkeys themselves. That list cell is an lh, lf, li, or ri record (covered in the regf overview); dereferencing each of its entries gives you the child nk offsets. A key with zero subkeys has count 0 and offset 0xFFFFFFFF. Cross-check the count against the list length: a mismatch is a tampering or corruption indicator.

Values work the same way. Offset 0x24 is the value count; offset 0x28 points at a value-list cell, which is a flat array of vk offsets. Each of those is a vk value record. Again, count of zero pairs with a 0xFFFFFFFF offset.

Security and class

Offset 0x2C is the sk (security descriptor) cell offset. Many keys share one sk — the hive deduplicates descriptors into a doubly-linked list — so a single sk offset appearing on hundreds of keys is normal. If you are hunting for keys an attacker has hidden behind restrictive ACLs (read access granted to SYSTEM only, for instance), this pointer is your way into the descriptor. Microsoft's own keys do not lock out Administrators; one that does is worth a look.

Offset 0x30 is the class-name offset and 0x4A holds its length. The class is an optional UTF-16 string attached to the key. Most keys have none (0xFFFFFFFF). Where it appears it is usually mundane, but a handful of keys store meaningful data there — the per-user ProfileGuid and certain policy keys are examples — so a parser that silently drops the class is throwing away occasionally useful content.

The key name

The name is inline, starting at offset 0x4C, with its byte length at 0x48. How you decode it depends on a single flag bit. If KEY_COMP_NAME (0x0020) is set in the flags word, the name is "compressed": one byte per character, effectively ASCII (Latin-1). If it is clear, the name is UTF-16 little-endian, two bytes per character. The length at 0x48 is always in bytes, so a five-character ASCII name reports length 5, while the same name in UTF-16 reports 10.

This is the classic mojibake bug. A parser that assumes every name is UTF-16 will render compressed names as Chinese-looking garbage, and one that assumes ASCII will turn genuinely Unicode key names — common on localized installs and in keys that store paths or device strings — into half-width nonsense. Read the flag, then decode accordingly. The name is not null-terminated; trust the length field, and note it cannot contain a backslash, since that is the path separator the kernel uses to walk between keys.

Reading one in a hex editor

Find an nk and the structure unfolds quickly. The 6E 6B signature, then two flag bytes (2C 00 is a common pair: compressed name plus no-delete), then eight bytes of FILETIME you can convert straight to a timestamp, then a parent offset you can follow up the tree. Counts and list offsets come next, then the name length and the inline name. Once you have done this by hand a couple of times, the output of any registry tool stops being magic — you can see exactly which bytes a reported "last write time" came from, and whether the tool got the name encoding right.

That verifiability is the point. When two tools disagree on a key's timestamp or render its name differently, the nk header is the ground truth, and you can read it yourself. The parser on this site surfaces the LastWritten time, flags, and decoded name for every key in the tree, in the browser, with nothing uploaded — but the value of knowing the format is that you never have to take any parser's word for it.

What to take away

The nk key node is a fixed header plus an inline name, and its fields map one-to-one onto the registry concepts you already know: parent, subkeys, values, security, class, and name. The flags field tells you whether a key is the root or a symbolic link — both worth checking on every key. And the LastWritten FILETIME is the registry's only per-key timestamp, which makes it the most-used field in registry forensics and the most-abused: it is the key's time, not a value's; it moves on any structural change; and it keeps only the latest write. Pair it with transaction-log replay and VSS to recover the sequence the live hive has collapsed.

Further reading

  • Google Project Zero, The Windows Registry Adventure #5: The regf file format: the deepest public writeup of the on-disk format and the inspiration for this series.
  • Joachim Metz, libregf: the C library behind most forensic tools, with format notes written from a parser implementor's perspective.
  • hivex: a smaller, readable C implementation that is easy to step through when you want a second reference for the nk layout.
  • Maxim Suhanov's regf specification, linked from the regf overview.