Skip to content

Latest commit

 

History

History
279 lines (238 loc) · 17.3 KB

File metadata and controls

279 lines (238 loc) · 17.3 KB

kmod의 커널 스레드 ptrace 경합으로 인한 root 권한 상승 (CVE-2003-0127)

메타데이터

항목 내용
CVE ID CVE-2003-0127
영향 소프트웨어 Linux kernel (커널 모듈 자동 로더 kmod, ptrace 서브시스템)
영향 버전 2.2.x (2.2.25 이전), 2.4.x (2.4.21 이전)
CVSS v2 7.2 (High) — AV:L/AC:L/Au:N/C:C/I:C/A:C (NVD)
공개일 2003-03-31 (NVD), 최초 발견 2003-01-25 (발견자 공개 주장)
취약점 유형 Race Condition → 잘못된 ptrace 권한 검사로 인한 Privilege Escalation
발견자 Wojciech Purczynski (cliph, iSEC Security Research), Rafal Wojtczuk 등
수정 커밋 linux-2.4.21-pre5 시점 kernel/fork.c, kernel/ptrace.c, kernel/sys.c, fs/exec.c 패치 (2003-03-17, LKML 배포)

개요

Linux 커널은 프로그램이 필요로 하는 커널 모듈을 그때그때 자동으로 적재하는 kmod 메커니즘을 갖고 있다. 이 메커니즘은 내부적으로 /sbin/modproberoot 권한으로 실행하는 커널 스레드를 만든다. CVE-2003-0127은 이 커널 스레드가 생성되는 순간부터 modprobe가 실제로 실행되기까지의 짧은 시간 동안, 그 스레드가 여전히 일반 사용자의 ptrace()에 노출되어 있었다는 결함이다. 로컬 공격자는 자신이 모듈 로딩을 유발하는 시스템 호출을 실행한 뒤, 그 결과로 생성될 커널 스레드의 PID를 추측해 ptrace(PTRACE_ATTACH) 로 미리 붙잡고, modprobe가 실행되는 순간 그 프로세스 메모리에 셸코드를 주입해 root 권한으로 실행시킬 수 있었다. 이 취약점은 2.2.x와 2.4.x 계열 대부분의 배포판에 영향을 주었고, 공개된 ptrace-kmod.c 익스플로잇이 널리 퍼졌다.

사전 지식

ptrace()dumpable — 왜 아무 프로세스나 디버깅할 수 없는가

ptrace(2)는 한 프로세스가 다른 프로세스의 레지스터·메모리를 읽고 쓰며 실행을 제어할 수 있게 해주는 시스템 호출로, gdbstrace 같은 디버거의 기반이다. 그런데 임의의 사용자 프로세스가 아무 프로세스에나 ptrace_attach()를 걸 수 있다면, 예를 들어 setuid root로 실행 중인 프로세스에 붙어 그 메모리를 조작해 root 권한 코드를 실행시킬 수 있다. 이를 막기 위해 커널은 "이 프로세스의 메모리를 외부에 노출해도(dump/attach) 안전한가"를 나타내는 dumpable 상태를 추적하고, ptrace_attach()는 대상이 dumpable하지 않으면(그리고 attacker가 CAP_SYS_PTRACE 같은 특권이 없으면) 거부한다.

프로세스가 dumpable하지 않게 되는 대표적인 경우는 setuid/setgid 실행이다. exec()가 유효 UID/GID를 파일 소유자 것으로 바꾸는 순간, 커널은 "지금 이 프로세스는 방금 전 프로세스보다 더 높은 권한을 얻었다"고 판단해 dumpable 플래그를 0으로 리셋한다. 그래야 원래 낮은 권한으로 그 프로세스를 추적하고 있던(또는 추적을 시도하는) 공격자가, 권한 상승 이후의 메모리에는 접근하지 못한다.

kmod: 커널이 스스로 사용자 공간 프로그램을 실행하는 방법

Linux 커널은 필요한 순간에 커널 모듈을 자동으로 적재하는 기능(request_module())을 갖고 있다. 예를 들어 지원하지 않는 파일시스템을 마운트하려 하면 커널은 해당 모듈을 싣기 위해 사용자 공간의 /sbin/modprobe를 실행한다. 이 실행은 일반적인 fork()+exec() 가 아니라, 커널 내부의 call_usermodehelper()kernel_thread() 경로를 통해 이루어진다. kernel_thread()는 커널 컨텍스트 안에서 새로운 태스크를 만드는 저수준 함수로, 이렇게 만들어진 태스크는 이미 커널이 부여한 자격(이 경우 UID/GID 0, 즉 root)을 갖고 시작한 뒤 /sbin/modprobeexec()한다.

여기서 사전 지식의 핵심 질문이 나온다. "이미 root로 실행 중이던 프로세스가 exec()으로 또 다른 root 프로그램을 실행하면, dumpable 플래그는 어떻게 되는가?" setuid 로직은 "실행 전후로 유효 UID가 실제 UID와 달라지는 순간"에만 dumpable을 초기화한다. 그런데 kmod 스레드는 처음부터 euid==uid==0인 상태로 만들어지므로, modprobeexec()해도 euid가 바뀌지 않는다 — 권한 상승이 setuid bit가 아니라 커널이 스레드를 만들 때 이미 부여한 자격에서 나오기 때문이다. 패치 전 fs/exec.c의 로직(if (current->euid == current->uid && ...) dumpable = 1;)은 이 경우 dumpable을 꺼주는 것이 아니라 오히려 1로 켜둔다. 즉, root 권한으로 동작하는 이 커널 헬퍼 프로세스에는 setuid 전이 기반의 ptrace 방어가 애초에 적용되지 않는 사각지대가 있었다.

fork()가 두 번 반환한다는 것과 PID 추측

로컬 익스플로잇은 대개 fork()의 성질을 이용한다. fork()는 호출 시 한 번 실행되지만 부모/자식 양쪽에서 각각 반환값이 다르게(자식에게는 0, 부모에게는 자식의 PID) 돌아온다. Linux는 일반적으로 PID를 순차적으로 할당하므로, 공격자가 자기 프로세스를 fork()한 직후 커널이 내부적으로 새 커널 스레드를 만든다면, 그 스레드의 PID는 공격자 프로세스 PID+1일 가능성이 높다. 이 예측 가능성이 뒤에 나올 공격 흐름의 전제가 된다.

취약점 분석

패치 전 흐름: kmod 스레드는 태어날 때부터 ptrace에 열려 있었다

패치 전 kernel_thread()는 단순히 아키텍처별 kernel_thread() 구현(예: arch/i386/kernel/process.c)을 그대로 호출할 뿐, "지금 나(호출자)를 누군가 ptrace하고 있는가" 또는 "새로 만들 스레드가 ptrace되지 않도록 보장해야 하는가"를 전혀 확인하지 않았다. 그 결과 call_usermodehelper()가 만든 root 권한의 modprobe 커널 스레드는, 그것이 exec()으로 modprobe 바이너리 이미지를 불러온 뒤에도 앞서 설명한 사각지대 때문에 mm->dumpable == 1 상태를 유지했다. 공격자 프로세스가 이 스레드의 PID를 미리 알아내 ptrace(PTRACE_ATTACH)를 걸어 두면, 커널의 ptrace_attach() 권한 검사(if (!task->mm->dumpable && !capable(CAP_SYS_PTRACE)) goto bad;)를 그대로 통과했다.

공개된 ptrace-kmod.c 익스플로잇(Wojciech Purczynski, 2003)의 흐름은 이 사각지대를 그대로 이용한다.

/* 자식 프로세스: 커널이 곧 만들 modprobe 스레드에 미리 ptrace를 건다 */
child = getpid();
victim = child + 1;                    /* 커널 스레드 PID를 예측 */

do
    err = ptrace(PTRACE_ATTACH, victim, 0, 0);
while (err == -1 && errno == ESRCH);   /* 아직 태어나지 않았으면 재시도 */

ptrace(PTRACE_SYSCALL, victim, 0, 0);  /* 시스템 콜 단위로 단일 실행 */
/* 부모 프로세스: 실제로 kmod를 유발하는 트리거 */
socket(AF_SECURITY, SOCK_STREAM, 1);   /* 존재하지 않는 프로토콜 → request_module() 유발 */

AF_SECURITY처럼 커널이 모르는 프로토콜 패밀리로 소켓을 만들면, 커널은 해당 기능을 제공할지도 모르는 모듈을 자동으로 적재하기 위해 request_module()call_usermodehelper()kernel_thread() 경로를 탄다. 이 순간 태어나는 스레드가 바로 공격자가 victim으로 미리 ptrace를 걸어 둔 대상이다. 공격자는 PTRACE_SYSCALL 로 이 스레드를 시스템 콜 경계마다 멈춰가며 따라가다가, execve()/sbin/modprobe 이미지가 메모리에 올라온 직후 진입점(entry point, regs.eip)에 셸코드를 PTRACE_POKETEXT로 덮어쓰고 PTRACE_DETACH로 놓아준다.

┌────────────────────────────────────────────────────────────────────┐
│ 패치 전 kmod / ptrace 경합 타임라인                                  │
├────────────────────────────────────────────────────────────────────┤
│ 공격자 프로세스(UID=attacker)                                        │
│   1. fork() → 자식 PID = child, victim = child+1 로 예측             │
│   2. 자식: ptrace(PTRACE_ATTACH, victim) 를 ESRCH 나올 때까지 반복    │
│   3. 부모: socket(AF_SECURITY, ...) 호출 → request_module() 유발      │
│                                                                      │
│ 커널                                                                 │
│   4. call_usermodehelper() → kernel_thread() 로 victim 생성           │
│      └─ euid=uid=0 이지만 setuid 전이가 없어 dumpable 리셋 안 됨      │
│   5. victim 이 SIGSTOP, 공격자 자식이 attach 성공(SIGCHLD 수신)       │
│   6. victim: execve("/sbin/modprobe", ...) 로 root 바이너리 적재      │
│      └─ dumpable 은 여전히 1 → ptrace 계속 허용됨 (사각지대)          │
│                                                                      │
│ 공격자 프로세스(계속)                                                 │
│   7. PTRACE_SYSCALL 로 syscall 경계마다 정지 → execve 완료 시점 탐지   │
│   8. PTRACE_POKETEXT 로 victim 의 EIP 위치에 셸코드 주입               │
│   9. PTRACE_DETACH → victim(=root 권한 modprobe)이 셸코드 실행        │
└────────────────────────────────────────────────────────────────────┘

깨진 불변식

정상적으로 커널이 보장해야 했던 불변식은 "root 권한으로 동작하는 프로세스는, 그 권한이 setuid 전이로 얻어졌든 커널이 처음부터 부여했든 관계없이 낮은 권한의 ptrace로부터 보호되어야 한다"는 것이다. 그러나 실제 검사 로직은 "euid와 uid가 바뀌는 setuid 전이가 일어났는가"만 확인했다. kernel_thread()로 만들어진 커널 헬퍼 프로세스는 이 조건에 해당하지 않으므로 보호 대상에서 빠졌다. 즉 이 취약점의 근본 원인은 메모리 손상이 아니라, "dumpable을 초기화해야 하는 조건"이 "권한이 바뀌는 모든 경로"가 아니라 "setuid exec 경로"만을 가정하고 설계된 논리적 허점이다.

수정 방법

2003년 3월 LKML에 배포된 패치는 두 가지를 동시에 바꾼다. 하나는 kernel_thread() 자체에 잠금과 검사를 추가하는 것이고, 다른 하나는 dumpable 판정을 mm->dumpable 하나가 아니라 태스크 단위의 task_dumpable 비트와 결합한 is_dumpable()로 바꾸는 것이다.

--- a/kernel/fork.c
+++ b/kernel/fork.c
@@
+long kernel_thread(int (*fn)(void *), void * arg, unsigned long flags)
+{
+	struct task_struct *task = current;
+	unsigned old_task_dumpable;
+	long ret;
+
+	/* lock out any potential ptracer */
+	task_lock(task);
+	if (task->ptrace) {
+		task_unlock(task);
+		return -EPERM;
+	}
+
+	old_task_dumpable = task->task_dumpable;
+	task->task_dumpable = 0;
+	task_unlock(task);
+
+	ret = arch_kernel_thread(fn, arg, flags);
+
+	/* never reached in child process, only in parent */
+	current->task_dumpable = old_task_dumpable;
+
+	return ret;
+}
--- a/kernel/ptrace.c
+++ b/kernel/ptrace.c
@@ int ptrace_check_attach(struct task_struct *child, int kill)
 {
+	mb();
+	if (!is_dumpable(child))
+		return -EPERM;
+
 	if (!(child->ptrace & PT_PTRACED))
 		return -ESRCH;
@@ ptrace_attach 권한 검사
-	if (!task->mm->dumpable && !capable(CAP_SYS_PTRACE))
+	if (!is_dumpable(task) && !capable(CAP_SYS_PTRACE))
 		goto bad;
--- a/include/linux/sched.h
+++ b/include/linux/sched.h
@@
+	int did_exec:1;
+	unsigned task_dumpable:1;
@@
+#define is_dumpable(tsk)	((tsk)->task_dumpable && (tsk)->mm->dumpable)

핵심은 새로 추가된 kernel_thread() 래퍼가 실제 아키텍처 구현(arch_kernel_thread)을 호출하기 전에 두 가지를 강제한다는 점이다. 첫째, 지금 이 스레드를 만들려는 호출자 자신이 이미 누군가에게 ptrace되고 있다면 즉시 -EPERM으로 실패시켜, 공격자가 자기 자신에게 ptrace를 걸어 놓고 kmod를 유발하는 경로 자체를 막는다. 둘째, 새 스레드를 만드는 동안 task_dumpable을 0으로 내려, is_dumpable()mm->dumpable 하나만이 아니라 이 새 비트도 함께 확인하게 만든다. fs/exec.c의 setuid 판정 로직도 mm->dumpable과 별개로 task_dumpable = 1을 명시적으로 설정하도록 바뀌어, 이후에는 "setuid 전이가 없었던 root 권한 프로세스"도 더 이상 자동으로 dumpable 상태에 머무르지 않는다.

┌────────────────────────────────────────────────────────────────┐
│ 패치 후 kmod 스레드 생성                                          │
├────────────────────────────────────────────────────────────────┤
│ 공격자: 자신에게 ptrace 걸어둔 채 socket(AF_SECURITY,...) 호출     │
│           │                                                      │
│           ▼                                                      │
│ request_module() → call_usermodehelper() → kernel_thread()       │
│           │                                                      │
│           ├─ task_lock(current); current->ptrace 있음?            │
│           │      └─ YES → return -EPERM (스레드 생성 자체가 실패) │
│           │                                                      │
│           └─ (ptrace 없는 정상 경로)                              │
│                 task_dumpable = 0 로 내린 채 arch_kernel_thread() │
│                 └─ execve("/sbin/modprobe") 이후에도               │
│                    is_dumpable() == false → ptrace_attach 거부    │
└────────────────────────────────────────────────────────────────┘

이 패치는 이후 linux-2.4.21과 각 배포판(Red Hat RHSA-2003:098/145, SuSE, Mandrake, Debian 등)의 보안 업데이트에 반영되었다. 당시 운영자에게 필요한 조치는 배포판이 제공하는 커널 보안 업데이트(2.2.25 / 2.4.21 이상 또는 backport 패치)로 갱신하고 재부팅하는 것이었다.

PoC 관련 언급

이 리포트는 공개된 ptrace-kmod.c(Wojciech Purczynski, 2003, iSEC Security Research)의 소스코드를 정적으로 읽고 흐름을 설명하는 데만 사용했으며, 이 자동화의 원칙에 따라 코드를 컴파일하거나 실행하지 않았다. 셸코드 바이트열 자체의 세부 동작(포트 바인딩 등)은 분석 대상에서 제외했다.

더 살펴볼 점

  • 오늘날 커널이 커널 스레드나 유저모드 헬퍼를 만들 때 사용하는 CLONE_UNTRACED, CLONE_PIDFD, no_new_privs 같은 최신 격리 장치는 이 시절의 task_dumpable 아이디어와 어떻게 이어지는가?
  • ptrace_may_access()와 Yama LSM(ptrace_scope)이 오늘날 로컬 ptrace 공격 표면을 얼마나 더 줄였는지 비교해 보면 흥미롭다.
  • setuid 전이 기준의 dumpable 판정처럼 "권한 변경 지점 하나만 가정한" 방어 로직이 다른 서브시스템에도 비슷한 사각지대를 남기고 있지 않은지 점검해 볼 만하다.

참고 자료

참고 외 별도 확인 링크

본문 참고 자료로 충분했다.