⬆️ ⬇️

Exam Surveillance: ExamCookie Program

I learned that the Danish government not only suspended the Digital Exam Monitor program, which we analyzed and completely circumvented in the previous article , but could have completely shut down this system a week after we informed them about the hacking method. I don’t want to think that purely because of us the Danish government abandoned the idea of ​​monitoring exams, but our work was clearly noticed.



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



ExamCookie



This tool recently hit the news because of an investigation into a violation of the GDPR. We decided to take a look at the second largest competitor of the aforementioned system of spying on students during the exams: ExamCookie . This is a commercial tracking system that is used by more than 20 Danish schools. There is no documentation on the site, except for the following description:



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

')

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


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



To start surveillance, you need to use your UNI login, which works on various educational sites, or manually enter your credentials. We did not use the tool, so we cannot say in which cases the input is used manually. 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 main page of the ExamCookie website. It is an x86 .NET application. For reference, the analyzed binary MD5 hash 63AFD8A8EC26C1DC368D8FF8710E337D , signature EXAMCOOKIE APS 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 easily readable IL code and metadata gives the perfect source code.



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



Obfuscation (laughter to tears)



Opening the application in dnSpy, we quickly noticed the 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) { } 


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



 // 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(); } \ u200B \ u206F \ u206C \ u202D \ u200D \ u200E \ u202D \ u206B \ u206F \ u206F \ u202C \ u202A \ u206B \ u202E \ u202A \ u206C \ u202A \ u206C \ u200B \ u206A \ u202D // 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(); } \ u200F \ u200E \ u200C \ u200C \ u200F \ u200F \ u206E \ u206A \ u206A \ u200B \ u202C \ u206A \ u206B \ u200D \ u206E \ u200E \ u202D \ u206B \ u202C \ u206C \ u202D // 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(); } 


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



We instantly recognized this unpacking 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, because the above-mentioned constructor decodes the bodies and resources of the method. But, to our surprise, the developer of obfuscation decided ... not to rename the metadata:







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



In any case, unpacking any binary protected by confuser (ex) is very simple: use the .NET binary dumper or the ret instruction's breakpoint in <MODULE> .ctor and dump it yourself. The process takes 30 seconds, and this packer will always remain my favorite, because protection against debug never works .



We decided to use MegaDumper: it's a bit faster than manually dumping:







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







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



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



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


Yes, the good old Confuser (Ex) string encryption is the best pseudo-security in the .NET world. It’s good that Confuser (Ex) was so often hacked that deobfuscation tools are available for each mechanism on the Internet, so we’ll not touch anything about .NET. Let's run on the CodeCracker ConfuserExStringDecryptor 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]); } 


This is the whole application protection, broken in less than a minute ... We will not post our tools here, because we did not develop them and we do not have the source code. But anyone who wants to repeat the work can find them on Tuts4You . We no longer have a tuts4you account, so we can not put a link to the mirror.



Functionality



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





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 built with the .NET NetworkInterface.GetAllNetworkInterfaces() function, just like in the previous 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 monitors only the active application. The implementation is inflated, therefore we present the following 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, you can change the names of the processes as you like,” so we can safely bypass this protection.



If you have read a previous article about another exam surveillance program, you will probably recognize this implementation of subpar for searching 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 difficult to understand how the developers still do not understand how bad it is. Anyone can edit the URL in the browser, it is not even worth demonstrating.



VM detection



Contrary to what is said on the website, launching on a virtual machine sets the 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 disk and execute it, and then rely completely on 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 what file we are dealing with:







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



 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 work only when running in a virtualized environment, if it does not cause the machine to crash if not properly processed;):



 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 simply reads the registry key and runs two hypervisor checks that look weird compared to their other program. I wonder where they copied it? Oh, look, the article entitled “Detection of 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 to your taste.



Data protection



As mentioned earlier, an investigation is now under way 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 data is stored for up to three months.


We are not quite sure how they define the “security” of the server, since the credentials are hard-coded in the application and are stored in clear text in the metadata resources:



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


We have not studied the contents of the server (this is illegal), but we can assume that there is full access. Since the account is hard-coded in the application, there is no isolation between the containers of student data.



Legal Disclaimer: We have the right to publish API credentials, since they are stored in a shared binary file and, therefore, are not obtained illegally. However, their use with malicious intent clearly violates the law, so we strongly recommend readers not to use the above-mentioned credentials, and are not responsible for any potential actions.



Bypass



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



Process list



The .NET process interface internally caches process data using the ntdll!NtQuerySystemInformation system call. Hiding processes from him requires some work, because the process information is listed in many places. Fortunately, .NET retrieves only one specific type of information, so you do not have to use all latebros methods.



Code for bypassing checking 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<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; } 


Buffer



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 the 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 monitoring tool without disturbing the functionality of the program.



Screenshots



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





Opening sites



The Grabber URL is completely copied from the previous program, so that we can again use our code to bypass the protection. In the last article, we documented the AutomationElement structure, which results in the following hook being 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_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; } 


VM detection



Lazy detection of a virtual machine can be circumvented in two ways: 1) a program patch that is flushed to disk; or 2) redirect the process of creating a process to a dummy application. The latter seems obviously easier :). So, internally, Process.Start() calls CreateProcess , so it is enough to 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 a binary x86 file into the appropriate process.

Source: https://habr.com/ru/post/453536/



All Articles