HWID Fingerprinting: Hybrid Hardware Identification from Userland and Kernel

This post covers Peregrine’s hardware identification (HWID) system, which collects approximately 16 hardware and software identifiers from two layers: a Rust userland component and the kernel driver. The identifiers span MAC addresses, volume serials, SMBIOS firmware data, Windows registry GUIDs, disk serial numbers, and the boot environment GUID. Together they form a machine fingerprint that can survive individual spoofing attempts. For context on how this fits into the broader system, see the architecture overview.

Why Two Layers: Userland and Kernel

Hardware identification is fundamentally an arms race. Identifiers collected from userland are convenient: they use documented Win32 APIs, they do not require a driver, and they cover a wide surface area. But userland APIs ultimately read from data structures that a process running at the same privilege level can intercept or modify. A sufficiently motivated cheater can hook GetAdaptersInfo, patch the registry, or intercept GetSystemFirmwareTable before values reach the caller.

Kernel-mode collection raises the bar. When a driver opens \Device\Harddisk0\DR0 and sends IOCTL_STORAGE_QUERY_PROPERTY directly via ZwDeviceIoControlFile, it is talking to the storage stack with no userland shim to intercept [1]. The boot environment GUID comes from ZwQuerySystemInformation, which reads kernel-internal structures that usermode code cannot trivially patch [2].

Neither layer alone is sufficient. Userland provides breadth (nine registry keys, MAC addresses, volume serials, SMBIOS data). The kernel provides depth (disk serials from the storage stack, boot GUID from the OS loader). A cheater who spoofs registry values still has to contend with kernel-collected disk serials, and vice versa.

Userland Collection: Nine Registry Keys, MAC Addresses, SMBIOS, and Volume Serials

The Rust side collects identifiers through four functions, all called from a single entry point:

// From: peregrine-tauri/src-tauri/src/hwid.rs
pub fn collect_userland_hwids() -> Vec<HwidEntry> {
    let mut entries = Vec::new();
    collect_mac_addresses(&mut entries);
    collect_volume_serials(&mut entries);
    collect_smbios(&mut entries);
    collect_registry_ids(&mut entries);
    entries
}

Each HwidEntry carries a source tag ("userland" or "kernel"), a name, and a value. This uniform structure makes it straightforward to aggregate identifiers from both layers.

MAC Addresses via GetAdaptersInfo

Network adapter MAC addresses are one of the oldest hardware identifiers. Peregrine retrieves them through the GetAdaptersInfo API from iphlpapi.dll [3], walking the linked list of adapter structures:

// From: peregrine-tauri/src-tauri/src/hwid.rs
fn collect_mac_addresses(out: &mut Vec<HwidEntry>) {
    unsafe {
        let mut size: u32 = 0;
        GetAdaptersInfo(std::ptr::null_mut(), &mut size);
        if size == 0 { return; }

        let layout = std::alloc::Layout::from_size_align(size as usize, 8).unwrap();
        let buf = std::alloc::alloc_zeroed(layout);
        if buf.is_null() { return; }

        if GetAdaptersInfo(buf as *mut AdapterInfo, &mut size) == ERROR_SUCCESS {
            let mut ptr = buf as *const AdapterInfo;
            let mut idx = 0u32;
            while !ptr.is_null() {
                let a = &*ptr;
                let len = a.address_length as usize;
                if len > 0 && len <= 8 {
                    let mac = a.address[..len].iter()
                        .map(|b| format!("{:02X}", b))
                        .collect::<Vec<_>>().join(":");
                    let desc = CStr::from_ptr(a.description.as_ptr() as *const i8)
                        .to_string_lossy().into_owned();
                    out.push(HwidEntry {
                        source: "userland".into(),
                        name: format!("mac_{} ({})", idx, desc),
                        value: mac,
                    });
                    idx += 1;
                }
                ptr = a.next;
            }
        }
        std::alloc::dealloc(buf, layout);
    }
}

The two-pass pattern (call once with a null buffer to get the required size, allocate, call again) is standard for Windows enumeration APIs. Each adapter’s description is included in the name field so the output shows which physical or virtual NIC each MAC belongs to.

Volume Serial Numbers

Volume serial numbers are assigned by Windows at format time and stored in the volume boot record [4]. They are easy to collect and moderately useful as identifiers since they change only when a volume is reformatted:

// From: peregrine-tauri/src-tauri/src/hwid.rs
fn collect_volume_serials(out: &mut Vec<HwidEntry>) {
    for letter in ['C', 'D', 'E', 'F'] {
        let root: Vec<u16> = format!("{}:\\", letter)
            .encode_utf16().chain(std::iter::once(0)).collect();
        let mut serial: u32 = 0;
        let ok = unsafe {
            GetVolumeInformationW(
                root.as_ptr(), std::ptr::null_mut(), 0,
                &mut serial, std::ptr::null_mut(), std::ptr::null_mut(),
                std::ptr::null_mut(), 0,
            )
        };
        if ok != 0 && serial != 0 {
            out.push(HwidEntry {
                source: "userland".into(),
                name: format!("volume_serial_{}", letter),
                value: format!("{:04X}-{:04X}", serial >> 16, serial & 0xFFFF),
            });
        }
    }
}

The code probes drives C through F. Most machines will only return a value for C (and perhaps D), but checking additional letters catches secondary partitions.

SMBIOS UUID and Serial via GetSystemFirmwareTable

The SMBIOS (System Management BIOS) tables are firmware-level data structures that expose the system UUID, serial number, and product name [5]. Peregrine reads the raw SMBIOS table using GetSystemFirmwareTable with provider signature RSMB (0x52534D42), then parses Type 1 (System Information) entries:

// From: peregrine-tauri/src-tauri/src/hwid.rs
fn collect_smbios(out: &mut Vec<HwidEntry>) {
    unsafe {
        let size = GetSystemFirmwareTable(RSMB, 0, std::ptr::null_mut(), 0);
        if size < 8 { return; }

        let mut buf = vec![0u8; size as usize];
        let written = GetSystemFirmwareTable(RSMB, 0, buf.as_mut_ptr(), size);
        if written < 8 { return; }

        let table = &buf[8..written as usize];
        let mut off = 0usize;

        while off + 4 <= table.len() {
            let etype = table[off];
            let elen = table[off + 1] as usize;
            if elen < 4 { break; }

            if etype == 1 && elen >= 24 && off + 24 <= table.len() {
                // UUID at offset 8 within the Type 1 entry
                let u = &table[off + 8..off + 24];
                let uuid = format!(
                    "{:02X}{:02X}{:02X}{:02X}-{:02X}{:02X}-{:02X}{:02X}-\
                     {:02X}{:02X}-{:02X}{:02X}{:02X}{:02X}{:02X}{:02X}",
                    u[3], u[2], u[1], u[0], u[5], u[4], u[7], u[6],
                    u[8], u[9], u[10], u[11], u[12], u[13], u[14], u[15],
                );
                out.push(HwidEntry {
                    source: "userland".into(),
                    name: "smbios_uuid".into(),
                    value: uuid,
                });

                // Serial number string (index at offset 7)
                let serial_idx = table[off + 7] as usize;
                if serial_idx > 0 {
                    if let Some(s) = smbios_string(table, off, elen, serial_idx) {
                        if !s.is_empty() {
                            out.push(HwidEntry {
                                source: "userland".into(),
                                name: "smbios_serial".into(),
                                value: s,
                            });
                        }
                    }
                }
                // ...
                break;
            }
            // Skip to next entry
            // ...
        }
    }
}

The byte swapping in the UUID format (u[3], u[2], u[1], u[0]) follows the SMBIOS specification, which stores the first three fields of the UUID in little-endian order [6]. The SMBIOS UUID is one of the most stable identifiers available. It is burned into the firmware by the motherboard manufacturer and survives OS reinstalls, drive replacements, and most hardware changes short of replacing the motherboard itself.

Nine Registry Identifiers

The registry collection casts a wide net across keys that Windows populates during installation, activation, and telemetry setup:

// From: peregrine-tauri/src-tauri/src/hwid.rs
fn collect_registry_ids(out: &mut Vec<HwidEntry>) {
    let keys: &[(&str, &str, &str)] = &[
        ("machine_guid",        r"SOFTWARE\Microsoft\Cryptography", "MachineGuid"),
        ("hw_profile_guid",     r"SYSTEM\CurrentControlSet\Control\IDConfigDB\Hardware Profiles\0001", "HwProfileGuid"),
        ("computer_hw_id",      r"SYSTEM\CurrentControlSet\Control\SystemInformation", "ComputerHardwareId"),
        ("product_id",          r"SOFTWARE\Microsoft\Windows NT\CurrentVersion", "ProductId"),
        ("install_date",        r"SOFTWARE\Microsoft\Windows NT\CurrentVersion", "InstallDate"),
        ("install_time",        r"SOFTWARE\Microsoft\Windows NT\CurrentVersion", "InstallTime"),
        ("sus_client_id",       r"SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate", "SusClientId"),
        ("sqm_machine_id",      r"SOFTWARE\Microsoft\SQMClient", "MachineId"),
        ("activation_machine",  r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows Activation Technologies\AdminObject\Store", "MachineId"),
    ];

    for &(name, subkey, value) in keys {
        if let Some(v) = read_reg(subkey, value) {
            if !v.is_empty() {
                out.push(HwidEntry {
                    source: "userland".into(),
                    name: name.into(),
                    value: v,
                });
            }
        }
    }
}

Each key serves a different purpose:

  • MachineGuid (SOFTWARE\Microsoft\Cryptography): a GUID generated at Windows install time, widely used for machine identification [7]. It is trivially spoofable (one registry write), which is exactly why it cannot be the only identifier.
  • HwProfileGuid: identifies the hardware profile configuration [8].
  • ComputerHardwareId: a hash derived from SMBIOS data that Windows uses for driver targeting [9].
  • ProductId and InstallDate/InstallTime: Windows installation metadata. InstallDate is a Unix timestamp; InstallTime is a high-precision variant. Together they pin the installation event.
  • SusClientId: the GUID Windows Update uses to identify this machine to Microsoft’s update servers [10].
  • SQMMachineId: the Software Quality Metrics telemetry identifier [11].
  • Activation MachineId: used by Windows Activation Technologies.

The read_reg helper handles REG_SZ, REG_EXPAND_SZ, REG_DWORD, and REG_QWORD types transparently, using the standard two-pass query pattern (first call for size, second call for data).

Kernel Collection: Disk Serials and the Boot GUID

The kernel driver collects two categories of identifiers that are not reliably accessible from userland: physical disk serial numbers from the storage driver stack, and the boot environment GUID from the OS loader.

Disk Serial Numbers via IOCTL_STORAGE_QUERY_PROPERTY

Each physical disk has a serial number assigned by the manufacturer. The kernel reads it by opening the disk device object directly and sending IOCTL_STORAGE_QUERY_PROPERTY with StorageDeviceProperty (PropertyId = 0) [1]:

// From: PeregrineKernelComponent/Hwid.c
static void CollectDiskSerial(ULONG idx) {
    WCHAR path[64];
    RtlStringCchPrintfW(path, ARRAYSIZE(path), L"\\Device\\Harddisk%lu\\DR0", idx);

    UNICODE_STRING devName;
    RtlInitUnicodeString(&devName, path);

    OBJECT_ATTRIBUTES oa;
    InitializeObjectAttributes(&oa, &devName,
        OBJ_KERNEL_HANDLE | OBJ_CASE_INSENSITIVE, NULL, NULL);

    HANDLE hDisk = NULL;
    IO_STATUS_BLOCK iosb = { 0 };
    NTSTATUS status = ZwCreateFile(&hDisk, GENERIC_READ | SYNCHRONIZE,
        &oa, &iosb, NULL, FILE_ATTRIBUTE_NORMAL,
        FILE_SHARE_READ | FILE_SHARE_WRITE, FILE_OPEN,
        FILE_SYNCHRONOUS_IO_NONALERT | FILE_NON_DIRECTORY_FILE, NULL, 0);
    if (!NT_SUCCESS(status)) return;

    HWID_STORAGE_QUERY query = { 0 };
    UCHAR buf[1024] = { 0 };

    status = ZwDeviceIoControlFile(hDisk, NULL, NULL, NULL, &iosb,
        HWID_IOCTL_STORAGE_QUERY_PROPERTY,
        &query, sizeof(query), buf, sizeof(buf));
    ZwClose(hDisk);
    if (!NT_SUCCESS(status)) return;

    HWID_STORAGE_DESCRIPTOR* desc = (HWID_STORAGE_DESCRIPTOR*)buf;

    char serial[128] = { 0 };
    char vendor[128] = { 0 };
    char product[128] = { 0 };

    if (desc->SerialNumberOffset && desc->SerialNumberOffset < sizeof(buf))
        { RtlStringCchCopyA(serial, sizeof(serial),
            (char*)(buf + desc->SerialNumberOffset)); TrimSpaces(serial); }
    if (desc->VendorIdOffset && desc->VendorIdOffset < sizeof(buf))
        { RtlStringCchCopyA(vendor, sizeof(vendor),
            (char*)(buf + desc->VendorIdOffset)); TrimSpaces(vendor); }
    if (desc->ProductIdOffset && desc->ProductIdOffset < sizeof(buf))
        { RtlStringCchCopyA(product, sizeof(product),
            (char*)(buf + desc->ProductIdOffset)); TrimSpaces(product); }

    if (serial[0] == '\0') return;

    CHAR json[COMS_MAX_MESSAGE_SIZE];
    RtlStringCchPrintfA(json, ARRAYSIZE(json),
        "{ \"event\": \"hwid_data\", \"source\": \"kernel\", "
        "\"name\": \"disk_serial_%lu\", \"value\": \"%s\", "
        "\"detail\": \"%s %s\" }", idx, serial, vendor, product);
    ComsSendToUser(json, (ULONG)strlen(json));
}

The device path pattern \Device\Harddisk%lu\DR0 addresses physical disks by index. The driver probes indices 0 through 3, covering up to four physical disks. The STORAGE_DEVICE_DESCRIPTOR returned by the IOCTL contains offset-based strings for the serial number, vendor ID, and product ID [1]. All three are extracted and trimmed of trailing spaces (a common quirk of storage descriptors).

Disk serials collected from kernel mode bypass any userland hooks or registry spoofing. The request goes directly through the storage driver stack, which means a cheater would need to intercept it at the storage miniport level or modify the disk firmware itself.

Boot GUID via ZwQuerySystemInformation

The boot environment GUID uniquely identifies the current boot session. It is populated by the Windows boot loader and stored in a kernel-internal structure accessible through ZwQuerySystemInformation with class SystemBootEnvironmentInformation (0x5A) [2]:

// From: PeregrineKernelComponent/Hwid.c
static void CollectBootGuid(void) {
    UCHAR buf[64] = { 0 };
    NTSTATUS status = ZwQuerySystemInformation(
        SystemBootEnvironmentInformation, buf, sizeof(buf), NULL);
    if (!NT_SUCCESS(status)) return;

    GUID* g = (GUID*)buf;
    CHAR json[COMS_MAX_MESSAGE_SIZE];
    RtlStringCchPrintfA(json, ARRAYSIZE(json),
        "{ \"event\": \"hwid_data\", \"source\": \"kernel\", "
        "\"name\": \"boot_guid\", \"value\": "
        "\"%08lX-%04X-%04X-%02X%02X-%02X%02X%02X%02X%02X%02X\" }",
        g->Data1, g->Data2, g->Data3,
        g->Data4[0], g->Data4[1], g->Data4[2], g->Data4[3],
        g->Data4[4], g->Data4[5], g->Data4[6], g->Data4[7]);
    ComsSendToUser(json, (ULONG)strlen(json));
}

The boot GUID is tied to the EFI System Partition’s boot configuration. It is stable across reboots on the same installation but changes if the BCD store is rebuilt or the OS is reinstalled. This makes it useful as a “soft” identifier that, combined with more stable identifiers like SMBIOS UUID and disk serials, helps distinguish a genuinely new machine from a spoofed one.

The Collection Entry Point

Both kernel collectors are called from a single function that also signals completion:

// From: PeregrineKernelComponent/Hwid.c
NTSTATUS HwidCollectAll(void) {
    KdPrint(("Peregrine: HWID collection starting\n"));

    for (ULONG i = 0; i < 4; i++)
        CollectDiskSerial(i);

    CollectBootGuid();

    CHAR json[COMS_MAX_MESSAGE_SIZE];
    RtlStringCchPrintfA(json, ARRAYSIZE(json),
        "{ \"event\": \"hwid_kernel_complete\" }");
    ComsSendToUser(json, (ULONG)strlen(json));

    KdPrint(("Peregrine: HWID collection complete\n"));
    return STATUS_SUCCESS;
}

The hwid_kernel_complete event tells userland that all kernel identifiers have been queued and no more will follow.

The IOCTL Bridge: Getting Kernel Results to Userland

Kernel-collected identifiers need to reach the Rust application. Peregrine uses the same IOCTL-based message queue that carries all kernel-to-user events. The flow works as follows:

  1. The Rust application sends command byte 12 to the driver via IOCTL_PEREGRINE_SEND_FROM_USER:
// From: peregrine-tauri/src-tauri/src/driver_comm.rs
pub fn collect_hwid(&self) -> Result<(), String> {
    self.send_command(&[12])
}
  1. The kernel command dispatcher in Coms.c receives the byte and calls HwidCollectAll():
// From: PeregrineKernelComponent/Coms.c
case 12: { // collect hardware identifiers
    KdPrint(("Peregrine: user requested HWID collection\n"));
    HwidCollectAll();
    break;
}
  1. Each collector function formats its result as a JSON message and pushes it onto a ring buffer via ComsSendToUser. The ring buffer is protected by a spinlock and holds up to 1024 messages:
// From: PeregrineKernelComponent/Coms.c
NTSTATUS ComsSendToUser(_In_reads_bytes_(DataSize) const void* Data,
    _In_ ULONG DataSize)
{
    // ...
    KIRQL oldIrql;
    KeAcquireSpinLock(&g_ComsLock, &oldIrql);

    if (g_Count == COMS_MAX_QUEUE_DEPTH) {
        g_Tail = (g_Tail + 1) % COMS_MAX_QUEUE_DEPTH;
        g_Count--;
    }

    RtlCopyMemory(g_MessageQueue[g_Head].Data, Data, DataSize);
    g_MessageQueue[g_Head].Length = DataSize;
    g_Head = (g_Head + 1) % COMS_MAX_QUEUE_DEPTH;
    g_Count++;

    KeReleaseSpinLock(&g_ComsLock, oldIrql);
    return STATUS_SUCCESS;
}
  1. The Rust side polls the driver with IOCTL_PEREGRINE_RECV_TO_USER to dequeue messages one at a time. Each hwid_data event is deserialized and merged with the userland-collected identifiers.

The kernel never blocks waiting for userland to consume messages. If the ring buffer is full, the oldest message is overwritten. This design keeps kernel HWID collection fast and non-blocking.

The Combined Fingerprint

When both layers complete, the system holds a set of identifiers that might look like this:

[HWID] [userland] mac_0 (Intel(R) 82574L Gigabit Network Connection): 00:0C:29:45:7E:D3
[HWID] [userland] volume_serial_C: E09A-D4B5
[HWID] [userland] smbios_uuid: E80B4D56-AC42-5D2B-F64A-387A9A457ED3
[HWID] [userland] smbios_serial: VMware-56 4d 0b e8 42 ac 2b 5d-f6 4a 38 7a 9a 45 7e d3
[HWID] [userland] machine_guid: f75b72dd-812c-4fc0-96ad-41d9c308c79c
[HWID] [kernel] disk_serial_0: VMWare NVME_0000 ( VMware Virtual NVMe Disk)
[HWID] [kernel] boot_guid: D8D21EB8-1BBC-11F1-AB11-985C927DD3D5

This is a subset; a real collection would also include hw_profile_guid, computer_hw_id, product_id, install_date, install_time, sus_client_id, sqm_machine_id, and activation_machine from the registry, plus MAC addresses for additional adapters and disk serials for additional drives.

Ban Enforcement: From Identifiers to Decisions

The HWID collection system produces raw identifiers, not a ban decision. But the structure is designed to support several enforcement strategies:

Exact-match banning. Hash all identifiers together (e.g., SHA-256 over the sorted, concatenated values) to produce a single fingerprint. If the hash matches a banned entry, deny access. This is simple but brittle: changing any single identifier (reformatting a drive, swapping a NIC) produces a completely different hash.

Partial-match scoring. Assign weights to each identifier based on stability and difficulty of spoofing. SMBIOS UUID and disk serials are high-weight (firmware-level, hard to change). MachineGuid and volume serials are low-weight (single registry write or reformat). If the weighted overlap with a known-banned machine exceeds a threshold, flag the machine. This approach is more resilient because a cheater must change many identifiers simultaneously to fall below the threshold.

Clustering. Track identifier sets over time. If a “new” machine shares 12 of 16 identifiers with a banned machine, it is likely the same hardware with cosmetic changes. This catches incremental spoofing attempts where a cheater changes one or two values at a time.

The hybrid collection model directly supports these strategies by providing identifiers with varying levels of spoofability. Registry GUIDs are trip wires: easy to collect, easy for unsophisticated cheaters to overlook. Kernel-sourced disk serials are anchors: hard to fake without hardware-level intervention.

Limitations and Honest Assessment

Virtual machines collapse the fingerprint. On a VM, many identifiers (SMBIOS UUID, disk serial, MAC address) are controlled by the hypervisor and can be changed at will. The sample output above is from a VMware guest, which is obvious from the serial strings. VM detection (covered in the system integrity checks) can flag this, but it does not solve the underlying problem.

Userland identifiers are spoofable by definition. Any identifier collected via Win32 APIs or the registry can be intercepted or modified by a process running at the same privilege level. The kernel identifiers raise the bar but are not immune either; a cheater running their own kernel driver could hook the storage stack.

The disk serial probe is limited to four indices. Machines with more than four physical disks would have some serials uncollected, though this is uncommon for gaming systems.

No network transmission is shown here. The collection system produces local data. How that data reaches a server for ban checking is outside the scope of this post.

Drafted with LLM assistance from the Peregrine Anti-Cheat source code, reviewed and verified against the actual implementation.

References

[1] Microsoft Learn, “IOCTL_STORAGE_QUERY_PROPERTY control code,” https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddstor/ni-ntddstor-ioctl_storage_query_property

[2] Microsoft Learn, “ZwQuerySystemInformation function,” https://learn.microsoft.com/en-us/windows/win32/api/winternl/nf-winternl-ntquerysysteminformation

[3] Microsoft Learn, “GetAdaptersInfo function,” https://learn.microsoft.com/en-us/windows/win32/api/iphlpapi/nf-iphlpapi-getadaptersinfo

[4] Microsoft Learn, “GetVolumeInformationW function,” https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getvolumeinformationw

[5] Microsoft Learn, “GetSystemFirmwareTable function,” https://learn.microsoft.com/en-us/windows/win32/api/sysinfoapi/nf-sysinfoapi-getsystemfirmwaretable

[6] DMTF, “System Management BIOS (SMBIOS) Reference Specification,” https://www.dmtf.org/standards/smbios

[7] Microsoft Learn, “HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Cryptography,” https://learn.microsoft.com/en-us/windows/win32/msi/machineid

[8] Microsoft Learn, “Hardware Profiles,” https://learn.microsoft.com/en-us/windows/win32/sysinfo/hardware-profiles

[9] Microsoft Learn, “Specifying Hardware IDs Based on Computer Hardware ID,” https://learn.microsoft.com/en-us/windows-hardware/drivers/install/specifying-hardware-ids-for-a-computer

[10] Microsoft Learn, “Windows Update Agent API,” https://learn.microsoft.com/en-us/windows/win32/wua_sdk/portal-client

[11] Microsoft Learn, “Microsoft Customer Experience Improvement Program,” https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-server-2012-r2-and-2012/jj618322(v=ws.11)