Present a synthetic hardware identity to one process tree via bind mounts in a private mount namespace
Find a file
joe d57dd08a4e
README: TODO section on re-deriving the VAC file list
The vac set is transcribed from LWSS's 2020 teardown, which predates
CS2 and has never been verified against a live module. Records why that
matters, the two capture paths built so far (inotify inode-pinning and
the gdb breakpoint in steamservice.so), that neither has yielded a
module yet, and what to try next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 15:58:35 +01:00
hwid-profile Add a Makefile installing to a dir on Steam's PATH 2026-08-12 15:51:18 +01:00
Makefile Add a Makefile installing to a dir on Steam's PATH 2026-08-12 15:51:18 +01:00
README.md README: TODO section on re-deriving the VAC file list 2026-08-12 15:58:35 +01:00

hwid-profile

Present a synthetic hardware identity to a single process tree.

The identifier files a program reads out of /sys, /dev/disk and /etc are overlaid with fake values using bind mounts inside a private mount namespace (bwrap). The kernel values are never modified, nothing is written to disk outside the profile directory, and no process outside the wrapped tree sees any change. Unwrap the process and the machine is exactly as it was.

What it covers

Two independent collectors, because they read different things:

Set Files
vac The ten filenames VAC's hardware-scan module walks /sys/devices for — idVendor, idProduct, bDeviceClass, bDeviceSubClass, bInterfaceClass, bInterfaceSubClass, bInterfaceProtocol, vendor, device, class (per LWSS's 2020 teardown).
unique Genuinely per-unit serials, which VAC's list misses: USB device serials, the amdgpu unique_id, disk serials and filesystem UUIDs via /dev/disk/by-id and /dev/disk/by-uuid, and /etc/machine-id. steamclient.so reads by-id, by-uuid and product_serial directly.

Synthesised values keep the shape of the real ones — prefix (0x), length, case, and per-character class for free-form serials — so a spoofed profile still looks like plausible hardware rather than obvious garbage.

The vac list is second-hand and six years old — see TODO: re-derive the VAC file list.

What is deliberately not spoofed

The PCI vendor and device attributes of anything under /sys/class/drm. libdrm's drmGetDevices2() reads those to enumerate GPUs and the Vulkan loader builds its physical-device list from the result. Spoof them and Vulkan reports zero devices, so CS2 will not start. This was measured, not assumed, and hwid-profile check exists to re-verify it.

Requirements

  • Python 3 (stdlib only)
  • bubblewrap (bwrap)
  • vulkaninfo (optional — only for check)
  • game-launcher (optional — only for the Steam integration below)

Install

sudo make install

sudo is needed because the script goes to /usr/local/bin — a system directory on the PATH Steam launches games with. Native Steam doesn't reliably put ~/.local/bin (or ~/scripts) on that PATH, so installing there means launch options have to spell out an absolute path. Installed on PATH, a game's launch options are just:

hwid-profile run --rotate -- %command%

Override the location the usual way, and check it works before or after installing:

sudo make install PREFIX=/usr    # /usr/bin instead
make check                       # smoke test: rotate, print what it presents
sudo make uninstall              # leaves your profiles alone

make check runs a throwaway rotation and prints the spoofed and real machine-id alongside the Vulkan device count. The ids must differ and the counts must match — that second half is the failure mode worth watching, since a profile that blinds the GPU stops CS2 from starting at all.

Profiles live in ~/.local/share/hwid-profiles, overridable with $HWID_PROFILE_DIR. Throwaway rotations go to /tmp/.hwid-rotate and are deleted when the wrapped process exits (with a 24h GC as a safety net for runs that were killed before they could clean up).

Usage

hwid-profile gen NAME [--seed S]              create a profile
hwid-profile list                             list profiles + their VAC hwid
hwid-profile show NAME                        show what a profile presents
hwid-profile rm NAME                          delete a profile
hwid-profile run NAME -- CMD...               run CMD under a profile
hwid-profile run --rotate -- CMD...           run under a fresh throwaway identity
hwid-profile run --rotate --keep N -- CMD...  ... and save it as profile N
hwid-profile check NAME                       verify the GPU still works under it

gen --seed makes generation deterministic: the same seed reproduces the same identity, which is how you recreate a profile you lost.

--rotate is the recommendation. A fixed profile is a stable pseudonym — it hides your real hardware but still links your sessions to each other. Rotating gives an identity that is both plausible and unlinkable.

Everything after the first -- is the command, verbatim; it may contain its own --.

Poking at it

# what the wrapped process sees
hwid-profile run --rotate -- cat /etc/machine-id
hwid-profile run --rotate -- ls /dev/disk/by-id

# compare against the real values
cat /etc/machine-id

# the md5 VAC's module would compute, per profile, next to the real one
hwid-profile list

# GPU still enumerates under this profile?
hwid-profile check myprofile

Using it with game-launcher

game-launcher is a per-game launch-options manager for Steam: game-launcher (/usr/local/bin/game-launcher) reads $SteamAppId, resolves it through [aliases] in ~/.config/launch-options.ini, shows a yad picker when a game has more than one mode, and evals the chosen mode's command with %command% substituted. So spoofing is just another mode — you pick it from the launcher dialog at startup instead of editing Steam's launch options.

1. The config

Define the wrapper once as a var, then keep each mode as a plain/spoofed pair:

[vars]
; Runs the game under a throwaway spoofed hardware identity in a private
; mount namespace. Goes outside mangohud so the whole chain (including the
; Steam runtime container) inherits the namespace.
hwid_rotate = hwid-profile run --rotate --

[cs2]
wayland         = LD_PRELOAD="" mangohud %command% +exec autoexec.cfg
wayland-spoofed = LD_PRELOAD="" %hwid_rotate% mangohud %command% +exec autoexec.cfg

The spoofed mode is its plain counterpart with %hwid_rotate% inserted, and nothing else changed. Keeping both means the spoof is opt-in per launch from the picker rather than something you have to remember to strip out.

Two things about that fragment are load-bearing:

  • Resolvable name. The bare hwid-profile above works because make install puts it in /usr/local/bin, which Steam inherits. Install anywhere else — ~/.local/bin, ~/scripts — and Steam won't find it, so spell out the absolute path in the fragment instead.
  • Placement. %hwid_rotate% goes as far left as possible in the command — after any env assignments, outside mangohud, before %command%. Everything to the right of the -- runs inside the namespace, and the overlays are inherited by children. CS2 launches through pressure-vessel, which does its own recursive --ro-bind /sys; because it is recursive it picks up the overlays from the namespace it was started in. Put the wrapper too far right (inside the runtime container) and it has nothing left to affect.

LD_PRELOAD="" above is a shell assignment prefixing the whole command, so it stays leftmost and still applies through the namespace.

2. Checking a mode

Real modes tend to carry more than the example — resolutions, -threads, audio latency env vars, screen-layout scripts on either side. Check what one actually expands to before trusting it:

launch-options cs2 wayland-spoofed
launch-options --list cs2

If a mode brackets the launch with other commands separated by ; (screen-layout scripts, for instance), those are separate shell commands — the ; terminates the namespace command, so they run on the host, unwrapped, which is what you want.

3. Adding it to another game

Copy the mode, insert the fragment before %command%:

[somegame]
default         = mangohud %command%
default-spoofed = %hwid_rotate% mangohud %command%

Then relaunch from Steam and pick default-spoofed in the dialog. Nothing else needs changing; game-launcher picks up new modes from the config on every launch.

4. Using a fixed identity instead

If you want the same fake machine every time — e.g. to keep one account's sessions consistent — generate a named profile and point a mode at it:

hwid-profile gen smurf
hwid-profile check smurf     # confirm Vulkan still sees the GPU
[vars]
hwid_smurf = hwid-profile run smurf --

Or save a rotation you liked after the fact with run --rotate --keep NAME -- ....

Debugging a launch

game-launcher logs every launch, including the fully expanded final command:

tail -f /tmp/game-launcher.log

If the game starts but the identity is unchanged, check that the log's FINAL: line has the wrapper to the left of the runtime and that the path in it exists.

Notes

  • Read-only overlays: every bind is --ro-bind, so nothing the game does can write through to the real files.
  • The wrapper is transparent to whatever launched it — SIGINT/SIGTERM/ SIGHUP/SIGQUIT are forwarded to the child and its exit status is propagated, so Steam still sees the game exit normally.
  • A full profile is ~300 binds and ~50KB of arguments, past comfortable ARG_MAX, so args are handed to bwrap through a file descriptor.

TODO: re-derive the VAC file list

Why

VAC_NAMES — the ten filenames the vac set covers — is transcribed from LWSS's State of VAC (Linux) 2020, and nothing in this repo has verified it against a VAC module since. That post predates CS2 entirely; it describes a CS:GO-era module. Six years on, every part of it is an assumption:

  • whether those ten filenames are still the list, or still the whole list
  • whether the hash is still md5 over the files concatenated in walk order, which is what vac_hash() reproduces and what makes hwid-profile list comparable to the real thing
  • whether newer collectors read things the vac set doesn't touch at all — DMI/board serials, product_serial, TPM EK, EDID, CPUID

Being wrong here is quiet, which is the problem: an incomplete list still produces a confident-looking hash, and the identifiers it misses pass through untouched. The unique set is unaffected — it was derived independently from what steamclient.so actually reads, not from the 2020 post — so it stays valid whatever the module turns out to do.

The plan

Capture a current VAC module off the wire, disassemble its hardware-scan routine, and re-derive the list and the hash construction from that. Then update VAC_NAMES and vac_hash(), and note in this README what was confirmed and when.

What's built

Both capture scripts live in ~/tmp/vac-capture for now; fold them in here once either one produces a module.

  • vac-capture — inotify on /tmp, /var/tmp and /dev/shm. CS2 runs in the sniper container, but pressure-vessel shares the host /tmp, so a module written in-container is visible outside. VAC writes, dlopen()s and unlinks fast enough that copying on CREATE loses the race, so it opens and holds an fd instead — that pins the inode and the module stays readable through /proc/self/fd/N after the name is gone. Harvests at CLOSE_WRITE.
  • vacdump — drives Sumandora's vacdumper.gdbinit (in ~/git/vac-scripts) against the running Steam, breaking in steamservice.so's module-load path. Patches in the find range per session, since ASLR moves the r-xp segment on every Steam restart, and rewrites the dump path to an absolute one. Needs root via pkexec: yama ptrace_scope=1 won't let it attach to a non-descendant.

Where it stands

Nothing captured yet.

  • Three vac-capture sessions, zero modules. mappings.log caught only PipeWire memfds, no VAC ones.
  • One vacdump session got as far as arming: pattern found, one match, breakpoint set. It never fired before the session was detached.

Next steps

  • Tell the two failure modes apart. "VAC never sent a module this session" and "our hook is in the wrong place" both look like an empty output directory, and they need opposite responses. Needs a positive control — something known to trip the breakpoint — before more capture sessions are worth running.
  • Check the gdb pattern still matches. vacdumper.gdbinit is from Nov 2024. It found its byte pattern, so the code is probably still there, but "found the pattern" and "the pattern is still the module-load path" are different claims.
  • Cover the memfd blind spot. A module loaded from memfd_create() never touches the filesystem, so inotify cannot see it by construction. That is the most likely explanation for three empty runs. vac-capture --deep retries via /proc/PID/map_files and /proc/PID/mem under pkexec — run it that way.
  • Play longer, on a VAC-secured server. Modules arrive on VAC's schedule, not on launch. Short sessions may simply never have been sent one.
  • Then: disassemble. Recover the filename list, confirm or replace the md5 construction, and diff both against what this repo assumes today.