Skip to content

Latest commit

 

History

History
247 lines (204 loc) · 13.6 KB

File metadata and controls

247 lines (204 loc) · 13.6 KB

PTRACE_TRACEME 크리덴셜 오기록으로 인한 권한 상승 (CVE-2019-13272)

메타데이터

항목 내용
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는 이 함수가 서로 다른 두 가지 방식으로 잘못되어 있던 취약점이다.

  1. 크리덴셜 객체 수명(lifetime) 위반: RCU로 보호된 포인터를 안전하지 않은 방법으로 장기 참조로 승격시켜, 이미 해제 중인 크리덴셜 객체를 참조하게 될 수 있다(패닉 유발 가능).
  2. 잘못된 주체(subject)의 크리덴셜을 기록: PTRACE_TRACEME를 호출한 것은 자식 프로세스인데, 정작 기록되는 크리덴셜은 부모 프로세스의 것이다. 부모가 그 순간 특권(root)을 갖고 있었다면, 이후 부모가 권한을 낮추고 공격자가 제어 가능한 코드를 execve()로 실행하더라도 "특권 ptrace 관계"라는 기록은 그대로 남는다. Polkit의 pkexec 헬퍼처럼 일시적으로 root로 실행되는 프로그램과 조합하면 로컬 사용자가 root 권한을 얻을 수 있다.

사전 지식

ptrace 관계와 ptracer_cred

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의 수명 관리: RCU 포인터 vs 안정적인 참조

리눅스 커널에서 프로세스의 자격 증명은 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 콜백으로 해제되는 도중이라면 "죽은 객체를 되살리는" 상황이 생길 수 있다.

취약점 분석

패치 전 ptrace_link(): 경고를 무시한 API 사용

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 = 부모! */
	}
	...
}

근본 원인 ①: RCU 참조를 get_cred()로 승격 (객체 수명 위반)

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_TRACEME에서 "요청자"가 아닌 "부모"의 크리덴셜을 기록

더 심각한 것은 두 번째 문제다. 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_ATTACHPTRACE_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 관계가 그대로 남는" 시나리오 자체가 성립하지 않는다.

참고 자료

미해결/불확실 지점

  • 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를 직접 클론해 대조할 것.
  • "객체 수명 위반이 실제로 패닉까지 이어지는 정확한 타이밍 조건"은 커밋 메시지의 서술을 근거로 재구성했을 뿐, 직접 재현/검증하지는 않았다.