VirtualBox dynamic resolution considered harmful (for anonymity)

So, I learned an “interesting” (read: disturbing) fact about VirtualBox’s resolution “auto-resize” feature while trying to add a privacy feature to vm-config-dist’s wlr_resize_watcher utility. Some backstory for the feature itself:

Previously the utility always resized the compositor’s resolution to match that of the virtual display, but during wiki auditing I noticed that we previously had a feature where Whonix’s display resolution was locked to 1920x1080. This helped anonymity, since malware that infected the workstation wouldn’t be able to see anything unique about the VM’s display resolution, and browsers would be less likely to leak a unique or odd resolution to websites. That feature got lost somewhere along the line (I think it was missing even from the X11 session, but it’s certainly missing from the Wayland session), so after some discussion with Patrick I set out to implement it.

wlr_resize_watcher gets the resolution of the virtual display from /sys/class/drm/card*/modes (where * is a wildcard). There is one such file per display the hypervisor exposes to the guest. When resizing the guest window, the hypervisor will typically do something with the virtual display device to signal the changed display resolution. Linux then changes this modes file to list the new “real” resolution as the monitor’s native resolution, and sends a notification about the change via udev. wlr_resize_watcher currently works by detecting these udev events, reading the native resolution of the changed display, and then telling the compositor to resize the changed display’s resolution to the new native resolution.

The original plan for making this a bit more privacy-oriented was to introduce a config file option that would make wlr_resize_watcher not resize the compositor’s resolution when it detected a display resize. This is good for keeping browsers from leaking info about the user, but it doesn’t help if malware infects the workstation, since that malware can simply read the same sysfs file wlr_resize_watcher is looking at and get the display’s real resolution anyway. To mitigate that, we decided to add a notification that would tell the user that they should disable the automatic resolution resize feature of their hypervisor. The idea is that once the automatic resolution resize feature is disabled, the virtual display’s native resolution should change to the actual resolution of the host (edit: meant window, not host), and thus the user’s anonymity is better preserved.

This sounds great on paper, but when actually implementing it, I discovered VirtualBox makes it ridiculously hard to banish a non-generic (and thus fingerprint-able) resolution from the modes file. (I’m sure this is unintentional, but it’s nonetheless true sadly.) Basically what happens is:

  • The VM’s display resolution is saved in the VirtualBox VM’s XML, generally under ~/VirtualBox VMs/Whonix-Workstation-LXQt/Whonix-Workstation-LXQt.vbox, in an ExtraData item called GUI/LastGuestSizeHint. If you attempt to reboot to clear a non-standard resolution from the VM, it won’t work because that non-standard resolution is stored in the XML and will be restored to the VM on boot.
  • Clicking View -> Adjust Window Size will resize the virtual display’s size to the compositor’s resolution, but the non-standard resolution will still be present in the modes file!.
  • If you try to resize the guest window just a tiny bit, you’ll discover that you can get the native resolution to change to anything except one of the generic values elsewhere in the list (like 1920x1080). If you land exactly on a generic value, the native resolution will be left on the last non-standard value.
  • Even if you do manage to get the display to land exactly on 1024x768 via careful adjustments, the “stuck bad” native resolution is what gets saved back to the GUI/LastGuestSizeHint value, meaning that the same bad resolution will be loaded on next boot.

What this boils down to is that, once the user uses VirtualBox’s “Auto-resize guest display” once, there is no way to get the guest’s apparent native resolution to ever change back to a generic resolution like 1920x1080 without modifying the guest’s extra data directly. That means either editing the XML by hand, or (probably more conveniently) using vboxmanage to change the size hint. This has to be done on the host. Even if the user changes back to a normal resolution, the last non-standard resolution will linger around theoretically forever, providing a persistent fingerprinting mechanism.

KVM / virt-manager is much better-behaved in this respect; one needs to click View -> Resize to VM to get the native resolution to actually match the compositor’s resolution. Once this is done, all the resolutions displayed in the modes file are either various generic resolutions the graphics device supports, and the native resolution of 1920x1080 listed at the top.

I’m not sure what the best solution to this is; we could document this very-easy-to-trigger-and-very-hard-to-fix footgun on the Wiki, we could integrate a fix for this into some sort of installer or maintenance tool intended for the host, or we could try to find a way to get the guest to tell VirtualBox that it really wants its resolution to be one of the generic resolutions (this might be possible by talking to the /tmp/.iprt-localipc-DRMIpcServer socket, which is what VBoxDRMClient usually uses to tell the graphics driver the guest’s current resolution. It’s unknown whether this socket accepts multiple clients though, or if it can cope with a client disconnecting and some other client connecting).

1 Like

After some research, it looks like VBoxDRMClient doesn’t actually set the resolution via the IPC socket. Instead, it gets resolution info from… somewhere (another driver perhaps), then sends it to the vmwgfx driver via an ioctl. It might be possible to fix the resolution issue by using the same ioctl to tell the VMware graphics driver that all displays are the desired resolution. I haven’t fully worked out how to do this (calling ioctls from Python is hard), but I’m making some progress on that front. Worst case scenario, we could implement a helper application in C, but I’d like to avoid that if possible.

Edit: I did end up having to use a helper application in C. Figuring out the proper ioctl numbers requires architecture-specific calculations that are implemented as C macros and would be non-trivial and possibly unsafe to reimplement in Python. However, the helper application did half-work; I can set the native resolution of the display driver to a generic resolution, thus fixing the immediate fingerprinting risk, but the bad resolution is still stuck in the VM’s extra data, and gets rewritten on bootup (which means persistent malware can still fingerprint the user with this if it can start early enough in the boot process).

Edit 2: It looks like the last issue (the size hints not being properly reset) is probably going to be unfixable. From the VirtualBox source code, src/VBox/Frontends/VirtualBox/src/runtime/UIMachineView.cpp:

    /* If we are in normal or scaled mode and if GA are active,
     * remember the guest-screen size to be able to restore it when necessary: */
    /* As machines with Linux/Solaris and VMSVGA are not able to tell us
     * whether a resize was due to the system or user interaction we currently
     * do not store hints for these systems except when we explicitly send them
     * ourselves.  Windows guests should use VBoxVGA controllers, not VMSVGA. */

So basically, there is no way to set the size hint from within the guest. VirtualBox also has various bits of code that prevent sending a size hint if the window size exactly matches the framebuffer size (which is the very thing we’re trying to do here). That code is host-side, so this likely has to be fixed in VirtualBox itself. Filed a bug report: [Bug]: Virtual display's saved and native resolution remains the last non-standard resolution even if the VM window is resized to exactly fit a standard resolution · Issue #454 · VirtualBox/virtualbox · GitHub

Edit 3: The only real fingerprinting value to be had in the resolution from an anti-malware perspective is preventing malware from establishing that it’s re-infected a machine after that malware has been eliminated by a snapshot. I.e., if the same user gets infected multiple times, they can be re-identified by the resolution if we don’t set it to a generic value. Thus, I’m not sure it’s worth continuing to chase this, it only offers protection from one rare edge case.

So that it’s not lost, this is the code I wrote that was able to set the native resolution of one virtual display to 1920x1080, “fixing” the issue until the next display resize or reboot.

/*
 * Heavily based on VirtualBox's VBoxDRMClient code, from
 * src/VBox/Additions/x11/VBoxClient/display-drm.cpp
 */

#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <dirent.h>
#include <errno.h>
#include <err.h>
#include <stdbool.h>
#include <string.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <sys/types.h>
#include <drm/drm.h>

#define DRM_IOCTL_VMW_UPDATE_LAYOUT  _IOW('d', 0x40 + 20, struct drm_vmw_update_layout_arg)
#define DRM_DRIVER_VERSION_MAJOR_MIN 2
#define DRM_DRIVER_VERSION_MINOR_MIN 10

/*
 * Adapted from the Linux kernel, include/uapi/drm/vmwgfx_drm.h
 */
struct drm_vmw_rect {
  /* Position coordinates of a display; we don't really care about this */
  int32_t x;
  int32_t y;

  /* Size of a display; this is what we want to work with */
  uint32_t w;
  uint32_t h;
};
struct drm_vmw_update_layout_arg {
  /* Number of displays */
  uint32_t num_outputs;

  /* Padding, leave all zero */
  uint32_t pad64;

  /* Pointer cast to integer to an array of drm_vmw_rect structs */
  uint64_t rects;
};

const char *driver_dir_path = "/dev/dri";

int main(int argc, char** argv) {
  DIR *driver_dir = NULL;
  struct dirent *driver_dir_entry = NULL;
  char *dev_path = NULL;
  int dev_path_len = 0;
  bool found_suitable_vmware_gpu = false;
  int vmware_gpu_fd = 0;
  struct drm_vmw_rect *rect_arr = NULL;
  struct drm_vmw_update_layout_arg layout_set_arg = { 0 };

  if ((driver_dir = opendir(driver_dir_path)) == NULL) {
    err(1, "Could not open directory '/dev/dri'");
  }
  
  struct drm_version vers_data = { 0 };
  char vers_name[7];
  vers_data.name = vers_name;
  vers_data.name_len = 6;

  while ((driver_dir_entry = readdir(driver_dir)) != NULL) {
    if (driver_dir_entry->d_type != DT_CHR) {
      continue;
    }
    if (strncmp(driver_dir_entry->d_name, "renderD", strlen("renderD"))
      != 0) {
      continue;
    }

    dev_path_len = snprintf(NULL, 0, "%s/%s", driver_dir_path,
      driver_dir_entry->d_name) + 1;
    dev_path = calloc(dev_path_len, sizeof(char));
    if (dev_path == NULL) {
      err(1, "Could not allocate 'dev_path_len'");
    }
    snprintf(dev_path, dev_path_len, "%s/%s", driver_dir_path,
      driver_dir_entry->d_name);

    if ((vmware_gpu_fd = open(dev_path, O_RDWR)) == -1) {
      warn("Could not open GPU device '%s'", dev_path);
      free(dev_path);
      continue;
    }

    if (ioctl(vmware_gpu_fd, DRM_IOCTL_VERSION, &vers_data) == -1) {
      err(1, "DRM_IOCTL_VERSION failed");
    }
    if (strncmp(vers_data.name, "vmwgfx", 6) != 0) {
      free(dev_path);
      continue;
    }
    if (vers_data.version_major < DRM_DRIVER_VERSION_MAJOR_MIN) {
      free(dev_path);
      continue;
    }
    if (vers_data.version_minor < DRM_DRIVER_VERSION_MINOR_MIN
      && vers_data.version_major == DRM_DRIVER_VERSION_MAJOR_MIN) {
      free(dev_path);
      continue;
    }
    found_suitable_vmware_gpu = true;
    free(dev_path);
    break;
  }

  if (closedir(driver_dir) == -1) {
    err(1, "Could not close directory '/dev/dri'");
  }

  if (!found_suitable_vmware_gpu) {
    errx(1, "Could not find suitable VMware GPU!");
  }
  
  rect_arr = calloc(1, sizeof(struct drm_vmw_rect));
  if (rect_arr == NULL) {
    err(1, "Could not allocate 'rect_arr'");
  }
  rect_arr[0].w = 1920;
  rect_arr[0].h = 1080;

  layout_set_arg.num_outputs = 1;
  layout_set_arg.rects = (uint64_t)(rect_arr);
  if (ioctl(vmware_gpu_fd, DRM_IOCTL_VMW_UPDATE_LAYOUT, &layout_set_arg)
    == -1) {
    err(1, "DRM_IOCTL_VMW_UPDATE_LAYOUT failed");
  }
}

1 Like