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 |
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
| 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 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).
| 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.
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) |
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.
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 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 |
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 byteF4is0x26— a record-format flag
| 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 |
Templates use the same 0x180-byte record format as user records but with a different type byte and name layout.
| 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) |
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 |
The scan_records() function in dump_netacc.py discovers all records dynamically:
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
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
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 is an 11-bit hash of the username, computed by the function at seg21:0x3EC5 in NETAPI.DLL.
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| Username | Keyword |
|---|---|
| ADMIN | 0x06B9 (1721) |
| GSUPER | 0x05D8 (1496) |
| BACKUP | 0x0442 (1090) |
| USERR | 0x01F5 (501) |
| Y7930018 | 0x0721 (1825) |
| GUEST | 0x00A9 (169) |
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).
The LM hash is standard (identical to Samba/Windows LM):
- Uppercase the password
- Pad with nulls or truncate to 14 bytes
- Split into two 7-byte halves
- Each half → 8-byte DES key via
str_to_key() - DES-encrypt the constant
KGS!@#$%(8 bytes) with each key - Concatenate the two 8-byte ciphertexts → 16-byte LM hash
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)- 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
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).
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 keyLM_hash(password) = DES_decrypt(
key = str_to_key(keyword_repeated_4x[:7]),
ciphertext = stored_hash[+0x1D]
)
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 + h2import 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,
}| 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) |
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.
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.
Output in NetNTLMv1 format:
username::NET.ACC:decrypted_lm_hash_hex:0000000000000000:00000000
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) |
- Python 3.14+
pycryptodome(forCrypto.Cipher.DES)
# 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