본문 바로가기

Security

Apport Exploit Analysis

Exploiting crash handlers: LPE on Ubuntu 번역

Introduction

이 글에서는 Apport라는 Ubuntu 크래시 핸들러에 대해 설명한다. 어플리케이션에서 크래시가 발생하면 Apport는 커널에 의해서 실행되어 크래시된 프로세스에 대한 정보를 읽고 크래시 리포트를 만들어서 Ubuntu developers에 전송한다. 우리는 어떻게 방어 메커니즘을 우회할 수 있었고 크래시 핸들러를 조정하고, 로컬 권한 상승이 가능했는지 설명한다.

Crash handling in Linux

프로세스가 비정상적으로 종료되면, 커널에 의해서 코어 파일이 생성된다. 코어파일은 프로그램이 크래시되거나 비정상적으로 종료되는 등의 시점에 컴퓨터 프로그램의 저장된 메모리 상태를 저장한 파일이다. 프로세스가 SIGSEGV, SIGABRT 과 같은 시그널을 받으면, 기본적으로 커널은 프로세스를 종료하고 coredump 파일을 생성한다. 이 파일을 통해 충돌 발생 당시의 프로그램 상태를 검사할 수 있다.

기본적으로 coredump의 파일명은 core로 크래시가 발생한 프로세스의 현재 디렉터리에 위치한다. 그러나 coredump 파일이 생성되는 경로는 /proc/sys/kernel/core_pattern으로 설정할 수 있다. Apport의 경우, |/usr/share/apport/apport %p %s %c %d %P %E으로 작성되어 있다.

| 은 userspace programs이 크래시를 핸들링할 수 있도록 kernel 2.6.19에 도입된 기능이다. 파이프 뒤의 경로는 충돌을 처리하기 위해 실행될 프로그램의 경로다. (이 경우에는 Apport에 해당한다.) coredump의 내용은 표준 입력(stdin)으로 프로그램에 전달된다.

% 지정자는 다음과 같은 의미를 가진다.

%p  PID of dumped process, as seen in the PID namespace in
   which the process resides.
%s  Number of signal causing dump.
%c  Core file size soft resource limit of crashing process
   (since Linux 2.6.24).
%d  Dump mode—same as value returned by prctl(2)
   PR_GET_DUMPABLE (since Linux 3.7).
%P  PID of dumped process, as seen in the initial PID
   namespace (since Linux 3.12).
%E  Pathname of executable, with slashes ('/') replaced by
   exclamation marks ('!') (since Linux 3.0).

coredump 파일을 생성하는 user-space 프로그램이 root로 실행되기 때문에, 우리는 Apport가 매력적인 공격 벡터임을 발견했습니다.

Apport

Apport는 프로세스 크래시가 발생하면 시작되고 두 가지 책임을 가지고 있습니다.

  • 크래시 리포트를 /var/crash/에 환경과 process maps과 같은 정보와 함께 생성한다.
  • 크래시된 프로세스의 현재 디렉터리에 coredump 파일을 생성한다.

코어덤프를 쓰기 위해서, Apport는 write_user_coredump라는 함수를 사용한다. 해당 함수는 표준입력으로부터 coredump를 읽고, 프로세스의 현재 디렉터리에 새로운 coredump 파일을 생성한다. 우리의 목표는 컨트롤 가능한 사용자의 권한을 통해 root로 coredump 파일을 생성하는 것이다. 이것은 일반 사용자는 생성할 수 없는 디렉터리에 우리의 페이로드를 root 권한으로 파일을 생성할 수 있게 할 것이다.

coredump 파일은 root의 소유자라 생성될 것이라 생각할 수 있지만, 실제로 Apport는 coredump를 작성하거나 프로세스 정보를 읽기 직전에 충돌이 발생한 프로세스의 권한을 삭제한다. 이는 root의 권한 남용을 방지하기 위해 수행된다.

리눅스의 모든 프로세스는 세 가지의 주 사용자와 group ID가 존재한다.

  • Real user ID and real group ID (ruid and rgid)
    • 프로세스 소유자를 결정한다.
  • Effective user ID and effective group id (euid and egid)
    • 공유 자원(메세지, 큐, 공유메모리)에 접근을 결정한다.
  • Saved set-user-ID and saved set-group-id
    • 권한을 삭제한 후, suid 권한을 복원하는데 사용되는 초기 euid의 사본을 저장하기 위한 suid 및 guid 프로그램에서 사용

write_user_coredump을 호출하기 전에, Apport는 충돌한 프로세스 사용자의 권한을 영구적으로 삭제하는 drop_privileges() 함수를 호출한다. (setuid, setgid 사용)

def drop_privileges(real_only=False):
    '''Change user and group to real_[ug]id

    Normally that irrevocably drops privileges to the real user/group of the
    target process. With real_only=True only the real IDs are changed, but
    the effective IDs remain.
    '''
    if real_only:
        # Drop any supplemental groups
        if os.getuid() == 0:
            os.setgroups([])
        os.setregid(real_gid, -1)
        os.setreuid(real_uid, -1)
    else:
        os.setgid(real_gid)
        os.setuid(real_uid)
        assert os.getegid() == real_gid
        assert os.geteuid() == real_uid
    assert os.getgid() == real_gid
    assert os.getuid() == real_uid

drop_privileges는 크래시가 발생한 프로세스에서 가져온 real_uidreal_gid 변수에 따라 모든 Apport 권한을 삭제한다. 이를 통해 Apport는 충돌한 프로세스 권한을 사용하여 coredump 파일을 생성한다.

이러한 변수는 get_pid_info라는 함수에서 초기화된다.

def get_pid_info(pid):
    '''Read /proc information about pid'''

    global pidstat, real_uid, real_gid, cwd, proc_pid_fd

    proc_pid_fd = os.open('/proc/%s' % pid, os.O_RDONLY | os.O_PATH | os.O_DIRECTORY)

    # unhandled exceptions on missing or invalidly formatted files are okay
    # here -- we want to know in the log file
    pidstat = os.stat('stat', dir_fd=proc_pid_fd)

    # determine real UID of the target process; do *not* use the owner of
    # /proc/pid/stat, as that will be root for setuid or unreadable programs!
    # (this matters when suid_dumpable is enabled)
    with open('status', opener=proc_pid_opener) as f:
        for line in f:
            if line.startswith('Uid:'):
                real_uid = int(line.split()[1])
            elif line.startswith('Gid:'):
                real_gid = int(line.split()[1])
                break
    assert real_uid is not None, 'failed to parse Uid'
    assert real_gid is not None, 'failed to parse Gid'

    cwd = os.open('cwd', os.O_RDONLY | os.O_PATH | os.O_DIRECTORY, dir_fd=proc_pid_fd)

get_pid_info 함수는 크래시가 발생한 프로세스의 /proc/[PID]/status 파일을 읽고 "Uid:" 및 "Gid:"가 포함된 행을 분석한다. RUID와 GUID인 첫 번째 열에서 값을 가져온다. 우리는 프로세스 이름으로 다른 UID와 GID값을 주입할 수 있음을 발견했다.

/proc/[PID]/status에는 여러 필드가 있고, 그 중 하나는 Name 필드다.

Name:   cat
Umask:  0002
State:  R (running)
Tgid:   14496
Ngid:   0
Pid:    14496
PPid:   13369
TracerPid:      0
Uid:    1000    1000    1000    1000
Gid:    1000    1000    1000    1000

우리는 a\\rUid: 0\\rGid: 0 이름의 프로세스를 생성하고 충돌을 일으켜, get_pid_info를 조작하여 /proc/[PID]/status 를 주입한 내용으로 분석할 수 있게 함을 발견했다. → CVE-2021-25682

SUID programs

이제 root로 권한을 유지할 수 있지만, coredump 파일을 root 권한으로 쓸 수는 없다. 이는 write_user_coredump 함수에서 검사가 진행되기 때문이다.

# don't write a core dump for suid/sgid/unreadable or otherwise
# protected executables, in accordance with core(5)
# (suid_dumpable==2 and core_pattern restrictions); when this happens,
# /proc/pid/stat is owned by root (or the user suid'ed to), but we already
# changed to the crashed process' real uid
assert pidstat, 'pidstat not initialized'
if pidstat.st_uid != os.getuid() or pidstat.st_gid != os.getgid():
    error_log('disabling core dump for suid/sgid/unreadable executable')
    return

Apport는 프로세스에 대해 현재 uid를 확인한다. 프로세스를 uid 1000으로 생성했다고 가정한다. 만약 프로세스 권한을 root로 변경하면, Apport는 suid 실행 파일을 실행했다고 생각할 것이다. (pidstat.st_uid == 1000 or os.getuid() == 0) 이 검사를 우회하기 위해 우리는 suid 프로그램(pidstat.st_uid == 0)을 시작하고 권한을 UID 0으로 설정할 수 있을 것이다. (위의 취약점과 함께) suid 프로그램에 대해 파일명을 변경할 수 있는 권한이 없더라도, 우리가 이름을 변경할 수 있는 suid 프로그램에 심볼릭 링크를 걸어 프로세스 이름(/proc/[PID]/status)을 컨트롤할 수 있다.

Dump mode

coredump 파일을 쓰기위한 마지막 열쇠는 프로세스 덤프모드 설정이다. (프로세스 인자의 %d) 덤프모드는 프로세스가 suid 프로세스인지 여부를 나타낸다. 이것은 무단 메모리 접근을 막는 방어 메커니즘이다. 덤프 모드는 일반 프로세스 크래시에 대해서는 1, suid 프로세스에 대해서는 2로 설정된다. Apport는 프로세스의 덤프모드가 1인지 2인지 확인한다., 2라면 core_ulimit를 0으로 설정하고 코어파일을 쓸 수 없게 한다.

dump_mode = options.dump_mode
...
...
if dump_mode == '2':
    error_log('not creating core for pid with dump mode of %s' % (dump_mode))
    # a report should be created but not a core file
    core_ulimit = 0

만약, 반복적으로 실행되는 프로세스(덤프모드는 1)에서 crash를 일으키고, Apport가 실행 중인 도중에 해당 프로세스를 kill하고, 같은 PID를 가진 suid 프로세스로 바꿔치기하면 어떨까? 해당 트릭으로 실제로는 suid 프로세스를 핸들링하지만, Apport는 일반 프로세스를 핸들링하는 것으로 속일 수 있다.

PID recycling

이 기술은 Github Security Lab의 Kevin의 연구에서 사용되었으며, 우리에게 많은 도움이 되었다. 모든 리눅스 프로세스에는 고유한 PID가 존재한다. PID는 0부터 MAX_PID까지 순차적으로 할당된다. MAX_PID에 도달하면, 카운터는 현재 사용 중인 PID는 건너뛰고 0에서부터 다시 시작한다. 이 메커니즘을 통해, 죽은 프로세스의 PID를 재활용할 수 있다.

/proc/sys/kernel/pid_max 파일에서, MAX_PID의 기본값은 Ubuntu 18.04의 경우 32768이고 Ubuntu 20.04의 경우 4194304이다. 그렇다면 코어 파일이 쓰이기 전에, 어떻게 약 400만 개의 PID를 생성할 수 있을까? 많은 PID를 생성할 수 있게 충분한 시간 동안 Apport 프로세스를 정지시킬 방법을 찾아야한다. 따라서, get_pid_info 함수가 호출되기 전에, 바꿔치기가 발생해야 한다.

Apport가 시작되면 가장 먼저 /var/run/apport.lock를 잠근다. 이미 실행 중인 Apport 인스턴스가 있는 경우, 새로 생성된 Apport는 30초 동안 중단되어 파일을 잠그려고 시도하고 종료된다. 만약 크래시 발생 전에 Apport의 다른 인스턴스를 실행하는 방법을 찾을 수 있다면, 이는 30초 안에 PID를 바꿔야한다.

Ubuntu 18.04에서 30초는 MAX_PID를 생성하는데 충분한 시간이지만, Ubuntu 20.04에서는 약 3~4분 정도가 소요된다. 30초내에 수행하려면 400만 개의 프로세스를 fork한 뒤에 프로세스에 crash를 발생시키는 것이다.

Apport의 첫 번째 인스턴스를 중지하기 위해, /var/crash 경로에 .crash 파일을 만든다. 이 FIFO 파일은 누군가가 파일에 쓸 때까지 첫 번째 프로세스를 중지시킨다. (Apport가 FIFO를 읽으려고 하기 때문에) → CVE-2021-25684

/var/run/apport.lock는 잠기고, 추가적인 Apport 인스턴스가 30초 동안 기다린다. 하지만 Apport에 프로세스 바꿔치기에 대한 방어 메커니즘이 존재함을 발견한다.

# Check if the process was replaced after the crash happened.
# Ideally we'd use the time of dump value provided by the kernel,
# but this would be a backwards-incompatible change that would
# require adding support to apport outside and inside of containers.
apport_start = get_apport_starttime()
process_start = get_process_starttime()
if process_start > apport_start:
    error_log('process was replaced after Apport started, ignoring')
    sys.exit(0)

이 함수는 /proc/apport_pid/stat/proc/crash_pid/stat 두 개의 파일을 열고, 22번째 열에서 각 프로세스의 시작 시간을 읽는다. 흥미롭게도 stat 파일의 두 번째 열에는 프로세스 이름도 존재한다. 위에서 우리는 프로세스 이름을 a\\rUid: 0\\rGid: 0로 했다. 그래서 22번째 열이 24번째 열이 되고, 새로운 22번째 열은 apport_start 시간보다 작은 숫자가 포함되어 우회할 수 있다. → CVE-2021-25683

모든 방어 메커니즘을 성공적으로 우회했으므로 이제 지정된 디렉터리에 root가 소유한 coredump 파일을 만들 수 있다.

코어덤프 파일 내용 컨트롤

코어덤프는 크래시가 발생한 프로세스 메모리 내용을 포함한다. 우리는 원하는 내용을 코어 덤프 파일에 포함하고 싶다. 우리는 원하는 내용이 포함된 프로그램에서 문자열 변수를 만들 수 있다. 코어 덤프가 생성될 때, 메모리는 그 변수값을 포함할 것이다.

우리는 코어 덤프를 실행하기 위해 남용할 수 잇는 메커니즘을 찾으려고 했다. 운 좋게도 우리는 Fllat Security의 Shiga가 작성한 포스팅을 발견할 수 있었고 Logrotat를 타겟으로 삼았다.

Logrotate

Logrotate는 Ubuntu에서 매일 실행되어 로그파일을 관리하는 프로그램이다. Logrotate는 우리가 컨트롤 할 수 있는 /etc/logrotate.d에 설정이 포함되어 있다. Logrotate의 non-strict한 설정때문에, 우리는 우리의 코어덤프를 valid한 설정파일로 디렉터리에 위치시킬 수 있다. 코어덤프 파일에는 logrotate에 의해 실행될 유효한 설정이 포함될 것이다.

익스플로잇 계획

  1. 미끼 프로세스의 예측 가능한 프로그램 이름으로 FIFO 파일을 만든다.
  2. 미끼 프로세스를 실행하고 첫 번째 Apport 인스턴스를 정지한다.
  3. /usr/bin/sudo에 대한 심볼릭 링크를 만들고, 이름을 a\\rUid: 0\\rGid: 0로 바꾼다.
  4. 작업 디렉터리를 /etc/logrotate.d/로 변경한다. (chdir)
  5. 크래시 프로그램을 생성한다.
  6. PID를 crash_pid - 1로 만들기 위해 많은 프로세스를 fork 한다.
  7. 추가 Apport 인스턴스를 생성하기 위해, 크래시 프로그램에 SIGSEGV를 보낸다. (30초 남음)
  8. SIGKILL을 전송하여 크래시 프로그램을 종료한다. (그 다음 생성될 프로세스의 pid는 crash_pid)
  9. fork로 새로운 프로세스를 생성하고, a\\rUid: 0\\rGid: 0를 실행한다.
  10. 첫 번째 Apport 인스턴스를 FIFO 파일에 쓴다.

두 번째 Apport 인스턴스는 계속 실행된다. get_pid 함수는 새로운 suid 프로세스 아래에 있지만 Apport는 덤프모드가 1이라고 생각하고 코어 덤프를 /etc/logrotate.d/ 에 기록한다. 우리는 netcat에 연결하는 Reverse shell 페이로드를 코어덤프 설정에 포함하여 root 쉘을 획득하는데 성공했다.