Exam Tracking: ExamCookie

Original author: Carl Schou
  • Transfer
I learned that the Danish government did not just suspend the Digital Exam Monitor program, which we analyzed and completely circumvented in the previous article , but perhaps completely shut down this system a week after we informed them of the hacking method. I don’t want to think that it was purely because of us that the Danish government rejected the idea of ​​monitoring exams, but our work was clearly noticed.

In this article, we will outline technical details of how another schoolchildren tracking tool works: ExamCookie. If you are only interested in bypassing the system, scroll down to the appropriate section.

ExamCookie


Recently, this tool hit the news due to an investigation into the violation of GDPR. We decided to take a look at the second largest competitor of the aforementioned school tracking system during the exams: ExamCookie . This is a commercial tracking system used by more than 20 Danish schools. There is no documentation on the site other than the following description:

ExamCookie is a simple software that monitors the student’s computer activity during the exam to make sure the rules are followed. The program prohibits students from using any illegal form of assistance.

ExamCookie saves all the activity on the computer: active URLs, network connections, processes, clipboard and screenshots when resizing the window.

The program works simply: by entering the exam, you run it on your computer, and it monitors your activity. When the exam is completed, the program closes, and you can remove it from the computer.

To start tracking, you need to use your UNI login, which works on various educational sites, or manually enter credentials. We did not use the tool, so we can’t say in which cases manual entry is used. Perhaps this is done for students who do not have a UNI login, which we do not consider possible.



Binary Information


The program can be downloaded from the ExamCookie home page. It is an x86 .NET application. For reference, the analyzed binary MD5 hash has the 63AFD8A8EC26C1DC368D8FF8710E337DEXAMCOOKIE APS signature dated April 24, 2019. As the last article showed , the analysis of the .NET binary can hardly be called reverse engineering, because the combination of easy-to-read IL code and metadata provides perfect source code.

Unlike the previous monitoring program, the developers of this tool not only deleted it from the debug log, but also obfuscated it. At least they tried :-)

Obfuscation (laughter to tears)


When we opened the application in dnSpy, we quickly noticed a missing entry point:

// 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)
{
}

Strange, some kind of wrapper is usually assumed, it changes the bodies of the methods from the constructor of the module, which runs to the actual entry point, let's see:

// Token: 0x06000001 RID: 1 RVA: 0x00058048 File Offset: 0x00055048
static ()
{
	.\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();
	.\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();
	.\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();
}

Cool. It's 2019, and people still use Confuser (Ex).

We instantly recognized this decompression code and checked the assembler headers:

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

At the moment, we thought that the code would actually be obfuscated, since the aforementioned constructor decrypts the bodies and resources of the method. But, to our surprise, the obfuscation developer decided ... not to rename the metadata:



This kills all the buzz of reverse engineering. As we said in a previous article , I would like to encounter the real problem of a properly protected, high-quality surveillance tool, the analysis of which will take more than five minutes.

In any case, unpacking any confuser (ex) protected binary is very simple: use the .NET binaries dumper or the break statement breakpoint at.ctor and dump yourself. The process takes 30 seconds, and this packer will always remain my favorite, because protection against debug never works at all .

We decided to use MegaDumper: this is slightly faster than manually dumping:



After dumping the ExamCookie binary, the following message should appear:



Now you have a directory with all assembler fragments that are loaded into the corresponding process, this time with decrypted method bodies.

Whoever implemented this obfuscation, thank God, at least he encrypted the lines:

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

Yes, the good old string encryption Confuser (Ex), the best pseudo-security in the world of .NET. It’s good that Confuser (Ex) was hacked so often that deobfuscation tools are available on the Internet for each mechanism, so we won’t touch anything related to .NET. Run the ConfuserExStringDecryptor from CodeCracker on the binary dump :



It converts the previous snippet to this:

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’s all the protection of the application, broken in less than a minute ... We won’t post our tools here, because we did not develop them and we do not have source code. But anyone who wants to repeat the job can find them on Tuts4You . We no longer have a tuts4you account, so we can’t link to the mirrors.

Functionality


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

  • Process List (every 5000 ms)
  • Active application (every 1000 ms)
  • Clipboard (every 500 ms)
  • Screenshot (every 5000 ms)
  • List of network adapters (every 20,000 ms)

The rest of the application is very boring, so we decided to skip the entire initialization procedure and go directly to the functions responsible for capturing information.

Adapter


Network adapters are assembled by the .NET function NetworkInterface.GetAllNetworkInterfaces(), just 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


This is getting interesting. Instead of registering all open windows, the utility controls only the active application. The implementation is overblown, therefore we present a beautifulized pseudocode:

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);
}

Great ... people still use process names to differentiate them. They never stop and do not think: “Wait a minute, you can change the names of processes as you like,” so we can safely bypass this protection.

If you read a previous article about another exam tracking program, you will probably recognize this subpar implementation for 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 cherry on the cake:

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

This is literally the same implementation as in the previous article. It is hard to understand how the developers still have not realized how bad it is. Anyone can edit the URL in the browser, this is not even worth demonstrating.

Virtual machine discovery


Contrary to what the website says, starting in a virtual machine sets a flag. The implementation 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";
	}
}

Well, for some reason, they write an external binary to the disk and execute it, and then rely completely on the I / O results. This really happens quite often, but the transfer of such important work to another unprotected process is so-so. Let's see which file we are dealing with:



So now we use C ++? Well, interoperability is actually not necessarily bad. And this may mean that we now really have to work on reverse engineering (!!). Let's look at the 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 verifies the presence of VMWare I / O port 'VX':

int __fastcall vm_detect::vmware_port()
{
  int result; // eax
  result = __indword('VX');
  LOBYTE(result) = 0;
  return result;
}

Next, the execution of the virtual pc extension instruction is checked , which should only work when launched in a virtualized environment, if it does not lead to a machine crash if it is processed incorrectly;):

char vm_detect::vpcext()
{
  char result; // al
  result = 1;
  __asm { vpcext  7, 0Bh }
  return result;
}

... no real reverse engineering, just 30 seconds to rename two functions :(

This program just reads the registry key and runs two hypervisor checks that look strange compared to their other program. I wonder where they copied this? Oh, look, the article titled “Methods for discovering virtual (sic) machines,” which explains these methods :). In any case, these detection vectors can be circumvented by editing the .vmx file or using an enhanced version of any hypervisor of your choice.

Data protection


As mentioned earlier, an investigation is underway for non-compliance with the GDPR, and their website states:

Data is encrypted and sent to a secure Microsoft Azure server, which can only be accessed with the correct credentials. After the exam, the data is stored for up to three months.

We are not quite sure how they determine the “security” of the server, since the credentials are hardcoded in the application and stored in completely clear text in the metadata resources:

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

We did not examine the contents of the server (this is illegal), but we can assume that full access is provided there. Since the account is hardcoded in the application, there is no isolation between the student data containers.

Legal disclaimer: We reserve the right to publish API credentials because they are stored in a public binary file and therefore are not obtained illegally. However, using them with malicious intent clearly violates the law, therefore, we strongly recommend that readers not use the aforementioned credentials in any way, and are not responsible for any potential actions.

Bypass


Since this application is incredibly reminiscent of Digital Exam Monitor, we just updated the ayyxam code to support ExamCookie.

Process list


The .NET process interface internally caches process data using a system call ntdll!NtQuerySystemInformation. To hide processes from it requires some work, because information about the process is indicated in many places. Fortunately, .NET only retrieves one specific type of information, so you don’t have to use all the latebros methods .

Code to bypass validation of active processes.

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(
			reinterpret_cast(entry) + entry->NextEntryOffset);
	};
	// ITERATE AND HIDE PROCESS
	auto entry = reinterpret_cast(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(protected_id) && previous_entry != nullptr)
		{
			// SKIP ENTRY
			previous_entry->NextEntryOffset += entry->NextEntryOffset;
		}
		// SAVE PREVIOUS ENTRY FOR SKIPPING
		previous_entry = entry;
	}
	return value;
}

Buffer


He is responsible for the internal implementation of buffers in .NET ole32.dll!OleGetClipboard, which is very susceptible to hooks. Instead of spending a lot of time analyzing internal structures, you can simply return S_OK, and .NET error handling will do the rest:

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

This will hide the entire buffer from the ExamCookie surveillance tool without disturbing the functionality of the program.

Screenshots


As always, people take a ready-made .NET implementation of the desired function. To get around this function, we did not even have to change anything in the previous code. Screenshots are controlled by the Graphics.CopyFromScreen.NET function . It is essentially a wrapper for transmitting bit blocks, which calls gdi32!BitBlt. As in video games to combat anti-cheat systems that take screenshots, we can use the BitBlt hook and hide any unwanted information before taking a screenshot.


Opening sites


The grabber URL is completely copied from the previous program, so again we can reuse our code to bypass the protection. In the last article, we documented the AutomationElement structure, as a result of which the following hook is launched:

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);
	// 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 discovery


Lazy detection of a virtual machine can be circumvented in two ways: 1) a patch of a program that is flushed to disk; or 2) a redirect of the process creation process to a dummy application. The latter seems clearly simpler :). So, it Process.Start()calls internally CreateProcess, so just make a hook and redirect it 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 in the Github repository . The program works by injecting the x86 binary into the corresponding process.

Also popular now: