커널 exploit에 입문하기 어려운 큰 원인 중 하나가 디버깅 방법을 잘 모르겠다는 점입니다.
이 절에서는 gdb를 사용해 qemu 위에서 동작하는 Linux 커널을 디버깅하는 방법을 배웁니다.

먼저 연습문제 LK01의 파일을 다운로드해 주세요.

root 권한 획득

로컬에서 Kernel Exploit을 디버깅할 때, 일반 유저 권한이라면 불편한 점이 많습니다. 특히 커널이나 커널 드라이버 처리에 브레이크포인트를 설정하거나, 유출된 주소가 무슨 함수의 주소인지를 조사할 때, root 권한이 없으면 커널 공간의 주소 정보를 얻을 수 없습니다.
Kernel Exploit을 디버깅할 때는, 우선 root 권한을 획득합시다. 이 절의 내용은 앞 장 예제의 (2)번과 같으므로, 이미 푼 분들은 확인 정도로 가볍게 읽어주세요.

커널이 기동하면 가장 먼저 하나의 프로그램이 실행됩니다. 이 프로그램은 설정에 따라 경로는 다양하지만, 많은 경우 /init이나 /sbin/init 등에 존재합니다. LK01의 rootfs.cpio를 전개하면 /init이 존재합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/bin/sh
# devtmpfs does not get automounted for initramfs
/bin/mount -t devtmpfs devtmpfs /dev

# use the /dev/console device node from devtmpfs if possible to not
# confuse glibc's ttyname_r().
# This may fail (E.G. booted with console=), and errors from exec will
# terminate the shell, so use a subshell for the test
if (exec 0</dev/console) 2>/dev/null; then
exec 0</dev/console
exec 1>/dev/console
exec 2>/dev/console
fi

exec /sbin/init "$@"

여기에는 특별히 중요한 처리는 쓰여 있지 않지만, /sbin/init을 실행하고 있습니다. 또한, CTF 등에서 배포되는 작은 환경에서는 /init에 직접 드라이버를 설치하거나 쉘을 기동하는 등의 처리가 쓰여 있는 경우가 있습니다. 실제로 마지막 exec 행 앞에 /bin/sh라고 적으면 커널 기동 시에 root 권한으로 쉘을 기동할 수 있습니다. 단, 드라이버 설치 등 다른 필요한 초기화 처리가 실행되지 않으므로, 이번에는 이 파일을 수정하지 않습니다.
자, /sbin/init에서 최종적으로는 /etc/init.d/rcS라는 쉘 스크립트가 실행됩니다. 이 스크립트는 /etc/init.d 안에 있는 S로 시작하는 이름의 파일을 실행해 나갑니다. 이번에는 S99pawnyable이라는 스크립트가 존재합니다. 이 스크립트에는 다양한 초기화 처리가 쓰여 있는데, 막바지의 다음 행에 주목해 주세요.

1
setsid cttyhack setuidgid 1337 sh

이 행이 이번 커널 기동 시에 유저 권한으로 쉘을 기동하고 있는 코드입니다. cttyhack은 Ctrl+C 등의 입력을 사용할 수 있게 해주는 명령어입니다. 그리고 setuidgid 명령어를 사용해 유저 ID와 그룹 ID를 1337로 설정하고, /bin/sh를 기동하고 있습니다. 이 숫자를 0(=root 유저)으로 바꿉니다.

1
setsid cttyhack setuidgid 0 sh

또한, 자세한 내용은 다음 장에서 설명하겠지만, 일부 보안 기법을 무효화하기 위해 다음 행도 주석 처리해서 지워 주세요.

1
2
-echo 2 > /proc/sys/kernel/kptr_restrict    # 변경 전
+#echo 2 > /proc/sys/kernel/kptr_restrict # 변경 후

변경했다면 cpio로 다시 묶어서, run.sh를 실행하면 아래 스크린샷처럼 root 권한으로 쉘을 사용할 수 있게 되어 있을 것입니다. (묶는 방법은 앞 장을 참조)

root 권한으로 쉘 기동

qemu에 어태치

qemu는 gdb로 디버깅하기 위한 기능을 탑재하고 있습니다. qemu에 -gdb 옵션을 넘겨서, 프로토콜, 호스트, 포트 번호를 지정하여 listen할 수 있습니다. run.sh를 편집해서, 예를 들어 다음 옵션을 추가하면 로컬 호스트의 TCP 12345번 포트에서 gdb를 대기시킬 수 있습니다.

1
-gdb tcp::12345

앞으로의 연습에서는 별도의 설명 없이 12345번 포트를 이용하여 디버깅하겠지만, 자신이 좋아하는 번호를 이용해도 문제없습니다.

gdb로 어태치하려면 target 명령어로 타겟을 설정합니다.

1
pwndbg> target remote localhost:12345

이것으로 접속이 완료되면 성공입니다. 나머지는 일반적인 gdb 명령어를 이용하여 레지스터나 메모리의 읽기/쓰기, 브레이크포인트 설정 등이 가능합니다. 메모리 주소는 「그 브레이크포인트를 건 컨텍스트에서의 가상 주소」가 됩니다. 즉, 커널 드라이버나 유저 공간 프로그램이 사용하고 있는 익숙한 주소에 그대로 브레이크포인트를 설정해도 됩니다.

이번에는 대상이 x86-64입니다. 만약 여러분의 gdb가 기본적으로 디버깅 대상 아키텍처를 인식하지 못하는 경우, 다음과 같이 아키텍처를 설정할 수 있습니다. (보통은 자동으로 인식해 줍니다.)

1
pwndbg> set arch i386:x86-64:intel

커널 디버깅

/proc/kallsyms라는 procfs를 통해, Linux 커널 내에 정의된 주소와 심볼 목록을 볼 수 있습니다. 다음 장의 KADR 절에서도 설명하겠지만, 보안 기법에 의해 커널 주소는 root 권한이라도 보이지 않을 수 있습니다.
root 권한 획득 절에서 이미 했지만, 초기화 스크립트의 아래 행을 주석 처리하는 것을 잊지 말아 주세요. 이것을 하지 않으면 커널 공간의 포인터가 보이지 않게 됩니다.

1
2
echo 2 > /proc/sys/kernel/kptr_restrict     # 변경 전
#echo 2 > /proc/sys/kernel/kptr_restrict # 변경 후

자, 실제로 kallsyms를 살펴봅시다. 양이 방대하므로 head 등으로 앞부분만 봅니다.

/proc/kallsyms의 앞부분

이처럼 심볼의 주소, 주소가 위치한 섹션, 심볼명 순서로 나열되어 출력됩니다. 섹션은 예를 들어 "T"라면 text 섹션, "D"라면 data 섹션처럼 표시되며, 대문자는 전역으로 익스포트된 심볼을 나타냅니다. 이 문자들의 상세한 사양은 man nm에서 확인할 수 있습니다.
예를 들어 위 그림이라면 0xffffffff81000000이 _stext라는 심볼 주소임을 알 수 있습니다. 이것은 커널이 로드된 베이스 주소에 해당합니다.

그럼, 다음으로 commit_creds라는 이름의 함수 주소를 grep으로 찾아보세요. 찾았다면 0xffffffff8106e390이 히트할 것입니다. gdb로 이 함수에 브레이크포인트를 걸고 계속합니다.

1
2
pwndbg> break *0xffffffff8106e390
pwndbg> conti

이 함수는 사실 새로운 프로세스가 만들어질 때 등에 호출되는 함수입니다. 쉘에서 ls 명령어 등을 치면 브레이크포인트에서 gdb가 반응할 것입니다.

commit_creds에 걸린 브레이크포인트로 정지하는 모습

첫 번째 인자 RDI에는 커널 공간의 포인터가 들어 있습니다. 이 포인터가 가리키는 메모리를 살펴봅시다.

commit_creds에서의 메모리 확인

이처럼 커널 공간에서도 유저 공간과 마찬가지로 gdb 명령어를 이용할 수 있습니다. pwndbg 등의 확장 기능도 사용할 수 있지만, 물론 커널 공간용으로 작성된 확장 기능이 아니라면 동작하지 않으니 주의해 주세요.
커널 디버깅용 기능이 탑재된 디버거 등도 있으므로, 여러분이 선호하는 디버거를 사용해 주세요.

드라이버 디버깅

다음으로 커널 모듈을 디버깅해 봅시다.
LK01에는 vuln이라는 이름의 커널 모듈이 로드되어 있습니다. 로드되어 있는 모듈의 목록과 그 베이스 주소는 /proc/modules에서 확인할 수 있습니다.

/proc/moudles의 내용

이것을 보면 vuln이라는 모듈이 0xffffffffc0000000에 로드되어 있음을 알 수 있습니다. 참고로 이 모듈의 소스 코드와 바이너리는 배포 파일의 src 디렉토리에 존재합니다. 소스 코드의 상세 분석은 다른 장에서 하겠지만, 이 모듈의 함수에 브레이크포인트를 걸어 봅시다.
IDA 등으로 src/vuln.ko를 열면 몇 가지 함수가 보입니다. 예를 들어 module_close를 보면, 상대 주소는 0x20f임을 알 수 있습니다.

IDA에서 본 module_close 함수

따라서 현재 커널 상에서는 0xffffffffc0000000 + 0x20f에 이 함수의 시작 부분이 존재할 것입니다. 이곳에 브레이크포인트를 걸어 봅시다.

gdb로 module_close 함수에 브레이크포인트 걸기

자세한 내용은 앞의 장에서 분석하겠지만, 이 모듈은 /dev/holstein이라는 파일에 매핑되어 있습니다. cat 명령어를 사용하면 module_close를 호출할 수 있습니다. 브레이크포인트에서 멈추는지 확인합시다.

늑대군

드라이버 심볼 정보가 필요한 경우는 add-symbol-file 명령을 사용해서, 첫 번째 인자로 가지고 있는 드라이버, 두 번째 인자로 베이스 주소를 넘기면 심볼 정보를 읽어 들여줄 거야. 함수명을 사용해 브레이크포인트를 설정할 수 있지.

1
# cat /dev/holstein

stepinexti 같은 명령어도 이용할 수 있습니다. 이처럼 커널 공간의 디버깅은 어태치 방법이 다를 뿐, 사용할 수 있는 명령어나 디버깅 방법은 유저 공간과 아무런 차이가 없습니다.


이 장에서는 commit_creds에 브레이크포인트를 걸어 RDI 레지스터가 가리키는 메모리 영역을 확인했습니다. 같은 것을 이번에는 유저 권한 쉘(cttyhack으로 uid를 1337로 한 경우)에서 gdb를 사용해 확인해 봅시다.
또한 root 권한(uid=0)의 경우와 일반 유저 권한(uid=1337 등)의 경우를 비교하여, commit_creds의 첫 번째 인자로 전달되는 데이터에 어떤 차이가 있는지 확인해 주세요.