lockdown browser reverse engineered
This analysis was the culmination of a ~6 month journey. It is unfortunate, that the software considered the “gold standard” for remote proctored exams is in the current state it is. This post was motivated purely for informational purposes and I do not condone any of the following contents. This is to serve as a formal warning to to the Respondus team; please step up your game. There are many bad actors using this information (and have been since the dawn of your program). Anyways, I’m open to consulting if you want any of this fixed. 😉 peace!
This article will cover a few distinct areas, respectively in order:
- Main Module (LockdownBrowser.exe)
- External Module (LockdownBrowser.dll)
- Registry Modifications
- Black/Whitelist
- VM Detection
- Misc. Detections
- Misc. Modifications
Main Module
The main module is responsible for almost everything, from rendering to reading webpages, to applying protections. The main module utilizes the Chromium Embedded Framework (CEF) for all web rendering. It should be noted before proceeding that the main module handles all operations regarding detection reporting.
The first step of reversing any sort of binary or program, at least for me, is to through the unmodified static binary into IDA and see what’s going on. Following that same procedure on LockdownBrowser.exe yields nothing but a bunch of junk data, obviously packed. The next step is to dump the binary at runtime. Doing as such confirmed one of my fears… virtualization.
Taking a look at the PE’s entry point we are greeted with something very familiar if you have ever jumped into a VM:
Furthermore, we can confirm this is a VM if we follow the chain just a little bit more to find the call to ‘GetSystemInfo’:
loc_682A7E:
lea edx, [ebp+SystemInfo]
push edx ; lpSystemInfo
call ds:GetSystemInfo
jmp loc_701D85
Thankfully though, the imports of the main module appear to be relatively okay, only having a few errors in it which can be attributed to a poor dump. We will go over some of these imports in the later sections of the article in-depth.
External Module
The external module, LockdownBrowser.dll, is primarily responsible for applying global low-level hooks to the users keyboard, mouse, and shell. The module itself is very small, and extremely easy to reverse-engineer for even a beginner. While I do not understand why this is an external module, I do thank Respondus for making it very easy to reverse this.
Opening the binary in IDA, we are able to see that, unlike the main module, the external module is neither obfuscated or virtualized. Furthermore, by opening the exports, we are able to see some very interesting functions:
Personally, I am not one to critique professional naming or function naming style, but this did strike me as a little odd. Taking a guess on which one is the first function called by the main module, I opened ‘CLDBDoSomeStuff’. It does not take a genius to figure out what they’re doing here, but for convenience I have commented the following code to make it easy to follow:
int __cdecl CLDBDoSomeStuff(int *hook_flags)
{
allow_middle_right = ((unsigned int)*hook_flags >> 7) & 1;// Do we allow middle + right clicks?
if ( ((unsigned int)*hook_flags >> 6) & 1 )
{
keyboard_hook = SetWindowsHookExA(WH_KEYBOARD_LL, (HOOKPROC)cb_keyboard, hmod, 0);// Set hook and store original ret
shell_hook = SetWindowsHookExA(WH_SHELL, cb_shell, hmod, 0);
if ( allow_middle_right )
local_mouse_hook = SetWindowsHookExA(WH_MOUSE, cb_mouse, hmod, 0);
else
local_mouse_hook = SetWindowsHookExA(WH_MOUSE, cb_mouse_restrictive, hmod, 0);
mouse_hook = local_mouse_hook;
}
}
Based on a quick analysis of this function, we can see that this module sets 3 different Windows hooks, one for the keyboard, one for the shell, and one for the mouse. Naturally, I began looking into the callbacks, the first one, and biggest one, being the keyboard callback.
There are a lot of checks in the keyboard hook, so once again, for convenience I have commented all the different keycodes and operations which they are checking in this hook. You will notice that most operations simply stop the key from actually executing, all except the alt-tab check, which reports it to the main application, setting off a detection flag.
LRESULT __stdcall cb_keyboard(int code, WPARAM wParam, LPKBDLLHOOKSTRUCT lParam)
{
if ( code )
return CallNextHookEx(keyboard_hook, code, wParam, (LPARAM)lParam);
v3 = GetAsyncKeyState(0x11) >> 15; // ctrl key
v4 = lParam->flags & 0x20; // alt-tab
if ( v4 && lParam->vkCode == 9 && (wParam == 0x104 || wParam == 0x100) )// WM_KEYDOWN || WM_SYSKEYDOWN
{
foreground_window = GetForegroundWindow(); // Foreground window is set by LDB?
PostMessageA(foreground_window, 0x111u, 0x8015u, 0);// Tell main application
return 1;
}
vkCode = lParam->vkCode;
if ( lParam->vkCode == 0x1B ) // VK_ESCAPE
{
if ( v3 )
return 1;
v8 = v4 == 0;
goto LABEL_45;
}
switch ( vkCode )
{
case 9u: // VK_TAB
if ( v4 )
return 1;
LABEL_44:
v8 = v3 == 0;
LABEL_45:
if ( !v8 )
return 1;
return CallNextHookEx(keyboard_hook, code, wParam, (LPARAM)lParam);
case 0x1Bu: // VK_ESCAPE
v8 = v4 == 0;
goto LABEL_45;
case 0x5Bu: // VK_WIN
case 0x5Cu:
case 0x2Cu: // VK_SNAPSHOT
case 0x5Du: // VK_APPS
case 0x13u: // VK_PAUSE
case 0x71u: // VK_F2
case 0x72u:
return 1;
}
if ( vkCode != 0x73 ) // VK_F4
{
if ( vkCode == 0x75 // VK_F->24
|| vkCode == 0x77
|| vkCode == 0x78
|| vkCode == 0x79
|| vkCode == 0x7A
|| vkCode == 0x7B
|| vkCode == 0x7C
|| vkCode == 0x7D
|| vkCode == 0x7E
|| vkCode == 0x7F
|| vkCode == 0x80
|| vkCode == 0x81
|| vkCode == 0x82
|| vkCode == 0x83
|| vkCode == 0x84
|| vkCode == 0x86
|| vkCode == 0x87 )
{
return 1;
}
if ( vkCode != 9 )
return CallNextHookEx(keyboard_hook, code, wParam, (LPARAM)lParam);
goto LABEL_44;
}
if ( !v4 )
return 1;
return CallNextHookEx(keyboard_hook, code, wParam, (LPARAM)lParam);
The other hooks are similar, except for the fact that if they detect something which shouldn’t be allowed, they just stop it from executing, and do not report it to the main application. If you do, however, want to see these, feel free to leave a comment and I can send you the commented code for these other callbacks.
Bypassing
Bypassing the set hooks was the first, and is probably the easiest bypass to execute on this secure browser, which allows you to simply alt-tab and use external applications. To bypass the keyboard callback, all you need to do is replace the conditional jump at the start which checks the current code for nullptr and replace it with a relative jmp instruction:
All other callbacks can be bypassed with similar byte-patches as shown above.
Registry Modifications
After bypassing the keyboard checks and doing some testing on my own, I noticed that Lockdown actually disables the Task Manager process from starting. After a quick google search I was able to find out how to do this programmatically using the Windows API. Doing a simple string search for ‘DisableTaskMgr’ brings us to a very helpful data region where we can see all the different registry values they set:
Bypassing
Bypassing this part of the ‘lockdown’ is fairly straightforward and does not really require much explanation. The main module only sets these values once and does not check if they are still correct later on at all, meaning you can just reset them to their original values without any issue:
Blacklist & Whitelist
Upon startup of Lockdown Browser you are typically greeted with a message along the lines of: “You must close x.exe to proceed, do you want to close this process?”. Answering ‘Yes’ to the dialog box results in a call to TerminateProcess on the aforementioned process, closing it. Answering ‘No’ to the dialog box executes the exit sequence, resetting all changes made to the system before closing.
What intrigued me about this subroutine is that it only targeted certain applications, which made me think that there was a blacklist of processes.
Setting a breakpoint on the TerminateProcess import address and logging the return address of the call when you press ‘Yes’ allowed me to trace back to the subroutine where the main module enumerates through all current running processes and checks to see if they are blacklisted.
TerminateBlacklist:
mov [esp-4], esi
lea esp, [esp-4]
push offset loc_C81CF9
push ds:TerminateProcess
jmp locret_6CCBD5
EnumerateBlacklist:
mov edx, g_Globals
mov ecx, [edx+0C904h]
mov eax, [edx+0C8FCh]
mov esi, [edx+0C91Ch]
mov eax, [ecx+eax]
mov [esi+edi+28h], eax
add edi, 2Ch
The pointer ‘g_Globals’ is a 4-byte pointer I had seen referenced multiple times throughout my analysis of the main module. Looking back, a more appropriate name would be something along the lines of ‘g_DetectionManager’.
Opening ‘g_Globals’ in memory and browsing through the pointers accessed above such as 0x0C91C brings us to a custom array type containing hundreds of strings. Taking a look through the struct type and looking back at the array iteration code, I wrote a basic structure and proceeded to dump all blacklisted processes:
class BlacklistedProcess
{
public:
PCHAR window_name;
PCHAR process_name;
char pad[0x24];
};
Below you will find a text file containing all blacklisted processes, including their window and process name:
While not provided in this article, the list of ‘whitelisted’ processes is located directly below the blacklisted list.
Bypassing
There are a few approaches to bypassing this check and termination of blacklisted processes. I tested out two myself, and one is likely going to be a lot better in the long-run.
The first method of bypassing the process blacklist would be to directly hook TerminateProcess from KERNEL32.dll and either forcing a successful return, or passing NULL as the process handle to terminate.
BOOL Lockdown::Hooks::TerminateProcess(HANDLE process, UINT exit_code)
{
return static_cast<BOOL(__stdcall*)(HANDLE, UINT)>((PVOID)import_hook::get_hooked_func_real_address("TerminateProcess"))(NULL, exit_code); // spoof success
}
While this does work in preventing LockdownBrowser from terminating blacklisted processes, it will also make Lockdown continue to try to close these blacklisted processes, which could be bad if they decide to log that in the future.
The second approach, and one that is much simpler in my opinion, would be to either clear the array of blacklisted processes, or set its length to zero, forcing any attempted loop of the array to not iterate it at all. I have provided my array template below.
template <class T>
class TArray
{
public:
T* items;
size_t num_items;
};
VOID Lockdown::Globals::ClearBlacklistedProcesses()
{
this->GetBlacklistedProcesses()->num_items = 0;
}
Doing this results in the close process dialog box never even opening as it does not detect any blacklisted process at all.
VM Detection
The most obvious route to bypass these browsers is to simply download it on a VM and run it inside of that. Similar to the ETS Secure Browser, Lockdown also detects VM usage, and a lot better than ETS at that! I am no expert on VM detection, but after reading a few articles online about how to detect it, as well as having seen some curious strings from my previous exploration of the Globals pointer, I was able to figure out how they do their detections.
Following the same procedure of the blacklisted processes, I dumped the driver names and system names which the main module scans for, which I have placed in a text file below to download if you are curious:
Taking the only hint I had, the memory location of the driver name scans, I worked my way backwards until I found a call to ‘cpuid’:
Following the call chain to cpuid until we find where eax is set (about 6 jmp’s, thanks VMProtect) to ‘1’.
Setting eax to 1 and calling cpuid returns “the CPU’s stepping, model, and family information” (https://en.wikipedia.org/wiki/CPUID#EAX=1:_Processor_Info_and_Feature_Bits) , which is often used for detecting a VM. I traced this call back even further until I found a jmp which was referenced by both a ‘ret’ and equally a ‘push’ instruction, signifying the start of a function which has been obliterated by VMProtect:
You may notice that the address above is named ‘VMDetection_2’, which can be attributed to the fact that there is more than one check for a VM within the main module. However, I will leave finding that up to the reader as practice.
Bypassing
Bypassing the VM protection is quite trivial. After a painstaking half hour of rebuilding the function line for line in pseudo-code, I came to the realization that the only way the detection is passed on to the rest of the application is via a flag set in the Globals pointer. After even more analysis of the return of the function we can see that it is a ‘void’ type, meaning that it does not actually return anything. This makes for a very easy bypass since we only need to write a single byte to the function address.
Keep in mind that this works since the original instruction length is 5 and we only need a single byte.
BOOL Lockdown::DisableVMDetection()
{
/*
Original
E9 E4 53 54 00 jmp VMDetection_inner
Modified
C3 90 90 90 90 retn
*/
uint8_t shell2[] = { 0xC3, 0x90, 0x90, 0x90, 0x90 };
DWORD old_protection;
auto patch_address = (uintptr_t)GetModuleHandleA(NULL) + 0x61C60;
VirtualProtect((PVOID)(patch_address), 5, PAGE_EXECUTE_READWRITE, &old_protection);
memcpy((PVOID)patch_address, &shell2, sizeof shell2);
VirtualProtect((PVOID)(patch_address), 5, old_protection, &old_protection);
return TRUE;
}
The only remaining part, and quite crucial, is ensuring that this byte-patch is executed before the function can be called.
Misc Detections
By this point you have pretty much gotten around everything Lockdown does. If you can use the program in a VM, then what else is there to do? Now, if you try to enter an exam with that byte-patch you will be greeted with a friendly message: “The LockDown Browser software has been illicitly modified and will now shut down. You will not be able to continue.”
Seeing this message for the first time was a little surprising, but it did present me the opportunity to delve into the dialog/messagebox system a little bit more.
The string that was passed to the dialog mentioned above did not exist in my dump, which made me suspect it was located in memory somewhere. Finding the function to get the string error message was easier than I originally anticipated, simply tracing back from a MessageBoxA call and analyzing the parameters passed to it led me to a function I called ‘GetDialogMessage’:
loc_682085: ; DATA XREF: .text:00ADE8A2↓o
add esp, 4
mov [esp-4], eax
lea esp, [esp-4]
push 0
push offset loc_9B0136
push ds:MessageBoxA
retn
loc_ADE89F: ; CODE XREF: .text:00ADC048↑j
push dword ptr [ebp+8]
push offset loc_682085
push offset GetDialogMessage
retn
Reversing how this function actually retrieved said dialog strings was a whole other problem on its own. It took me about half an hour alone to understand the arithmetic behind the returned pointer.
Below I have posted the code which replicated the function to a degree, returning the english string to the passed error code:
PCHAR Lockdown::Application::GetDialogMessage(int i)
{
return (PCHAR)((uintptr_t)this + *(DWORD*)((uintptr_t)this + 4 * i + *(DWORD*)((uintptr_t)this + 0x2DE5C)));
}
Although it is only one line, reversing that was like hell.
I have posted below a text file containing all current error messages and their associated identifiers:
Now that we know the unique identifier behind the message we recieved, we can search for all occurrences of it in the binary and start searching for the function that passes it to our GetDialogMessage function.
Tracing back the ‘push 0x1B’ instruction that we found passed to the GetDialogMessage function, we can find out how this value is pushed before being sent via PostMessageW. We backtrace until we find this chunk of assembly which is essentially the conditional part of the if statement before it gets the appropriate dialog message:
Taking a look at first conditional, we see a simple data check, comparing a pointer to null, and if the test then it will skip the rest of the check completely. We see two other function calls part of the condition check. Opening the first one and following the jmp tree we find this non obfuscated chunk:
The best assumption I could make about this function is that it ensure the name of the exe currently running is ‘LockDownBrowser.exe” and has not changed or been modified. If this returns false, meaning the module name is not the same, then it will send the modified message to the main loop.
Following the jmp train through the next function and finding the next ‘test’ instruction, we can easily see the next condition of the integrity check:
While this may look like nonsense at first, a closer look by following the stack execution brings us here:
We can see that the return of WinVerifyTrust is pushed onto the stack and is actually the return value of this function, meaning if WinVerifyTrust fails, so does the function, resulting in the aforementioned dialog being thrown. If you want to look into this more, you can take a look at the Microsoft documentation on the function here, which it looks like Respondus replicated: https://docs.microsoft.com/en-us/windows/win32/seccrypto/example-c-program–verifying-the-signature-of-a-pe-file
Bypassing these checks is similar to the other text section checks that we byte-patched before, the only difference being that you need to force eax to be 1 before returning. I will leave that as an exercise to the reader.
While looking through the list of dumped dialogs, I noticed some pertaining to secondary monitors, specifically: “You must disconnect your second monitor or broadcast screen before using LockDown Browser.”
Similar to how we found the integrity check, we can search for the immediate identifier to figure out how they raise this flag. Alternatively, if you know much about the Windows API you can look in the imports and see the functions they use to enumerate the current display devices.
LockDown uses both “EnumDisplayMonitors” and “EnumDisplayDevicesA”. I would suggest looking into how both of these functions operate and what they return if you want to bypass them. A simple IAT hook spoofing the return information is enough to get around this.
Misc. Modifications
LockDown does not do much else besides what is mentioned above, however, there are a few things that they do still change.
Clipboard
Upon focus of the primary foreground window of LockDown, a subroutine is executed to empty the users clipboard: OpenClipboard, EmptyClipboard, CloseClipboard. This can be very annoying, especially if you have a very long password you don’t want to type out on your laptop’s tiny keyboard. A simply IAT hook on the EmptyClipboard function and returning ‘true’ is enough to prevent this from happening.
Shell Window
The shell window, aka your taskbar, is hidden upon initialization of LockDown. There’s not much here to really bypass since LockDown only hides this window upon initialization and shows it again upon exit.
The following code simply reshows the Shell window:
BOOL Lockdown::FixShellWindow()
{
HWND shell_wnd = FindWindowA("Shell_TrayWnd", NULL);
if (!shell_wnd)
return FALSE;
ShowWindow(shell_wnd, SW_SHOW);
return TRUE;
}
Conclusion
Despite many claims by sources that don’t have any proof, LockDown Browser is actually not spyware (shocker, right?). However, they have tried hard to prevent people cheating on their exams, but yet left a lot of very obvious vulnerabilities which can be exploited very easily.
I believe that LockDown will continue evolve and will likely patch everything I have mentioned in this article eventually too, which is good for them, and I hope they do. I am more surprised that LockDown is considered the ‘gold standard’ when it comes to test taking, and I think a lot of improvements can still be made.
There is a lot that I have not discussed in this article such as logs that are reported to the server, webcam detection, and how CEF is used to block exams when other detections are reached. I do not encourage cheating in any way, but I do know people pay a lot of money to be able to cheat on these browsers. If I revealed everything about this browser there would likely be a lot more issues in the future for Respondus and probably for me as well.