지원서 뜯어보기 [중]
들어가며
올해 32기 지원 서류에 있는 문제 중 하나인 포너블 문제를 풀어보려고 한다. 3문제 중 [중] 난이도의 이 문제만 포너블인데 아무도 풀지 않았다. 최대한 차근차근 풀이해보겠다.
셸코드란?
셸코드(Shellcode)는 시스템의 특정 명령을 실행하는 작은 사이즈의 프로그램을 뜻하여, 일반적으로 기계어 코드로 작성되어 있다. 이는 셸을 획득하기 위한 익스플로잇을 목적으로 하기 위해 제작되는 어셈블리 코드 조각이라고도 볼 수 있다. 셸코드로 불리는 이유는 일반적으로 명령 셸을 시작시켜 그곳으로부터 공격자가 영향 받은 컴퓨터를 제어하기 때문이다.
orw 셸코드
orw 셸코드란란 파일을 열고(Open) 내용을 읽은 후(Read) 화면에 출력(Write)하는 셸코드로 여기서 open, read, write 시스템 호출을 순서대로 사용한다.
여기서 시스템 호출(system call)이란 운영 체제의 커널이 제공하는 서비스에 대해 응용 프로그램의 요청에 따라 커널에 접근하기 위한 인터페이스이다.
아래는 orw 셸코드에 필요한 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 |
작동 방식은 open 함수로 파일을 연 후, 생성된 fd를 read 함수에 넘기고 읽은 후에 스택에 결과를 저장하게 된다.
1. open syscall
시그니처: *int open(const char pathname, int flags, mode_t mode);
역할: 파일을 연다.
시스템 호출 번호: rax = 2
레지스터 사용:
rdi => 파일 경로 (예: /tmp/flag)
rsi => flag(열기 옵션 (O_RDONLY, O_WRONLY, O_RDWR 등))
rdx => mode(O_RDONLY(읽기 전용)- 0, O_WRONLY(쓰기 전용)- 1, O_RDWR(읽기/쓰기)- 2) *orw 셸코드에서는 O_RDONLY이기 때문에 0으로 설정
1 | push 0x67 |
syscall의 반환 값, 위 코드에서 보면 open으로 획득한 /tmp/flag의 fd는 rax에 저장된다.
2. read syscall
역할: 파일에서 데이터를 읽는다.
시그니처: *ssize_t read(int fd, void buf, size_t count);
시스템 호출 번호: rax = 0
레지스터 사용:
rdi => 파일 디스크립터
rsi => 읽은 데이터를 저장할 버퍼 주소
rdx => 읽을 바이트 수
1 | mov rdi, rax ; rdi = fd |
### 3. write syscall 역할: 데이터를 출력한다.
시그니처: *ssize_t write(int fd, const void buf, size_t count);
시스템 호출 번호: rax = 1
레지스터 사용:
rdi => 출력할 대상의 fd (1은 표준 출력 stdout)
rsi => 출력할 버퍼 주소
rdx => 출력할 바이트 수
1 | mov rdi, 1 ; stdout |
사실 여기까지 orw 셸코드의 개념을 알아봤으면 이제 문제를 풀 방법을 선택할 수 있는데 첫번째는 위 asm 전체 코드를 다 합쳐서 컴파일 한 뒤, objdump를 이용하여 디스어셈블해서 필요한 셸코드만 추출한 후 최종 셸코드를 문제 서버에 전송하는 방법이다. 두번째는 pwntools에 셸코드를 작성해주는 함수인 shellcraft()가 있는데 이걸 이용하여 코드를 짠 다음 해당 익스플로잇 코드를 실행시키는 것이다.
아무래도 두번째 방법이 더 쉽기 때문에 두번째 방법으로 진행하도록 하겠다.
#문제 풀기
flag의 위치는 "/home/shell_basic/flag_name_is_loooooong"라는 힌트가 나와있고 execve, execveat 시스템 콜을 사용하지 말라고 하니 위에서 했던 orw를 이용하면 될 것 같다. 문제 파일을 보면 shell_basic 실행파일과 shell_basic.c 코드 파일이 있다. 코드 파일 먼저 살펴 보겠다.
##코드 일단 문제 코드 먼저 보겠다.
1 | // Compile: gcc -o shell_basic shell_basic.c -lseccomp |
아까 문제 조건에서 본 것 처럼 execve과 execveat 시스템 콜을 막아놓은 함수가 보이고 그 밑에 main()이 보인다. 우리가 봐야할 부분은 여기다.
1 | char *shellcode = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); |
mmap()을 이용해 크기 0x1000의 메모리를 할당하고 해당 영역은 읽기(R), 쓰기(W), 실행(X) 권한을 모두 가진다.
1 | printf("shellcode: "); |
위 코드는 ‘shellcode: ‘가 출력되면 그 뒤에 표준 입력(fd=0은 stdin)으로 최대 0x1000 바이트의 데이터를 shellcode 메모리로 읽어들이는 것이다. 즉 여기가 셸코드를 입력하는 부분이므로 우리는 여기서 orw 셸코드를 작성해서서 shellcode로 넘겨주면 되는 것이다. 가장 먼저 문제 서버 주소와 포트 번호를 확인해준다.
그 다음 실행파일의 아키텍처를 checksec으로 확인해주겠다.
amd64인 것을 확인할 수 있다. 이제 익스플로잇 코드를 짜보겠다.
1 | # open할 flag 파일 경로 |
shellcraft.open(“/home/shell_basic/flag_name_is_loooooong”)는 open(“/home/shell_basic/flag_name_is_loooooong”) syscall에 해당하는 x86-64 어셈블리 코드를 생성하게 되는데 이 시스템 호출의 결과는 rax 레지스터에 저장되고, 이것이 fd(파일 디스크립터)가 된다.
1 | shell += shellcraft.read('rax', 'rsp', 0x30) |
read(fd, buf, 0x30)를 의미한다. rax가 방금 open한 파일 디스크립터이므로, 이 파일에서 데이터를 읽는다. read를 이용하여여 rsp(스택 위치)에서 최대 0x30 바이트를 읽어온다. 혹시나 읽어오는 바이트가 플래그보다 부족해도 나중에 고치면 되니 적당히 0x30으로 해놓는다.
1 | shell += shellcraft.write(1, 'rsp', 0x30) |
다음 코드는 write(stdout, buf, 0x30)을 의미한는데 이는 write(stdout, buf, size) 시스템 호출이다. stdout의 번호는 1이기 때문에 고정해놓고 방금 rsp에 읽어둔 내용을 0x30 바이트 만큼 읽어 stdout으로 터미널에 플래그를 출력한다.
그 다음 asm으로 셸코드를 기계어로 변환한 후, 아까 위에서 본 코드 파일의 셸코드를 입력하는 부분에 우리가 쓴 셸코드를 보내면 된다.
이렇게 되면 전체 익스플로잇 코드는 다음과 같다.
1 | from pwn import * |
이제 이 코드를 실행시키면
위 사진처럼 DH{ca562d7cf1db6c55cb11c4ec350a3c0b}라는 플래그 값을 얻을 수 있다.
마치며
사실 asm 코드 컴파일 한 다음, 셸코드 다 추출해서 전송하는 방식으로 작성할까 했지만 pwntools 작성 방식이 더 이해하기도 쉽고 간편하고 많이 쓰는 방식이기도 해서 위 방식으로 작성했다.
참고문헌
Chromium OS Docs. (n.d.). Linux System Call Table . https://chromium.googlesource.com/chromiumos/docs/+/master/constants/syscalls.md.
Dreamhack. (n.d.). Exploit Tech: Shellcode. https://learn.dreamhack.io/50