Skip to content

Instantly share code, notes, and snippets.

@nikdoof
Created July 8, 2026 12:44
Show Gist options
  • Select an option

  • Save nikdoof/48e05799c53acf8548d2821ccd18035d to your computer and use it in GitHub Desktop.

Select an option

Save nikdoof/48e05799c53acf8548d2821ccd18035d to your computer and use it in GitHub Desktop.
OS/2 LAN Manager 2.0 UAS Database Dumper
"""
Dump NET.ACC — OS/2 LAN Manager UAS Database
Usage:
python3 dump_netacc.py # Dump all records
python3 dump_netacc.py <file> # Dump other NET.ACC
python3 dump_netacc.py --scan # Show scan results
python3 dump_netacc.py --header-only # Header only
python3 dump_netacc.py --raw <offset> # Hex dump raw bytes
python3 dump_netacc.py --quiet # Suppress scan noise
python3 dump_netacc.py --decrypt # Show decrypted LM hashes
python3 dump_netacc.py --hashcat # Hashcat mode 3000 (LM halves, decrypted)
python3 dump_netacc.py --hashcat --mode 5500 # NetNTLMv1 format
"""
import argparse
import struct
import sys
from collections.abc import Sequence
from datetime import datetime, timedelta
from pathlib import Path
from Crypto.Cipher import DES
RECORD_SIZE = 0x180
PRIV_NAMES = {0: "GUEST", 1: "USER", 2: "ADMIN"}
ACB_FLAGS: dict[int, str] = {
0x0001: "DISABLED",
0x0002: "HOMDIRREQ",
0x0004: "PWNOTREQ",
0x0008: "TEMPDUP",
0x0010: "NORMAL",
0x0020: "MNS",
0x0040: "DOMTRUST",
0x0080: "WSTRUST",
0x0100: "SVRTRUST",
0x0200: "PWNOEXP",
0x0400: "AUTOLOCK",
}
def str_to_key(seven: bytes) -> bytes:
k = bytearray(8)
k[0] = seven[0] >> 1
k[1] = ((seven[0] & 0x01) << 6) | (seven[1] >> 2)
k[2] = ((seven[1] & 0x03) << 5) | (seven[2] >> 3)
k[3] = ((seven[2] & 0x07) << 4) | (seven[3] >> 4)
k[4] = ((seven[3] & 0x0F) << 3) | (seven[4] >> 5)
k[5] = ((seven[4] & 0x1F) << 2) | (seven[5] >> 6)
k[6] = ((seven[5] & 0x3F) << 1) | (seven[6] >> 7)
k[7] = seven[6] & 0x7F
for i in range(8):
k[i] = (k[i] << 1) & 0xFF
return bytes(k)
def decrypt_stored(data: bytes, kw: int) -> bytes:
kw_bytes = struct.pack("<H", kw)
kw_x4 = (kw_bytes * 4)[:7]
key = str_to_key(kw_x4)
h1 = DES.new(key, DES.MODE_ECB).decrypt(data[:8])
h2 = DES.new(key, DES.MODE_ECB).decrypt(data[8:])
return h1 + h2
# ── Helpers ──────────────────────────────────────────────────────────
def parse_filetime(raw: bytes) -> str:
t = struct.unpack("<Q", raw)[0]
if t == 0:
return "(zero)"
try:
dt = datetime(1601, 1, 1) + timedelta(seconds=t / 10_000_000)
return dt.strftime("%Y-%m-%d %H:%M:%S")
except (OverflowError, ValueError):
return f"0x{t:016X}"
def acb_str(flags: int) -> str:
return (
" | ".join(name for mask, name in sorted(ACB_FLAGS.items()) if flags & mask)
or "(none)"
)
def read_cstr(data: bytes, start: int, max_len: int = 0) -> str:
end = data.find(b"\x00", start)
if end == -1:
end = start + (max_len or min(256, len(data) - start))
return data[start:end].decode("cp437", errors="replace")
def hexdump(data: bytes, offset: int, size: int) -> None:
for i in range(0, size, 16):
chunk = data[offset + i : offset + i + 16]
hex_str = " ".join(f"{b:02X}" for b in chunk)
ascii_str = "".join(chr(b) if 0x20 <= b < 0x7F else "." for b in chunk)
addr = offset + i
print(f" {addr:04X}: {hex_str:48s} {ascii_str}")
print()
# ── Header ───────────────────────────────────────────────────────────
def dump_header(data: bytes) -> None:
print("=== NET.ACC Header ===")
sig = data[0:16].decode("ascii", errors="replace").strip("\x00")
ver = data[0x10:0x14].decode("ascii", errors="replace").strip("\x00")
srv = data[0x44:0x58].split(b"\x00")[0].decode("cp437", errors="replace")
desc = data[0x5C:0x8C].split(b"\x00")[0].decode("cp437", errors="replace")
data_off = struct.unpack_from("<I", data, 0xA0)[0]
print(f" Signature: {sig}")
print(f" Version: {ver}")
print(f" Server: {srv}")
print(f" Description: {desc}")
print(f" Data offset: 0x{data_off:04X}")
print(f" Version flags: 0x{struct.unpack_from('<I', data, 0x14)[0]:08X}")
print(f" Created: {parse_filetime(data[0x90:0x98])}")
print(f" Modified: {parse_filetime(data[0x98:0xA0])}")
print()
# ── User record ──────────────────────────────────────────────────────
def dump_user(data: bytes, offset: int, do_decrypt: bool = False) -> None:
if offset + RECORD_SIZE > len(data):
print(f" Record at 0x{offset:04X}: truncated\n")
return
rec = data[offset : offset + RECORD_SIZE]
type_byte = rec[0]
if type_byte in (0x40, 0x55):
name = read_cstr(rec, 0x00, 21) or read_cstr(rec, 0x06, 15)
else:
name = read_cstr(rec, 0x06, 15)
stored = rec[0x1D : 0x1D + 16]
old_hash = rec[0x22:0x32].hex()
field_a = rec[0x16:0x22].hex()
ac_flags = struct.unpack_from("<H", rec, 0x5C)[0]
header_off = offset - 0x40
kw = struct.unpack("<H", data[header_off + 0x0A : header_off + 0x0A + 2])[0]
lm_hash = ""
if do_decrypt:
decrypted = decrypt_stored(stored, kw)
lm_hash = decrypted.hex()
rid_data = rec[0x37:0x3F]
rids = [struct.unpack_from("<H", rid_data, i)[0] for i in range(0, 8, 2)]
hdir = read_cstr(rec, 0xF0)
comment_off = 0xF0 + len(hdir.encode("cp437")) + 1 if hdir else 0xF1
comment = read_cstr(rec, comment_off) if comment_off < RECORD_SIZE else ""
type_str = "(template)" if type_byte == 0x55 else ""
priv_word = struct.unpack_from("<H", rec, 0x50)[0]
priv_name = PRIV_NAMES.get(priv_word & 0xFF, f"0x{priv_word:04X}")
age_raw = rec[0x1B:0x21]
age_val = struct.unpack("<I", age_raw[:4])[0] if len(age_raw) >= 4 else 0
print(f" Type: 0x{type_byte:02X} {type_str}")
print(f" Username: {name}")
print(f" Keyword: 0x{kw:04X}")
print(f" Stored (+0x1D): {stored.hex()}")
if lm_hash:
print(f" Decrypted LM: {lm_hash}")
print(f" Old hash (+0x22):{old_hash}")
print(f" Field A: {field_a} (pwd age: {age_val} secs)")
print(f" Privilege: {priv_name}")
print(f" Account Ctrl: 0x{ac_flags:04X} {acb_str(ac_flags)}")
print(f" Group RIDs: {' '.join(f'0x{r:04X}' for r in rids)}")
print(f" Home Dir: {hdir or '(empty)'}")
if comment:
print(f" Comment: {comment}")
print()
# ── Group record ─────────────────────────────────────────────────────
def dump_group(data: bytes, offset: int, name_hint: str = "") -> None:
print(f"--- Group at 0x{offset:04X} ---")
header = data[offset : offset + 0x40]
name = name_hint or read_cstr(header, 0x00, 21)
comment = read_cstr(header, 0x15, 60)
member_count = (
struct.unpack_from("<I", data, offset + 0x60)[0]
if offset + 0x64 <= len(data)
else 0
)
prev_ptr = struct.unpack_from("<I", header, 0)[0]
next_ptr = struct.unpack_from("<I", header, 4)[0]
print(f" Name: {name}")
print(f" Prev/Next: 0x{prev_ptr:04X} 0x{next_ptr:04X}")
print(f" Member count: {member_count}")
print(f" Header flags: {' '.join(f'{b:02X}' for b in header[:32])}")
if comment and comment != name:
print(f" Description: {comment}")
print()
# ── Scanning ─────────────────────────────────────────────────────────
def _is_group_name(s: str) -> bool:
return (s.isupper() and s.isalpha() and len(s) >= 3) or (
s.startswith("@") and len(s) > 3
)
def _is_valid_username(s: str) -> bool:
return (
s.isascii()
and len(s) >= 1
and len(s) <= 15
and s.isprintable()
and s[0].isupper()
)
def scan_records(data: bytes) -> dict:
user_offsets: set[int] = set()
template_offsets: set[int] = set()
group_offsets: dict[int, str] = {}
# Phase 1: scan groups.
# Groups are uppercase/@-prefixed names stored at record-level offsets,
# usually in the lower portion of the file (before user records).
skip_until = 0
for off in range(0x200, min(len(data), 0x9000)):
if off < skip_until:
continue
name_limit = data.find(b"\x00", off, off + 21)
if name_limit == -1:
continue
name = data[off:name_limit]
if not name or len(name) < 3 or len(name) > 20:
continue
try:
s = name.decode("ascii")
except UnicodeDecodeError:
continue
if not _is_group_name(s):
continue
# Exclude names inside printable-text runs (false positive in descriptions)
if off >= 4:
before = data[off - 4 : off]
if all(0x20 <= b < 0x7F for b in before):
continue
if s.startswith("@"):
# Could be a template (@USER_TEMPLATE) or a group (@GROUP_TEMPLATE).
# If the name-field at +0x06 holds a valid username, it's a template.
subname = read_cstr(data, off + 0x06, 15)
if _is_valid_username(subname):
template_offsets.add(off)
skip_until = off + len(name) + 1
continue
group_offsets[off] = s
skip_until = off + len(name) + 1
# Phase 2: scan user/template records in areas not covered by groups.
blocked: set[int] = set()
for off in group_offsets:
blocked.update(range(off, off + 0x80))
skip_until = 0
for off in range(0x200, len(data)):
if off < skip_until:
continue
if off in blocked:
continue
if off + RECORD_SIZE > len(data):
continue
if data[off] not in (0x00, 0x40, 0x55):
continue
has_std_padding = data[off + 1 : off + 6] == b"\x00" * 5
if data[off] == 0x00 and not has_std_padding:
continue
name_bytes = data[off + 0x06 : off + 0x16]
name = name_bytes.split(b"\x00")[0]
if not name or len(name) < 1 or len(name) > 15:
continue
try:
s = name.decode("ascii")
except UnicodeDecodeError:
continue
if not _is_valid_username(s):
continue
# Verify the header keyword field exists (non-binary junk)
header_off = off - 0x40
kw = struct.unpack("<H", data[header_off + 0x0A : header_off + 0x0A + 2])[0]
if kw == 0 and data[off] == 0x00:
if data[header_off : header_off + 0x40] == b"\x00" * 64:
continue
if data[off] in (0x40, 0x55):
template_offsets.add(off)
else:
user_offsets.add(off)
skip_until = off + RECORD_SIZE
return {
"users": sorted(user_offsets),
"templates": sorted(template_offsets),
"groups": group_offsets,
}
# ── Statistics ───────────────────────────────────────────────────────
def dump_stats(data: bytes, scans: dict) -> None:
print("=== File Statistics ===")
print(f" File size: {len(data)} bytes (0x{len(data):04X})")
user_count = len(scans["users"])
template_count = len(scans["templates"])
group_count = len(scans["groups"])
print(f" User records: {user_count}")
print(f" Template recs: {template_count}")
print(f" Group records: {group_count}")
print(f" Total records: {user_count + template_count + group_count}")
print()
# ── CLI ──────────────────────────────────────────────────────────────
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Dump NET.ACC — OS/2 LAN Manager UAS Database"
)
parser.add_argument("file", nargs="?", type=Path)
parser.add_argument("--scan", action="store_true")
parser.add_argument("--header-only", action="store_true")
parser.add_argument("--raw", type=lambda x: int(x, 0), metavar="OFFSET")
parser.add_argument("--decrypt", action="store_true")
parser.add_argument("--hashcat", action="store_true")
parser.add_argument("--mode", choices=["3000", "5500"], default="3000")
for flag in ("--quiet", "--all", "--summary"):
parser.add_argument(flag, action="store_true", help=argparse.SUPPRESS)
return parser.parse_args(argv)
# ── Main ─────────────────────────────────────────────────────────────
def main() -> None:
args = parse_args()
data = args.file.read_bytes()
if args.hashcat:
user_offsets = sorted(scan_records(data)["users"])
if args.mode == "3000":
hdr = "# user decrypted LM hash (32 hex)"
print(hdr, file=sys.stderr)
print("# " + "-" * (len(hdr) - 2), file=sys.stderr)
for off in user_offsets:
if off + RECORD_SIZE > len(data):
continue
rec = data[off : off + RECORD_SIZE]
name = read_cstr(rec, 0x06, 15)
header_off = off - 0x40
kw = struct.unpack(
"<H", data[header_off + 0x0A : header_off + 0x0A + 2]
)[0]
stored = rec[0x1D : 0x1D + 16]
h = decrypt_stored(stored, kw)
mname = name.ljust(10)
print(f"# {mname} {h.hex()}", file=sys.stderr)
print(f"{h.hex().upper()}")
elif args.mode == "5500":
print("# hashcat mode 5500 - NetNTLMv1", file=sys.stderr)
for off in user_offsets:
if off + RECORD_SIZE > len(data):
continue
rec = data[off : off + RECORD_SIZE]
name = read_cstr(rec, 0x06, 15)
header_off = off - 0x40
kw = struct.unpack(
"<H", data[header_off + 0x0A : header_off + 0x0A + 2]
)[0]
stored = rec[0x1D : 0x1D + 16]
h = decrypt_stored(stored, kw)
print(f"{name}::NET.ACC:{h.hex().upper()}:{'00' * 16}:{'00' * 8}")
return
print(f"NET.ACC: {args.file} ({len(data)} bytes)")
print()
if args.raw is not None:
off = args.raw
sz = min(0x180, len(data) - off)
print(f"=== Hex dump at 0x{off:04X} ===")
hexdump(data, off, sz)
return
dump_header(data)
if args.header_only:
print(" Raw header (0x00-0xFF):")
hexdump(data, 0, 0x100)
return
scans = scan_records(data)
if args.scan:
dump_stats(data, scans)
print(
f"=== Scan Results: {len(scans['users'])} users, "
f"{len(scans['templates'])} templates, "
f"{len(scans['groups'])} groups ==="
)
print()
if scans["groups"]:
print("=== Groups ===")
for off in sorted(scans["groups"]):
name = scans["groups"][off]
print(f" 0x{off:04X} {name}")
print()
if scans["templates"]:
print("=== Templates ===")
for off in sorted(scans["templates"]):
name = read_cstr(data, off, 21)
print(f" 0x{off:04X} {name}")
print()
return
print("=== User Records ===")
for off in scans["users"]:
if off + RECORD_SIZE > len(data):
continue
name = read_cstr(data, off + 0x06, 15)
print(f"--- User at 0x{off:04X} ({name}) ---")
dump_user(data, off, args.decrypt)
print("=== Group Records ===")
for off in sorted(scans["groups"]):
dump_group(data, off, scans["groups"][off])
print("=== Template Records ===")
for off in sorted(scans["templates"]):
name = read_cstr(data, off, 21)
print(f"--- Template at 0x{off:04X} ({name}) ---")
dump_user(data, off)
if __name__ == "__main__":
main()

NET.ACC — OS/2 LAN Manager 2.0 UAS Database Format

Overview

NET.ACC is the User Accounts System (UAS) database file from OS/2 Microsoft LAN Manager 2.0 Primary Domain Controllers. It is the direct predecessor of the Windows NT SAM registry hive.

Property Value
Signature MICROSOFT LANMAN (16 bytes at offset 0)
Version 2.0 (at offset 0x10)
Server name At offset 0x44 (null-terminated, max 20 bytes)
Description At offset 0x60 (null-terminated, max 48 bytes)
Typical size 74,752 bytes
Padding 0x00 in header, 0xFF at end of file

File Layout

Offset          Size    Description
──────────────────────────────────────────────
0x0000          0x0100  File header (256 bytes)
0x0100-0x07FF   ~1.7K   Zero-padded header area
0x0800-0x0CB2   ~1.2K   Reserved / alignment
0x0CB3+                  Record area:
                           Group records (linked list)
                           User records (0x180 bytes each)
                           Template records (0x180 bytes each)
0x12300+         0x4K    0xFF padding to end of file

File Header (0x0000–0x00FF)

Offset Size Type Description
0x00 16 ASCII Signature: MICROSOFT LANMAN
0x10 4 ASCII Version string (2.0 with null padding)
0x14 4 LE DWORD Version flags (typically 0x00010000)
0x18 8 bytes Unknown (00 00 80 51 01 00 FF FF)
0x20 4 LE DWORD Unknown (0xFFFFFFFF)
0x24 4 LE DWORD Unknown (0x0005FFFF)
0x38 4 LE DWORD Unknown timestamp-like value
0x3C 4 LE DWORD Offset/length field
0x40 4 bytes Unknown
0x44 20 ASCII Server name (null-terminated, e.g. SERVERX1)
0x60 48 CP437 Description (null-terminated, e.g. LANMAN 2.0 UAS DATABASE)
0x90 8 FILETIME Create timestamp (100-ns intervals since 1601-01-01)
0x98 8 FILETIME Modify timestamp (100-ns intervals since 1601-01-01)
0xA0 4 LE DWORD Data offset pointer (e.g. 0xA740 = 42,816)
0xA4-0xFF 92 bytes Unknown / padding

Group Record Structure

Group records are stored at arbitrary offsets in the record area (typically 0x0CB3–0x9000). They are NOT at fixed intervals — each group record can be at any offset. Groups are identified by scanning for ASCII uppercase names (3–20 chars, all-alpha or @-prefixed).

Group Record Layout (~0x40 bytes meaningful, no fixed size)

Offset Size Description
+0x00 4 Name bytes (also readable as prev_ptr LE DWORD)
+0x04 4 Next pointer (LE DWORD) — linked list linkage
+0x08 2 Next pointer extension
+0x0A var Remainder of name (name spans +0x00 to first null byte, max 21 bytes)
+0x15 60 Description (null-terminated CP437, max 60 bytes)
+0x60 4 Member count (LE DWORD)

The group name starts at +0x00 and is null-terminated (max 21 bytes). The "prev_ptr" and "next_ptr" fields are the first bytes of the name interpreted as integers; they are not meaningful pointer values.

Group Detection Algorithm

For each offset in range(0x200, min(file_size, 0x9000)):
    1. Check for null-terminated ASCII string (3-20 chars) at current offset
    2. Name must match: isupper() and isalpha() and len >= 3
       OR: starts with '@' and len > 3
    3. Filter: skip if the 4 bytes before the name are all printable ASCII
       (false positive in description fields)
    4. For @-prefixed names: check if +0x06 holds a valid username (see
       template detection). If yes, it's a template (not a group).
    5. Skip to after the null terminator + 1 to avoid re-matching

Groups found in the reference file:

Offset Name Description
0x0AAD DGR (none)
0x0CB3 CUSERS (none)
0x0E24 SERVERS (none)
0x20EE LOCAL (none)
0x24B0 DOSUSERS Members can access dos utilities
0x2950 @GROUP_TEMPLATE Template group for rmacc. DO NOT DELETE
0x2C34 USERS (none)
0x3858 ADMINS (none)
0x3D8C GROUPA An example group that runs up WinWord
0x3DD6 GROUPB An example group that runs up Excel
0x414E GUESTS (none)

User Record Structure

Each user record is exactly 0x180 (384) bytes. Records are detected by scanning for the type byte pattern. The header is stored 0x40 bytes before the record start.

On-Disk Layout

Record offset:  RECORD_START
Header offset:  RECORD_START - 0x40
Record Offset Size Field
+0x00 1 Type byte (0x00 = user, 0x40 = @-template, 0x55 = template)
+0x01–+0x05 5 Zero padding (for type 0x00 and some 0x55 records)
+0x06–+0x15 16 Username (null-terminated ASCII, max 15 chars)
+0x16–+0x1C 7 Field A (password age / timestamp data)
+0x1D–+0x2C 16 Encrypted LM hash (DES-encrypted)
+0x2D–+0x31 5 Flags / metadata
+0x32–+0x41 16 Field B (4 group membership RIDs, LE WORD at bytes 5-12)
+0x42–+0x4F 14 Field C (flags / padding)
+0x50–+0x51 2 Privilege level (LE WORD: 0=GUEST, 1=USER, 2=ADMIN)
+0x52–+0x5B 10 Padding / flags
+0x5C–+0x5D 2 Account control flags (ACB_* bitmask, LE WORD)
+0x5E–+0xEF 146 Padding
+0xF0 var Home directory path (null-terminated CP437)
after var Comment text (null-terminated CP437, follows home dir)

Header Layout (at RECORD_START - 0x40)

Header Offset Size Field
+0x00–+0x09 10 Unknown / reserved
+0x0A–+0x0B 2 Keyword (LE WORD) — 11-bit hash of the username
+0x0C–+0x3F 52 Remainder of header

Overlapping Fields

The fields at +0x1D and +0x22 overlap:

+0x1D: [T0  T1  T2  T3  T4  T5  T6  T7  T8  T9  T10 T11 T12 T13 T14 T15]  transform data (16 bytes)
+0x22: [T5  T6  T7  T8  T9  T10 T11 T12 T13 T14 T15  F0  F1  F2  F3  F4]  "old hash" view (overlaps)
  • T0–T15: The 16-byte DES-encrypted LM hash (the only meaningful data)
  • F0–F4: 5 bytes of flags/metadata. The last byte F4 is 0x26 — a record-format flag

Account Control (ACB) Flags

Bit Flag Description
0x0001 DISABLED Account is disabled
0x0002 HOMDIRREQ Home directory required
0x0004 PWNOTREQ Password not required
0x0008 TEMPDUP Temporary duplicate account
0x0010 NORMAL Normal account
0x0020 MNS MNS logon account
0x0040 DOMTRUST Domain trust account
0x0080 WSTRUST Workstation trust account
0x0100 SVRTRUST Server trust account
0x0200 PWNOEXP Password never expires
0x0400 AUTOLOCK Account auto-locked

Template Record Structure

Templates use the same 0x180-byte record format as user records but with a different type byte and name layout.

Template vs. User Differences

Aspect User Record Template Record
Type byte 0x00 0x40 or 0x55
Name location At +0x06 only Full name at +0x00 (includes type byte as '@'); substring at +0x06
Zero padding at +1..+5 Always 5 zeros May NOT be zeros (name runs through here)
Display name Read from +0x06 Full name from +0x00 (e.g. @USER_TEMPLATE)

Template Detection

Templates are found in two ways:

1. Via group scan (range 0x200–0x9000): When an @-prefixed name is found, check if the bytes at +0x06 form a valid username. If so, it's a template, not a group.

2. Via full-file scan (range 0x200–end): Type byte 0x40 or 0x55 with a valid username at +0x06, even without the standard 5-zero padding. This detects templates that are outside the group scan range (past 0x9000).

Offset Name
0x8F86 @USER_TEMPLATE
0xA386 @STATION

Record Discovery Algorithm

The scan_records() function in dump_netacc.py discovers all records dynamically:

Phase 1: Groups

Range: 0x200 to min(file_size, 0x9000), step 1
Skip past: previously found group name (len + 1)

For each offset:
  1. Find null terminator within 21 bytes → get candidate name
  2. Skip if name length < 3 or > 20
  3. Skip if not valid group name (all-uppercase alpha, or @-prefixed)
  4. Skip if 4 preceding bytes are all printable ASCII (false positive filter)
  5. If @-prefixed: check +0x06 for valid username → if yes, classify as template
  6. Otherwise: classify as group

Phase 2: Users & Templates

Build blocked set: for each group offset, block range(off, off + 0x80)
Range: 0x200 to file_size, step 1
Skip past: blocked offsets (group areas), already-found records (RECORD_SIZE)

For each offset:
  1. Type byte must be 0x00, 0x40, or 0x55
  2. If type 0x00: require 5 zero bytes at +1..+5
  3. Read name at +0x06 (null-terminated, max 15 bytes, ASCII)
  4. Name must be a valid username (printable ASCII, first char uppercase)
  5. For type 0x00: verify header keyword at offset-0x40+0x0A is non-zero
     (or the 64-byte header is not all zeros)
  6. Type 0x40 or 0x55 → template; type 0x00 → user

Username Validation

def _is_valid_username(s: str) -> bool:
    return s.isascii() and len(s) >= 1 and len(s) <= 15 and \
           s.isprintable() and s[0].isupper()

The first character must be an uppercase ASCII letter (A–Z). This excludes @-prefixed names at +0x06 (which would get partial names starting with _ or other chars) while allowing names with digits like USERG1.


The Keyword

The keyword is an 11-bit hash of the username, computed by the function at seg21:0x3EC5 in NETAPI.DLL.

Username Hash Algorithm

def hash_username(name: str) -> int:
    """11-bit hash function from seg21:3EC5."""
    si = 0
    for c in name.encode('latin-1'):
        si ^= c
        si = ((si >> 8) & 7) | ((si << 3) & 0xFFFF)
    return si & 0x07FF  # 11-bit mask

Example Values

Username Keyword
ADMIN 0x06B9 (1721)
GSUPER 0x05D8 (1496)
BACKUP 0x0442 (1090)
USERR 0x01F5 (501)
Y7930018 0x0721 (1825)
GUEST 0x00A9 (169)

Keyword Storage

The keyword is stored at the record header:

header_offset = record_offset - 0x40
keyword = struct.unpack('<H', data[header_offset + 0x0A])[0]

For new records, the keyword is computed and stored at creation time. For existing records, the keyword retains whatever value was read from the UAS database (it should match the recomputed hash of the username, but this is not validated by the server).


LM Hash Computation

The LM hash is standard (identical to Samba/Windows LM):

Algorithm

  1. Uppercase the password
  2. Pad with nulls or truncate to 14 bytes
  3. Split into two 7-byte halves
  4. Each half → 8-byte DES key via str_to_key()
  5. DES-encrypt the constant KGS!@#$% (8 bytes) with each key
  6. Concatenate the two 8-byte ciphertexts → 16-byte LM hash

str_to_key()

Standard LM key derivation:

def str_to_key(seven: bytes) -> bytes:
    k = bytearray(8)
    k[0] = seven[0] >> 1
    k[1] = ((seven[0] & 0x01) << 6) | (seven[1] >> 2)
    k[2] = ((seven[1] & 0x03) << 5) | (seven[2] >> 3)
    k[3] = ((seven[2] & 0x07) << 4) | (seven[3] >> 4)
    k[4] = ((seven[3] & 0x0F) << 3) | (seven[4] >> 5)
    k[5] = ((seven[4] & 0x1F) << 2) | (seven[5] >> 6)
    k[6] = ((seven[5] & 0x3F) << 1) | (seven[6] >> 7)
    k[7] = seven[6] & 0x7F
    for i in range(8):
        k[i] = (k[i] << 1) & 0xFF
    return bytes(k)

Notes

  • The NETAPI.DLL implementation does NOT uppercase the password before hashing (it copies password bytes verbatim). However, the standard LM hash computation DOES uppercase. The stored transform data uses the uppercased LM hash as plaintext (confirmed by the write path in NETAPI.DLL's seg21:7661, which receives the already-computed standard LM hash before encrypting it).
  • lm_hash("") = AAD3B435B51404EEAAD3B435B51404EE

The Transform: How LM Hashes Are Encrypted On Disk

This is the critical discovery. The LM hash is NOT stored in plain text. It is DES-encrypted with a key derived from the username hash (keyword).

Encryption (Write Path)

stored_hash[+0x1D] = DES_encrypt(
    key = str_to_key(keyword_repeated_4x[:7]),
    plaintext = LM_hash(password)
)

Where keyword_repeated_4x[:7] is the 2-byte keyword repeated 4 times, truncated to 7 bytes:

kw_bytes = struct.pack('<H', kw)     # e.g. 0x06B9 → b'\xB9\x06'
kw_x4 = (kw_bytes * 4)[:7]           # b'\xB9\x06\xB9\x06\xB9\x06\xB9'
key = str_to_key(kw_x4)              # 8-byte DES key

Decryption (Authentication / Read Path)

LM_hash(password) = DES_decrypt(
    key = str_to_key(keyword_repeated_4x[:7]),
    ciphertext = stored_hash[+0x1D]
)

Implementation

from Crypto.Cipher import DES

def str_to_key(seven: bytes) -> bytes:
    k = bytearray(8)
    k[0] = seven[0] >> 1
    k[1] = ((seven[0] & 0x01) << 6) | (seven[1] >> 2)
    k[2] = ((seven[1] & 0x03) << 5) | (seven[2] >> 3)
    k[3] = ((seven[2] & 0x07) << 4) | (seven[3] >> 4)
    k[4] = ((seven[3] & 0x0F) << 3) | (seven[4] >> 5)
    k[5] = ((seven[4] & 0x1F) << 2) | (seven[5] >> 6)
    k[6] = ((seven[5] & 0x3F) << 1) | (seven[6] >> 7)
    k[7] = seven[6] & 0x7F
    for i in range(8):
        k[i] = (k[i] << 1) & 0xFF
    return bytes(k)

def decrypt_stored(stored: bytes, kw: int) -> bytes:
    """Decrypt 16-byte stored hash to 16-byte LM hash."""
    kw_bytes = struct.pack('<H', kw)
    kw_x4 = (kw_bytes * 4)[:7]
    key = str_to_key(kw_x4)
    h1 = DES.new(key, DES.MODE_ECB).decrypt(stored[:8])
    h2 = DES.new(key, DES.MODE_ECB).decrypt(stored[8:])
    return h1 + h2

def encrypt_lm(lm_hash: bytes, kw: int) -> bytes:
    """Encrypt 16-byte LM hash to 16-byte stored hash."""
    kw_bytes = struct.pack('<H', kw)
    kw_x4 = (kw_bytes * 4)[:7]
    key = str_to_key(kw_x4)
    h1 = DES.new(key, DES.MODE_ECB).encrypt(lm_hash[:8])
    h2 = DES.new(key, DES.MODE_ECB).encrypt(lm_hash[8:])
    return h1 + h2

Reading a Full Record

import struct

def read_user_record(data: bytes, record_offset: int) -> dict:
    """Parse a user/template record and decrypt the stored hash."""

    record = data[record_offset : record_offset + 0x180]

    # Type byte
    type_byte = record[0]

    # Name: at +0x06 for users, at +0x00 for templates
    if type_byte in (0x40, 0x55):
        name_end = record.find(b'\x00', 0, 21)
        name = record[0:name_end].decode('ascii', errors='replace')
    else:
        name_end = record.find(b'\x00', 0x06, 0x16)
        name = record[0x06:name_end].decode('ascii', errors='replace')

    # Header and keyword
    header_off = record_offset - 0x40
    kw = struct.unpack('<H', data[header_off + 0x0A : header_off + 0x0C])[0]

    # Stored encrypted hash at +0x1D
    stored = record[0x1D : 0x1D + 16]

    # Decrypt to LM hash
    lm_hash = decrypt_stored(stored, kw)

    # Additional fields
    priv = struct.unpack('<H', record[0x50:0x52])[0] & 0xFF
    ac_flags = struct.unpack('<H', record[0x5C:0x5E])[0]
    home_dir_end = record.find(b'\x00', 0xF0)
    home_dir = record[0xF0:home_dir_end].decode('cp437', errors='replace') if home_dir_end > 0xF0 else ''

    return {
        'type': type_byte,
        'name': name,
        'keyword': kw,
        'stored_hash': stored,
        'lm_hash': lm_hash,
        'privilege': priv,
        'ac_flags': ac_flags,
        'home_dir': home_dir,
    }

Key vs Plaintext Roles

Standard LM NET.ACC On-Disk Storage
DES Key str_to_key(password_half) str_to_key(keyword_repeated_4x[:7])
DES Input KGS!@#$% (8 bytes) LM hash half (8 bytes)
Output Standard LM hash (16 bytes) Encrypted LM hash at file[+0x1D] (16 bytes)

Authentication Flow

Server receives LM hash from client (standard computation)
Server reads stored hash at +0x1D
Server DES_encrypts stored hash with keyword-derived key
Server compares encrypted result against received LM hash

DES_encrypt(key=keyword_key, plaintext=stored_hash[0:8]) == received_LM_hash[0:8]
DES_encrypt(key=keyword_key, plaintext=stored_hash[8:16]) == received_LM_hash[8:16]

This is equivalent to:

received_LM_hash == DES_encrypt(key=keyword_key, plaintext=stored_hash)
              == DES_encrypt(key=keyword_key, plaintext=DES_encrypt(key=keyword_key, plaintext=LM_hash))
              == LM_hash  (DES is symmetric: E(K, E(K, P)) = P is false;
                           but E(K, D(K, C)) = C, and we have D(K, C) = P,
                           so E(K, P) = E(K, D(K, C)) = C = received)

The server receives the standard LM hash (E(password, KGS!@#$%)), reads the stored ciphertext (E(keyword, LM_hash)), encrypts it with the keyword (E(keyword, E(keyword, LM_hash))), and compares against the received hash. If they match, the password is correct.


Hashcat Mode 3000 (LM Hash Output)

For hashcat mode 3000 (LM hash cracking), output each user's decrypted LM hash:

# user       decrypted LM hash (32 hex)
# -------------------------------------
# ADMIN      23a38fef7f1de2a4aad3b435b51404ee
23A38FEF7F1DE2A4AAD3B435B51404EE

Hashcat mode 3000 expects lines of hex-encoded LM hashes, one per user.

Hashcat Mode 5500 (NetNTLMv1)

Output in NetNTLMv1 format:

username::NET.ACC:decrypted_lm_hash_hex:0000000000000000:00000000

File Utility (dump_netacc.py)

The reference implementation dump_netacc.py provides:

Flag Description
(no flag) Dump all records (users, groups, templates) with hex values
--scan Scan-only mode, lists all discovered records
--header-only Show file header only
--raw <offset> Hex dump raw bytes at offset
--decrypt Include decrypted LM hashes in user dump
--hashcat Hashcat mode 3000 (decrypted LM hashes to stdout)
--hashcat --mode 5500 Hashcat mode 5500 (NetNTLMv1 format)

Dependencies

  • Python 3.14+
  • pycryptodome (for Crypto.Cipher.DES)

Usage Examples

# Full dump with decrypted hashes
python3 dump_netacc.py files/NET.ACC --decrypt

# Hashcat-ready LM hashes
python3 dump_netacc.py files/NET.ACC --hashcat

# NetNTLMv1 format
python3 dump_netacc.py files/NET.ACC --hashcat --mode 5500

# Scan for all records
python3 dump_netacc.py files/NET.ACC --scan

# Hex dump a specific offset
python3 dump_netacc.py files/NET.ACC --raw 0x9100
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment