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