이번 시간에는 rop, jop 공격을 이해하기 위해 아래 프로젝트를 활용하여
직접 디버깅하면서 스택 프레임이 생겨지는 과정과 각 명령어 의미, 각 범용 레지스터들이 어떻게 활용되는지 알아볼 것이다.
https://github.com/geesun/arm64_rop_jop
ROP
1. 스택 프레임 형성 및 각 명령어 수행 과정 이해하기
main
peda-arm > disas main Dump of assembler code for function main: 0x0000000000400464 <+0>: stp x29, x30, [sp, #-16]! 0x0000000000400468 <+4>: mov x29, sp 0x000000000040046c <+8>: bl 0x40041c <rop_bad_func> 0x0000000000400470 <+12>: mov w0, #0x0 // #0 0x0000000000400474 <+16>: ldp x29, x30, [sp], #16 0x0000000000400478 <+20>: ret End of assembler dump.
코드
int main() {
어셈블러
0x0000000000400464 <+0>: stp x29, x30, [sp, #-16]!
설명
x29 레지스터
프레임 포인터 레지스터로, 스택 프레임을 추적할 때 사용된다.
함수가 호출되어 새로운 스택 프레임이 생성될 때, x29 레지스터가 스택에 저장된다.
x30 레지스터
lr 링크 레지스터로, BL/BLR 명령에 의해 다른 주소로 분기될 때
pc 레지스터값을 이 레지스터에 저장한다.
이전 함수로 돌아가기 위한 주소가 담겨있다고 보면 된다.
따라서, 현재 함수의 프레임을 생성시키기 위한 준비 작업으로,
이전 함수의 프레임 포인터 주소인 x29 레지스터값과
이전 함수로 되돌아가기 위한 복귀 주소를 의미하는 x30 레지스터값이 스택에 저장하는데,
이때 저장하기 위해 16바이트 공간을 먼저 늘리고 진행한다.
어셈블러
0x0000000000400468 <+4>: mov x29, sp
설명
현재 스택 포인터를 의미하는 sp 레지스터 값을 프레임 포인터를 의미하는 x29 레지스터로 복사한다.
따라서, 현재 함수의 새로운 프레임 포인터를 설정하기 위한 목적으로, 해당 명령어가 존재한다.
코드
rop_bad_func();
어셈블러
0x000000000040046c <+8>: bl 0x40041c <rop_bad_func>
설명
BL (Branch with Link) <rop_bad_func>
내부적으로 아래와 같이 수행된다.
(1) mov lr, = next instruction (pc)
(2) mov pc, =dest
BL 명령어에 의해 rop_bad_func 함수가 호출되고 난뒤에 lr 레지스터에 다시 main 함수로 되돌아갈 복귀 주소가 저장되고,
rop_bad_func 함수로 분기되어, 그 함수의 주소로 pc 값이 바뀌게 된다.
여기서 pc 레지스터는 프로그램 카운터로, 다음 명령을 가리키게 사용된다.
x86_64 아키텍처에 사용되는 RIP 레지스터 사용 용도와 비슷하다.
코드
void rop_bad_func() {
어셈블러
0x000000000040041c <+0>: stp x29, x30, [sp, #-48]! 0x0000000000400420 <+4>: mov x29, sp
설명
다시 한번 더 프레임을 생성시키기 위한 준비 작업으로, 스택 포인터 sp를 48만큼 빼서 그만큼 스택 공간을 확장시킨다.
main 함수에서의 프레임 포인터 주소와 main 함수로 복귀하기 위한 주소가 스택에 저장된다.
또, mov x29, sp 명령어에 의해
현재 rop_bad_func 함수의 새로운 프레임 포인터를 설정하기 위한 목적으로,
sp 레지스터값을 x29 레지스터로 복사한다.
rop_bad_func
peda-arm > disas rop_bad_func Dump of assembler code for function rop_bad_func: 0x000000000040041c <+0>: stp x29, x30, [sp, #-48]! 0x0000000000400420 <+4>: mov x29, sp 0x0000000000400424 <+8>: stp xzr, xzr, [x29, #24] 0x0000000000400428 <+12>: str xzr, [x29, #16] 0x000000000040042c <+16>: str wzr, [x29, #44] 0x0000000000400430 <+20>: adrp x0, 0x450000 <_nl_locale_subfreeres+440> 0x0000000000400434 <+24>: add x0, x0, #0x5c0 0x0000000000400438 <+28>: mov w1, #0x0 0x000000000040043c <+32>: bl 0x418de0 <open64> 0x0000000000400440 <+36>: str w0, [x29, #44] 0x0000000000400444 <+40>: add x0, x29, #0x10 0x0000000000400448 <+44>: mov x2, #0x200 0x000000000040044c <+48>: mov x1, x0 0x0000000000400450 <+52>: ldr w0, [x29, #44] 0x0000000000400454 <+56>: bl 0x418fd8 <read> 0x0000000000400458 <+60>: nop 0x000000000040045c <+64>: ldp x29, x30, [sp], #48 0x0000000000400460 <+68>: ret
코드
char data[16] = {0};
어셈블러
0x0000000000400424 <+8>: stp xzr, xzr, [x29, #24]
설명
STP 명령어는 Store Pair of Registers로,
레지스터를 쌍으로 저장, 즉 두 레지스터를 연속된 메모리 위치에 저장시킨다.
따라서, x29 프레임포인터로부터 24바이트, 32바이트 떨어진 주소에 각각 0으로 값을 지정시킨다.
코드
unsigned long long u64 = 0;
어셈블러
0x0000000000400428 <+12>: str xzr, [x29, #16]
설명
STR 명령어는 Store, 즉 레지스터의 값을 메모리에 저장하라는 의미이다.
x29 프레임 포인터로부터 16바이트 떨어진 위치에 값을 0으로 저장시킨다.
코드
int fd = 0;
어셈블러
0x000000000040042c <+16>: str wzr, [x29, #44]
설명
WZR은 4바이트 타입의 제로 레지스터를 의미한다.
x29 프레임포인터로부터 44바이트 떨어진 위치에 4바이트 타입의 0값을 저장시킨다.
코드
어셈블러
0x0000000000400430 <+20>: adrp x0, 0x450000 <_nl_locale_subfreeres+440> 0x0000000000400434 <+24>: add x0, x0, #0x5c0
설명
ADRP 명령어에 의해 x0 레지스터에 0x450000 페이지 주소가 저장되기에, x0 레지스터값은 0x450000이 된다.
여기서 ADD 명령어에 의해 0x5c0 값이 더해져, x0 레지스터값은 0x4505c0이 된다.
코드
어셈블러
0x0000000000400438 <+28>: mov w1, #0x0
설명
O_RDONLY를 의미하는 1이 w1 레지스터 값으로 지정된다.
코드
어셈블러
0x000000000040043c <+32>: bl 0x418de0 <open64>
설명
open 함수를 호출하기 위해 BL 명령어에 의해 분기된다.
여기서 알아야될 점은 매개변수가 첫 여덟 개까지는 x0
부터 x7
레지스터를 통해 전달되고,
추가 매개변수는 스택을 통해서 전달된다고 한다.
실제로,
x0은 open 함수의 1번째 매개변수로써 “./rop.data” 문자열을 가리키는 주소가 담겨있으며
x1은 open 함수의 2번째 매개변수로써 O_RDONLY를 의미하는 1이 담겨있다.
그렇게 open 함수가 호출되고나서 복귀하면, 그 함수의 리턴값이 x0 레지스터값에 저장된다.
(x86_64 환경에서의 함수 리턴값이 rax 레지스터로 저장되는 것과 비슷하다.)
코드
어셈블러
0x0000000000400440 <+36>: str w0, [x29, #44]
설명
x29 프레임포인터로부터 44바이트 떨어진 위치 (=fd)에
4바이트 타입의 w0 레지스터값인 3 (=open 리턴값)을 저장한다.
코드
read(fd,&u64,512);
어셈블러
0x0000000000400444 <+40>: add x0, x29, #0x10 0x0000000000400448 <+44>: mov x2, #0x200 0x000000000040044c <+48>: mov x1, x0 0x0000000000400450 <+52>: ldr w0, [x29, #44] 0x0000000000400454 <+56>: bl 0x418fd8 <read>
설명
add x0, x29, #0x10
read 함수의 매개변수에 u64 변수를 가리키기 위해
x0 레지스터값은 x29 프레임포인터 값에 16을 더한 값이 저장된다.
mov x2, #0x200
read 함수의 3번째 매개변수에 512를 넣기 위해
x2 레지스터 값은 512 값이 저장된다.
mov x1, x0
read 함수의 2번째 매개변수에 u64 변수를 가리키기 위해
x0 레지스터값을 x1 레지스터로 복사한다.
ldr w0, [x29, #44]
ldr 명령어는 load register로, 메모리에서 값을 읽어와 레지스터에 저장하는 명령어이다.
read 함수의 1번째 매개변수에 fd 값을 넣기 위해
x29 프레임포인터에서 +44바이트만큼 떨어진 위치에 있는 32비트 값 (fd값)을 읽어와서 ‘w0’ 레지스터에 저장한다.
bl 0x418fd8 <read>
마찬가지로 x0, x1, x2 매개변수와 함께 read 함수가 호출되고 난뒤에
read 리턴 값이 x0 레지스터에 저장되고, x29+16 지점(u64)에 read 함수를 통해 읽어온 버퍼가 저장된다.
어셈블러
0x0000000000400458 <+60>: nop
아무런 동작도 하지 않는 명령어이다.
코드
}
어셈블러
0x000000000040045c <+64>: ldp x29, x30, [sp], #48
설명
LDP
명령어는 “Load Pair”로,
두 개의 64비트 레지스터 값을 메모리에서 한 쌍으로 로드시킨다.
먼저, 스택에서 프레임 포인터 (x29
)와 링크 레지스터 (x30
)(=lr) 값을 sp+0, +8 지점으로부터 로드하여
이전 함수 호출의 상태를 복원한다.
또, 스택 포인터 sp를 +48 증가시켜,
rop_bad_func 함수 호출 전의 스택 포인터의 위치로 되돌린다.
어셈블러
ret
RET 명령어는 내부적으로 아래와 같이 수행된다.
mov pc, lr
lr (=x30) 레지스터 값으로 pc 레지스터에 지정하여
다시 main 함수로 복귀한다.
코드
int main() { ... return 0; }
어셈블러
0x0000000000400470 <+12>: mov w0, #0x0 0x0000000000400474 <+16>: ldp x29, x30, [sp], #16 0x0000000000400478 <+20>: ret
설명
main 함수의 에필로그 역할을 한다.
mov w0, #0x0
main 함수의 반환 값이 0이기에 w0 레지스터값을 0으로 지정시킨다.
ldp x29, x30, [sp], #16
마찬가지로, 먼저 스택에서 프레임 포인터 (x29
)와 링크 레지스터 (x30
)(=lr) 값을 sp+0, +8 지점으로부터 로드하여
이전 함수 호출의 상태를 복원한다.
그 다음, 스택 포인터 sp를 +16 증가시켜,
main 함수 호출 전의 스택 포인터의 위치로 되돌린다.
ret
pc 레지스터에 lr (=x30) 레지스터 값으로 지정하여
main 이전 함수로 복귀한다.
2. ROP 공격 이해하기
void rop_bad_func() { char data[16] = {0}; unsigned long long u64 = 0; int fd = 0; fd = open("./rop.data",O_RDONLY); read(fd,&u64,512); }
보다시피 read 함수를 코드를 보면 할당된 8바이트 크기의 u64 변수에 512바이트 만큼 입력받을 수 있어
버퍼 오버플로우 취약점이 발생한다.
rop.data 파일을 open,
read 함수가 수행되고 난 뒤의 스택을 살펴보면 아래와 같다.
우선은 main 함수가 끝나고 나면, 0x44f02c 주소로 복귀하게 된다.
0x44f02 어셈블러 코드는 다음과 같다.
... 0x000000000044f02c <+220>: ldr x19, [sp, #16] 0x000000000044f030 <+224>: ldp x29, x30, [sp], #48 0x000000000044f034 <+228>: ret
0x44f02 주소에 브레이크포인트를 걸고,
위 어셈블리 코드가 실행되기 전의 스택 구조는 아래와 같다.
아래 명령어들을 수행해보면 의미는 다음과 같다.
ldr x19, [sp, #16]
sp에서 +16바이트만큼 떨어진 위치에 있는 0x450d10 주소에 있는 값을 읽어와서 x19 레지스터에 저장한다.
ldp x29, x30, [sp], #48
프레임 포인터 (x29
)를 링크 레지스터 (x30
(= lr)) 값을 (최근) sp+0, sp+8지점으로부터 로드하기 때문에
x29 레지스터값은 0xffffffffffffffff, x30 (= lr) 레지스터값은 0x44f080이 된다.
그 다음, 스택 포인터 sp를 +48만큼 증가시킨다
0x44f02f 가젯이 실행되고 난 뒤 (ret 수행 전) 의 스택 구조는 다음과 같다.
ret이 수행되고 난뒤에는 이제 pc 레지스터값이 0x44f080가 될 것이다.
0x44f080 주소에 있는 가젯도 살펴보면,,
0x000000000044f080 <+304>: mov x0, x19 0x000000000044f084 <+308>: ldr x19, [sp, #16] 0x000000000044f088 <+312>: ldp x29, x30, [sp], #48 0x000000000044f08c <+316>: ret
0x44f02f 가젯이 실행되고 난 뒤 (ret 수행 전) 의 스택 구조는 다음과 같다.
mov x0, x19
x19 레지스터값이 x0 레지스터로 복사된다.
따라서 x0 레지스터값은 “/bin/sh” 문자열 주소를 가지게 된다.
ldr x19, [sp, #16]
(최근) sp에서 +16바이트만큼 떨어진 위치에 있는 0x7ffffff340 주소에 있는 값을 읽어와서 x19 레지스터에 저장한다.
따라서 x19 레지스터값은 0xffffffffffffffff가 된다.
ldp x29, x30, [sp], #48
프레임 포인터 (x29
)를 링크 레지스터 (x30
(= lr)) 값을 (최근) sp+0, sp+8지점으로부터 로드하기 때문에
x29 레지스터값은 0xffffffffffffffff, x30 (= lr) 레지스터값은 0x406ae8이 된다.
그 다음, 스택 포인터 sp를 +48만큼 증가시킨다.
여기까지 수행한 후, ret을 수행하면
LR 레지스터 값은 곧 pc 레지스터값이 되며, LR 값은 0x406ae8인 system 함수 주소,
x0 레지스터값은 “/bin/sh” 문자열 주소를 가지게 되므로,
system(“/bin/sh”) 함수가 호출되면서 쉘이 따지게 되는 것이다.
JOP
jop.c
#include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <stdlib.h> typedef void (*jop_func_t)(); void jop_symbol() { system("/bin/ls"); } void jop_bad_func() { jop_func_t func = NULL; char data[16] = {0}; unsigned long long u64 = 0; int fd = 0; fd = open("./jop.data",O_RDONLY); func = jop_symbol; read(fd,&u64,512); func(); } int main() { jop_bad_func(); return 0; }
IDA에서 디컴파일시켜 스택 오프셋을 확인해보면 아래와 같고,
void __cdecl jop_bad_func() { unsigned __int64 u64; // [xsp+18h] [xbp+18h] BYREF unsigned __int8 data[16]; // [xsp+20h] [xbp+20h] BYREF int fd; // [xsp+34h] [xbp+34h] jop_func_t func; // [xsp+38h] [xbp+38h] ...
jop.data 파일 내용으로 버퍼 오버플로우를 발생시키면
스택 내용은 아래와 같이 변하게 된다.
보다시피 func 주소에 있던 자리가 0x441d84값으로 덮어쓰이게 되면서,
이제 func() 함수를 호출하려고 하면, 0x441d84 가젯 코드가 실행된다.
코드는 아래와 같다.
0x0000000000441d84 <+260>: ldp x0, x1, [x29, #48] 0x0000000000441d88 <+264>: ldp d0, d1, [x29, #64] 0x0000000000441d8c <+268>: ldp d2, d3, [x29, #80] 0x0000000000441d90 <+272>: ldr x30, [x29, #232] 0x0000000000441d94 <+276>: mov sp, x29 0x0000000000441d98 <+280>: ldr x29, [x29] 0x0000000000441d9c <+284>: add sp, sp, #0x100 0x0000000000441da0 <+288>: br x30
위 명령어들을 수행했을때를 스택과 같이 그림으로 설명하면 아래와 같다.
0x0000000000441d84 <+260>: ldp x0, x1, [x29, #48]
각각
x0 레지스터는 x29+48 지점으로부터 로드하기 때문에 system 함수 주소,
x1 레지스터는 x29+56 지점으로부터 로드하기 때문에 0x441d84 값이 된다.
0x0000000000441d88 <+264>: ldp d0, d1, [x29, #64]
0x0000000000441d8c <+268>: ldp d2, d3, [x29, #80]
d0, d1, d2… 레지스터는 벡터 및 부동 소수점 연산하는데 사용되므로 무시한다.
0x0000000000441d90 <+272>: ldr x30, [x29, #232]
x30 레지스터가 x29+232 지점으로부터 로드하기 때문에
x30 레지스터는 0x441cf4 가젯 주소를 가진다.
0x0000000000441d94 <+276>: mov sp, x29
이미 sp와 x29값이 같기 때문에 무시한다.
0x0000000000441d98 <+280>: ldr x29, [x29]
x29 레지스터는 로드시켜 이제 x29 주소에 있는 값을 가지게 되어
main() 프레임 포인터 주소값인 0x7ffffff2f0이 된다.
0x0000000000441d9c <+284>: add sp, sp, #0x100
sp 스택 프인터가 0x100만큼 증가한다.
0x0000000000441da0 <+288>: br x30
BR은 Branch로, BL/BLR 명령어와 달리 함수가 끝나고 복귀하지 않는다.
x86_64 아키텍처의 jmp 명령어와 비슷하다고 보면 된다.
따라서, pc는 x30 레지스터 값인 0x441cf4 가젯으로 분기된다.
분기된 후, 실행될 0x441cf4 가젯을 한번 살펴보자.
0x0000000000441cf4 <+116>: mov x16, x0 0x0000000000441cf8 <+120>: ldp x0, x1, [x29, #96] 0x0000000000441cfc <+124>: ldp x2, x3, [x29, #112] 0x0000000000441d00 <+128>: ldp x4, x5, [x29, #128] 0x0000000000441d04 <+132>: ldp x6, x7, [x29, #144] 0x0000000000441d08 <+136>: ldp d0, d1, [x29, #160] 0x0000000000441d0c <+140>: ldp d2, d3, [x29, #176] 0x0000000000441d10 <+144>: ldp d4, d5, [x29, #192] 0x0000000000441d14 <+148>: ldp d6, d7, [x29, #208] 0x0000000000441d18 <+152>: ldp x29, x30, [x29] 0x0000000000441d1c <+156>: add sp, sp, #0x100 0x0000000000441d20 <+160>: br x16
위 명령어들을 수행했을때를 스택과 같이 그림으로 중요한 부분만 나타내면 아래와 같다.
중요한 부분만 설명하자면,
0x0000000000441cf4 <+116>: mov x16, x0
x0 레지스터로부터 복사받기 때문에
x16 레지스터값은 system 함수 주소를 가진다.
0x0000000000441cf8 <+120>: ldp x0, x1, [x29, #96]
x29+96 지점으로부터 로드시키기 때문에
x0 레지스터값이 “/bin/sh” 문자열 주소를 가지게 된다.
나머지는 크게 신경 안써도 되고,
0x0000000000441d20 <+160>: br x16
이제 x16 레지스터에 있던 system 함수 주소로 분기되기 되므로,
최종적으로 system(“/bin/sh”) 함수가 호출되는 것이다.