Back to Blog

Reversing a Driver: Uncovering NtDeviceIoControlFile

reverse-engineeringkerneldriverwindowsobfuscation
2025-08-239 min read

A brief walkthrough of how I identified an obfuscated API resolve routine in a Windows driver and reproduced the decryption to reveal the target function.

Target Function (Decompiled)

Relevant excerpt (condensed):

c
1// Resolve GetProcAddress(GetModuleHandleA("ntdll.dll"), <obfuscated>)
2qmemcpy(ProcName, "NuFhznilQxMz", 12);
3*(DWORD *)&ProcName[12] = 2122350970;
4*(DWORD *)&ProcName[16] = 2138789756;
5*(WORD *)&ProcName[20]  = 5497;
6
7// byte[i] -= i for i in [0, 0x16)
8BYTE *p = ProcName; unsigned v = 0;
9do { *p++ -= v++; } while (v < 0x16);
10
11qword_1801FB9F0 = GetProcAddress(GetModuleHandleA("ntdll.dll"), ProcName);

Identification

  • What stands out:
    • GetModuleHandleA("ntdll.dll") followed by GetProcAddress suggests resolving an Nt* export at runtime.
    • A small buffer named ProcName is built from a string literal plus integers, then immediately transformed in a tight loop.
  • Length clue: the loop runs while v < 0x16 → exactly 22 bytes are transformed. That usually means an ANSI API name with a trailing \x00 is in play.
  • Hypothesis: a position-dependent Caesar-style deobfuscation of an export name.

Step-by-step Decryption

Step 1 — Recreate the exact bytes

The code writes two DWORDs and one WORD into the buffer. On Windows/x86-64, these assignments store values in little-endian order.

python
1import struct
2
3buf = bytearray(b"NuFhznilQxMz")          # 12 bytes
4buf += struct.pack("<I", 2122350970)      # DWORD @ +12 (little-endian)
5buf += struct.pack("<I", 2138789756)      # DWORD @ +16 (little-endian)
6buf += struct.pack("<H", 5497)            # WORD  @ +20 (little-endian)
7
8# Sanity: we must have 22 bytes
9assert len(buf) == 22
10print(buf.hex())

Why little-endian? The assignment *(DWORD *)&ProcName[12] = 2122350970; writes the integer as raw bytes in native order.

Step 2 — Understand the transform

The loop is byte[i] = byte[i] - i for i in [0, 22). In other words, each position is decremented by its index.

  • i = 0: 'N' - 0 = 'N'
  • i = 1: 'u' - 1 = 't'
  • i = 2: 'F' - 2 = 'D'

This already hints at NtD... which matches common Nt* exports.

Mathematically: out[i] = (in[i] - i) & 0xFF. The mask keeps values in byte range.

Step 3 — Apply it safely

python
1decrypted = bytes((b - i) & 0xFF for i, b in enumerate(buf[:0x16]))
2print(decrypted)
3print(decrypted.rstrip(b"\x00").decode("ascii"))

You should see b'NtDeviceIoControlFile\x00' and the ASCII string NtDeviceIoControlFile.

Step 4 — Wrap it into a helper you can reuse

python
1def decrypt_export_name(raw_bytes: bytes, length: int = 0x16) -> str:
2    part = raw_bytes[:length]
3    out = bytes((b - i) & 0xFF for i, b in enumerate(part))
4    return out.rstrip(b"\x00").decode("ascii")
5
6assert decrypt_export_name(buf) == "NtDeviceIoControlFile"

Step 5 — Validate against the binary behavior

The result is fed to GetProcAddress using the ntdll.dll handle, consistent with resolving the NtDeviceIoControlFile export.

Why This Works

  • The per-index subtraction is a cheap, position-dependent Caesar variant that survives naive strings scans.
  • Splitting the name across a literal and integer stores hides long ASCII runs in the data section.
  • Restricting the transform to 22 bytes preserves any subsequent buffer contents.

Surrounding Behavior (Context)

After resolving the function pointer, the routine:

  • Builds a small header from { a1, GetCurrentThreadId(), GetTickCount(), ... } followed by the caller-provided payload.
  • Obfuscates the combined buffer with an 8-bit rotate-right by 3 and an XOR with the total length: dst[i] = len ^ ROR8(src[i], 3) where len = a3 + 24.
  • Invokes the resolved routine with arguments matching NtDeviceIoControlFile slots and lengths.

Troubleshooting (for first-time attempts)

  • Wrong endianness: use "<I" and "<H" when rebuilding integers.
  • Off-by-one length: the loop uses 0x16 (22). Decrypt exactly 22 bytes.
  • Unicode vs ANSI: this code resolves an ANSI export name; decoding as UTF-16 will produce garbage.
  • Trailing NUL: strip a final \x00 when converting to text.

Takeaways

  • Look for short arithmetic loops over small constants near GetProcAddress to spot deobfuscation.
  • Reconstruct buffers exactly (sizes, offsets, endianness) before applying transforms.
  • Validate outputs early by checking for plausible Nt* names and using rstrip("\x00").

References