Prologue

The /sys directory is commonly mounted with a virtual file system called sysfs. This is a pesudo file system exporting various internal knobs and information from kernel and hardware drivers.

A Graphics device, abstracted as a GPU in this article implements the Direct Rendering Manager / Kernel Mode Setting interface in Linux kernel. They present as pairs of cardX, renderDXXX devices for userspace, the former is commonly used by privileged clients like the Wayland Compositor, for example to present directly on screen, while render nodes like renderD128 is used by applications as dedicated off-screen rendering and compute interfaces.

Traditional sandboxing practices typically just mount the entire /sys directory into sandbox root, but this is suboptimal for a number of reasons:

  • Because all GPUs are available to sandboxed client, a client might render on or wake the discrete GPU wasting power, or a graphics-heavy client may render on integrated GPU instead.
  • Sensitive information could be used to fingerprint hardware. Examples include the PCI bus, and the dmi virtual device, which holds publicly accessible /sys/devices/virtual/dmi/id/chassis_version, /sys/devices/virtual/dmi/id/chassis_vendor, etc. that precisely identifies a hardware model.
  • On some systems, the input permissions are screwed and unprivileged processes are able to speculate /sys/class/input/*, traversing symbolic links to read input devices directly. This enables key-logging effectively bypassing the Wayland security model.

Plugging the leak

The first action to take is blocking /sys entirely by removing the bind-mount rule for /sys, and mount a tmpfs over:

1
2
3
4
5
6
7
BindRule::VirtualFS {
dest: "/sys".into(),
class: crate::bind::types::VirtualFS::Tmpfs {
size_mb: None,
perms: None,
},
},

While this does block off access to the sysfs, it breaks a lot of applications making use of it, including any GPU accelerated client, and some may even be crashing. So it is clear that we need some selective bind-mount logic there. We can solve the crashing bit by creating various pesudo directories under /sys:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
BindRule::VirtualFS {
dest: "/sys/devices".into(),
class: crate::bind::types::VirtualFS::Tmpfs {
size_mb: None,
perms: None,
},
},
BindRule::VirtualFS {
dest: "/sys/bus".into(),
class: crate::bind::types::VirtualFS::Tmpfs {
size_mb: Some(0),
perms: None,
},
},

Udev

udev is a device manager and a device API set for the Linux kernel. While it primarily manages the /dev filesystem, there is also information stored regarding nodes and symbolic links in /sys. It can provide information both interactively via the udevadm command-line executable, or libudev. Let’s first query interactively for all sysfs nodes belonging to the drm subsystem:

1
udevadm info -e | awk '/^P:/{p=$0} /^E: SUBSYSTEM=drm$/{print p}'

This is the equivalent of calling udev::Enumerator::new() to initialise an enumerator, calling .match_subsystem("drm") and finally .scan_devices().

With discovery being settled, it’s time to translate a udev::Device into vectors of bind rules using the gathered sysfs node and dev node. But there is more to take care of, what about other symbolic links pointing to the device? Strictly speaking applications might still expect them to exist. That’s where we make use of the DEVLINKS property in Udev.

DEVLINKS is a special property in udev. It holds a space-separated list of symbolic links that points back to the sysfs node. As such, we can split an OsString using " " to yield individual symbolic links. It is then converted into PathBufs and finally appended into Portable’s global mount queue.

The above already works well for simple devices like input and cameras in Portable, it also solves the last 2 issues listed. However some extra care has to be taken for GPUs.

More on GPU

Active GPU selection

The aforementioned GPU issue could not be mitigated just yet. As applications would still have access to all GPUs.

Traditionally, Portable looks for connectors nested inside drm minor devices for a special property: connected:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
device := devs[dev]
connWg.Go(func() {
connectStat := device.SysattrValue("status")

var isConnected bool

switch connectStat {
case "connected":
isConnected = true
case "disconnected":
isConnected = false
default:
isConnected = false
}
if isConnected {
connectorChan <- device
}

})

This allows us to see if a certain GPU has active display output. We would consider a graphics card active if there are one or more connected connectors. But this is fundamentally flawed for one situation, that is for hybrid graphics laptops which connects the internal eDP panel to iGPU and all output routed to dGPU. Although this is considered a hardware design flaw, we still need a workaround for the sheer user base.

The kernel has other tricks that we can leverage though. Should a display be connected to a GPU during boot, it will create a special property called boot_display (previously boot_vga, located in the parent device) which is set to “1”. Portable now takes this trick to its advantage as the active and default GPU is determined by the presence of said properties. Additionally, since the PCI device tree (not to be confused with ARM devicetree) and other hardware nodes has been hidden, sandboxed applications can no longer fingerprint hardware using just /sys sysfs. It also enables better detection for hybrid setups and reduces lookup code path for us.