지난 번 배운 것: 산술 연산, 논리연산, 비교, 분기
- 산술 연산: add, sub, inc, dec
- 논리 연산: and, or, xor, not
- 비교: cmp, test
- 분기: jmp, je, jg
운영체제의 핵심 자료구조인 스택, C언어의 함수에 대응되는 프로시저, 그리고 시스템 해킹의 관점에서 매우 중요한 시스템콜과 관련된 어셈블리를 소개할 것입니다.
- 스택: push, pop
- 프로시저: call, leave, ret
- 시스템콜: syscall
** x86-64 어셈블리 명령어 Pt.2
Opcode: 스택
x64 아키텍처에는 다음의 명령어로 스택을 조작할 수 있다.
rsp는 스택의 최상
push val: val을 스택 최상단에 쌓음
연산
rsp -= 8
[rsp] = val
예제
[Register]
rsp = 0x7fffffffc400
[Stack]
0x7fffffffc400 | 0x0 <- rsp
0x7fffffffc408 | 0x0
[Code]
push 0x31337
결과
[Register]
rsp = 0x7fffffffc3f8
[Stack]
0x7fffffffc3f8 | 0x31337 <- rsp
0x7fffffffc400 | 0x0
0x7fffffffc408 | 0x0
- push 0x31337 명령어는 rsp를 8바이트 감소시키고, 그 새로운 위치에 값 0x31337을 저장하는 동작을 합니다.
- 따라서 rsp는 0x7fffffffc3f8로 변경되고, 해당 위치에 0x31337이 저장됩니다.
pop reg: 스택 최상단의 값을 꺼내서 reg에 대입
연산
rsp += 8
reg = [rsp-8]
예제
[Register]
rax = 0
rsp = 0x7fffffffc3f8
[Stack]
0x7fffffffc3f8 | 0x31337 <- rsp
0x7fffffffc400 | 0x0
0x7fffffffc408 | 0x0
[Code]
pop rax
rsp가 스택의 최상단이므로 제일 위에는 rsp가 있다.
결과
[Register]
rax = 0x31337
rsp = 0x7fffffffc400
[Stack]
0x7fffffffc400 | 0x0 <- rsp
0x7fffffffc408 | 0x0
pop rax로 최상단에 있던 0x31337의 값을 rax에 넣었다.
Opcode: 프로시저
컴퓨터 과학에서 프로시저(Procedure)는 특정 기능을 수행하는 코드 조각을 말한다. 프로시저를 사용하면 반복되는 연산을 프로시저 호출로 대체할 수 있어서 전체 코드의 크기를 줄일 수 있으며, 기능별로 코드 조각에 이름을 붙일 수 있게되어 코드의 가독성을 높일 수 있다.
프로시저를 부르는 행위를 호출(Call)이라고 부르며, 프로시저에서 돌아오는 것을 반환(Return)이라고 한다. 프로시저를 호출할 때는 프로시저를 실행하고 나서 원래의 실행 흐름으로 돌아와야 하므로, call 다음의 명령어 주소(Return Address, 반환 주소)를 스택에 저장하고 프로시저로 rip를 이동시킨다.
x64 어셈블리 언어에는 프로시저의 호출과 반환을 위한 call, leave, ret 명령어가 있다.
call addr: addr에 위치한 프로시저 호출
연산
push return_address
jmp addr
예제
[Register]
rip = 0x400000
rsp = 0x7fffffffc400
[Stack]
0x7fffffffc3f8 | 0x0
0x7fffffffc400 | 0x0 <- rsp
[Code]
0x400000 | call 0x401000 <- rip
0x400005 | mov esi, eax
...
0x401000 | push rbp
- rip = 0x400000: 현재 실행 중인 명령어의 위치(Instruction Pointer)
- rsp = 0x7fffffffc400: 스택 포인터 레지스터로, 현재 스택의 최상단
- 0x7fffffffc400 위치에 0이 있다.
- 0x7fffffffc3f8 위치도 0이다
결과
[Register]
rip = 0x401000
rsp = 0x7fffffffc3f8
[Stack]
0x7fffffffc3f8 | 0x400005 <- rsp
0x7fffffffc400 | 0x0
[Code]
0x400000 | call 0x401000
0x400005 | mov esi, eax
...
0x401000 | push rbp <- rip
- call 0x401000은 함수 호출을 위해 리턴 주소(0x400005)를 스택에 저장하고, 프로그램의 실행 흐름을 0x401000으로 점프시킵니다.
- 스택: 리턴 주소(0x400005)가 스택의 최상단에 저장됩니다.
- 레지스터: rip은 이제 호출한 함수의 시작 위치(0x401000)로 이동하고, 그곳에서 새로운 명령어를 실행합니다.
leave: 스택 프레임 정리
연산
mov rsp, rbp : 스택 포인터(rsp)를 프레임 포인터(rbp)의 값으로 바꾼다. 즉, 현재 함수에서 사용하던 스택 영역을 다시 해제하는 과정으로, 스택 포인터를 함수의 시작 지점으로 되돌린다.
pop rbp : 프레임 포인터(rbp)에 저장된 값을 스택에서 꺼내고, 이전 함수의 프레임 포인터로 복원한다.이는 함수가 호출되기 전의 상태로 돌아가는 과정이다.
예제
[Register]
rsp = 0x7fffffffc400
rbp = 0x7fffffffc480
[Stack]
0x7fffffffc400 | 0x0 <- rsp
...
0x7fffffffc480 | 0x7fffffffc500 <- rbp
0x7fffffffc488 | 0x31337
[Code]
leave
- 0x7fffffffc400에 rsp가 있고, 여기에는 0x0이 저장되어 있습니다.
- 0x7fffffffc480에는 rbp가 있고, 여기에 0x7fffffffc500이 저장되어 있습니다. 즉, 이 값은 함수가 호출되기 전에 저장된 이전 rbp의 값입니다.
- 0x7fffffffc488에는 0x31337 값이 있습니다.
leave 명령어 실행
결과
[Register]
rsp = 0x7fffffffc488
rbp = 0x7fffffffc500
[Stack]
0x7fffffffc400 | 0x0
...
0x7fffffffc480 | 0x7fffffffc500
0x7fffffffc488 | 0x31337 <- rsp
...
0x7fffffffc500 | 0x7fffffffc550 <- rbp
- mov rsp, rbp:
- rsp 레지스터에 rbp 값이 복사됩니다.
- 그러므로 rsp는 이제 0x7fffffffc480에서 0x7fffffffc488으로 변경됩니다.
- pop rbp:
- rbp에 스택의 최상단에 있는 값을 할당합니다. 이때, 스택의 최상단 값은 0x7fffffffc500입니다.
- 그래서 rbp는 이제 0x7fffffffc500이 됩니다.
- 동시에 rsp는 스택의 포인터를 한 칸 증가시켜 0x7fffffffc488로 이동합니다.
ret: return address로 반환
연산
pop rip
예제
[Register]
rip = 0x401008
rsp = 0x7fffffffc3f8
[Stack]
0x7fffffffc3f8 | 0x400005 <- rsp
0x7fffffffc400 | 0
[Code]
0x400000 | call 0x401000
0x400005 | mov esi, eax
...
0x401000 | mov rbp, rsp
...
0x401007 | leave
0x401008 | ret <- rip
함수가 call 명령어로 호출된다. 함수가 호출되면, 현재 코드가 어디에서 실행중이었는지 반환주소(return address)를 스택에 저장하고 함수로 이동한다.
- call 0x401000: 이 명령어는 0x401000에 있는 함수로 이동하면서, 반환해야 할 주소인 0x400005를 스택에 저장합니다.
- rip는 현재 실행 중인 명령어를 가리키는 레지스터입니다. 함수가 호출되기 전에는 rip가 0x400000을 가리키고 있습니다.
- 호출된 함수가 끝나면 다시 돌아와야 하니까, 다음에 실행할 명령어 주소 0x400005를 스택에 저장합니다
결과
[Register]
rip = 0x400005
rsp = 0x7fffffffc400
[Stack]
0x7fffffffc3f8 | 0x400005
0x7fffffffc400 | 0x0 <- rsp
[Code]
0x400000 | call 0x401000
0x400005 | mov esi, eax <- rip
...
0x401000 | mov rbp, rsp
...
0x401007 | leave
0x401008 | ret
💡스택프레임이란?
|
스택은 함수별로 자신의 지역변수 또는 연산과정에서 부차적으로 생겨나는 임시 값들을 저장하는 영역입니다. 만약 이 스택 영역을 아무런 구분 없이 사용하게 된다면, 서로 다른 두 함수가 같은 메모리 영역을 사용할 수 있게 됩니다.
예를 들어 A라는 함수가 B라는 함수를 호출하는데, 이 둘이 같은 스택 영역을 사용한다면, B에서 A의 지역변수를 모두 오염시킬 수 있습니다. 이 경우, B에서 반환한 뒤 A는 정상적인 연산을 수행할 수 없습니다. 따라서 함수별로 서로가 사용하는 스택의 영역을 명확히 구분하기 위해 스택프레임이 사용됩니다. 대부분의 Application binary interface (ABI)에서는 함수는 호출될 때 자신의 스택프레임을 만들고, 반환할 때 이를 정리합니다. Background: Calling Convention에서 이를 더 자세히 살펴볼 수 있습니다. |
Opcode: 시스템 콜
윈도우, 리눅스, 맥 등의 현대 운영체제는 컴퓨터 자원의 효율적인 사용을 위해, 그리고 사용자에게 편리한 경험을 제공하기 위해, 내부적으로 매우 복잡한 동작을 한다. 운영체제는 연결된 모든 하드웨어 및 소프트웨어에 접근할 수 있으며, 이들을 제어할 수도 있다. 그리고 해킹으로부터 막강한 권한을 보호하기 위해 커널모드와 유저모드로 권한을 나눈다.

커널모드는 운영체제가 전체 시스템을 제어하기 위해 시스템 소프트웨어가 부여하는 권한이다. 파일시스템, 입력/출력, 네트워크 통신, 메모리 관리 등 모든 저수준의 작업은 사용자 모르게 커널 모드에서 진행된다. 커널 모드에서는 시스템의 모든 부분을 제어할 수 있기 때문에, 해커가 커널 모드까지 진입하게 되면 시스템은 거의 무방비 상태가 된다. 이에 대해서는 나중에 제작될 Linux Kernel Exploit 로드맵에서 자세히 배울 수 있다.
유저모드는 운영체제가 사용자에게 부여하는 권한이다. 브라우저를 이용하여 드림핵을 보거나, 유튜브를 시청하는 것, 게임을 하고 프로그래밍을 하는 것 등은 모두 유저 모드에서 이루어진다. 리눅스에서 루트 권한으로 사용자를 추가하고, 패키지를 내려받는 행위 등도 마찬가지이다. 유저 모드에서는 해킹이 발생해도, 해커가 유저모드의 권한까지밖에 획득하지 못하기 때문에 해커로부터 커널의 막강한 권한을 보호할 수 있다.
시스템 콜(system call, syscall)은 유저 모드에서 커널 모드의 시스템 소프트웨어에게 어떤 동작을 요청하기 위해 사용된다. 소프트웨어 대부분은 커널의 도움이 필요하다. 예를 들어 사용자가 cat flag를 실행하면 cat은 flag라는 파일을 읽어서 사용자의 화면에 출력해줘야한다. 그런데 flag는 파일 시스템에 존재하므로 이를 읽으려면 파일 시스템에 접근할 수 있어야 한다. 유저모드에서는 이를 직접할 수 없으므로 커널이 도움을 줘야한다. 여기서 도움이 필요하다는 요청을 시스템 콜이라고 한다. 유저 모드의 소프트웨어가 필요한 도움을 요청하면 커널이 요청한 동작을 수행하여 유저에게 결과를 반환한다.
x64 아키텍처에서는 시스템콜을 위해 syscall 명령어가 있다.
요청: rax
인자 순서: rdi -> rsi -> rdx -> rcx -> r8 -> r9 -> stack
예제
[Register]
rax = 0x1
rdi = 0x1
rsi = 0x401000
rdx = 0xb
[Memory]
0x401000 | "Hello Wo"
0x401008 | "rld"
[Code]
syscall
결과
Hello World
오른쪽의 syscall table을 보면, rax가 0x1일 때, 커널에 write 시스템콜을 요청합니다. 이때 rdi, rsi, rdx가 0x1, 0x401000, 0xb 이므로 커널은 write(0x1, 0x401000, 0xb)를 수행하게 됩니다.
write함수의 각 인자는 출력 스트림, 출력 버퍼, 출력 길이를 나타냅니다. 여기서 0x1은 stdout이며, 이는 일반적으로 화면을 의미합니다. 0x401000에는 Hello World가 저장되어 있고, 길이는 0xb로 지정되어 있으므로, 화면에 Hello World가 출력됩니다.
- rax = 0x1: write 시스템 콜을 요청합니다. 0x1은 파일에 데이터를 쓰는 시스템 콜 번호입니다.
- rdi = 0x1: 파일 디스크립터로 1은 표준 출력(stdout)을 의미합니다. 즉, 화면에 데이터를 출력하겠다는 의미입니다.
- rsi = 0x401000: 출력할 문자열의 주소입니다. 이 주소에는 "Hello Wo"라는 문자열이 저장되어 있습니다.
- rdx = 0xb: 출력할 데이터의 크기입니다. 여기서는 "Hello World"의 길이인 11바이트(0xb)를 지정합니다.
x64 syscall 테이블
이하는 시스템 콜 테이블의 일부이다. 총 갯수가 300개에 달하고, 검색하면 쉽게 찾을 수 있으므로 외울 필요는 없다. 나중에 셸코딩을 하다보면 자연스럽게 중요한 몇 가지는 익숙해지게 됨.
| syscall | rax | argo(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
|
Epilogue
강의 요약🗒️
- 스택
- push val: rsp를 8만큼 빼고, 스택의 최상단에 val을 쌓습니다.
- pop reg: 스택 최상단의 값을 reg에 넣고, rsp를 8만큼 더합니다.
- 프로시저
- call addr: addr의 프로시저를 호출합니다.
- leave: 스택 프레임을 정리합니다.
- ret: 호출자의 실행 흐름으로 돌아갑니다.
- 시스템 콜
- syscall: 커널에게 필요한 동작을 요청합니다.
'공부중 > 시스템 해킹' 카테고리의 다른 글
| Tool: pwntools (0) | 2024.09.14 |
|---|---|
| Tool: gdb (0) | 2024.09.12 |
| [Dreamhack] x86 Assembly: Essential Part (1) (0) | 2024.09.11 |
| [Dreamhack] Linux memory Layout (0) | 2024.09.09 |
| [Dreamhack]x86-64 아키텍처 (0) | 2024.09.09 |
댓글