Biko's House of Horrors

Hey, Teacher, Leave My VMs Alone

Disclaimer: The information presented here is for educational purposes only.

TL;DR: You can run the Safe Exam Browser in a VM. Read on for the nitty-gritty details, or skip to the conclusion to try it for yourself.

Part 1: The beginning of the end

Back in 2020, at the very beginning of the COVID-19 pandemic, my university began transitioning classes to a remote format. Basically, all classes were now done over Zoom.

There was one problem, however: exams. God forbid we allow students to just... do the exam at home? Unsupervised? Everyone's going to cheat! What'll happen to the grade average?! The horror!

So instead of doing the right thing (i.e. writing better exams), the university decided to do what every other university in the world has done — force the students to install invasive exam software.

That's how I met SEB.

SEB

The Safe Exam Browser (aka SEB) is:

... a web browser environment to carry out e-assessments safely. The software turns any computer temporarily into a secure workstation. It controls access to resources like system functions, other websites and applications and prevents unauthorized resources being used during an exam.

To be fair, this is orders of magnitude better than proctoring software. And even though my university did, for a short time, employ an actual proctoring "solution", it was quickly abandoned. Small miracles.1

Still, SEB behaves like a prank program from the early 2000's. Remember those?

Recording of a prank program that makes the Windows start button avoid the mouse cursor

You can get this particular one here.

Like those pranks, SEB installs hooks, messes with the registry, and generally makes a nuisance of itself. Their own FAQ even contains a list of antivirus products that SEB is compatible with, which is alarming, to say the least.

Add to that the numerous complaints from students about SEB locking out their computers without any way to shut it down short of a hard reset, and it's not a great picture.

Under no circumstances was I going to install this on my main OS, so I set out to find an alternative.

The easy way

The simplest way to avoid installing SEB on your own machine is to... not do that 😎. Instead, you can use Rufus to create a bootable flash drive with Windows on it (select the "Windows To Go" option) and... that's it! Install SEB on that without fear of bricking your main system.

For the extra paranoid, you can also go into Device Manager on this portable system and disable all the other hard disks, so nothing will "accidentally" corrupt data there. Or, you could physically remove the disks 😈.

But, as the saying goes, мы не ищем лёгких путей2.

Virtually impossible

Let's try running SEB in a VM.

SEB splash screen showing an error message: "It is not allowed to run SEB on a virtual machine! SEB will quit now."

Oh.

Well that's unfortunate.

But, SEB is open source under the Mozilla Public License, so we can search the code for the error message, and from there figure out how it checks for a VM. Here's the relevant bit (cleaned up a little), from version 2.33:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private static bool IsInsideVM()
{
  using (var searcher = new ManagementObjectSearcher(
      "Select * from Win32_ComputerSystem"))
  {
    using (var items = searcher.Get())
    {
      foreach (var item in items)
      {
        string manufacturer =
          item["Manufacturer"].ToString().ToLower();
        string model =
          item["Model"].ToString().ToLower();
        if ((manufacturer == "microsoft corporation"
             && !model.Contains("surface"))
            || manufacturer.Contains("vmware")
            || manufacturer.Contains("parallels software")
            || manufacturer.Contains("xen")
            || model.Contains("xen")
            || model.Contains("virtualbox"))
        {
          return true;
        }
      }
    }
  }
  return false;
}

Okay, so the code queries for all instances of the Win32_ComputerSystem WMI class, and checks whether any instance has a "suspicious" manufacturer or model string. In my VMware VM the manufacturer is "VMware, Inc.", and the model is "VMware Virtual Platform", so this check fails.

What can we do about this?

The easiest way would be to recompile SEB ourselves without this check. Alternatively, we could patch SEB in-memory. However, we might run afoul of some validation mechanisms: for instance, SEB might be verifying that its binaries are correctly signed.

The operative word here is might. We don't know whether SEB is doing any of those things, and how difficult circumventing them is going to be. I don't think it's doing anything of the sort, but let's assume for a second that it does.

Assuming we can't recompile SEB, and without patching it in-memory, what can we do?

Well, we could try modifying the VMware configuration. According to this, we could hack the BIOS image VMware uses and edit the manufacturer/model strings. Or, we could make VMware report the same manufacturer/model as the host.

But that's boring.

How about figuring out where Win32_ComputerSystem gets the information from, and patching that?

License and registration, please

Alright, first guess: the information comes from the registry. Specifically, there's a very nice key called HKEY_LOCAL_MACHINE\HARDWARE\DESCRIPTION\System\BIOS that has this:

Screenshot of the registry key with the "SystemManufacturer" and "SystemProductName" values circled

These are exactly the same values we get from Win32_ComputerSystem! Unfortunately, modifying them has no effect on SEB. And these values reset to the defaults after a reboot.

Managing the system

Seems like we have no choice but to dive into the WMI class' implementaiton.

According to the documentation, Win32_ComputerSystem is implemented in CIMWin32.dll. "Manufacturer" is a unique-enough string, so we can start by searching for that, and looking at the cross-references. One of them comes from a function called CWin32ComputerSystem::GetCompSysInfo4:

Screenshot of Ghidra's decompiler output, showing a reference to the "Manufacturer" string, and another to a CSMBios class

Hmm, that SMBIOS reference looks interesting. The whole function is pretty convoluted, by we can make a guess that since "Manufacturer" and CSMBios appear near each other in the code, they must be related somehow.

According to Wikipedia:

[T]he System Management BIOS (SMBIOS) specification defines data structures (and access methods) that can be used to read management information produced by the BIOS of a computer.

Very promising.

That CSMBios::m_pTable variable is also referenced from the CSMBios::InitData function, which presumably does some initialization. Seems like a good place to look next.

At the very start of the function there is a reference to a global variable named guidSMBios:

Ghidra screenshot showing the reference to guidSMBios

Ghidra screenshot showing the bytes of the guidSMBios variable

Reconstructing this GUID we get {8F680850-A584-11D1-BF38-00A0C9062910}. A quick search later and we find this Word document, which indicates that the GUID belongs to the MSSmBios_RawSMBiosTables class in the root\wmi namespace. One of the fields of this class is SMBiosData, which contains the raw SMBIOS tables.

The document also links to the SMBIOS specification. Of particular interest are the structure definitions, where we find:

Screenshot of the beginning of the System Information (Type 1) structure, with the Manufacturer and Product Name fields circled

Great, so here's the plan:

  1. Hook whatever is responsible for the MSSmBios_RawSMBiosTables class.
  2. When a request comes in:
    1. Call the original code.
    2. Parse the SMBiosData field in the response to find the System Information (Type 1) table.
    3. Patch the "Manufacturer and "Product Name" strings to something that does not contain "VMware".

Okay, but who is responsible for the MSSmBios_RawSMBiosTables class? The answer is back in that Word document: mssmbios.sys. Further investigation reveals that it has a device object under the name \Device\WMIDataDevice.

So we're going to hook IRP_MJ_SYSTEM_CONTROL, which is the IRP responsible for answering WMI queries:

  1. Open the \Device\WMIDataDevice device object.
  2. From there, get the driver object.
  3. Hook the dispatch routine for IRP_MJ_SYSTEM_CONTROL.
  4. When an IRP comes in:
    1. Build a new IRP with the same arguments (notably, with the same output buffer pointer), and send it to the original dispatch routine.
    2. When the new IRP is completed, parse the SMBiosData field in the response to find the System Information (Type 1) table.
    3. Patch the "Manufacturer and "Product Name" strings to something that does not contain "VMware".
    4. Complete the original IRP.

Sebastian

And thus, we have Sebastian.sys5, the driver that implements our evil scheme:

Demo showing how SEB fails to run inside a VM without Sebastian, how Sebastian modifies the Manufacturer and Model values, and how SEB runs correctly with Sebastian present

Part 2: Winter wonderland

Fast-forward to the winter 2022 end-of-term exams. This was during the Omicron variant's outbreak, so once again almost all exams were done from home. Out of curiosity, I decided to check whether my old code still worked with the new SEB version: 3.3.1.

Surprisingly, it did! The VM detection code is mostly unchanged:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/// <summary>
/// Virtualbox: VBOX, 80EE
/// RedHat: QUEMU, 1AF4, 1B36
/// </summary>
private static readonly string[] PCI_VENDOR_BLACKLIST = {
  "vbox", "vid_80ee", "qemu", "ven_1af4", "ven_1b36",
  "subsys_11001af4"
};
private static readonly string VIRTUALBOX_MAC_PREFIX =
  "080027";
private static readonly string QEMU_MAC_PREFIX =
  "525400";

public bool IsVirtualMachine()
{
  var isVirtualMachine = false;
  var manufacturer = systemInfo.Manufacturer.ToLower();
  var model = systemInfo.Model.ToLower();
  var macAddress = systemInfo.MacAddress;
  var plugAndPlayDeviceIds =
    systemInfo.PlugAndPlayDeviceIds;

  isVirtualMachine |=
    manufacturer.Contains("microsoft corporation")
    && !model.Contains("surface");
  isVirtualMachine |= manufacturer.Contains("vmware");
  isVirtualMachine |=
    manufacturer.Contains("parallels software");
  isVirtualMachine |= model.Contains("virtualbox");
  isVirtualMachine |= manufacturer.Contains("qemu");

  if (macAddress != null && macAddress.Count() > 2)
  {
    isVirtualMachine |=
      macAddress.StartsWith(QEMU_MAC_PREFIX)
      || macAddress.StartsWith(VIRTUALBOX_MAC_PREFIX);
  }

  foreach (var device in plugAndPlayDeviceIds)
  {
    isVirtualMachine |=
      PCI_VENDOR_BLACKLIST.Any(device.ToLower().Contains);
  }

  return isVirtualMachine;
}

The manufacturer and model checks are still present, but now SEB also checks for a MAC address by VirtualBox and some virtual devices from VirtualBox and QEMU.

Since I'm using VMware, Sebastian still fools this code.

However, SEB now fails in a different manner:

Error message box with the text: "The active display configuration is not permitted. 1 internal or external display(s) are allowed, but 0 internal and 0 external display(s) were detected. Please consult the log files for more information. SEB will now shut down..."

That's... something.

Nothing basic about it

Luckily, SEB provides a log at %localappdata%\SafeExamBrowser\Logs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/* Safe Exam Browser, Version 3.3.1 (x86), Build 3.3.1.388
/* Copyright © 2021 ETH Zürich, Educational Development and Technology (LET)
/*
/* Please visit https://www.github.com/SafeExamBrowser for more information.

# Application started at 2022-01-06 14:49:28.465
# Running on Windows 10, Microsoft Windows NT 10.0.19043.0 (x86)
# Computer 'DESKTOP-HBKES5N' is a  _______________________ manufactured by ____________
# Runtime-ID: 0ee9f6b4-bcc0-42cd-b9a9-96f2e5ae7a9c

...

2022-01-06 14:49:29.198 [07] - INFO: Validating virtual machine policy...
2022-01-06 14:49:29.201 [07] - DEBUG: [VirtualMachineDetector] Computer 'DESKTOP-HBKES5N' appears to not be a virtual machine.
2022-01-06 14:49:29.202 [07] - INFO: Validating display configuration...
2022-01-06 14:49:29.244 [07] - ERROR: [DisplayMonitor] Failed to query displays!

   Exception Message: Not supported
   Exception Type: System.Management.ManagementException

   at System.Management.ManagementException.ThrowWithExtendedInfo(ManagementStatus errorCode)
   at System.Management.ManagementObjectCollection.ManagementObjectEnumerator.MoveNext()
   at System.Linq.Enumerable.<CastIterator>d__97`1.MoveNext()
   at SafeExamBrowser.Monitoring.Display.DisplayMonitor.TryLoadDisplays(IList`1& displays) in C:\Users\appveyor\projects\seb-win-refactoring\SafeExamBrowser.Monitoring\Display\DisplayMonitor.cs:line 169

2022-01-06 14:49:29.246 [07] - WARNING: [DisplayMonitor] Failed to validate display configuration, active configuration is not allowed.
2022-01-06 14:49:29.247 [07] - ERROR: Display configuration is not allowed!
2022-01-06 14:49:31.169 [07] - INFO: ### -------------------------------------- Session Start Failed -------------------------------------- ###

...

There are two things of note here:

  1. Sebastian works as expected, and reports spoofed manufacturer and model names (lines 8 and 14).
  2. Querying the display configuration fails with... "Not supported"?

We do have a source line number, and there we find:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
using (var searcher = new ManagementObjectSearcher(
  @"Root\WMI",
  "SELECT * FROM WmiMonitorBasicDisplayParams"))
using (var results = searcher.Get())
{
  var displayParameters =
    results.Cast<ManagementObject>();

  foreach (var display in displayParameters)
  {
    displays.Add(new Display
    {
      Identifier =
        Convert.ToString(display["InstanceName"]),
      IsActive =
        Convert.ToBoolean(display["Active"])
    });
  }
}

Okay, let's try querying WmiMonitorBasicDisplayParams ourselves.

wbemtest error message box with the message "Number: 0x8004100c; Facility: WMI; Description: Not supported"

Oh.

Well, perhaps Sebastian messes something up. Let's try this query again on a clean VM.

The exact same message box as above"

Okay, what about on the host?

Dialog box with two entries as the result of the WMI query on the host machine

Okay, we get an object for each one of the connected monitors. Seems like it's an issue with VMware, then.

Unfortunately, there's virtually no information on this error online. But at least we're not alone.

Some further searching reveals that this particular query in the SEB code was probably added because of this issue 😡.

Time to dig deeper.

lizard.sys

According to the documentation of WmiMonitorBasicDisplayParams, the class is implemented in WmiProv.dll. This DLL is apparently the WDM Provider:

The WDM (Windows Driver Model) provider grants access to the classes, instances, methods, and events of hardware drivers that conform to the WDM model. The classes for hardware drivers reside in the "root\wmi namespace".

Okay, so there's some driver that actually responds to WMI queries for this class. But which one? Lucky for us, there's a log at %systemroot%\system32\wbem\logs. Most of it looks like this:

1
2
3
4
5
6
***************************************
Could not get pointer to binary resource for file:
C:\Windows\System32\drivers\monitor.sys[MonitorWMI](Thu Jan 13 16:22:37 2022.44015) :
***************************************
WDM call returned error: 4200
WDM call returned error: 4200

It appears as though we get a bunch of 4200 errors from monitor.sys, but the timestamps don't match the times of the queries to WmiMonitorBasicDisplayParams. It's a start, though. The error number can also be interpreted as ERROR_WMI_GUID_NOT_FOUND, so that's promising too.

Looking inside monitor.sys, we find it has a function with the name EvtWmiMonitorBasicDisplayParamsQueryBlock. Presumably, this answers queries for the class we're interested in. All this function does is send an IOCTL with the code 0x232423 to the PDO (Physical Device Object), which happens to be vm3dmp_loader.sys — a VMware driver.6

Ghidra screenshot showing the entirety of EvtWmiMonitorBasicDisplayParamsQueryBlock. There's not much there.

Let's set a breakpoint on this function and see where it's called from, so that we may hook it.

It's not called.

Okay, where is this function referenced from? The only place is inside a function called WmiRegisterDataBlocks:

Ghidra screenshot showing the reference to EvtWmiMonitorBasicDisplayParamsQueryBlock

Okay, so this sends an IOCTL to the PDO, and if that fails with STATUS_BUFFER_OVERFLOW then it calls WmiRegisterDataBlock (singular) with a pointer to our event handler function. Otherwise, it just traces an error.

Well, let's see what the call to the PDO actually returns. Reboot, set a breakpoint, and... It fails with 0xC01D0001, aka STATUS_MONITOR_NO_DESCRIPTOR.

According to this, the error means that the monitor does not support EDID, which is a way for a monitor to describe its capabilities to a video source (e.g. a graphics card). This kinda sorta makes sense, as the VM doesn't actually have a monitor, but still kinda weird.

Attempt #1: QEMU

Is this a VMware-only thing? Let's try the WMI query on QEMU and see what happens.7

With QEMU 6.2.0, the query works! Specifically, using the following VM configuration:8

qemu-system-x86_64.exe -hda path\to\disk.img -boot c -cpu qemu64 -smp cores=2 -m 4G -vga std -nic user -usbdevice tablet -rtc base=localtime

Dialog box with a single entry as the result of the WmiMonitorBasicDisplayParams WMI query on QEMU

Unfortunately, QEMU on my AMD machine is too slow: you can't use HAXM, and I wasn't going to install Hyper-V. And so SEB times out at launch.

Attempt #2: Patch EDID

Maybe we can fake an EDID for the virtual monitor VMware provides? According to the documentation this is certainly possible. And, according to Wikipedia, the basic EDID format doesn't seem to contain any problematic fields (i.e. fields that might cause problematic behaviour of the OS).

There are also tools to simplify the creation of EDID blobs, and even a 010 Editor template.

Unfortunately, applying the patch as documented doesn't help with the WMI issue. Or, more likely, I was doing it wrong.

Here's the EDID blob I was using, if you want to try it yourself:

1
2
3
4
5
6
7
8
9
Windows Registry Editor Version 5.00

[HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Enum\DISPLAY\Default_Monitor\4&427137e&0&UID0\Device Parameters\EDID_Override]
"0"=hex:00,ff,ff,ff,ff,ff,ff,00,59,b7,13,37,be,ef,ca,fe,01,00,01,03,80,00,00,\
  00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,01,01,01,01,01,01,01,01,01,01,\
  01,01,01,01,01,01,64,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,\
  00,00,10,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,10,00,00,00,00,\
  00,00,00,00,00,00,00,00,00,00,00,00,00,10,00,00,00,00,00,00,00,00,00,00,00,\
  00,00,00,00,f6

Attempt #3: Our very own WMI provider

Wait.

If monitor.sys doesn't register the WMI class...

What's stopping us from registering it ourselves, and returning whatever we want?

Turns out — nothing!

The tricky part is getting the structure definitions from the WMI MOF file. Here's how it can be done:

  1. Use C:\Windows\System32\wbem\mofcomp.exe to compile wmicore.mof into a binary MOF file.
    • wmicore.mof can be found in the Windows Driver Kit.
    • The MOF file doesn't compile as-is, because it contains preprocessor directives. So pluck out only the necessary structures and compile those.
  2. Use wmimofck.exe to create a header from the binary MOF.
    • wmimofck.exe can be found in the WDK.
    • There's a lot of noise in the header — #defines and GUIDs and whatnot. Since we're only interested in the structures, everything else can be safely deleted.
    • Don't worry about the InstanceName and Active fields not being present in the generated C structures. Apparently that's fine.

Eventually, we get something like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
typedef struct _WmiMonitorSupportedDisplayFeatures
{
    // VESA DPMS Standby support
    BOOLEAN StandbySupported;

    // VESA DPMS Suspend support
    BOOLEAN SuspendSupported;

    // Active Off/Very Low Power Support
    BOOLEAN ActiveOffSupported;

    // Display type
    UCHAR DisplayType;

    // sRGB support
    BOOLEAN sRGBSupported;

    // Has a preferred timing mode
    BOOLEAN HasPreferredTimingMode;

    // GTF support
    BOOLEAN GTFSupported;

} WmiMonitorSupportedDisplayFeatures,
  *PWmiMonitorSupportedDisplayFeatures;

typedef struct _WmiMonitorBasicDisplayParams
{
    // Video input type
    UCHAR VideoInputType;

    // Max horizontal image size (in cm)
    UCHAR MaxHorizontalImageSize;

    // Max vertical image size (in cm)
    UCHAR MaxVerticalImageSize;

    // Display transfer characteristic (100*Gamma-100)
    UCHAR DisplayTransferCharacteristic;

    // Supported display features
    WmiMonitorSupportedDisplayFeatures
        SupportedDisplayFeatures;

} WmiMonitorBasicDisplayParams,
  *PWmiMonitorBasicDisplayParams;

There's one other thing we have to take care of. Recall that each WMI class instance has an "instance name". What instance name should we give to the object returned from our new WMI provider?

If we look again at the SEB code that performs the WMI query, we'll see that a few lines further down it performs another query for the WmiMonitorConnectionParams class. And, this class already exists on the VM!

Notably, the instance names of WmiMonitorConnectionParams and WmiMonitorBasicDisplayParams objects match. That is, for each object of the former class, there is an object with the same name of the latter class.

So, when registering our own WMI provider for WmiMonitorBasicDisplayParams, we have to supply the same instance name returned by WmiMonitorConnectionParams. But how do we get it?

Well, why not ask WMI?

Ideally, we'd want to perform the whole thing in kernel-mode, using something like IoWMIQueryAllData. Unfortunately, our driver gets loaded very early in the boot process, so that we can hook the SMBIOS driver before CIMWin32.dll queries it and caches the result (this is Sebastian v1 code). This means we can't issue this query from the kernel, as the provider for WmiMonitorConnectionParams hasn't been loaded yet.

So we'll do it the hard way. We'll write a co-installer.

A co-installer is a DLL that gets called during different phases of a driver's installation process. Notably, it gets called before the driver is loaded. This means we can perform the WMI query from the co-installer, stash the result somewhere in the registry, then let the driver retrieve it from there.

Where in the registry should we store the data? I learned the hard way that not all registry hives are present when our driver gets loaded during boot. However, HKLM\SYSTEM\CurrentControlSet\Services is present, since it contains the driver's service key.9 So we'll store the data in HKLM\SYSTEM\CurrentControlSet\Services\Sebastian\Parameters.

Sebastian v2

And so, we have an updated Sebastian:

Demo showing how SEB fails to run inside a VM without Sebastian, how Sebastian modifies the Manufacturer and Model values and fixes the display WMI query, and how SEB runs correctly with Sebastian present

Conclusion

Code and some binaries are here. Again: for educational purposes only. Running SEB in a VM during an exam is probably a violation of your university's rules (it is of mine). Go the easy way if you want to be safe.

I don't have anything profound to say, except that if a student wants to cheat, they'll always find a way. So maybe universities should stop treating students as the enemy, because that has never worked.


  1. This particular piece of software shall remain nameless, but it did feature all the great stuff: video surveillance, ID scans, AI... As far as I know it was not as bad as the more infamous "solutions", so thanks for that. ↩︎

  2. We're not looking for easy ways. ↩︎

  3. That is what was available at the time. See below for an analysis of more recent versions. ↩︎

  4. Actually, there are several references to this string from that function, but with my power of hindsight I can tell you that this is the interesting one 😎. ↩︎

  5. "May your choices have better results than mine." ↩︎

  6. As far as I can tell, this thing loads one of vm3dmp.sys, vm3dmp-debug.sys, or vm3dmp-stats.sys based on some configuration, and presumably forwards IRPs to them. Anyway, irrelevant↩︎

  7. Yes, I know SEB has a lot of checks to detect QEMU. I didn't think of it at the time 😅. ↩︎

  8. Based on this↩︎

  9. This is the key whose path you get in DriverEntry↩︎