Kernel Module¶
Experimental and unverified
The kernel module backend is not covered by the installer and has never been booted. CI compiles it into WSL's 6.6 and 6.18 kernel trees, but nothing here has been validated on a running kernel -- treat every claim below as intent rather than observed behaviour. For kernel-level enforcement today, use BPF mode.
A Linux Security Module that enforces W-bit permissions directly in the kernel VFS layer, on the mounted filesystems userspace has registered for enforcement -- in practice the Windows drives WSL2 mounts under /mnt.
Requires a custom WSL2 kernel build
For stock kernels, use BPF mode instead -- it provides the same kernel-level enforcement without a custom build.
Building¶
1. Clone the WSL2 kernel¶
git clone https://github.com/microsoft/WSL2-Linux-Kernel.git
cd WSL2-Linux-Kernel
git checkout linux-msft-wsl-6.6.y # or linux-msft-wsl-6.18.y
2. Copy the UGOW LSM source¶
3. Wire it into the kernel build¶
Add to security/Makefile:
Add to security/Kconfig:
On WSL's 6.18 series (and any kernel from 6.12 on), also count UGOW in the kernel's fixed LSM total:
Those kernels size their LSM tables from the list in
include/linux/lsm_count.h. An LSM missing from it is dropped at boot whenever
every other built-in LSM is active, so the build stops with an #error until
the patch is applied. 6.6 has no such list and needs no patch.
4. Enable in kernel config¶
# Start from the WSL2 default config
cp Microsoft/config-wsl .config
# Enable the LSM (depends on SECURITY and SECURITYFS)
scripts/config --enable CONFIG_SECURITY_UGOW
# Optional: gate root as well. Default is y (root exempt).
# scripts/config --disable CONFIG_SECURITY_UGOW_ROOT_EXEMPT
# Add "ugow" to the LSM order, keeping the WSL2 defaults intact
scripts/config --set-str CONFIG_LSM "landlock,lockdown,yama,loadpin,safesetid,integrity,apparmor,bpf,ugow"
make -j$(nproc)
lsm= on the kernel command line overrides CONFIG_LSM entirely
If %USERPROFILE%\.wslconfig sets kernelCommandLine = lsm=..., the kernel
ignores CONFIG_LSM and enables only the modules named there -- so ugow
is skipped and nothing is enforced, no matter how the kernel was
configured. The kmod then does not create /sys/kernel/security/ugow/
(and logs ugow: not in the active LSM list), so the CLI stops detecting
it instead of accepting grants no hook checks. The BPF setup instructions tell you to add exactly such a line,
so this is easy to hit if you have used both backends.
Either remove the lsm= line, or append ,ugow to it. Keep apparmor in
the list (snapd and Docker confinement depend on it), and keep bpf if you
may go back to the BPF backend.
Check what actually loaded:
| Kconfig option | Default | Effect |
|---|---|---|
CONFIG_SECURITY_UGOW |
n |
Builds the LSM in. Depends on SECURITY and SECURITYFS -- securityfs is the only control interface, so without it the module has no way to receive grants |
CONFIG_SECURITY_UGOW_ROOT_EXEMPT |
y |
Processes whose filesystem UID is 0 bypass W-bit checks, matching the BPF backend. Say n only if you intend root itself to be gated -- system services, package managers and container runtimes routinely write to mounted Windows drives as root and will start receiving EACCES unless uid 0 is granted explicitly |
5. Install the custom kernel¶
Copy the built kernel to Windows and point WSL at it:
# From PowerShell
Copy-Item \\wsl$\Ubuntu\path\to\WSL2-Linux-Kernel\vmlinux C:\Users\you\wsl-kernel\vmlinux
Create or edit %USERPROFILE%\.wslconfig:
Restart WSL:
securityfs Interface¶
Once booted with the custom kernel, everything is driven through four files under /sys/kernel/security/ugow/. All writes require CAP_SYS_ADMIN.
| File | Mode | Purpose |
|---|---|---|
devices |
0600 |
Write +major:minor to enforce a device, -major:minor to stop. Read to list enforced devices |
grant |
0200 |
Write uid major:minor path to add a grant |
revoke |
0200 |
Write uid major:minor path to remove one |
grants |
0400 |
Read to list all current grants as uid<TAB>major:minor<TAB>path |
Two properties of the format matter:
- Enforcement is opt-in per device. Nothing is gated until a device is registered via
devices, so enabling the LSM cannot by itself make a filesystem read-only. This mirrors the BPF backend'starget_devsmap and avoids guessing at filesystem type names, which have changed across WSL releases (9p,drvfs,virtiofs). - Paths are superblock-relative, not absolute. The kernel derives a dentry's path with
dentry_path_raw(), which stops at the filesystem root -- so the file a user calls/mnt/c/docker/fis/docker/fhere. Pairing the relative path with the superblock's device keeps grants unambiguous when several drives are mounted, since/dockeron C: and/dockeron D: differ in device. Userspace does the mountpoint arithmetic and sends both halves.
Assuming /mnt/c is device 0:45:
# Enforce the device backing /mnt/c
echo "+0:45" | sudo tee /sys/kernel/security/ugow/devices
# Grant UID 9500 write access to /mnt/c/data
echo "9500 0:45 /data" | sudo tee /sys/kernel/security/ugow/grant
# Revoke it
echo "9500 0:45 /data" | sudo tee /sys/kernel/security/ugow/revoke
# List all grants and enforced devices
cat /sys/kernel/security/ugow/grants
cat /sys/kernel/security/ugow/devices
The ugow CLI auto-detects the kmod backend, resolves the mountpoint and device for you, registers the device on the first grant, and writes the relative path:
Because the device has to be resolved with stat(), kmod grants and revokes through the CLI need the path to exist even though the grant itself is stored as a string. ugow sync skips and reports grants whose path is currently missing; they apply the next time it runs.
How It Works¶
The LSM hooks into the kernel's VFS layer at these points:
| Hook | Enforces |
|---|---|
inode_permission |
W-bit on any write-access permission check |
file_open |
W-bit when opening a file for writing |
inode_create |
Parent W-bit for file creation |
inode_link |
Destination parent W-bit and W-bit on the existing file |
inode_unlink |
Parent W-bit for file deletion |
inode_symlink |
Parent W-bit for symlink creation |
inode_mkdir |
Parent W-bit for directory creation |
inode_rmdir |
Parent W-bit for directory removal |
inode_rename |
Source parent W-bit + destination parent W-bit |
inode_setattr |
W-bit for chmod, chown, truncate, and explicit timestamp changes |
inode_link checks both ends because the ancestor walk starts from whichever name is used: without the source check, linking a protected file into a granted directory would make it writable through its second name. inode_setattr exists for the same reason chmod is gated in the FUSE shim -- ungated, it would let any caller re-mode files they hold no grant on.
Every gated operation first checks that the superblock's device is registered and that the caller is not exempt, both of which are cheap and happen before any allocation. Grants live in a kernel hash table with RCU-based read access for lock-free lookups on the hot path. Inheritance walks up the relative path component by component, so a grant on /data covers /data/sub/file.txt, and a grant on / covers the whole device.
The hooks are armed at LSM init, and the securityfs interface is created later from an initcall, since securityfs cannot be used that early in boot. Arming first is harmless: nothing is enforced until a device is registered through that interface. The interface is only created if the hooks were armed, so an LSM left out of the boot order shows no control files rather than ones that accept grants nothing enforces.
Persistence Across Reboots¶
The grant table lives in kernel memory and is lost on every wsl --shutdown. ugow-sync.service -- installed and enabled by default by setup.sh, and conditional on /sys/kernel/security/ugow/grant existing -- runs ugow sync at boot to replay the SQLite store. Device registration happens implicitly, since the CLI registers a grant's device before writing the grant.
Overhead and correctness vs BPF¶
The kmod matches on the path string, whereas BPF mode keys on
(inode, device, uid). That difference cuts both ways:
- Per-operation cost is higher than BPF. Each gated op allocates a 4 KB
buffer, reconstructs the superblock-relative path with
dentry_path_raw(), then hashes andstrcmps the path at each ancestor level. BPF does allocation-free integer map lookups instead. Both remain kernel-resident -- so both are far cheaper than the FUSE shim's userspace round-trip, and both are "near zero" relative to it -- but kmod does strictly more work per op than BPF. - In exchange, it avoids BPF's inode-keying caveats. Path matching survives delete-and-recreate, has no fixed inheritance-depth cap, and the stored grant does not depend on an inode remaining valid. See the BPF caveats.