S&P 2023에 나온 When Top-down Meets Bottom-up: Detecting and Exploiting Use-After-Cleanup Bugs in Linux Kernel (Lin Ma 외) 논문 읽고 정리한 글. 논문에서 만든 툴이 UACatcher다.
한 줄로 요약하면 이렇다. 디바이스를 detach하면 cleanup routine은 커널을 bottom-to-top으로 정리하며 올라가고, syscall routine은 top-to-bottom으로 내려온다. 이 타이밍이 꼬여서 cleanup이 먼저 메모리를 날려버렸는데 내려오던 syscall이 그걸 dereference하면 터지는 게 Use After Cleanup (UAC)이다.
UAC는 왜 생기나
커널에서 디바이스 드라이버들은 여러 겹으로 쌓여 있는(stacked) 경우가 많다. 이 구조 때문에 cleanup 방향과 syscall 방향이 서로 정반대로 엇갈린다.
Syscall Routine (top -> down) Cleanup Routine (bottom -> up)
│ ▲
▼ │
┌───────────────────────┐ ┌───────────────────────┐
│ Top Layer │ │ Top Layer │
└──────────┬────────────┘ └──────────▲────────────┘
│ │
▼ │
┌───────────────────────┐ ┌──────────┴────────────┐
│ Intermediate Layer │ │ Intermediate Layer │
└──────────┬────────────┘ └──────────▲────────────┘
│ │
▼ │ [1] Deallocation Site
┌───────────────────────┐ ┌──────────┴────────────┐
│ Bottom Driver │ │ Bottom Driver │
└───────────────────────┘ └───────────────────────┘
│
▼ [2] Dereference Site (CRASH!)
- 디바이스 detach (bottom-to-top): 디바이스를 제거하면 드라이버에 등록된 remove pointer function이 cleanup routine을 실행한다. 정리는 아래에서 위로 레이어를 타고 올라가면서 일어난다.
- syscall 호출 (top-to-bottom): 반대로 유저가 syscall을 때리면 커널은 위에서 아래로 레이어를 체크하면서 내려온다.
- 엇갈림: cleanup이 이미 밑에서 메모리를 다 정리해버렸는데 syscall은 내려오느라 아직 모른다면? 이미 해제된 걸 그대로 참조하게 되고, 이게 UAC다.
deallocation site, dereference site, dPair
용어를 좀 정리하면 핵심은 두 군데다:
- Deallocation Site: bottom-up cleanup이 일어나면서 메모리가 해제되는 곳
- Dereference Site: top-down syscall이 내려오면서 그 객체를 역참조하는 곳
- dPair (dealloc-deref pair): 이 두 지점을 묶은 쌍
그러니까 dPair에서 deallocation이 dereference보다 먼저 일어나면 UAC가 터진다.
$$ \text{UAC 발생 조건} \iff \text{Time}(S_{dealloc}) < \text{Time}(S_{deref}) $$
락(mutex, spinlock)이나 refcount가 이 레이스를 제대로 못 막아주면 그대로 버그가 된다.
UACatcher는 이걸 어떻게 찾나
커널 전체를 사람이 눈으로 다 까볼 수는 없으니까 UACatcher 같은 툴로 찾는다. 대충 flow는 이렇다:
커널 전체 스캔
│
▼
1. 타겟 Layer 탐색
└─ 그냥 layer는 다 취약하지 않음
└─ Bottom device driver를 찾고 removal pointer / unreg-entry 함수 탐색
│
▼
2. Stacked Layer Picture 완성
└─ unreg-entry 함수를 타고 역추적해서 top layer까지의 계층을 그림
│
▼
3. dPair Table 추출
└─ bottom-up deallocation 지점과 top-down dereference 지점 매칭
│
▼
4. Synchronization & 경로 제약조건 분석
└─ 락이나 제약조건으로 UAC가 방지되는지 검증하고 버그 후보 확정
아무 layer나 쑤신다고 버그가 나오는 게 아니라서, bottom device driver layer 찾고 → removal 포인터 찾고 → 그 포인터의 unreg-entry 함수를 찾아서 top layer까지 stacked layer 그림을 먼저 그린다.
그 다음에 deallocation이랑 dereference 지점들을 매핑해서 dPair table을 만들고, 락 여부랑 경로 제약조건을 따져서 실제로 UAC가 날 수 있는 곳을 짚어내는 식이다.
근데 이거 익스하기 되게 힘들다
이론상 버그가 있다고 해도 실제로 exploit하는 건 되게 힘들다. 커널 쓰레드끼리 도는 거라 타이밍 맞추기가 빡세다. 그나마 쉽게 하려면 조건이 좀 필요하다.
race window가 커야 한다
당연한 얘기지만 race window가 클수록 유리하다. deallocation부터 dereference까지 사이에 시간 많이 잡아먹는 함수나 락 병목 같은 게 끼어 있으면 레이스 이기기가 훨씬 쉬워진다.
트리거는 pty로
조건이 맞으면 pseudo-terminal (pty)을 써볼 수 있다. line discipline을 골라서 TTY 드라이버로 보내면 연결/해제를 마음대로 주무를 수 있기 때문이다.
[ 유저 레벨 흐름 ]
1. /dev/ptmx 열어서 pseudo-terminal 실행 ──> fd1 (master), fd2 (slave) 획득
2. ioctl syscall로 fd2에 line discipline 등록 요청
3. fd1으로 나오는 모든 통신 정보를 읽고 다시 fd1에 써줌 (정상 통신 유지, crash 방지)
4. fd2를 닫음 (close) ──> cleanup routine 실행 (bottom-to-top)
5. 이때 반대쪽에서 syscall을 내려보내면 ──> 타이밍 맞아서 UAC 발생!
fd1으로 나오는 걸 계속 읽고 써주지 않으면 정상 동작이 아니라서 원치 않는 크래시가 먼저 뜰 거 같다. 그래서 파이프라인 유지가 중요하다.
그래서 실제로 위험한가
| 디바이스 드라이버 종류 | 권한 요구사항 | 실제 공격 관점에서의 위험도 |
|---|---|---|
| 가상 드라이버 (Virtual Driver) | 루트 (root) 필요 | 사실상 무쓸모 (이미 루트인데 굳이?) |
| PTY / TTY 디바이스 | 일반 유저 가능 | 익스플로잇 성공해도 Low-privilege 수준 |
| NFC Stack 등 비권한 드라이버 | 일반 유저 가능 | 매우 위험 (Local Privilege Escalation / Panic) |
현실적으로 제일 까다로운 건 권한 문제다.
- 가상 드라이버 코드를 올리려면 루트가 필요하고, 중간에 던지는
ioctl같은 syscall도 루트가 필요한 경우가 많다. 이미 루트면 커널 익스를 할 이유가 없으니 실용성은 별로다. - 그렇다고 pseudo-terminal을 쓰자니 익스를 성공해도 low privilege라 얻을 게 별로 없다.
근데 예외가 있다. NFC stack처럼 일반 유저 권한으로도 디바이스를 쓸 수 있는 드라이버 스택에 UAC가 있으면? 그때는 얘기가 완전히 달라진다. 권한 없는 계정에서 시스템을 죽이거나(DoS) 권한 상승까지 노릴 수 있는 되게 위험한 취약점이 된다.