[리버스 엔지니어링] x86 Assembly(2)
이 포스트에서는 운영체제의 핵심 자료구조인 스택, C언어의 함수에 대응하는 프로시저와 관련된 명령어를 소개하겠다.
여러 레지스터가 별도의 설명없이 들어갈 것이기 때문에 이 포스트에서 사용하는 레지스터를 간단하게 정리해보았다.
레지스터 | 이름 | 역할 |
rip | Instruction Pointer | 다음 실행할 명령어 주소 |
rax | Accumulator Register | 계산, 함수 리턴값 등 범용 사용 |
rsp | Stack Pointer | 스택 최상단 위치 |
rbp | Base Pointer | 함수 스택 프레임 기준점 |
이 레지스터들을 잘 기억해두자
1. 스택
들어가기 전에 간략하게 스택이 뭔지 설명하고 가겠다.
쉽게 말해서 나중에 넣은게 먼저 나오는 구조의 자료 저장소이다.
push는 스택에 데이터를 넣는 것이고 pop은 스택에서 데이터를 꺼내는 것이다.
밑에서 push 설명할 때도 나오듯이 스택은 아래로 자란다. 스택은 실제로 위에 첨부해놓은 그림과 다르게 위아래로 뒤집어 놓은 것처럼 생겼다. 그래서 자료가 메모리 주소 기준으로 아래 방향으로 자란다.
그런데 이제 우리는 편의상 스택 구조 관점에서는 위로 쌓인다고 한다. 내가 위에서 설명한 아래 방향으로 자란다는 것은 메모리 주소 관점이다.
1.1 push
rsp -= 8
[rsp] = val
→ 스택(stack)에 값을 푸시할 때의 동작을 간단하게 표현한 것
→ 스택 포인터(rsp)를 8바이트만큼 아래로 이동
→ push가 실행될 때 내부적으로 CPU가 하는 동작
[Register]
rsp = 0x7fffffffc400
[Stack]
0x7fffffffc400 | 0x0 <- rsp
0x7fffffffc408 | 0x0
[Code]
push 0x31337
이 코드를 실행시키면
[Register]
rsp = 0x7fffffffc3f8
[Stack]
0x7fffffffc3f8 | 0x31337 <- rsp
0x7fffffffc400 | 0x0
0x7fffffffc408 | 0x0
→ 이렇게 된다.
1.2 pop
pop은 스택에서 값을 "꺼내고" 레지스터로 옮기는 동작이다. 값이 스택에서 없어지는 것이 아니라 물리적으로 그 자리에 있던 값은 그대로 있지만 rsp가 위로 이동했기 때문에 이후에는 그 자리에 접근하지 않는다.
rsp += 8
reg = [rsp-8]
→ 스택 포인터(rsp)를 위로 8바이트씩 올린다. (가장 최근에 쌓인 값 하나를 버린 것처럼 보임)
→ 이는 pop reg의 매커니즘이다.
[Register]
rax = 0
rsp = 0x7fffffffc3f8
[Stack]
0x7fffffffc3f8 | 0x31337 <- rsp
0x7fffffffc400 | 0x0
0x7fffffffc408 | 0x0
[Code]
pop rax
이 코드를 실행시키면
[Register]
rax = 0x31337
rsp = 0x7fffffffc400
[Stack]
0x7fffffffc400 | 0x0 <- rsp
0x7fffffffc408 | 0x0
→ 이렇게 된다.
2. 프로시저
프로시저는 어떤 작업을 수행하기 위해 묶어놓은 명령어들의 집합이다.
프로시저를 부르는 행위는 호출(Call)이라고 하며 프로시저에서 돌아오는 것을 반환(Return)이라고 부른다.
x64 어셈블리 언어에서는 프로시저의 호출과 반환을 위한 call, leave, ret 명령어가 있다.
2.1 call
함수를 호출한다. call 명령어는 실행되면서 리턴할 주소인 다음주소를 스택에 push하고 점프한다.
push return_address
jmp addr
→ 리턴할 주소를 스택에 저장한 후 addr 함수로 이동한다.
[Register]
rip = 0x400000
rsp = 0x7fffffffc400
[Stack]
0x7fffffffc3f8 | 0x0
0x7fffffffc400 | 0x0 <- rsp
[Code]
0x400000 | call 0x401000 <- rip
0x400005 | mov esi, eax
...
0x401000 | push rbp
이 코드를 실행시키면
[Register]
rip = 0x401000
rsp = 0x7fffffffc3f8
[Stack]
0x7fffffffc3f8 | 0x400005 <- rsp
0x7fffffffc400 | 0x0
[Code]
0x400000 | call 0x401000
0x400005 | mov esi, eax
...
0x401000 | push rbp <- rip
→ 이렇게 된다.
2.2 leave
함수 종료 전 정리 즉, 함수 안에서 설정한 스택 프레임을 정리한다.
mov rsp, rbp
pop rbp
→ rsp를 rbp로 되돌린다.
[Register]
rsp = 0x7fffffffc400
rbp = 0x7fffffffc480
[Stack]
0x7fffffffc400 | 0x0 <- rsp
...
0x7fffffffc480 | 0x7fffffffc500 <- rbp
0x7fffffffc488 | 0x31337
[Code]
leave
이 코드를 실행시키면
[Register]
rsp = 0x7fffffffc488
rbp = 0x7fffffffc500
[Stack]
0x7fffffffc400 | 0x0
...
0x7fffffffc480 | 0x7fffffffc500
0x7fffffffc488 | 0x31337 <- rsp
...
0x7fffffffc500 | 0x7fffffffc550 <- rbp
→ 이렇게 된다.
mov rsp, rbp // rsp가 rbp가 가리키는 주소로 바뀐다.
pop rbp // 새로운 pop 방식 rbp = [rsp], rsp += 8 (역순 방식)
// 위에서 설명한 pop 방식 rsp += 8 rbp = [rsp-8]
2.3 ret
함수 복귀 즉, call로 저장했던 리턴 주소로 복귀한다.
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
이 코드를 실행시키면
[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
→ 이렇게 된다.
즉, leave가 함수에 사용된 스택을 정리하고 ret이 다시 실행 주소로 돌아가는게 함수 종료의 한 세트이다.