| 항목 | 내용 |
|---|---|
| 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/modprobe를 root 권한으로
실행하는 커널 스레드를 만든다. CVE-2003-0127은 이 커널 스레드가 생성되는 순간부터
modprobe가 실제로 실행되기까지의 짧은 시간 동안, 그 스레드가 여전히 일반 사용자의
ptrace()에 노출되어 있었다는 결함이다. 로컬 공격자는 자신이 모듈 로딩을 유발하는
시스템 호출을 실행한 뒤, 그 결과로 생성될 커널 스레드의 PID를 추측해 ptrace(PTRACE_ATTACH)
로 미리 붙잡고, modprobe가 실행되는 순간 그 프로세스 메모리에 셸코드를 주입해 root
권한으로 실행시킬 수 있었다. 이 취약점은 2.2.x와 2.4.x 계열 대부분의 배포판에 영향을
주었고, 공개된 ptrace-kmod.c 익스플로잇이 널리 퍼졌다.
ptrace(2)는 한 프로세스가 다른 프로세스의 레지스터·메모리를 읽고 쓰며 실행을
제어할 수 있게 해주는 시스템 호출로, gdb나 strace 같은 디버거의 기반이다.
그런데 임의의 사용자 프로세스가 아무 프로세스에나 ptrace_attach()를 걸 수
있다면, 예를 들어 setuid root로 실행 중인 프로세스에 붙어 그 메모리를 조작해
root 권한 코드를 실행시킬 수 있다. 이를 막기 위해 커널은 "이 프로세스의 메모리를
외부에 노출해도(dump/attach) 안전한가"를 나타내는 dumpable 상태를 추적하고,
ptrace_attach()는 대상이 dumpable하지 않으면(그리고 attacker가 CAP_SYS_PTRACE
같은 특권이 없으면) 거부한다.
프로세스가 dumpable하지 않게 되는 대표적인 경우는 setuid/setgid 실행이다.
exec()가 유효 UID/GID를 파일 소유자 것으로 바꾸는 순간, 커널은 "지금 이
프로세스는 방금 전 프로세스보다 더 높은 권한을 얻었다"고 판단해 dumpable 플래그를
0으로 리셋한다. 그래야 원래 낮은 권한으로 그 프로세스를 추적하고 있던(또는 추적을
시도하는) 공격자가, 권한 상승 이후의 메모리에는 접근하지 못한다.
Linux 커널은 필요한 순간에 커널 모듈을 자동으로 적재하는 기능(request_module())을
갖고 있다. 예를 들어 지원하지 않는 파일시스템을 마운트하려 하면 커널은 해당 모듈을
싣기 위해 사용자 공간의 /sbin/modprobe를 실행한다. 이 실행은 일반적인 fork()+exec()
가 아니라, 커널 내부의 call_usermodehelper() → kernel_thread() 경로를 통해
이루어진다. kernel_thread()는 커널 컨텍스트 안에서 새로운 태스크를 만드는
저수준 함수로, 이렇게 만들어진 태스크는 이미 커널이 부여한 자격(이 경우 UID/GID 0,
즉 root)을 갖고 시작한 뒤 /sbin/modprobe를 exec()한다.
여기서 사전 지식의 핵심 질문이 나온다. "이미 root로 실행 중이던 프로세스가
exec()으로 또 다른 root 프로그램을 실행하면, dumpable 플래그는 어떻게 되는가?"
setuid 로직은 "실행 전후로 유효 UID가 실제 UID와 달라지는 순간"에만 dumpable을
초기화한다. 그런데 kmod 스레드는 처음부터 euid==uid==0인 상태로 만들어지므로,
modprobe를 exec()해도 euid가 바뀌지 않는다 — 권한 상승이 setuid bit가 아니라
커널이 스레드를 만들 때 이미 부여한 자격에서 나오기 때문이다. 패치 전 fs/exec.c의
로직(if (current->euid == current->uid && ...) dumpable = 1;)은 이 경우 dumpable을
꺼주는 것이 아니라 오히려 1로 켜둔다. 즉, root 권한으로 동작하는 이 커널
헬퍼 프로세스에는 setuid 전이 기반의 ptrace 방어가 애초에 적용되지 않는 사각지대가
있었다.
로컬 익스플로잇은 대개 fork()의 성질을 이용한다. fork()는 호출 시 한 번
실행되지만 부모/자식 양쪽에서 각각 반환값이 다르게(자식에게는 0, 부모에게는 자식의
PID) 돌아온다. Linux는 일반적으로 PID를 순차적으로 할당하므로, 공격자가 자기
프로세스를 fork()한 직후 커널이 내부적으로 새 커널 스레드를 만든다면, 그 스레드의
PID는 공격자 프로세스 PID+1일 가능성이 높다. 이 예측 가능성이 뒤에 나올 공격
흐름의 전제가 된다.
패치 전 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 패치)로 갱신하고 재부팅하는 것이었다.
이 리포트는 공개된 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 판정처럼 "권한 변경 지점 하나만 가정한" 방어 로직이 다른 서브시스템에도 비슷한 사각지대를 남기고 있지 않은지 점검해 볼 만하다.
- CVE-2003-0127 — NVD — CVSS v2, 영향 버전 범위
- ptrace-kmod.c — Wojciech Purczynski, PacketStorm Security — 공개 익스플로잇 원문(정적 분석만 수행)
- 실제 커널 패치 diff (linux-2.4.21-pre5, LKML 배포, 2003-03-17) —
kernel/fork.c,kernel/ptrace.c,fs/exec.c,kernel/sys.c실제 diff - Tracing the Ptrace: A case study in internal root compromise and Incident handling — Sangram Gayal, SANS/GIAC GCIH 2003 — 익스플로잇 단계별 코드 분석
- There's a party at ring0... (...and you're invited) — Tavis Ormandy, Julien Tinnes, CanSecWest 2010 — 커널 로컬 권한 상승 취약점 계보에서 CVE-2003-0127을 ptrace/kernel threads 유형의 대표 사례로 인용
- Linux Kernel 2.2.x/2.4.x (RedHat) - 'ptrace/kmod' Local Privilege Escalation — Exploit-DB #3 — 익스플로잇 메타데이터(발견자, 공개일)
본문 참고 자료로 충분했다.