| 항목 | 내용 |
|---|---|
| CVE ID | CVE-2019-13272 |
| 영향 버전 | Linux 4.10 ~ 5.1.17 미만 |
| CVSS v3.1 | 7.8 (High) — AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H |
| 공개일 | 2019-07-17 (NVD), 수정은 2019-07-04 |
| 발견자 | Jann Horn (Google Project Zero) |
| 비고 | CISA KEV 목록 등재. Polkit pkexec 헬퍼 + PTRACE_TRACEME 조합으로 실제 악용된 사례 다수 |
ptrace_link()는 한 프로세스가 다른 프로세스의 ptrace 추적자(tracer)가 될 때, "누구의
권한으로 이 추적 관계가 만들어졌는가"를 기록해두는 커널 함수다. 이 기록(ptracer_cred)은
이후 이 ptrace 관계가 특권을 가진 관계인지 판단하는 데 쓰인다. CVE-2019-13272는 이 함수가
서로 다른 두 가지 방식으로 잘못되어 있던 취약점이다.
- 크리덴셜 객체 수명(lifetime) 위반: RCU로 보호된 포인터를 안전하지 않은 방법으로 장기 참조로 승격시켜, 이미 해제 중인 크리덴셜 객체를 참조하게 될 수 있다(패닉 유발 가능).
- 잘못된 주체(subject)의 크리덴셜을 기록:
PTRACE_TRACEME를 호출한 것은 자식 프로세스인데, 정작 기록되는 크리덴셜은 부모 프로세스의 것이다. 부모가 그 순간 특권(root)을 갖고 있었다면, 이후 부모가 권한을 낮추고 공격자가 제어 가능한 코드를execve()로 실행하더라도 "특권 ptrace 관계"라는 기록은 그대로 남는다. Polkit의pkexec헬퍼처럼 일시적으로 root로 실행되는 프로그램과 조합하면 로컬 사용자가 root 권한을 얻을 수 있다.
ptrace 관계는 두 가지 경로로 만들어진다.
PTRACE_ATTACH: 프로세스 A가ptrace(PTRACE_ATTACH, B, ...)를 호출해 B의 추적자가 됨. 이 경우 "요청자"는 A(=current) 자신이다.PTRACE_TRACEME: 프로세스 B가ptrace(PTRACE_TRACEME)를 호출해 자신의 부모를 추적자로 등록시킴. 이 경우 "요청자"는 B(=current) 자신이지만, 추적자로 등록되는 대상은 B가 아니라 B의 부모다.
두 경로 모두 최종적으로 ptrace_link(child, new_parent)를 호출해서, child->ptracer_cred
필드에 "이 추적 관계가 어떤 권한으로 성립됐는지"를 기록해둔다. 이 값은 이후 이 추적
관계가 특권을 가진 것으로 취급돼야 하는지 판단하는 데 쓰인다.
리눅스 커널에서 프로세스의 자격 증명은 struct cred로 표현되고, 참조 카운트
(atomic_t usage)로 수명이 관리된다. include/linux/cred.h(당시 커널 기준)에는 이런
함수/매크로들이 있다.
/* 단순 atomic_inc — 이미 0으로 내려가 해제 예정인 객체에 써서는 안 됨 */
static inline const struct cred *get_cred(const struct cred *cred)
{ ... return get_new_cred(nonconst_cred); } /* 내부적으로 atomic_inc(&cred->usage) */
/* RCU 보호 구간에서 얻은 포인터를 안전하게 승격시키는 전용 함수 */
static inline const struct cred *get_cred_rcu(const struct cred *cred)
{
if (!atomic_inc_not_zero(&nonconst_cred->usage)) /* 0이면 승격 실패 */
return NULL;
...
}
/*
* __task_cred - Access a task's objective credentials
* ...
* The result of this function should not be passed directly to get_cred();
* rather get_task_cred() should be used instead.
*/
#define __task_cred(task) rcu_dereference((task)->real_cred)즉 커널 자체 문서(주석)가 명시적으로 "__task_cred()의 결과를 get_cred()에 바로
넘기지 말라"고 경고하고 있다. __task_cred()는 RCU 읽기 구간에서만 유효함이 보장되는
포인터를 반환하는데, get_cred()는 (RCU 안전 버전인 get_cred_rcu()와 달리) 단순
atomic_inc만 수행하기 때문에, 그 크리덴셜이 이미 참조 카운트 0에 도달해 RCU 콜백으로
해제되는 도중이라면 "죽은 객체를 되살리는" 상황이 생길 수 있다.
void __ptrace_link(struct task_struct *child, struct task_struct *new_parent,
const struct cred *ptracer_cred)
{
BUG_ON(!list_empty(&child->ptrace_entry));
list_add(&child->ptrace_entry, &new_parent->ptraced);
child->parent = new_parent;
child->ptracer_cred = get_cred(ptracer_cred);
}
static void ptrace_link(struct task_struct *child, struct task_struct *new_parent)
{
rcu_read_lock();
__ptrace_link(child, new_parent, __task_cred(new_parent));
rcu_read_unlock();
}호출부는 다음 두 곳이다 (kernel/ptrace.c).
/* PTRACE_ATTACH 경로 */
static int ptrace_attach(struct task_struct *task, ...)
{
...
retval = __ptrace_may_access(task, PTRACE_MODE_ATTACH_REALCREDS);
...
ptrace_link(task, current); /* new_parent = current (요청자 자신) → 문제 없음 */
}
/* PTRACE_TRACEME 경로 */
static int ptrace_traceme(void)
{
...
if (!ret && !(current->real_parent->flags & PF_EXITING)) {
current->ptrace = PT_PTRACED;
ptrace_link(current, current->real_parent); /* new_parent = 부모! */
}
...
}ptrace_link()는 __task_cred(new_parent)로 RCU 보호 포인터를 얻은 뒤, RCU 세이프
버전(get_cred_rcu)이 아니라 일반 get_cred()를 그 포인터에 바로 적용한다. 이는
cred.h 주석이 명시적으로 금지한 사용법이다. 대상 프로세스(new_parent)가 마침
그 순간 자격 증명을 교체(commit_creds) 중이어서 이전 크리덴셜의 참조 카운트가
0으로 막 떨어진 상태라면, get_cred()의 단순 atomic_inc가 이미 해제 절차(RCU
콜백)에 들어간 객체를 다시 살아있는 것처럼 만들어 버릴 수 있다 — 커널 패닉이나 이후
사용-후-해제(use-after-free)로 이어질 수 있는 객체 수명 위반이다.
더 심각한 것은 두 번째 문제다. ptrace_link(child, new_parent)가 기록하는 크리덴셜은
new_parent(즉 __task_cred(new_parent))의 것인데, 누가 이 ptrace 관계 생성을
요청했는지와 누구의 크리덴셜이 기록되는지가 PTRACE_TRACEME 경로에서 서로 다른
주체로 갈린다.
PTRACE_ATTACH 경로 (문제 없음)
요청자(current) = A ──▶ ptrace_link(child=B, new_parent=A)
기록되는 크리덴셜 = __task_cred(A) = A 자신의 크리덴셜 ← 요청자 == 기록 대상 (일치)
PTRACE_TRACEME 경로 (패치 전, 문제 있음)
요청자(current) = B(자식, 공격자가 제어 가능)
ptrace_link(child=B, new_parent=B의 부모 P)
기록되는 크리덴셜 = __task_cred(P) = 부모 P의 크리덴셜 ← 요청자(B) != 기록 대상(P) !
부모 프로세스 P가 이 시점에 root 권한(예: SUID 루트 헬퍼)으로 실행 중이었다면, 자식
B의 ptracer_cred에는 P의 root 크리덴셜이 박제된다. 이후 P가 자신의 권한을
낮추고(setuid 등) 공격자가 제어할 수 있는 프로그램을 execve()로 실행하더라도,
이미 만들어진 "B ↔ P 특권 ptrace 관계" 기록 자체는 바뀌지 않는다. 결과적으로 공격자는
한때 root였던 P와, 자신이 처음부터 제어하던 B 양쪽을 모두 조작할 수 있는 특권 ptrace
관계를 손에 넣게 된다. NVD 설명대로 이는 Polkit의 pkexec 헬퍼처럼 "일시적으로 root로
실행됐다가 이후 사용자 권한으로 떨어지며 대상 프로그램을 실행하는" 구조와 결합할 때
현실적인 로컬 권한 상승 경로가 된다(공개된 이차 자료들이 이런 조합을 언급하지만, 다음
"미해결/불확실 지점"에 적었듯 pkexec 내부의 정확한 흐름을 1차 자료로 대조하지는
못했다).
┌─────────────────────────────────────────────────────────────────┐
│ 부모 P (예: pkexec, 이 순간 root) │
│ └─ fork() → 자식 B (공격자가 실행 흐름을 제어) │
│ │ │
│ ├─ B가 PTRACE_TRACEME 호출 │
│ │ ptrace_link(B, P) │
│ │ B->ptracer_cred = __task_cred(P) = root │ ← 오기록
│ │ │
│ P가 이후 권한을 낮추고 execve() │
│ (P의 프로세스 자체가 공격자 영향권으로 들어감) │
│ │ │
│ ▼ │
│ 공격자가 B와 P 양쪽을 쥔 채, "특권 ptrace 관계"라는 │
│ 과거 기록만은 여전히 유효 → 이를 발판 삼아 다른 특권 동작 악용 │
└─────────────────────────────────────────────────────────────────┘
수정 커밋 6994eefb0053("ptrace: Fix ->ptracer_cred handling for PTRACE_TRACEME",
Jann Horn, 2019-07-04)은 단 세 줄을 바꿔 두 문제를 동시에 해결했다.
static void ptrace_link(struct task_struct *child, struct task_struct *new_parent)
{
- rcu_read_lock();
- __ptrace_link(child, new_parent, __task_cred(new_parent));
- rcu_read_unlock();
+ __ptrace_link(child, new_parent, current_cred());
}current_cred()는 (RCU 없이도) 안전하게 "지금 이 코드를 실행 중인 프로세스 자신의"
크리덴셜을 반환하는 매크로다. 커밋 메시지의 표현을 빌리면 "current는 바뀔 수 없고,
접근 제어의 주체로서 항상 옳다(current can't change under us, and current is the
proper subject for access control)."
이 한 줄 교체가 두 근본 원인을 모두 없앤다.
- 원인 ①(수명 위반) 해결: 더 이상 RCU 보호 포인터(
__task_cred)를 얻지도, 그것을get_cred()로 승격시키지도 않는다.current_cred()는 항상 안정적으로 유효한 포인터이므로rcu_read_lock()자체가 필요 없어졌다. - 원인 ②(잘못된 주체) 해결:
PTRACE_ATTACH든PTRACE_TRACEME든, 이제 항상 요청을 실행하고 있는 프로세스(current) 자신의 크리덴셜이 기록된다.PTRACE_TRACEME의 경우 요청자는 자식 B 자신이므로, 부모 P가 그 순간 root였는지와 무관하게 항상 B 자신의 (공격자가 이미 갖고 있던, 상승되지 않은) 권한만 기록된다.
PTRACE_TRACEME 경로 (패치 후)
요청자(current) = B(자식)
ptrace_link(child=B, new_parent=P)
기록되는 크리덴셜 = current_cred() = B 자신의 크리덴셜 ← 요청자 == 기록 대상 (항상 일치)
부모 P가 그 순간 root이든 아니든 더 이상 상관 없음 —
B의 ptracer_cred에는 절대 P의 권한이 대신 들어갈 수 없다.
이제 "요청자"와 "기록되는 크리덴셜의 주체"가 두 경로 모두에서 항상 일치하므로, 부모가 나중에 권한을 낮추고 execve 하더라도 자식에게 남는 것은 애초에 자식 자신의 권한 수준뿐이다 — 앞서 그린 "특권 ptrace 관계가 그대로 남는" 시나리오 자체가 성립하지 않는다.
- 수정 커밋 6994eefb0053799d2e07cd140df6c2ea106c41ee — torvalds/linux (Jann Horn, 전체 커밋 메시지에 두 원인이 상세히 설명됨)
- CVE-2019-13272 — NVD
- Linux 5.1.17 ChangeLog — kernel.org (이 수정이 포함된 릴리스)
- Bugzilla Red Hat 1730895 — CVE-2019-13272
- Bugtraq 공개 스레드 — seclists.org
- Project Zero issue 1903 (발견자 원 보고서, 접근 제한으로 본문 확인 못함)
- Google Project Zero issue 1903(발견자 본인의 원 보고서)은 접근이 되지 않아(빈 페이지로
응답) 본문을 직접 확인하지 못했다. 이 문서의 취약점 분석은 실제 수정 커밋의 diff와
커밋 메시지, 그리고 패치 전
kernel/ptrace.c/include/linux/cred.h소스를 직접 대조해 재구성한 것이다. pkexec헬퍼가 정확히 어느 지점에서 자식을 fork하고 어느 지점에서 권한을 낮추는지의 세부 흐름은 1차 자료(polkit 소스, Project Zero 원문)로 대조하지 못했다 — NVD 설명과 공개된 이차 자료(exploit-db, Metasploit 모듈 설명)가 공통으로 언급하는 조합만 인용했고, pkexec 내부 코드 레벨 대조는 하지 않았다. 필요하면 다음에 polkit 소스의pkexec.c를 직접 클론해 대조할 것.- "객체 수명 위반이 실제로 패닉까지 이어지는 정확한 타이밍 조건"은 커밋 메시지의 서술을 근거로 재구성했을 뿐, 직접 재현/검증하지는 않았다.