PicoCTF 2018 - Are you Root?
Note: This article is part of our PicoCTF 2018 BinExp Guide.
Spot the Bug
Now for something completely different: this challenge doesn’t contain a buffer overflow, but it does contain a relatively simple heap vulnerability. With the switch to heap challenges comes a corresponding switch to 64-bit binaries.
Generally, buffer-overflows were more common for two reasons on 32 bit systems:
- The small number of registers meant the ABI mandated that pretty much all arguments and variables ended up on the stack
- older code was compiled without protections like stack canaries
Nowadays, our 64 bit processors have MANY more registers, and we use those registers for passing arguments and storing variables. As a result, less ends up on the stack, and gaining control of the stack with a buffer-overflow gives you less control than it used to (return addresses are still on the stack, but the arguments passed to functions you rop into are probably not). Also, most compilers emit stack protections by default nowadays, so new code compiled for 64 bit systems almost certainly has stack canaries, and much of it will be compiled as PIE, enabling the full power of ASLR.
Let’s take a look at our first “heap” vulnerability. The heap is a block of memory that code can request and use dynamically at runtime. You request heap memory with malloc()
, and then when you are done with it you are expected to free()
it. The trick is that programmers often free memory and then accidentally continue to access it as-if they still owned it (called “use-after-free”). This is a critical error. Once you’ve freed memory, it is no longer yours, and almost any form of use-after-free is considered a vulnerability. Another issue is that the state of memory that you get when you call malloc()
is intentionally undefined. It could contain almost anything, and your code is expected to fully initialize its contents (as required). For simplicity, there is another function, calloc()
that does the same thing as malloc()
, but guarantees that the returned memory will be zero-initialized.
First up, the program defines some data:
typedef enum auth_level
{
ANONYMOUS = 1,
GUEST = 2,
USER = 3,
ADMIN = 4,
ROOT = 5
} auth_level_t;
struct user
{
char *name;
auth_level_t level;
};
Then there is a giant menu where you can select things to do. Lets look at one of them:
struct user *user;
// ...
user = NULL;
// ...
else if (!strncmp(buf, "login", 5))
{
if (user != NULL)
{
puts("Already logged in. Reset first.");
continue;
}
// ...
user = (struct user *)malloc(sizeof(struct user));
if (user == NULL)
{
puts("malloc() returned NULL. Out of Memory\n");
exit(-1);
}
user->name = strdup(arg); // Note: Hidden "malloc"
printf("Logged in as \"%s\"\n", arg);
}
Being “logged in” is the same as having a non-null user
pointer. That pointer is to a struct user
type, itself containing a pointer to a name
as well as an enum (integer) indicating the level of access the user has.
There are two important things to note:
- The name pointer is set to the return result of
strdup(string)
- which duplicates a string by allocating from the heap and then copying the string into that memory block - After allocating a user, the name pointer is explicitly initialized, but not the level
The rest of the code is fairly trivial. In order to use the “get-flag” function, your “level” must be exactly equal to 5. When “reset”, only the name is free
d (the user pointer is set to null, but its memory block is never freed. This is known as a “memory leak”).
Strategy
You can allocate a struct user
by using the “login <name>” command. When you do, the program will also allocate a duplicate copy of the name you give it. Using “reset” you can free()
the memory for the name (and only the name, as it turns out). You also know that user->level
is NEVER explicitly set by code (unless you use “set-auth” command, but that command won’t let you set the level to 5).
The strategy is to figure out how you can use the fact that the level is never explicitly initialized, along with the memory allocation and free commands you are exposed, to somehow trick the binary into thinking that your “level” is equal to 5.
Background Info
There are many possible implementations of malloc
. The differences between implementations are typically based on performance (or possibly memory safety), and choosing one depends heavily on the types and frequency of memory allocation your binary is expected to perform. The defacto malloc implementation for a linux system is the one inside gLibc (the GNU Libc library), and that is the one that will be used unless your program or system has been explicitly setup to use a different one.
The explicit behavior of gLibc depends heavily on the version present on your system. However, there are certain implementation details that are generally true regardless of version. For a good overview on how gLibc malloc works, review the Malloc Internals wiki page. We’ll be referring back to this document heavily in future heap challenges.
The first question we have, is how many bytes are allocated from the heap when allocate a user
? And what about when we duplicate the name
?
user = (struct user *)malloc(sizeof(struct user));
This command will allocate sizeof(struct user)
bytes from the heap. How many bytes is that? The answer: it depends. The C standard doesn’t mandate exactly how large this struct should be. Even the exact size of the enum field is open to interpretation. By default, on 64-bit systems, what most compilers will do is look at the alignment requirements of the struct, and then pad out the struct so that aligned access is maintained, even if several of these structures are consecutive in memory. Since the first field is a pointer, it would prefer that pointer field aligned to an 8-byte boundary (Even if we had an array of them). As a result, on 64-bit systems, most compilers would say that the sizeof(struct user)
is 16 bytes. The first 8 bytes would be the pointer. The next (probably) 4 bytes would be the enum. Finally, the remaining bytes would be padding.
strdup(name)
is somewhat simpler. Again, nothing is mandated other than the allocation should be large enough to hold the duplicated string. In general, what you’ll see is malloc(strlen(name) + 1)
followed by a strcpy
.
How does this help us? Well, gLibc doesn’t go to the operating system every time it needs more memory. It allocates a bunch all at once, and then attempts to re-use it until it runs out. When you call malloc()
, you could (and often will) receive back memory that had previously been free
d. Exactly what memory you get back, and what content it contains, depends heavily on your version of gLibc.
NOTE: The version of gLibc on the shell server is 2.23, which predates the
tcache
functionality. Attempting to run this binary against a modern version of gLibc will likely be difficult to exploit, because the chunk will end up in thetcache
(thread cache), and the second field of thetcache entry
will be stomped with a pointer to thetcache
structure, which is used to detect double-frees. Your best bet is to work on this problem directly on the shell server.
On 64 bit gLibc 2.23 installations, assuming TRIM_FASTBINS
is off (the default), then all allocations less than or equal to 24 bytes are serviced by 32-byte chunks, and freeing a 32-byte chunk will put it into the first “fastbin”. A fastbin is a singly linked list, where the first 8 bytes point to the “next” free chunk in the list, or null if there are no more free chunks (the remaining bytes remain untouched). Chunks within a fastbin are consumed in a LIFO (last-in-first-out) pattern. That means if you free a block of memory that is 24 bytes or less, and then request a new one, you will be returned the same memory that you just freed (although the first 8 bytes will have been modified).
If you think you now have the information you need to tackle this challenge, connect to it using nc 2018shell.picoctf.com 45906
this time around.
Exploitation
What do you think would happen if you were to free a name where the 9th (and final) byte was '\x05'
? What would happen when you then allocated a new struct user
(size 16)?
We learned above that freeing “small” blocks of memory will cause that memory to be re-used to service future malloc
requests in a (LIFO) last-in-first-out pattern. In this case, the chunk containing the name
string would get re-used for the very next malloc
request for 24 bytes or less. The first 8 bytes of that data block would have been modified to contain a pointer to next free chunk in the corresponding fastbin, however the remaining data would be untouched. As a result, the “level” field, after allocation, would contain the same '\x05'
value that was present in the original string. We can use this to “trick” the program into thinking we have level 5 access.
Steps:
- “login” with a name that is exactly 9 bytes long, and the last byte MUST be
'\x05'
. - “reset” (this frees the name, but not the
struct user
, which is abandoned/leaked) - “login” again, this time with any name. The first allocation (
malloc(sizeof(struct user)
) will be serviced by the same memory as the previously freed “name” variable. The first 8 bytes will be cleared, but the 9th byte will remain set. (The remaining bytes will all be zero because they remain untouched andmalloc()
originally got them that way from the operating system). - call “get-flag”, which will now print the flag because your authorization level will be set to 5.
Let’s try it:
$ printf "login 01234567\x5\nreset\nlogin a\nshow\nget-flag\n" | nc 2018shell.picoctf.com 45906
Available commands:
show - show your current user and authorization level
login [name] - log in as [name]
set-auth [level] - set your authorization level (must be below 5)
get-flag - print the flag (requires authorization level 5)
reset - log out and reset authorization level
quit - exit the program
Enter your command:
> Logged in as "01234567"
Enter your command:
> Logged out!
Enter your command:
> Logged in as "a"
Enter your command:
> Logged in as a [5]
Enter your command:
> picoCTF{===REDACTED===}
Boom! If you liked wrapping your head around this challenge, you’ll love some of the upcoming ones (they get considerably more difficult). For now, head back to the PicoCTF 2018 BinExp Guide to continue with the next challenge.