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> |
||
|---|---|---|
| hwid-profile | ||
| Makefile | ||
| README.md | ||
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 forcheck)- 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-profileabove works becausemake installputs 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, outsidemangohud, 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/SIGQUITare 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 tobwrapthrough 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 makeshwid-profile listcomparable to the real thing - whether newer collectors read things the
vacset 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/tmpand/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 onCREATEloses the race, so it opens and holds an fd instead — that pins the inode and the module stays readable through/proc/self/fd/Nafter the name is gone. Harvests atCLOSE_WRITE.vacdump— drives Sumandora'svacdumper.gdbinit(in~/git/vac-scripts) against the running Steam, breaking insteamservice.so's module-load path. Patches in thefindrange per session, since ASLR moves ther-xpsegment on every Steam restart, and rewrites the dump path to an absolute one. Needs root viapkexec: yamaptrace_scope=1won't let it attach to a non-descendant.
Where it stands
Nothing captured yet.
- Three
vac-capturesessions, zero modules.mappings.logcaught only PipeWire memfds, no VAC ones. - One
vacdumpsession 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.gdbinitis 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 --deepretries via/proc/PID/map_filesand/proc/PID/memunderpkexec— 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.