본문 바로가기
Security/System Hacking

[Dreamhack System Hacking] STAGE 2

by 단월໒꒱ 2022. 1. 16.

 

 

[Computer Architecture]

1. 컴퓨터 구조

 1) 컴퓨터 구조

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

   - 컴퓨터 기능 구조에 대한 설계, 명령어 집합구조, 마이크로 아키텍처, 기타 하드웨어 및 컴퓨팅 방법에 대한 설계 포함

 

 2) 컴퓨터 기능 구조에 대한 설계

   - 효율적인 연산을 위해 컴퓨터에 필요한 기능들을 고민, 설계하는 분야

   - ex. 폰 노이만 구조, 하버드 구조, 수정된 하버드 구조

 

 3) 명령어 집합구조(CPU 명령어에 대한 설계)

   - CPU가 처리해야하는 명령어 설계하는 분야

   - ex. ARM, MIPS, AVR, 인텔의 x86 및 x86-64 등

 

2. 폰 노이만 구조

 

 

 1) 컴퓨터의 핵심 기능

   - 연산, 제어, 저장

   - 연산과 제어 -> 중앙처리장치(CPU) 사용

   - 저장 -> 기억장치 사용

   - 장치 간에 데이터나 제어 신호 교환 -> 버스(전자 통로) 사용

 

 2) 중앙처리장치(CPU)

   - 프로그램의 연산을 처리하고 시스템을 관리하는 컴퓨터의 두뇌

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

   - 산술논리장치 / 제어장치 / 레지스터로 구성

    ① 산술논리장치 : 산술/논리연산 처리

    ② 제어장치 : CPU 제어

    ③ 레지스터 : CPU에 필요한 데이터 저장

 

 3) 기억장치

   - 컴퓨터가 동하는데 필요한 데이터 저장

   - 주기억장치 / 보조기억장치로 분류

    ① 주기억장치 : 프로그램 실행과정에서 필요한 데이터를 임시 저장 ex. 램(RAM)

    ② 보조기억장치 : 운영체제, 프로그램 등과 같은 데이터를 장기간 보관 ex. 하드드라이브(HDD), SSD

 

 4) 버스

   - 컴퓨터 부품 간에, 또는 컴퓨터 간에 신호를 전송하는 통로

   - 데이터 버스 / 주소 버스 / 제어 버스

    ① 데이터 버스 : 데이터 이동

    ② 주소 버스 : 주소 지정

    ③ 제어 버스 : 읽기/쓰기 제어

   - 랜선, 데이터 전송 소프트웨어, 프로토콜 등도 버스라 불림

 

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

   - CPU가 빠른 속도로 연산을 처리하기 위해 데이터의 빠른 교환이 필요

   - CPU의 연산 속도가 기억장치와 데이터 교환속도보다 압도적으로 빨라, 기억장치만 사용하면 병목현상 발생

   - 따라서 CPU는 교환 속도를 단축하기 위해 레지스터와 캐시라는 저장장치를 내부에 갖고 있음

 

3. 명령어 집합 구조

 1) 명령어 집합 구조(Instruction Set Architecture, ISA)

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

   - IA-32, x86-64(x64), MIPS, AVR 등 존재

 

 2) 인텔의 x86-64

   - 고성능 프로세서 설계를 위해 사용

   - 이를 기반으로 한 CPU는 전력소모 크고 발열도 상대적으로 심함

   - 데스크탑, 랩탑에 적합

 

 cf) 배터리 사용하는 드론, 공유기, 인공지능 스피커처럼 작은 임베디드 기기들은 인텔의 고성능 프로세서를 장착하기 부적합

    -> 많은 임베디드 장비들은 전력 소모와 발열이 적은 ARM, MIPS, AVR의 프로세서 주로 사용

 

4. x86-64 아키텍처

 1) x86-64 아키텍처

   - x64 아키텍처 : 인텔의 64비트 CPU 아키텍처

                              인텔의 32비트 CPU 아키텍처인 IA-32를 64비트 환경에서 사용할 수 있도록 확장한 것

 

 2) n 비트 아키텍처

   - n은 CPU가 한번에 처리할 수 있는 데이터의 크기

   - WORD : CPU가 이해할 수 있는 데이터의 단위

   - WORD의 크기는 CPU가 어떻게 설계됐느냐에 따라 달라짐

     ex. 일반적인 32비트 아키텍처

           -> ALU는 32비트까지 계산 가능, 레지스터의 요량 및 각종 버스들의 대역폭이 32비트

                이들로 구성된 CPU는 설계 상 32비트의 데이터까지만 처리 가능

 

 3) WORD가 크면 유리한 점

   - 작으면 CPU가 제공할 수 있는 가상메모리의 크기가 작음

      -> 많은 메모리 자원을 소모하는 전문 소프트웨어나 고사양의 게임을 실행할 때 부족할 수 있음

   - 크면 가용한 메모리 자원이 부족해서 소프트웨어의 최고 성능을 낼 수 없다거나 실행 불가능한 상황이 거의 발생하지 x

 

5. 범용 레지스터

 1) 범용 레지스터

   - 주용도는 있으나 그 외의 다양한 용도로 사용될 수 있는 레지스터

   - x86-64에서 각각의 범용 레지스터는 8바이트 저장 가능, 부호없는 정수 기준 2^64-1 까지의 수 표현 가능

 

 2) x64에서 자주 쓰이는 범용 레지스터의 종류

 

이름 주용도
rax (accumulator register) 함수의 반환 값
rbx (base register) x64에서는 주된 용도 없음
rcx (counter register) 반복문의 반복 횟수, 각종 연산의 시행 횟수
rdx (data register) x64에서는 주된 용도 없음
rsi (source index) 데이터를 옮길 때 원본을 가리키는 포인터
rdi (destination index) 데이터를 옮길 때 목적지를 가리키는 포인터
rsp (stack pointer) 사용 중인 스택의 위치를 가리키는 포인터
rbp (stack base pointer) 스택의 바닥을 가리키는 포인터

 

 

6. 세그먼트 레지스터

 1) 세그먼트 레지스터

   - x64로 아키텍처가 확장되면서 용도에 큰 변화가 생김

   - x64에 존재하는 세그먼트 레지스터 : cs, ss, ds, es, fs, gs (각 레지스터의 크기는 16비트)

   - cs, ds, ss 레지스터 : 코드 영역과 데이터, 스택 메모리 영역을 가리킬 때 사용

   - es, fs, gs 레지스터 : 운영체제 별로 용도를 결정할 수 있도록 범용적인 용도로 제작됨

 

7. 플래그 레지스터

 1) 플래그 레지스터

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

   - x64에는 RFLAGS라 불리는 64비트 크기의 플래그 레지스터 존재

   - 자신을 구성하고 있는 여러 비트들로 CPU의 현재 상태 표현 (마치 깃발을 올리고 내리는 것처럼)

   - RFLAGS는 최대 64개의 플래그를 사용 가능하지만 실제로는 아래의 20여개의 비트만 사용

 

 

 2) 자주 접하게 될 플래그

 

플래그 의미
CF (Carry Flag) 부호 없는 수의 연산 결과가 비트의 범위를 넘을 경우 설정됨
ZF (Zero Flag) 연산의 결과가 0일 경우 설정됨
SF (Sign Flag) 연산의 결과가 음수일 경우 설정됨
OF (Overflow Flag) 부호 있는 수의 연산 결과가 비트의 범위를 넘을 경우 설정됨

 

 

8. 명령어 포인터 레지스터

 1) 명령어 포인터 레지스터

   - 프로그램을 이룬 기계어 코드들 중에서 CPU가 어느 부분의 코드를 실행할 지 가리킴

   - x64 아키텍처의 명령어 레지스터는 rip이며 크기는 8byte

 

9. 레지스터 호환

 - 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비트를 가리킴

 - 마찬가지로 과거 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]

1. 세그먼트

 1) 세그먼트

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

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

   - 운영체제가 메모리를 용도별로 나누면 각 용도에 맞게 적절한 권한 부여 가능

      - 권한은 읽기, 쓰기, 실행이 존재

      - CPU는 메모리에 대해 권한이 부여된 행위만 가능

    

 

 

 

2. 코드 세그먼트

 1) 코드 세그먼트

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

   - 텍스트 세그먼트라고도 함

   - 읽기, 실행 권한 부여

   - 대부분 쓰기 권한 제거 (권한이 있으면 공격자가 악의적인 코드 삽입하기 쉬워짐)

 

3. 데이터 세그먼트

 1) 데이터 세그먼트

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

   - 읽기 권한 부여

 

 2) data 세그먼트

   - 쓰기 가능한 세그먼트

   - 전역변수와 같이 프로그램이 실행되면서 값이 변할 수 있는 데이터들이 위치

 

 3) rodata(read-only data) 세그먼트

   - 쓰기 불가능한 세그먼트

   - 전역상수처럼 값이 변하면 안되는 데이터들이 위치

 

4. BSS 세그먼트

 1) BSS 세그먼트

   - 컴파일 시점에 값이 정해지지 않은 전역 변수가 위치하는 메모리 영역

   - 개발자가 선언만 하고 초기화하지 않은 전역 변수 등이 포함됨

   - 이 세그먼트의 메모리 영역은 프로그램이 시작될 때 모두 0으로 값이 초기화됨

   - 읽기, 쓰기 권한 부여

 

5. 스택 세그먼트

 1) 스택 세그먼트

   - 프로세스의 스택이 위치하는 영역

   - 함수의 인자나 지역변수와 같은 임시 변수들이 실행 중에 여기에 저장

   - '스택 프레임'이라는 단위로 사용됨

   - 프로세스가 실행될 때 얼만큼의 스택 프레임을 사용하게 될 지 계산 불가능

      -> 운영체제가 프로세스 시작할 때 작은 크기의 스택 세그먼트를 먼저 할당하고 부족하면 확장시킴

           스택에 대해 '아래로 자란다'라는 표현을 사용하는데, 스택 확장 시 기존 주소보다 낮은 주소로 확장되기 때문

   - 읽기, 쓰기 권한 부여

 

 2) 스택 프레임

   - 함수가 호출될 때 생성, 반환될 때 해제

 

6. 힙 세그먼트

 1) 힙 세그먼트

   - 힙 데이터가 위치하는 세그먼트

   - 실행 중에 동적으로 할당 가능

   - 리눅스에서 스택 세그먼트와 반대 방향으로 자람

   - C언어에서 malloc(), calloc() 등을 호출해서 할당받은 메모리가 여기에 위치

   - 일반적으로 읽기, 쓰기 권한 부여

 

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

   - 두 세그먼트가 동일한 방향으로 자라고 연속된 메모리 주소에 할당될 경우 확장되는 과정에서 충돌 발생

   - 위의 문제를 해결하기 위해 스택을 메모리 끝에 위치하고 반대로 자라게 함

   - 이렇게 하면 힙과 스택은 메모리를 자유롭게 사용 가능

 

7. 요약

 

세그먼트 역할 일반적인 권한 사용 예
코드 세그먼트 실행 가능한 코드가 저장된 영역 읽기, 실행 main() 등의 함수 코드
데이터 세그먼트 초기화된 전역변수 또는 상수가 위치하는 영역 읽기, 쓰기 or 읽기 전용 초기화된 전역변수, 전역상수
BSS 세그먼트 초기화되지 않은 데이터가 위치하는 영역 읽기, 쓰기 초기화되지 않은 전역변수
스택 세그먼트 임시 변수가 저장되는 영역 읽기, 쓰기 지역 변수, 함수의 인자 등
힙 세그먼트 실행 중에 동적으로 사용되는 영역 읽기, 쓰기 malloc(), calloc() 등으로 할당받은 메모리

 

 

 

[x86 Assembly]

1. 어셈블리 언어

 1) 어셈블리 언어

   - 컴퓨터의 기계어와 치환되는 언어

   - CPU에 사용되는 ISA의 종류만큼 많은 수의 어셈블리어가 존재

     ex. x64에는 x64의 어셈블리어 존재

 

2. x64 어셈블리 언어

 1) 기본 구조

   - 명령어 + 피연산자로 구성

   - 명령어 : 동사에 해당

   - 피연산자 : 목적어에 해당

   - 예시

 

 

 

 2) 명령어 

 

데이터 이동 mov, lea
산술 연산 inc, dec, add, sub
논리 연산 and, or, xor, not
비교 cmp, test
분기 jmp, je, jg
스택 push, pop
프로시저 call, ret, leave
시스템 콜 syscall

 

 3) 피연산자

   - 상수, 레지스터, 메모리

   - 메모리 피연산자는 []으로 둘러싸인 것으로 표현, 앞에 TYPE PTR 추가 가능

      ㄴ 타입에는 BYTE, WORD, DWORD, QWORD가 올 수 있음 (각각 1, 2, 4, 8바이트의 크기 지정)

   - 예시

 

 

 

3. x86-64 어셈블리 명령어

 1) 데이터 이동

   - 어떤 값을 레지스터나 메모리에 옮기도록 지시

 

 

 

 

 

 2) 산술 연산

   - 덧셈, 뺄셈, 곱셈, 나눗셈 연산 지시

 

 

 

 3) 논리 연산

   - and, or, xor, neg 등의 비트 연산 지시

   - 연산은 비트 단위로 이루어짐

 

 

 

 4) 비교

   - 두 피연산자의 값 비교하고 플래그 설정

 

 

 

 5) 분기

   - rip을 이동시켜 실행 흐름을 바꿈

 

 

 

 6) 스택

   - push, pop 명령어로 스택 조작 가능

 

 

 

 7) 프로시저 

   - 특정 기능을 수행하는 코드 조각

   - 프로시저 사용하면 반복되는 연산을 프로시저 호출로 대체할 수 있음

   - 호출 : 프로시저 부르는 행위

   - 반환 : 프로시저에서 돌아오는 것

   - 프로시저를 호출할 때는 프로시저를 실행하고 원래의 실행 흐름으로 돌아와야 하므로, call 다음의 명령어 주소를 스택에 저장하고 프로시저로 rip을 이동시킴

 

 

 

 

 

 

 

   - 스택 프레임을 할당하고 해제하는 과정

 

012345678

 

 

 8) 시스템 콜

   - 유저 모드에서 커널 모드의 시스템 소프트웨어에게 어떤 동작을 요청하기 위해 사용

   - 도움이 필요하다는 요청

   - 필요한 기능과 인자에 대한 정보를 레지스터로 전달하면 커널이 이를 읽어서 요청을 처리함

   - 리눅스에서는 x64 아키텍처에서 rax로 무슨 요청인지 나타내고 아래의 순서대로 필요한 인자 전달

 

 

 

   - x64 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
close 0x03 unsigned int fd    
mprotect 0x0a unsigned long start size_t len unsigned long prot
connect 0x2a int sockfd struct sockaddr * addr int addrlen
execve 0x3b const char *filename const char *const *argv const char *const *envp

 

 

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

                       커널 모드에서 시스템의 모든 부분 제어 가능 -> 해커가 커널 모드까지 진입하면 시스템은 무방비 상태

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

                       유저 모드에서 해킹이 발생해도 유저 모드의 권한까지 밖에 획득하지 못하기 때문에 커널의 권한 보호 가능

 

 

 

Quiz : x86 Assembly 1

 

 

코드를 차근차근 보면

1. PTR[rsi+rcx]에 들어있는 값의 하위 8비트를 dl에 넣어준다.

2. dl과 0x30을 xor 연산한 값을 dl에 저장한다.

3. dl 값을 PTR[rsi+rcx]에 넣어준다.

4. rcx 값을 1 증가시킨다.

5. rcx와 0x19를 비교한 후 플래그를 설정해준다.

6. 앞에서 전자가 더 클 경우 end로 점프해준다.

7. 1번 코드로 다시 돌아간다.

 

위의 설명을 토대로 연산을 거친 후에 메모리에 저장된 데이터를 확인해보면 아래와 같다.

 

 

0x57 0x65 0x6c 0x63 0x6f 0x6d 0x65 0x20
0x74 0x6f 0x20 0x61 0x73 0x73 0x65 0x6d
0x62 0x6c 0x79 0x20 0x77 0x6f 0x72 0x6c
0x64 0x21 0x00 0x00 0x00 0x00 0x00 0x00

 

 

이를 문자열로 변환해주면 Welcome to assembly world!가 나온다.

 

 

 

[함께실습] Shellcode

1. 셀코드

 1) 셀코드

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

       ㄴ 익스플로잇 : 해킹 분야에서 상대 시스템을 공격하는 것

   - 일반적으로 셀을 획득하기 위해 셀코드를 사용

   - 어셈블리어로 구성

   

2. orw 셀코드

 1) orw 셀코드 작성

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

   - orw 셀코드 작성하기 위해 알아야 하는 syscall

 

 

예시)

"/tmp/flag"를 읽는 셀코드를 작성해보자

구현하려는 셀코드의 동작을 C언어 형식의 의사코드로 표현하면 아래와 같다.

 

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

 

 

① int fd = open("/tmp/flag", RD_ONLY, NULL);

 

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)

 

  - "/tmp/flag"라는 문자열을 메모리에 위치 시키기 -> 스택에 0x616c662f706d742f67(/tmp/flag)를 push

  - rdi가 이를 가리키도록 rsp를 rdi로 옮기기

  - rsi는 0으로 설정 (O_RDONLY는 0이기 때문)

  - rdx는 0으로 설정

  - rax를 open의 syscall 값인 2로 설정

 

 

② read(fd, buf, 0x30); 

 

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)

 

  - open으로 획득한 /tmp/flag의 fd는 rax에 저장 (syscall의 반환값이 rax에 저장되기 때문)

  - rax를 rdi에 대입

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

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

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

 

fd에 대한 설명은 https://jini00.tistory.com/53 에 간단하게 정리했으므로 패스

 

 

③ write(1, buf, 0x30);

 

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

 

  - 출력은 stdout으로 할 것이므로 rdi를 0x1로 설정

  - rsi와 rdx는 read에서 사용한 값 그대로 사용

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

 

 

④ 종합

 

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)
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)
mov rdi, 1        ; rdi = 1 ; fd = stdout
mov rax, 0x1      ; rax = 1 ; syscall_write
syscall           ; write(fd, buf, 0x30)

 

위의 세 부분을 모두 합치면 위와 같은 코드로 정리해볼 수 있다.

 

 2) orw 셀코드 컴파일 및 실행

   - 대부분의 운영체제는 실행 가능한 파일의 형식 규정하고 있음 (윈두우의 PE, 리눅스의 ELF가 대표적)

   - ELF(Executable and Linkable Format)은 크게 헤더와 코드, 기타 데이터로 구성

    ① 헤더 : 실행에 필요한 여러 정보가 적혀 있음

    ② 코드 : CPU가 이해할 수 있는 기계어 코드가 적혀 있음

   - 위에서 작성한 셀코드는 아스키로 작성된 어셈블리 코드

     - 기계어로 치환하면 CPU가 이해 가능, 하지만 ELF 형식이 아니라 리눅스에서 실행 불가

        -> gcc 컴파일을 통해 ELF 형식으로 변형하는 과정 필요!

   - 실제로 작동하는지 확인하기 위해 만든 orw.c 파일을 컴파일하고 실행했더니 성공적으로 저장한 문자열이 출력되었다.

   

 

 

 3) orw 셀코드 디버깅

   - 위에서 만든 orw 파일을 디버깅

 

 

중단점을 설정해주고 실행시키면 위와 같은 화면이 뜬다.

 

 

 

 

 

 

다음으로 넘어가서 첫번째 syscall 전까지 실행해보았다.

 

 

 

 

위에서와 마찬가지로 두번째 syscall 직전까지 실행하고 인자를 살펴보았다.

 

 

 

 

강의에서 확인해본 것처럼 x/s로 실행결과를 확인해보니 문자열이 성공적으로 저장된 것을 확인할 수 있었다.

 

 

 

 

마지막으로 write 시스템콜을 실행했다.

 

 

 

3. execve 셀코드

 - 임의의 프로그램을 실행하는 셀코드로, 이를 이용하면 서버의 셀 획득 가능

 - 다른 언급 없이 셀코드라고 하면 이를 의미하는 경우가 많음

 - execve 셀코드는 execve 시스템 콜만으로 구성

 

 

 

 

 

댓글