
eBPF 기반 Linux 루트킷 탐지기: 다중 채널 교차 뷰 분석(sched_switch, NMI, /proc)을 사용하여 DKOM, 트레이스포인트 변조 및 프로세스 은닉을 탐지하고, 하드웨어 수준 무결성 검증을 제공합니다.
시스템 프로세스 무결성 및 교차 뷰 분석
"노래할게, 그러니 반짝여, SPiCa..."
SPiCa는 Rust로 작성된 eBPF 기반 Linux 루트킷 탐지기입니다. 이름은 하츠네 미쿠의 노래 SPiCa와 그 노래가 언급하는 별인 Spica(처녀자리 알파별)에서 유래했습니다. Spica는 맨눈으로는 하나의 별처럼 보이지만 실제로는 분광 쌍성입니다: 서로 궤도를 도는 두 개의 별이지만 스펙트럼을 측정하지 않으면 별개의 천체로 구별할 수 없습니다. SPiCa는 동일한 원리를 커널 관찰에 적용합니다: 여러 독립적인 채널이 물리적으로 구별되는 메커니즘을 통해 동일한 커널 상태를 측정하며, 하나를 감추는 루트킷은 다른 채널에 의해 노출됩니다.
면책 조항: 이 코드베이스의 상당 부분은 GLM의 도움으로 생성되거나 리팩토링되었습니다. 엄격한 테스트와 반복적인 설계가 적용되었지만, 프로덕션 사용 전에 보안 및 성능을 위해 코드를 검토하십시오.
SPiCa는 eBPF 제한된 적을 무력화하도록 설계되었습니다. 이 적은 상승된 권한(CAP_BPF 또는 CAP_SYS_ADMIN)을 가져 특권 eBPF 프로그램을 커널에 로드하는 공격자입니다. 이 적은 BPF 검증기가 강력한 제약을 부과하기 때문에 LKM 루트킷보다 본질적으로 약합니다:
| 제약 | 적에 대한 영향 |
|---|---|
| 제한된 루프 (프로그램당 ~100만 명령어) | 모든 커널 메모리를 스캔할 수 없음 |
| 포인터 안전성 (직접 역참조하려면 증명 가능한 유효하고 제한된 대상 필요) | 임의 포인터를 따라갈 수 없음 |
| 제한된 헬퍼 (프로그램 유형별 허용 목록) | kmalloc, vmalloc 또는 커널 할당 함수를 호출할 수 없음 |
임의 커널 쓰기 불가 (bpf_probe_write_kernel 크게 제한됨) | task_struct, tracepoint funcs 배열 또는 BPF 프로그램 바이트코드를 수정할 수 없음 |
| 맵 접근은 로드된 참조로 범위 제한됨 | 다른 프로그램에 속한 맵에 접근 불가 (ID로 열거되지 않는 한 — §7 참조) |
부트 후 LSM 게이트에 의해 LKM이 차단되면, 이 제한된 적이 현실적인 남은 위협입니다. SPiCa의 회피 방지 메커니즘은 이 위협에 맞춰 조정되었습니다. 모든 방어는 자신이 무엇을 커버하고 무엇을 커버하지 않는지 솔직하게 명시합니다.
init_module 없이 임의 커널 쓰기를 가능하게 하는 메모리 손상. SPiCa는 쉬운 LKM 벡터를 차단함으로써 기준선을 높이지만 적의 상한선을 제한하지는 않습니다.SPiCa는 심층 방어 스택의 최후의 수단 계층이며, 위의 계층을 대체하지 않습니다.
SPiCa는 커널 후크에 연결된 4개의 eBPF 프로그램과, 시스템의 자체 뷰(/proc)에 대한 출력을 교차 상관시키는 사용자 공간 탐지 엔진을 실행합니다.
| 채널 | 메커니즘 | 무엇이 억제하는가 | 억제 비용 |
|---|---|---|---|
/proc | procfs 디렉터리 열거 | DKOM (프로세스를 작업 목록에서 제거) | 낮음 — 잘 알려진 루트킷 기술 |
sched_switch | 스케줄러의 eBPF tracepoint | funcs 배열에서 프로그램 분리; 컨텍스트 필터링 | 중간 — funcs 배열에 대한 커널 쓰기 필요 |
| NMI | 하드웨어 성능 카운터 인터럽트 → eBPF perf 이벤트 | IDT/NMI 핸들러 후킹 | 거의 확실한 커널 패닉 — 하드웨어 인터럽트, 마스킹 불가 |
핵심 아키텍처 속성: 루트킷이 동시에 세 채널을 모두 억제할 수 없으며, 억제 자체가 탐지 가능하거나 시스템을 불안정하게 만듭니다. NMI를 억제하려면 IDT(인터럽트 설명자 테이블)를 패치해야 하며, 대부분의 커널에서 패닉을 유발합니다. 이것이 "살아있는 폭탄"입니다. 공격자가 완전한 블라인드 상태에 도달할 수 있는 유일한 경로는 시스템을 충돌시키는 것입니다.
sched sees it, /proc doesn't → DKOM NMI finds sched's heartbeat stopped → TAMPER sched goes globally silent, /proc != empty → SILENT
각 탐지 클래스는 차등 판정입니다: 둘 이상의 채널 간의 불일치입니다. 탐지 엔진은 레지스트리 + /proc 스냅샷 + 채널 타임스탬프에 대한 순수 함수입니다 — I/O가 없고, 부작용이 없으며, 완전히 유닛 테스트 가능합니다.
### NMI 재설계: 관찰에서 무결성으로
원래 설계에서 NMI는 CPU를 샘플링하고 어떤 태스크가 실행 중인지 보고하는 두 번째 프로세스 관찰 채널이었습니다. 이는 중복이었습니다: sched\_switch가 이미 스케줄링을 관찰하고 있었고, NMI는 다른 메커니즘을 통해 동일한 데이터를 샘플링했습니다. 이 중복으로 인해 초당 CPU당 약 1000개 이상의 링 버퍼 이벤트가 발생했으며, 이 데이터의 99.999%는 '예, 스케줄러가 예상대로 작동하고 있습니다'를 확인하는 데 사용되었습니다.
재설계된 아키텍처에서 **NMI는 프로세스 관찰에서 트레이스포인트 무결성 검증으로 용도가 변경되었습니다.** 더 이상 CPU에서 실행 중인 프로세스를 보고하지 않습니다. 대신, 공유 `.bss` 하트비트를 읽어 `sched_switch`가 실제로 실행되고 있는지 확인합니다. 이로 인해:
1. NMI 링 버퍼 트래픽의 약 99%를 제거합니다 (정상 상태에서 거의 제로 이벤트)
2. 트레이스포인트 분리, 억제 및 BTF/어태치 실패를 직접 탐지합니다 (원래 BTF 버그 — [§11](#11-the-btf-bug-incident) 참조)
3. 하드웨어 인터럽트에서 실행되며, 트레이스포인트 디스패치 경로 외부에 있으므로 `bpf_override_return`, kprobe 차단, funcs-array 조작에 면역입니다
4. `.bss` 전역 변수를 BPF 헬퍼가 아닌 직접 메모리 접근을 통해 읽으므로, 헬퍼 함수에 대한 `fmod_ret`에 면역입니다
---
## 3. sched\_switch 관찰 채널
`sched_switch` 트레이스포인트에 연결된 eBPF 프로그램은 커널이 CPU에 프로세스를 스케줄링할 때마다 실행됩니다. 전통적인 (비 BTF) 고정 오프셋 읽기를 사용하여 트레이스포인트 인수에서 들어오는 태스크의 PID와 comm을 직접 읽습니다:```
ctx.read_at::<u32>(56) → next_pid
ctx.read_at::<[u8;16]>(40) → next_comm
의도적으로 BTF/CO-RE를 사용하지 않음. 트레이스포인트 인수 레이아웃은 커널 버전 간에 안정적입니다 (트레이스포인트 ABI의 일부입니다). 하드코딩된 오프셋을 사용하면 BTF로 해석된 구조체 네비게이션의 커널 버전 취약성을 피할 수 있습니다. 이는 §11에 문서화된 의도적인 설계 선택입니다.
매 호출 시, 프로그램은:
next_pid 및 next_comm을 읽습니다.ProcessInfo 구조체를 BASE_KEY로 XOR 난독화합니다.sc_sched 링 버퍼에 제출합니다.bpf_ktime_get_ns()를 .bss 전역 변수 SCHED_HEARTBEAT에 씁니다 — NMI 무결성 검사기가 모니터링하는 하트비트입니다.프로그램은 이 컨텍스트에서 의도적으로 bpf_get_current_pid_tgid()를 사용하지 않습니다. sched_switch 시점에서 "현재"는 나가는 태스크이지 들어오는 태스크가 아닙니다. 트레이스포인트 인수가 올바른 (들어오는) 프로세스 식별자를 제공합니다.
감지 엔진은 단일 단조 시간 기준을 사용합니다: SPiCa의 프로세스 시작 이후 나노초 (run_detection() 진입 이후 Instant::now()). 스케줄 이벤트가 도착하면, 이벤트 핸들러는 이벤트에 포함된 eBPF bpf_ktime_get_ns() 값이 아닌 호출자가 제공한 now (프로세스 로컬 나노초)를 저장합니다. 이는 커널 부트 시간과 프로세스 로컬 시간이 혼합될 경우 발생할 수 있는 시간 기준 불일치를 방지합니다 — 이전 버전에 존재했으며 모든 활성 조건이 조용히 실패하게 만든 버그입니다.
NMI 프로그램은 하드웨어 인터럽트 레벨에서 발생하는 하드웨어 성능 카운터 (HW_CPU_CYCLES)에 연결됩니다. NMI는 cli로 비활성화할 수 없습니다 — 이를 억제하려면 PMU를 다시 프로그래밍하거나 IDT를 후킹해야 하며, 둘 다 뚜렷하고 불안정을 초래합니다.
무결성 검사 메커니즘은 매우 간단합니다:``` .bss (shared within SPiCa's ELF object — all programs see the same memory): SCHED_HEARTBEAT ← written by sched_switch on every invocation NMI_LAST_HB ← NMI's record of the last heartbeat it observed NMI_FIRST_TICK ← first NMI invocation timestamp (grace period reference) NMI_LAST_EMIT ← throttle: last event emission timestamp
sched_switch runs → SCHED_HEARTBEAT = bpf_ktime_get_ns()
NMI fires (once per second, throttled): if SCHED_HEARTBEAT == NMI_LAST_HB: if past grace period (5 seconds since first NMI tick): → sched_switch stopped running → emit event_type=1 (TAMPER) else: → sched_switch is alive → emit event_type=0 (heartbeat OK) NMI_LAST_HB = SCHED_HEARTBEAT
### 이 범위 내의 모든 공격을 탐지하는 이유
| 공격 | 발생 상황 | NMI가 감지하는 것 |
|---|---|---|
| Tracepoint가 funcs 배열에서 분리됨 | sched\_switch 중단 | `SCHED_HEARTBEAT` 동결 → TAMPER |
| BTF 버그 / 연결 실패 (무음) | sched\_switch가 실행되지 않음 | `SCHED_HEARTBEAT` 0 유지 → TAMPER (유예 후) |
| 스케줄러 함수에 `bpf_override_return` | tracepoint 발동을 방해하는 경우 | `SCHED_HEARTBEAT` 동결 → TAMPER |
| 바이트코드 제자리 패치 | 임의의 커널 쓰기 필요 (LKM 수준) | eBPF 위협 모델 외부 |
| 링 버퍼 소비자 포인터 조작 | sched 이벤트가 사용자 공간에 도달하지 않음 | `SCHED_HEARTBEAT`는 여전히 진행됨 (프로그램 실행) → 거짓 TAMPER 없음; 사용자 공간이 `max(sched_last)` 스테일을 통해 탐지 → SILENT |
### `.bss`를 특별히 사용하는 이유
`.bss` 전역 변수는 BPF 프로그램의 내부 데이터 섹션에 저장되며, 로더가 관리하는 내부 배열 맵이 지원합니다. 그 특징은 다음과 같습니다:
- **별도로 고정 불가능** — `/sys/fs/bpf/`에 이름이 있는 맵으로 나타나지 않음
- **`bpf_map_update_elem` 훅을 통한 가로채기 불가** — `.bss` 쓰기는 직접 메모리 저장이며, 맵 업데이트 시스템 콜이 아닙니다. 이전 `sc_canary` 메커니즘 (`.bss` 복사본을 이름 있는 맵 복사본과 비교하여 `bpf_map_update_elem` 가로채기를 탐지)은 더 이상 필요하지 않습니다.
- **동일한 ELF 객체 내 프로그램 간 공유** — sched\_switch와 NMI가 외부 인터페이스 없이 `.bss`를 통해 통신
### BPF 기반 가로채기에 대한 면역
NMI 무결성 검사기는 근본적인 특성 때문에 BPF 재정의 공격에 구조적으로 면역입니다: `bpf_override_return`은 함수 **호출**을 가로채지만, NMI 검사기는 검증하는 대상을 *호출*하지 않습니다 — 직접 `.bss` 메모리를 *읽습니다*. 메모리 읽기는 함수 호출이 아니므로 반환 값을 재정의할 수 없습니다.
추가로:
- `bpf_probe_read_kernel` (대안 설계에서 커널 구조체 읽기에 사용됨)은 모든 주소를 허용하는 오류 안전 헬퍼입니다 — 하지만 SPiCa의 `.bss` 하트비트 설계는 이것조차 필요하지 않습니다. 검사기는 직접 로드 명령어를 통해 `.bss` 전역 변수를 읽습니다.
- NMI 프로그램은 NMI 컨텍스트에서 실행되며, 이 컨텍스트에서 kprobes는 구조적으로 신뢰할 수 없습니다 (커널이 이를 지연 또는 억제합니다). 검사기 실행에 대한 kprobe 기반 공격은 하드웨어와 싸우는 것과 같습니다.
### NMI 이벤트 의미론