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?
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.
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:
|
|
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:
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::GetCompSysInfo
4:
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
:
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:
Great, so here's the plan:
- Hook whatever is responsible for the
MSSmBios_RawSMBiosTables
class. - When a request comes in:
- Call the original code.
- Parse the
SMBiosData
field in the response to find the System Information (Type 1) table. - 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:
- Open the
\Device\WMIDataDevice
device object. - From there, get the driver object.
- Hook the dispatch routine for
IRP_MJ_SYSTEM_CONTROL
. - When an IRP comes in:
- Build a new IRP with the same arguments (notably, with the same output buffer pointer), and send it to the original dispatch routine.
- When the new IRP is completed, parse the
SMBiosData
field in the response to find the System Information (Type 1) table. - Patch the "Manufacturer and "Product Name" strings to something that does not contain "VMware".
- Complete the original IRP.
Sebastian
And thus, we have Sebastian.sys
5, the driver that implements our evil scheme:
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:
|
|
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:
That's... something.
Nothing basic about it
Luckily, SEB provides a log at %localappdata%\SafeExamBrowser\Logs
:
|
|
There are two things of note here:
- Sebastian works as expected, and reports spoofed manufacturer and model names (lines 8 and 14).
- Querying the display configuration fails with... "Not supported"?
We do have a source line number, and there we find:
|
|
Okay, let's try querying WmiMonitorBasicDisplayParams
ourselves.
Oh.
Well, perhaps Sebastian messes something up. Let's try this query again on a clean VM.
Okay, what about on the host?
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:
|
|
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
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
:
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
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:
|
|
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:
- Use
C:\Windows\System32\wbem\mofcomp.exe
to compilewmicore.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.
- 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 —
#define
s and GUIDs and whatnot. Since we're only interested in the structures, everything else can be safely deleted. - Don't worry about the
InstanceName
andActive
fields not being present in the generated C structures. Apparently that's fine.
Eventually, we get something like this:
|
|
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:
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.
-
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. ↩︎
-
We're not looking for easy ways. ↩︎
-
That is what was available at the time. See below for an analysis of more recent versions. ↩︎
-
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 😎. ↩︎
-
As far as I can tell, this thing loads one of
vm3dmp.sys
,vm3dmp-debug.sys
, orvm3dmp-stats.sys
based on some configuration, and presumably forwards IRPs to them. Anyway, irrelevant. ↩︎ -
Yes, I know SEB has a lot of checks to detect QEMU. I didn't think of it at the time 😅. ↩︎
-
This is the key whose path you get in
DriverEntry
. ↩︎