SECRET CLUB

Exam surveillance - the return. (ExamCookie)


It has come to my attention that the Danish government has not only postponed The Digital Exam Monitor, that we analyzed and completely bypassed in our previous article, but also possibly discontinued a week after we contacted them about our bypass. Not to speculate that we independently got the Danish government to withdraw their decision to monitor exams, but we surely didn’t go unnoticed.

The following article will showcase the inner workings of the surveillance tool, if you are solely interested in bypassing it, scroll down to the Circumvention part at the bottom.

ExamCookie

Recently this tool was in the news due to an investigation into their GDPR compliance. We decided to finally take a look at the second biggest competitor to the aforementioned Digital Exam Monitor: ExamCookie. This is a commercially developed surveillance tool utilized by over 20 Danish schools. Their website contains no documentation besides the following description:

ExamCookie is a simple piece of software that monitors student’s computer activity while attending an exam to ensure the exam rules are being upheld. The program prevents any students from using any illicit form of help during the exam.

ExamCookie stores all activity on the computer: active URL-addresses, network connections, processors, clipboard and screenshots when screen size is altered.

The usage is simple, you execute it on your own machine when you’re physically attending the exam, and it monitors your activity. When the exam is done, the tool closes down and you are free to remove it from your machine.

To start the internal surveilance during an exam, you need to use your own UNI-login, which is used for a variety of educational websites, or manual credentials. We have not used this tool, so we can’t answer when the manual login is used, but perhaps it is for students that do not have a UNI-Login, which we don’t believe is possible.

Executable information

The program can be downloaded from the front page of the ExamCookie website, and is a x86 .NET application. For reference, the analyzed binary has the MD5 hash: 63AFD8A8EC26C1DC368D8FF8710E337D and was signed April 24, 2019 by EXAMCOOKIE APS. As the last article showcased, analyzing .NET binaries is barely reverse-engineering, as the combination of easily-readible IL code and metadata basically yields perfect source code.

Unlike the last surveilance tool, this binary has not only been stripped of debug logging but also obfuscated, or at least attempted :-)

Obfuscation (😂)

Opening up the application in dnSpy, we quickly noticed the missing entrypoint:

// Token: 0x0600003D RID: 61 RVA: 0x00047BB0 File Offset: 0x00045FB0
[STAThread]
[DebuggerHidden]
[EditorBrowsable(EditorBrowsableState.Advanced)]
[MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)]
internal static void Main(string[] Args)
{
    
}

That’s odd, this usually suggests some kind of packer that modifies method bodies from the module constructor, which is executed prior to the actual entrypoint, let’s take a look:

// Token: 0x06000001 RID: 1 RVA: 0x00058048 File Offset: 0x00055048
static <Module>()
{
	<Module>.\u206B\u202B\u200B\u206F\u206C\u202D\u200D\u200E\u202D\u206B\u206F\u206F\u202C\u202A\u206B\u202E\u202A\u206C\u202A\u206C\u200B\u206A\u202D\u206C\u202C\u206C\u200F\u202C\u206C\u202C\u200C\u206A\u200C\u206C\u200B\u206B\u202B\u206E\u202C\u202B\u202E();
	<Module>.\u206C\u200D\u200F\u200E\u200C\u200C\u200F\u200F\u206E\u206A\u206A\u200B\u202C\u206A\u206B\u200D\u206E\u200E\u202D\u206B\u202C\u206C\u202D\u206D\u200C\u200F\u206E\u200F\u206E\u206A\u202B\u206B\u200E\u206B\u202E\u206F\u206A\u202E\u202C\u202A\u202E();
	<Module>.\u200B\u202D\u200F\u200F\u202A\u206D\u202C\u206B\u206E\u202A\u206F\u206C\u200D\u200C\u202D\u200F\u202B\u202C\u202B\u206D\u206D\u202D\u206E\u200D\u206D\u206A\u202A\u202C\u200C\u206F\u206B\u206E\u200D\u202E\u206F\u200C\u206B\u200E\u206D\u206A\u202E();
}

/j

Wow. It’s <CURRENT YEAR> and people are still using Confuser(Ex).

/uj

We instantly recognized this unpacking code, and checked assembly headers for verification:

[module: ConfusedBy("Confuser.Core 1.1.0+a36320377a")]

Okay, at this point we figured it would actually be obfuscated, as the aforementioned constructor decrypts method bodies and resources, but to our surprise, whatever developer responsible for obfuscating the binary decided to… not rename metadata:

Which essentially takes out all the fun of reversing this, as we mentioned in the last article: we’d love the challenge of a properly protected, high-quality surveillance tool that does not take five minutes to analyze.

Either way, unpacking any confuser(ex) protected binary is very easy: use a .NET binary dumper or breakpoint on the ´ret´ instruction in <MODULE>.ctor and dump binaries yourself. This process takes 30 seconds and will forever be my favorite packer to dump, as the anti-debugging code never fucking works.

We decided to use MegaDumper, as it was a tad faster than doing it by hand:

When you’ve dumped the ExamCookie binary, the following message should show:

You now have a directory containing every single managed assembly loaded into the respective process, this time with decrypted method bodies.

Now, HOPEFULLY whoever obfuscated this at least encrypted strings:

else if (System.Windows.Forms.Clipboard.ContainsData(DataFormats.SymbolicLink))
{
	Module1.DebugPrint(<Module>.smethod_5<string>(1582642794u), new object[0]);
}
else if (System.Windows.Forms.Clipboard.ContainsData(DataFormats.Tiff))
{
	Module1.DebugPrint(<Module>.smethod_2<string>(4207351461u), new object[0]);
}
else if (System.Windows.Forms.Clipboard.ContainsData(DataFormats.UnicodeText))
{
	Module1.DebugPrint(<Module>.smethod_5<string>(3536903244u), new object[0]);
}
else if (System.Windows.Forms.Clipboard.ContainsData(DataFormats.WaveAudio))
{
	Module1.DebugPrint(<Module>.smethod_2<string>(2091555364u), new object[0]);
}

Yep, good old Confuser(Ex) string encryption, the best pseudo-security the .NET scene has ever seen. Good thing Confuser(Ex) has been repeatedly broken so hard that deobfuscation tools for every single mechanism are available on the internet, and thank god for that, we prefer not to touch anything .NET related. Let’s launch ConfuserExStringDecryptor by CodeCracker and run it on the dumped binary:

Which tranforms the previous snippet to:

else if (System.Windows.Forms.Clipboard.ContainsData(DataFormats.SymbolicLink))
{
	Module1.DebugPrint("ContainsData.SymbolicLink", new object[0]);
}
else if (System.Windows.Forms.Clipboard.ContainsData(DataFormats.Tiff))
{
	Module1.DebugPrint("ContainsData.Tiff", new object[0]);
}
else if (System.Windows.Forms.Clipboard.ContainsData(DataFormats.UnicodeText))
{
	Module1.DebugPrint("ContainsData.UnicodeText", new object[0]);
}
else if (System.Windows.Forms.Clipboard.ContainsData(DataFormats.WaveAudio))
{
	Module1.DebugPrint("ContainsData.WaveAudio", new object[0]);
}

That was the entire security layer of the application torn apart in under a minute… We won’t upload these tools anywhere, as we did not create them or have the source code, but they can be found on Tuts4You for anyone that needs to replicate. We do not have a tuts4you account anymore, so we can’t link any download mirrors.

Functionality

Surprisingly, no real “hidden functionality” was found. As stated on the website, the following information is periodically sent to the server:

The rest of the application is very tedious, so we’ve decided to skip the entire initialisation routine and jump right to the functions responsible for the aforementioned information grab:

Adapter

Network adapters are gathered by the .NET function NetworkInterface.GetAllNetworkInterfaces(), exactly like in the last article:

NetworkInterface[] allNetworkInterfaces = NetworkInterface.GetAllNetworkInterfaces();
foreach (NetworkInterface networkInterface in allNetworkInterfaces)
{
	try
	{
                // ...
                // TEXT FORMATTING OMITTED
                // ...

		dictionary.Add(networkInterface.Id, stringBuilder.ToString());
		stringBuilder.Clear();
	}
	catch (Exception ex)
	{
		AdapterThread.OnExceptionEventHandler onExceptionEvent = this.OnExceptionEvent;
		if (onExceptionEvent != null)
		{
			onExceptionEvent(ex);
		}
	}
}
result = dictionary;

Active application

Now this is where it gets interesting, instead of logging all open windows, only the active application is being monitored by the utility. The implementation is bloated so here’s beautified pseudocode instead:

var whiteList = { 
    "devenv",
    "ExamCookie.WinClient",
    "ExamCookie.WinClient.vshost",
    "wermgr",
    "ShellExperienceHost" };

// GET WINDOW INFORMATION
var foregroundWindow = ApplicationThread.GetForegroundWindow();
ApplicationThread.GetWindowRect(foregroundWindow, ref rect);
ApplicationThread.GetWindowThreadProcessId(foregroundWindow, ref processId);
var process = Process.GetProcessById(processId);

if (process == null)
    return;

// LOG BROWSER URL
if (IsBrowser(process))
{
    var browserUrl = UiAutomation32.GetBrowserUrl(process.Id, process.ProcessName);

    // SEND BROWSER URL TO SERVER
    if (ValidBrowserUrl(browserUrl))
    {
        ReportToServer(browserUrl);
    }

}
else if (!whiteList.contains(process.ProcessName, StringComparer.OrdinalIgnoreCase))
{
    ReportToServer(process.MainWindowTitle);
}

/j

Great… People are still using process names to differentiate processes, they never stop and think “Wait, we can change process names all we like”, which is why we can repeatedly bypass these programs.

/uj

If you read the last article about another exam surveillance tool, you will probably recognize this subpar implementation to find browsers:

private bool IsBrowser(System.Diagnostics.Process proc)
{
	bool result;
	try
	{
		string left = proc.ProcessName.ToLower();
		if (Operators.CompareString(left, "iexplore", false) != 0 && 
                    Operators.CompareString(left, "chrome", false) != 0 && 
                    Operators.CompareString(left, "firefox", false) != 0 &&
                    Operators.CompareString(left, "opera", false) != 0 && 
                    Operators.CompareString(left, "cliqz", false) != 0)
		{
			if (Operators.CompareString(left, "applicationframehost", false) != 0)
			{
				result = false;
			}
			else
			{
				result = proc.MainWindowTitle.Containing("Microsoft Edge");
			}
		}
		else
		{
			result = true;
		}
	}
	catch (Exception ex)
	{
		result = false;
	}
	return result;
}
private string GetBrowserName(string name)
{
	if (Operators.CompareString(name.ToLower(), "iexplore", false) == 0)
	{
		return "IE-Explorer";
    } 
    else if (Operators.CompareString(name.ToLower(), "chrome", false) == 0)
    {
		return "Chrome";
    } 
    else if (Operators.CompareString(name.ToLower(), "firefox", false) == 0)
    {
        return "Firefox";
    } 
    else if (Operators.CompareString(name.ToLower(), "opera", false) == 0)
    {
        return "Opera";
    } 
    else if (Operators.CompareString(name.ToLower(), "cliqz", false) == 0)
    {
        return "Cliqz";
    }
    else if (Operators.CompareString(name.ToLower(), "applicationframehost", false) == 0)
    {
        return "Microsoft Edge";
    }

    return "";
}

And the icing on the cake:

private static string GetBrowserUrlById(object processId, string name)
{
	// ...
    automationElement.GetCurrentPropertyValue(/*...*/);
    
    return url;
}

This is literally the same implementation as we showcased in our last article, and it’s hard to fathom that people still haven’t realized how bad this is, let alone the developers of such an utility. Anyone can edit the URL in the browser, it’s not even worth showcasing.

Virtual machine detection

Contrary to what the website states, you will actually get flagged if you run this in a virtual machine. The implementation of this part is…interesting.

File.WriteAllBytes("ecvmd.exe", Resources.VmDetect);

using (Process process = new Process())
{
	process.StartInfo = new ProcessStartInfo("ecvmd.exe", "-d")
	{
		CreateNoWindow = true,
		UseShellExecute = false,
		RedirectStandardOutput = true
	};
	process.Start();
	try
	{
		using (StreamReader standardOutput = process.StandardOutput)
		{
			result = standardOutput.ReadToEnd().Replace("\r\n", "");
		}
	}
	catch (Exception ex3)
	{
		result = "-5";
	}
}

Okay, for some reason they’re writing an external binary to disk and executing it, then completely relying on I/O for results. Not uncommon, really, but outsourcing important work like this to another unsecured process is meh. Let’s take a look and see what kind of file we’re dealing with:

Eehh, so now we’re using C++? Fair enough, interopability isn’t necesarily bad, and this might mean we have to actually reverse(!!). Let’s take a look using IDA:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  int v3; // ecx
  BOOL v4; // ebx
  int v5; // ebx
  int *v6; // eax
  int detect; // eax
  bool vbox_key_exists; // bl
  char vpcext; // bh
  char vmware_port; // al
  char *vmware_port_exists; // ecx
  char *vbox_detected; // edi
  char *vpcext_exists; // esi
  int v14; // eax
  int v15; // eax
  int v16; // eax
  int v17; // eax
  int v18; // eax
  int v20; // [esp+0h] [ebp-18h]
  HKEY result; // [esp+Ch] [ebp-Ch]
  HKEY phkResult; // [esp+10h] [ebp-8h]

  if ( argc != 2 )
    goto LABEL_20;
  v3 = strcmp(argv[1], "-d");
  if ( v3 )
    v3 = -(v3 < 0) | 1;
  if ( !v3 )
  {
    v4 = (unsigned __int8)vm_detect::vmware_port() != 0;
    result = 0;
    v5 = (vm_detect::vpcext() != 0 ? 2 : 0) + v4;
    RegOpenKeyExA(HKEY_LOCAL_MACHINE, "HARDWARE\\ACPI\\DSDT\\VBOX__", 0, 0x20019u, &result);
    v6 = sub_402340();
LABEL_16:
    sub_404BC0((int)v6, v20);
    return 0;
  }
  detect = strcmp(argv[1], "-s");
  if ( detect )
    detect = -(detect < 0) | 1;
  if ( !detect )
  {
LABEL_20:
    phkResult = 0;
    vbox_key_exists = RegOpenKeyExA(HKEY_LOCAL_MACHINE, "HARDWARE\\ACPI\\DSDT\\VBOX__", 0, 0x20019u, &phkResult) == 0;
    vpcext = vm_detect::vpcext();
    vmware_port = vm_detect::vmware_port();
    vmware_port_exists = "1";
    vbox_detected = "1";
    if ( !vbox_key_exists )
      vbox_detected = "0";
    vpcext_exists = "1";
    if ( !vpcext )
      vpcext_exists = "0";
    if ( !vmware_port )
      vmware_port_exists = "0";
    result = (HKEY)vmware_port_exists;
    v14 = std::print((int)&dword_433310, "VMW=");
    v15 = std::print(v14, (const char *)result);
    v16 = std::print(v15, ",VPC=");
    v17 = std::print(v16, vpcext_exists);
    v18 = std::print(v17, ",VIB=");
    v6 = (int *)std::print(v18, vbox_detected);
    goto LABEL_16;
  }
  return 0;
}

This checks if the VMWARE I/O port ‘VX’ is present:

int __fastcall vm_detect::vmware_port()
{
  int result; // eax

  result = __indword('VX');
  LOBYTE(result) = 0;
  return result;
}

This checks the execution of the virtual pc extension instruction, which should only work when ran in a virtualized environment, if it doesn’t crash the machine for handling it incorrectly ;):

char vm_detect::vpcext()
{
  char result; // al

  result = 1;
  __asm { vpcext  7, 0Bh }
  return result;
}

… no actual reversing for us, only 30 seconds to rename two functions :(

This program just reads a registry key and runs two hypervisor-specific checks, kind of odd compared to their other program, I wonder where they copy-pasted that? Oh look, an article called “VIrtual (sic) Machine Detection Techniques” that explains those methods :) Anyway, these detection vectors can be mitigated by editing the .vmx file or using a hardened version of whatever hypervisor you desire.

Data security

As previously mentioned, this company is in the spotlight for failing to comply with GDPR, and their website states this:

Your data gets sent with the help of encryption to a secure Microsoft Azure server, that you can only access with the correct credentials. Your data may be stored up to 3 months after an exam.

We’re not quite sure how they define “secure”, as the credentials are hardcoded into the application and are stored completely in plaintext in the metadata resources:

Endpoint: https://examcookiewinapidk.azurewebsites.net
Username: VfUtTaNUEQ
Password: AwWE9PHjVc

We have not looked at the access these credentials yield (that’s illegal), but can only speculate that they have complete access as they are hardcoded in the application, thus no isolation between student data containers exists.

Legal disclaimer: It is not illegal for us to publicize API information like above, as it is stored in a publicly-available binary and thus not illegaly obtained. Usage with malicious intent is however very illegal, so we strongly suggest the reader to not use the aforementioned credentials for anything and we are not responsible for any potential action.

Circumvention

Since this application resembles The Digital Exam Monitor in an incredible fashion, we decided to just update the ayyxam project to support ExamCookie, which means that the following snippets will be recycled from our previous article.

Process list

The .NET process interface will internally cache process data using the ntdll!NtQuerySystemInformation syscall. Hiding processes from this syscall requires a little bit of work, as many of the information types contain process information. Luckily, .NET only fetches one specific type of information, so we do not need to go full latebros

The active application check is also affected by this.

NTSTATUS WINAPI ayyxam::hooks::nt_query_system_information(
	SYSTEM_INFORMATION_CLASS system_information_class, PVOID system_information, 
	ULONG system_information_length, PULONG return_length)
{
	// DONT HANDLE OTHER CLASSES
	if (system_information_class != SystemProcessInformation)
		return ayyxam::hooks::original_nt_query_system_information(
				system_information_class, system_information,
				system_information_length, return_length);

	// HIDE PROCESSES
	const auto value = ayyxam::hooks::original_nt_query_system_information(
			system_information_class, system_information, 
			system_information_length, return_length);

	// DONT HANDLE UNSUCCESSFUL CALLS
	if (!NT_SUCCESS(value))
		return value;

	// DEFINE STRUCTURE FOR LIST
	struct SYSTEM_PROCESS_INFO
	{
		ULONG                   NextEntryOffset;
		ULONG                   NumberOfThreads;
		LARGE_INTEGER           Reserved[3];
		LARGE_INTEGER           CreateTime;
		LARGE_INTEGER           UserTime;
		LARGE_INTEGER           KernelTime;
		UNICODE_STRING          ImageName;
		ULONG                   BasePriority;
		HANDLE                  ProcessId;
		HANDLE                  InheritedFromProcessId;
	};

	// HELPER FUNCTION: GET NEXT ENTRY IN LINKED LIST
	auto get_next_entry = [](SYSTEM_PROCESS_INFO* entry)
	{
		return reinterpret_cast<SYSTEM_PROCESS_INFO*>(
			reinterpret_cast<std::uintptr_t>(entry) + entry->NextEntryOffset);
	};

	// ITERATE AND HIDE PROCESS
	auto entry = reinterpret_cast<SYSTEM_PROCESS_INFO*>(system_information);
	SYSTEM_PROCESS_INFO* previous_entry = nullptr;
	for (; entry->NextEntryOffset > 0x00; entry = get_next_entry(entry))
	{
		constexpr auto protected_id = 7488;
		if (entry->ProcessId == reinterpret_cast<HANDLE>(protected_id) && previous_entry != nullptr)
		{
			// SKIP ENTRY
			previous_entry->NextEntryOffset += entry->NextEntryOffset;
		}

		// SAVE PREVIOUS ENTRY FOR SKIPPING
		previous_entry = entry;
	}

	return value;
}

Clipboard

The internal implementation for clipboards in .NET is handled by ole32.dll!OleGetClipboard, which is very prone to hooks. Instead of spending a lot of time analyzing the internal structures, you can merely return S_OK and let .NET error handling do the rest:

std::int32_t __stdcall ayyxam::hooks::get_clipboard(void* data_object[[maybe_unused]])
{
	// LOL
	return S_OK;
}

This will hide anything you copy from the ExamCookie surveillance tool, without breaking functionality of the program.

Screenshots

As always, people use the .NET implementation of whatever they use. This make reusing our previous code really easy, we actually don’t have to modify anything at all to bypass this screen capture. Screenshots are managed by the Graphics.CopyFromScreen .NET function, that is essentially a bit-block transfer wrapper, internally calling gdi32!BitBlt. As we do in video games to prevent anti-cheats from screenshotting, we can hook BitBlt and hide any unwanted information before screenshots are performed.

BOOL __stdcall ayyxam::hooks::bit_blt(HDC hdc, int x, int y, int cx, int cy, HDC hdc_src, int x1, int y1, DWORD rop)
{
	// HIDE WINDOW
	const auto window_handle = FindWindowA("Notepad", nullptr);
	ShowWindow(window_handle, SW_HIDE);
	
	// SCREENSHOT
	auto result = ayyxam::hooks::original_bit_blt(hdc, x, y, cx, cy, hdc_src, x1, y1, rop);
	
	// SHOW WINDOW
	ShowWindow(window_handle, SW_SHOW);
	
	return result;
}

Open website

The URL grab is a complete clone, which means we can reuse even more code :-) We documented the AutomationElement structure in the last article, and this was the resulting hook:

std::int32_t __stdcall ayyxam::hooks::get_property_value(void* handle, std::int32_t property_id, void* value)
{
	constexpr auto value_value_id = 0x755D;
	if (property_id != value_value_id)
		return ayyxam::hooks::original_get_property_value(handle, property_id, value);

	auto result = ayyxam::hooks::original_get_property_value(handle, property_id, value);

	if (result != S_OK) // SUCCESS?
		return result;

	// VALUE URL IS STORED AT 0x08 FROM VALUE STRUCTURE
	class value_structure
	{
	public:
		char pad_0000[8];	//0x0000
		wchar_t* value;		//0x0008
	};
	auto value_object = reinterpret_cast<value_structure*>(value);

	// ZERO OUT OLD URL
	std::memset(value_object->value, 0x00, std::wcslen(value_object->value) * 2);

	// CHANGE TO GOOGLE.COM
	constexpr wchar_t spoofed_url[] = L"https://google.com";
	std::memcpy(value_object->value, spoofed_url, sizeof(spoofed_url));

	return result;
}

Virtual machine detection

The lazy virtual machine detection can be bypassed in two ways: patch the program that gets dropped on disk, or redirect the process creation routine to a dummy application - the latter sure sounds easy :) Process.Start() calls CreateProcess internally, so all we have to do is hook this and redirect to any dummy application that prints the character ‘0’.

BOOL WINAPI ayyxam::hooks::create_process(
	LPCWSTR application_name,
	LPWSTR command_line,
	LPSECURITY_ATTRIBUTES process_attributes,
	LPSECURITY_ATTRIBUTES thread_attributes,
	BOOL inherit_handles,
	DWORD creation_flags,
	LPVOID environment,
	LPCWSTR current_directory,
	LPSTARTUPINFOW startup_information,
	LPPROCESS_INFORMATION process_information
)
{

	// REDIRECT PATH OF VMDETECT TO DUMMY APPLICATION
	constexpr auto vm_detect = L"ecvmd.exe";
	if (std::wcsstr(application_name, vm_detect))
	{
		application_name = L"dummy.exe";
	}

	return ayyxam::hooks::original_create_process(
		application_name, command_line, process_attributes,
		thread_attributes, inherit_handles, creation_flags,
		environment, current_directory, startup_information,
		process_information);
}

Download

The entire project is available on my Github, and can be used by injecting the x86 binary into the respective process.