
문제 개요
알려진 파일의 내용물을 쉘코드를 통해 알아내는 문제다. open, read, write라는 세 개의 시스템 콜을 이용하여 특정 파일을 읽어오는 어셈블리 코드를 작성하는 것이 목적이다.
사전지식 - 쉘코드(shellcode)란?
시스템을 공격하기 위해 만든 어셈블리 코드를 쉘코드라고 한다.
시스템 해킹의 목적은 공격 대상 시스템의 쉘(shell)을 획득하는 것이다. 쉘을 획득하면 해당 시스템의 운영체제에 명령을 내릴 수 있고, 이는 시스템의 제어권을 갖게 된다는 것을 의미하기 때문이다.
일반적으로 이 쉘코드는 공격 대상의 쉘을 획득하기 위해 사용하는 방식이기 때문에 쉘코드라는 이름이 붙었다.
쉘코드와 관련한 내용은 아래 링크 참고
https://securitychan.tistory.com/23
목표
사실 해당 문제는 목표로 하는 정보(파일 위치)도 매우 친절하게 알려주고, 쉘코드만 입력하면 친절하게 그걸 실행시켜주는 프로그램이 공격대상이기 때문에 우리가 할 일은 매우 명확하다
“파일을 읽고 출력하는 쉘코드를 작성한다.”
사실 파이썬의 pwntools 라이브러리에 있는 shellcraft라는 놈을 이용하면 상당히 편하게 쉘코드를 작성할 수 있다. 하지만 이번 문제는 어셈블리 코드에 대한 이해를 목적으로 하고 있는 문제이기 때문에 손으로 직접 어셈블리 코드를 작성할 것이고, 실제로 그 과정들을 정리해 놓았다.
절대 필자가 파이썬 실력이 부족해서 머리를 고생시키고 있는 것이 아니다.
진짜 아니라고
풀이
파일을 읽고 출력하는 C언어 코드를 작성하면
char buf[0x30];
int fd = open("/home/shell_basic/flag_name_is_loooooong", O_RDONLY, NULL);
read(fd, buf, 0x30);
write(stdout, buf, 0x30);
이다.
한 줄씩 풀이를 해보자면
char buf[0x30];
파일 내용물을 읽어오는 과정에서 사용할 임시 공간이다. 문제 해결에 사용되는 flag 값이 48자가 넘지는 않을 것이라 일단 임의로 크기를 잡았다. 부족하면 더 늘리면 그만이다.
int fd = open("/home/shell_basic/flag_name_is_loooooong", O_RDONLY, NULL);
open이라는 시스템 콜 함수를 이용해 파일을 여는 동작이다.
첫 번째 인자는 파일 위치다.
두 번째 인자는 oflag 값인데, 파일을 열 때 지정할 옵션을 지정하는 부분이다. 읽기 전용, 쓰기 전용, 파일 생성 등 파일을 열 때의 특정 동작을 지시할 수 있다.
세 번째 인자는 mode값인데, 새 파일을 생성하는 경우 해당 파일의 권한을 설정할 때 사용한다. 이 문제에서는 파일을 읽어오기만 하기 때문에 NULL로 설정하였고, 아예 생략해도 지장은 없다.
open은 파일을 열고, 결과값으로 파일 디스크립터(file descriptor) 값을 반환한다.
파일 디스크립터(file descriptor)란?
운영체제가 열려 있는 파일을 구분하기 위해 지정한 정수값을 말한다. 일반적으로 파일을 다룰 때는 파일을 열고, 파일을 열면서 지정된 파일 디스크립터 값을 통해 파일을 제어한다.
read(fd, buf, 0x30);
read라는 시스템 콜 함수를 이용해 파일 내용을 읽어오는 동작이다.
첫 번째 인자는 파일 디스크립터 값이다. open의 결과로 반환된 값이 목표 파일의 파일 디스크립터이니 이를 그대로 사용한다.
두 번째 인자는 데이터를 읽어서 저장할 버퍼(buffer, 임시공간)다. read 동작이 끝나면 읽어온 내용은 여기에 저장된다.
세 번째 인자는 읽어올 데이터의 길이다. 여기서도 일단 임의로 48자를 지정하였지만, 부족하면 늘리면 그만이다.
참고로, 0x30은 16진수고, 10진수로는 48에 해당한다.
write(stdout, buf, 0x30);
write라는 시스템 콜 함수를 이용해 buf에 있는 내용을 파일에 쓰는 동작이다.
첫 번째 인자는 파일 디스크립터 값이다. 이 문제에서는 파일을 읽고, 우리가 해당 내용을 볼 수 있도록 출력해야 한다. 이 경우 표준출력에 쓰는 동작을 수행하면 된다. 표준출력(stdout)의 파일 디스크립터 값은 기본적으로 1번이다.
두 번째 인자는 쓸 데이터가 저장된 버퍼다. 위의 read에서 읽어온 값이 buf에 저장되어 있으니, 이를 그대로 갖다 쓴다.
세 번째 인자는 쓸 데이터의 길이다. 이것도 임의로 지정한 값이고, 더 이상 말 안해도 이 문제에선 크게 중요한 값은 아니라는 걸 이해했을 것이다.
이제부터 이 네 줄에 해당하는 어셈블리어 코드를 하나하나 작성해볼 것이다.
shell_basic.asm
참고 - x86-64 architecture 어셈블리어로 작성된 코드입니다.

위 코드는 “/home/shell_basic/flag_name_is_loooooong” 이라는 문자열을 stack에 저장하는 과정이다.
여기서 눈에 띄는 점은, push 0xffff처럼 직접 값을 stack에 push하지 않고 rax라는 레지스터에 값을 저장한 뒤에 rax 레지스터의 값을 스택에 push한다는 점이다.
x86-64 아키텍쳐에서는 실제 바이트값을 push하는 동작을 지원하지 않는다고 한다. 따라서 rax 레지스터를 문자열을 스택에 push하기 위해 임시로 사용한 것이다.
자세히 보면 또 하나 눈에 띄는 점은, 바이트의 순서가 역순으로 되어있다는 점이다.
첫 번째 mov 명령어를 통해 넣는 바이트인 0x676e6f6f6f6f6f6f를 그대로 해석해보면 gnoooooo이다. 이는 문자열의 마지막 부분일 뿐더러, 그 순서마저 앞뒤가 반대로 되어있다.
이는 컴퓨터가 메모리에 데이터를 저장할 때 리틀 엔디안(little endian) 방식으로 저장하기 때문이다. x86-64 아키텍쳐에서는 리틀 엔디안 방식으로 바이트를 저장한다. 여기서는 일단 ‘우리가 일반적으로 쓰는 순서와 반대로 저장한다’고 이해하면 된다.

위 코드는 open이라는 시스템 콜을 호출하기 위해 레지스터에 값을 지정하는 과정이다.
rdi에는 open의 첫 번째 인자가 들어간다. 위 단계에서 파일 이름을 스택에 저장했으니, 여기에는 rsp의 값이 들어간다. rsp는 스택 포인터 값이 저장된 레지스터로, 아까 저장한 문자열을 가리킨다.
rsi에는 O_RDONLY의 값인 0이 들어가고, rdx에는 NULL이 들어간다.
rax에는 open의 시스템 콜 번호인 2를 넣고 syscall을 통해 시스템 콜을 호출한다.

위 코드는 read 시스템 콜을 호출하기 위해 레지스터에 값을 지정하는 과정이다.
rdi에는 첫 번째 인자인 목표 파일의 파일 디스크립터 값이 들어간다. 시스템 콜을 호출한 뒤 결과값은 rax 레지스터를 통해 반환되고, 바로 위 단계에서 open의 리턴값인 파일 디스크립터 값은 rax 레지스터에 저장되어 있을 터이니 rax를 rdi에 넣는다.
rsi에는 read를 통해 읽어온 데이터를 저장할 임시 공간을 지정해야 한다. 우리는 0x30만큼의 공간을 할당하려고 하니, 스택 포인터값을 먼저 rsi에 넣고, 여기에서 0x30만큼의 값을 빼서 0x30만큼의 사용하지 않는 스택 공간을 버퍼로 활용한다.
참고 - stack은 높은 주소공간에서 낮은 주소 순서로 사용한다. 따라서 데이터가 추가되면 될 수록 메모리 주소 값은 낮아지게 된다.
rdx에는 버퍼 크기만큼인 0x30을 저장하고, rax에는 read의 시스템 콜 번호인 0을 넣고 시스템 콜을 호출한다.

위 코드는 write 시스템 콜을 호출하기 위해 레지스터에 값을 지정하는 과정이다.
xor을 통해 rdi의 값을 0으로 만들고, inc을 통해 rdi의 값을 1 증가시켜 결과적으로 rdi에는 1이라는 값이 들어갔다. 이는 표준출력(stdout)의 파일 디스크립터 값이다.
rsi는 그대로 사용한다. read 단계에서 rsi가 가리키는 곳에 데이터를 읽어왔으니, rsi는 이미 우리가 원하는 데이터를 가리키고 있다. rdx 역시 read 단계에서와 동일한 값을 사용하니 그대로 둔다.
rax에는 write의 시스템 콜 번호인 1을 넣고 시스템 콜을 호출한다.
이를 이용해 완성된 코드는 다음과 같다.

잠깐, 왜 굳이 이렇게 짰나요?
사실 어셈블리어를 어느 정도 이해하고, 위 과정을 따라 내려오면 약간 이상한 점을 발견할 수 있다.
예를 들어, open을 호출하는 단계에서 레지스터에 값을 집어넣을 때mov rsi, 0 mov rdx, 0 mov rax, 2이렇게 집어넣으면 안되는건가? 싶은 생각이 든다.
물론 일반적인 상황에서는 정상적으로 돌아간다. 하지만 쉘코드로 사용할 때는 간혹 문제가 발생하기도 한다.

위에서 언급한 것처럼 바꾼 결과이다.
3e부분을 보면 0을 직접 레지스터에 넣는 과정에서 binary 코드는 \x00 의 NULL 바이트가 여러 개 생기게 되는데, 특정 상황에서 시스템이 NULL 바이트를 만났을 때 코드가 종료됐다고 인식하게 될 수 있기 때문이다.따라서 0을 넣는 경우엔 xor을 통해 지우기도 하고, 작은 수를 넣을 때는 스택에 push-pop하여 간접적으로 레지스터에 전달하거나 xor을 통해 지운 후 inc 명령어를 이용하기도 한다. 일종의 꼼수라고 볼 수 있다. (해킹이 원래 꼼수가 많이 필요하다.)
이렇게 수정한 코드는 아래에서 확인할 수 있는데, 실제로 확인해보면 \x00 바이트가 단 한 개도 포함되지 않는 것을 확인할 수 있다.
여담이지만, 해당 문제에서는 NULL 바이트가 포함되어도 딱히 코드 실행에 큰 문제가 생기지는 않는다는 걸 나중에 알게 되었다. 그냥 연습한 셈 치기로 했다.
이렇게 완성된 코드는
nasm -f elf64 shell_basic.asm
objcopy --dump-section .text=shell_basic.bin shell_basic.o
이 두 명령어를 통해 object 파일로 변환하고
object 파일을 통해 binary 코드 형태로 변환한다.
참고로 nasm 명령어를 통해 생성된 shell_basic.o(object 파일)의 내용물을 objdump를 통해 뜯어보면 이런 식이다.

좌측의 바이트들을 나열하게 되면 우리가 원하던, 파일을 읽고 출력하는 쉘코드가 되는 것이다. .bin 파일은 이 바이트들이 나열된 파일이라고 보면 된다.
그리고 이제 이 binary 코드를 입력하기만 하면 우리가 원하는 flag값을 얻어올 수 있을 것이다.
그래야만 했다.
오류 발생, 디버깅

파일을 제대로 읽어오지 못하는 오류가 발생했다.
그래서 문제 파일을 그대로 받아서 디버깅을 실행하였다.

open을 실행한 직후이다. rax의 값이 0xff…ffe(10진수로 -1인가 그럴 것이다)이라는 뜻은, 파일을 여는 데에 실패했다는 뜻이다. 스택을 아무리 봐도 파일 명이 잘못된 것은 아니다. 내 컴퓨터에 테스트용으로 해당 파일도 준비해 놓았는데도.
권한의 문제도 아니라면 파일명이 제대로 들어가지 않았다는 뜻인데, 해결 방법은 꽤 간단했다.
C언어에서는 문자열을 입력할 때 항상 마지막에 NULL값이 들어간다. 이는 문자열이 끝났음을 알려주는 표시이다.
그렇다면 어셈블리 코드를 작성할 때에도 문자열의 마지막에 NULL값을 넣는다면 해결이 되지 않을까?

그렇지 그렇게 나와줘야지.
문제 해결
이렇게 어셈블리어를 이용하여 쉘코드를 작성하고, 이를 통해 목표 시스템의 파일 내용을 가져올 수 있었다.
아래는 최종적으로 사용한 코드다.
shell_basic.asm
