[Linux Kernel eBPF] Validating a BPF Trampoline UAF False Positive
목차
- 한 줄 결론
- 문제 제기
- 분석 범위와 가정
- 관련 코드와 호출 맥락
- 왜 버그처럼 보였는지
- 검증 전략
- 판단 근거
- 1. 객체 수명: bpf_trampoline_put()은 refcount 하나만으로 free하지 않는다
- 2. 메모리 lifetime / ownership: bpf_trampoline_lookup()과 direct_ops_ip_lookup()은 같은 종류의 반환이 아니다
- 3. 상태 전이: lookup 가능 상태와 free 가능 상태는 겹쳐 보이지만, 실제로는 순서가 있다
- 4. 락/동기화: unlock 뒤 소비 구간은 direct_mutex 문맥 안에서 일어난다
- 5. 락/동기화 보강: mutex_trylock(&tr->mutex)은 UAF 흔적이 아니라 deadlock 회피 장치다
- 6. config 대조군: CONFIG_HAVE_SINGLE_FTRACE_DIRECT_OPS=n 경로는 좋은 대조군이다
- 경쟁 시나리오를 놓고 다시 보기
- 실험/재현 결과
- 반론 처리
- 착시의 구조
- 결론
- 남은 리스크와 후속 조치
한 줄 결론
linux/kernel/bpf/trampoline.c의 direct_ops_ip_lookup()는 lock을 풀고 struct bpf_trampoline *를 반환한다. 겉모양만 보면 전형적인 Use-After-Free 패턴처럼 보이지만, 현재 저장소의 sources/linux 트리 기준 v7.0.0-rc6 (9147566d8016)와 x86_64 경로를 따라가면 이 의혹은 False Positive로 판단하는 쪽이 맞다고 본다.
확신 수준은 높게 보지만, 이 글은 아직 동적 실험 결과를 포함하지 않는다. 그래서 결론도 정적 분석으로 확인한 범위 안에서만 조심해서 적는다.
문제 제기
출발점은 단순했다. 서로 다른 두 AI 정적 분석 모델이 거의 같은 지점을 집었다. direct_ops_ip_lookup()가 trampoline_mutex를 풀고 tr 포인터를 반환하고, 그 뒤 호출자 bpf_tramp_ftrace_ops_func()가 그 포인터를 사용하니 다른 스레드가 사이에 bpf_trampoline_put(tr)를 호출하면 UAF가 될 수 있다는 주장이다.
문제가 된 표면 패턴은 실제로 꽤 그럴듯하다.
static struct bpf_trampoline *direct_ops_ip_lookup(struct ftrace_ops *ops,
unsigned long ip)
{
struct hlist_head *head_ip;
struct bpf_trampoline *tr;
mutex_lock(&trampoline_mutex);
head_ip = &trampoline_ip_table[hash_64(ip, TRAMPOLINE_HASH_BITS)];
hlist_for_each_entry(tr, head_ip, hlist_ip) {
if (tr->ip == ip)
goto out;
}
tr = NULL;
out:
mutex_unlock(&trampoline_mutex);
return tr;
}
unlock 뒤 raw pointer 반환, 그리고 호출자에서 이어지는 dereference. 커널 코드에서 이런 모양은 늘 의심부터 하게 된다. 나도 처음에는 그 반응이 과하다고 보지 않았다.
분석 범위와 가정
이 글의 범위는 아래로 제한한다.
- 분석 대상:
sources/linux/kernel/bpf/trampoline.c,sources/linux/kernel/trace/ftrace.c - 소스 기준:
sources/linux트리의v7.0.0-rc6, commit9147566d8016 - arch:
x86_64기준 - config:
CONFIG_DYNAMIC_FTRACE_WITH_DIRECT_CALLS=yCONFIG_HAVE_SINGLE_FTRACE_DIRECT_OPS=y경로를 주 타깃으로 봄CONFIG_HAVE_SINGLE_FTRACE_DIRECT_OPS=n경로는 대조군으로 함께 봄
- 제외 범위:
- 다른 arch의 ftrace 세부 구현
- 동적 실험 결과
- 메일링리스트 토론이나 패치 히스토리 전수 확인
즉 이 글의 결론은 어디까지나 현재 저장소에 포함된 소스와 그 호출 경로를 기준으로 한 정적 분석 결과다.
관련 코드와 호출 맥락
이번 의혹을 판단할 때 중요한 함수는 많지 않다. 여섯 개 정도만 잡아도 흐름이 보인다.
| 함수 | 역할 | 이번 글에서 중요한 점 |
|---|---|---|
direct_ops_ip_lookup() | ip로 trampoline 검색 | trampoline_mutex 아래에서 hash lookup 후 unlock 뒤 반환 |
bpf_tramp_ftrace_ops_func() | direct ftrace callback | 반환된 tr을 바로 소비하는 창구 |
prepare_direct_functions_for_ipmodify() | IPMODIFY와 DIRECT 충돌 조정 | direct_mutex 보유 상태에서 ops_func 호출 |
cleanup_direct_functions_after_ipmodify() | unregister 후 정리 | 이쪽도 direct_mutex 아래에서 ops_func 호출 |
bpf_trampoline_put() | 마지막 ref를 반납하고 조건부 free | refcount == 0, progs_hlist 비움, hash 제거 뒤 free |
direct_ops_free() | direct ftrace 자원 정리 | 실제 free 직전 단계에서만 호출 |
호출 맥락을 아주 거칠게 줄이면 이렇다.
- ftrace 쪽이 direct/IPMODIFY 충돌을 조정한다.
- 그 과정에서
ops->ops_func()로bpf_tramp_ftrace_ops_func()를 호출한다. - callback 안에서
direct_ops_ip_lookup()로tr을 찾는다. - 이어서
mutex_trylock(&tr->mutex)로 trampoline 상태 조정을 시도한다. - 필요하면
bpf_trampoline_update()까지 이어진다.
짧은 call flow로 적으면 다음과 같다.
prepare_direct_functions_for_ipmodify()
or cleanup_direct_functions_after_ipmodify()
-> op->ops_func(...)
-> bpf_tramp_ftrace_ops_func()
-> direct_ops_ip_lookup()
-> mutex_trylock(&tr->mutex)
-> bpf_trampoline_update(...)
핵심은 이 callback이 그냥 아무 문맥 없이 호출되는 것이 아니라는 점이다. prepare_direct_functions_for_ipmodify()와 cleanup_direct_functions_after_ipmodify()는 둘 다 direct_mutex 문맥에서 ops_func를 호출한다.
이 점은 trampoline.c 안의 주석에도 드러난다.
/* The normal locking order is
* tr->mutex => direct_mutex (ftrace.c) => ftrace_lock (ftrace.c)
*
* The following two commands are called from
*
* prepare_direct_functions_for_ipmodify
* cleanup_direct_functions_after_ipmodify
*
* In both cases, direct_mutex is already locked. Use
* mutex_trylock(&tr->mutex) to avoid deadlock in race condition
* (something else is making changes to this same trampoline).
*/
이 주석 하나만으로 결론을 내려서는 안 되지만, 적어도 이 코드가 어떤 락 전제 위에서 짜였는지는 분명히 말해 준다.
왜 버그처럼 보였는지
이번 의혹이 설득력 있어 보였던 이유도 분명하다.
첫째, direct_ops_ip_lookup()는 refcount를 올리지 않는다. bpf_trampoline_lookup()처럼 owning reference를 주는 함수가 아니라, 그냥 pointer를 찾아 돌려주는 함수처럼 보인다.
둘째, unlock 뒤 반환된 포인터를 호출자가 곧바로 쓴다. 이 모양만 떼어 놓고 보면 lookup -> unlock -> dereference라는 아주 전형적인 UAF 도식이다.
셋째, free 경로도 눈에 띈다. bpf_trampoline_put()은 최종적으로 kfree(tr)까지 가는 함수라서, 표면적으로는 "lookup 직후 다른 CPU가 put해서 free"라는 그림을 쉽게 떠올리게 만든다.
넷째, mutex_trylock(&tr->mutex)도 처음 보면 수상하다. race를 피하려고 급히 넣은 장치처럼 읽히기 쉽다.
다섯째, 서로 다른 두 AI가 같은 결론을 냈다는 사실 자체도 착시를 강화한다. 독립적인 분석 둘이 같은 지점을 짚으면, 사람도 그 결론을 더 빨리 믿게 된다.
결국 이 의혹은 코드 몇 줄만 보면 충분히 그럴듯하다. 문제는 그 몇 줄이 실제 lifetime 보장의 전부가 아니라는 데 있다.
검증 전략
이번에는 동적 실험 대신 정적 분석을 더 촘촘하게 묶는 쪽으로 갔다.
direct_ops_ip_lookup()의 lookup 범위를 확인했다.bpf_tramp_ftrace_ops_func()가 어떤 상위 경로에서 호출되는지 따라갔다.trampoline_mutex,tr->mutex,direct_mutex,ftrace_lock의 역할과 순서를 분리해서 봤다.bpf_trampoline_lookup()와direct_ops_ip_lookup()의 참조 의미를 비교했다.bpf_trampoline_put()이 실제 free까지 가는 선행 조건을 정리했다.- 가장 공격적인 interleaving을 텍스트로 세워 놓고, 어느 지점에서 경합이 닫히는지 확인했다.
CONFIG_HAVE_SINGLE_FTRACE_DIRECT_OPS=n경로를 대조군으로 같이 봤다.
실험이 빠져 있는 만큼, 표현도 더 조심해야 한다. 이 글에서 말하는 것은 "정적 분석 범위에서는 False Positive로 보인다"이지, "이제 모든 환경에서 완전히 증명됐다"는 아니다.
판단 근거
이 글에서 실제로 닫아야 하는 질문은 넷이다.
tr은 언제 free 가능 상태가 되는가.direct_ops_ip_lookup()이 돌려준 포인터는 어떤 종류의 참조인가.- unlock 뒤 소비 구간을 어떤 락과 ordering이 닫아 주는가.
- 가장 공격적인 interleaving을 세워도 정말 UAF로 닫히는가.
아래는 그 질문 순서대로 정리한 판단 근거다.
1. 객체 수명: bpf_trampoline_put()은 refcount 하나만으로 free하지 않는다
LLM 보고서가 가장 크게 단순화한 지점은 여기라고 본다.
bpf_trampoline_put()이 실제 free까지 가는 순서는 생각보다 강하다.
trampoline_mutex를 잡는다.refcount_dec_and_test()가 true여야 한다.tr->mutex가 잡혀 있지 않아야 한다.- 모든
progs_hlist[i]가 비어 있어야 한다. - 그 다음에야
hlist_del(&tr->hlist_key)와hlist_del(&tr->hlist_ip)를 한다. - 마지막으로
direct_ops_free(tr)와kfree(tr)가 이어진다.
즉 free는 단순히 "다른 CPU가 put 하나 호출"로 곧바로 닫히는 연산이 아니다. 마지막 put이 의미를 가지려면 그 전에 이미 trampoline이 teardown 가능한 상태여야 한다.
여기서 먼저 하나의 불변식을 뽑을 수 있다.
tr이 free되려면 반드시refcount == 0, 모든progs_hlist비움, hash 제거가 선행되어야 한다.
2. 메모리 lifetime / ownership: bpf_trampoline_lookup()과 direct_ops_ip_lookup()은 같은 종류의 반환이 아니다
먼저 여기부터 갈라야 한다.
bpf_trampoline_lookup()은 hit 시 refcount_inc(&tr->refcnt)를 수행한다. 이 함수가 돌려주는 것은 명시적인 owning reference다. 반면 direct_ops_ip_lookup()은 refcount를 올리지 않는다. 이 함수가 돌려주는 값은 장기 보관용 참조가 아니라 callback 안에서 짧게 소비되는 borrowed pointer로 읽는 편이 맞다.
이 차이를 놓치면 금방 이런 오해로 간다.
- refcount를 안 올렸으니 수명 보장이 전혀 없다
- 그러니 unlock 뒤 dereference는 곧바로 UAF다
하지만 커널 코드에서는 refcount가 없는 pointer가 곧바로 잘못된 pointer를 뜻하지 않는다. borrowed pointer의 생존은 다른 ordering이 닫아 주는 경우가 있다. 이번 코드가 그쪽에 가깝다.
3. 상태 전이: lookup 가능 상태와 free 가능 상태는 겹쳐 보이지만, 실제로는 순서가 있다
direct_ops_ip_lookup()이 tr을 찾았다는 것은 그 시점에 tr이 아직 trampoline_ip_table에 남아 있다는 뜻이다. 반대로 bpf_trampoline_put()이 실제 free를 하려면 먼저 hlist_del(&tr->hlist_ip)를 끝내야 한다.
그래서 최소한 다음 문장은 성립한다.
direct_ops_ip_lookup()이tr을 반환한 순간, 그 CPU가 본 상태에서는 아직hlist_del(&tr->hlist_ip)이전이다.
이 문장만으로 unlock 뒤 전체 구간의 안전이 자동으로 보장되지는 않는다. 다만 free가 되려면 먼저 lookup 가능 상태를 벗어나야 한다는 ordering 제약은 분명히 생긴다.
같은 뜻을 더 조심스럽게 적으면 이렇다.
hash membership은 안전의 충분조건이 아니라, free 이전 단계를 보여 주는 강한 필요조건이다.
4. 락/동기화: unlock 뒤 소비 구간은 direct_mutex 문맥 안에서 일어난다
이번 의혹을 단순한 UAF로 보기 어려운 결정적인 이유는 호출 문맥이다.
prepare_direct_functions_for_ipmodify()는 direct_mutex가 잡혀 있음을 lockdep_assert_held_once(&direct_mutex)로 강제한 뒤, 필요한 direct ops에 대해 op->ops_func(..., FTRACE_OPS_CMD_ENABLE_SHARE_IPMODIFY_PEER)를 호출한다. cleanup_direct_functions_after_ipmodify()도 내부에서 mutex_lock(&direct_mutex)를 한 뒤 FTRACE_OPS_CMD_DISABLE_SHARE_IPMODIFY_PEER callback을 보낸다.
즉 UAF 의혹과 직접 맞닿아 있는 PEER callback은 trampoline_mutex만 풀린 무방비 구간에서 실행되는 것이 아니라, 이미 direct_mutex ordering 안에 들어와 있다.
이 사실은 bpf_trampoline_update(tr, false) 호출과도 맞물린다. callback 안에서는 이미 direct_mutex가 잡혀 있으니, update 경로가 그 락을 다시 잡지 않도록 false를 넘긴다. 이 설계는 우연이 아니라 호출 문맥을 전제로 한 것이다.
이 대목에서 두 번째 불변식을 적을 수 있다.
문제의 PEER callback은
trampoline_mutex만 풀린 무방비 구간이 아니라,direct_mutexordering 안에서 실행된다.
5. 락/동기화 보강: mutex_trylock(&tr->mutex)은 UAF 흔적이 아니라 deadlock 회피 장치다
처음에는 이 줄이 가장 수상해 보였다.
if (!mutex_trylock(&tr->mutex)) {
msleep(1);
return -EAGAIN;
}
하지만 바로 위 주석을 함께 읽으면 의미가 달라진다. 정상 순서는 tr->mutex => direct_mutex => ftrace_lock인데, callback 쪽은 이미 direct_mutex를 쥔 상태에서 들어온다. 여기서 평범하게 mutex_lock(&tr->mutex)를 해버리면 락 순서 역전이 생길 수 있다. 그래서 trylock으로 확인하고, 실패하면 짧게 물러나 -EAGAIN으로 다시 시도하게 만든다.
즉 이 줄은 race를 감추는 흔적이 아니라, 이미 알려진 lock ordering 안에서 deadlock을 피하려는 장치로 읽는 편이 자연스럽다.
6. config 대조군: CONFIG_HAVE_SINGLE_FTRACE_DIRECT_OPS=n 경로는 좋은 대조군이다
문제가 된 ip hash lookup 경로는 CONFIG_HAVE_SINGLE_FTRACE_DIRECT_OPS=y에서만 등장한다. 이 설정이 꺼지면 direct_ops_ip_lookup()은 그냥 ops->private을 반환한다.
이 차이는 중요하다. 이번 의혹이 보편적인 "unlock 후 포인터 반환" 문제라기보다, 전역 singleton direct_ops 설계를 표면만 보고 읽었을 때 생기는 착시에 가깝다는 뜻이기 때문이다.
경쟁 시나리오를 놓고 다시 보기
가장 공격적으로 보면 이런 그림이다.
| 시점 | CPU0 | CPU1 | 해석 |
|---|---|---|---|
| 1 | bpf_tramp_ftrace_ops_func() 진입 | CPU0는 이미 direct_mutex 문맥 안에 있다 | |
| 2 | direct_ops_ip_lookup()로 tr 발견 후 trampoline_mutex unlock | 이 시점의 tr은 아직 ip hash에 남아 있다 | |
| 3 | 같은 tr을 teardown해서 마지막 put까지 가려 함 | teardown 준비 자체가 tr->mutex, direct_mutex, update 경로를 거쳐야 한다 | |
| 4 | mutex_trylock(&tr->mutex) 시도, 성공하면 상태 조정, 실패하면 -EAGAIN | 이미 같은 trampoline을 다른 쪽이 만지는 상황이면 CPU0가 물러난다 | |
| 5 | callback 종료 | 이후에야 teardown이 더 진행될 수 있음 | "lookup 직후 곧바로 free"라는 단순 도식이 그대로 닫히지 않는다 |
이 표의 요지는 단순하다. CPU1이 정말로 kfree(tr)까지 가려면, 그 전에 trampoline이 이미 teardown 가능한 상태가 되어야 한다. 그런데 그 준비 단계 자체가 tr->mutex와 direct_mutex ordering에 묶여 있다. CPU0는 바로 그 ordering 안에서 callback을 실행 중이다.
그래서 이번 포인터는 owning reference는 아니지만, 그렇다고 아무 보장도 없는 raw pointer도 아니다. callback 문맥과 teardown ordering이 수명을 짧게 붙들어 두는 borrowed pointer에 가깝다.
이 대목을 한 문장으로 줄이면 이번 글의 핵심 불변식은 다음이다.
direct_ops_ip_lookup()이tr을 찾았고, 그 포인터가bpf_tramp_ftrace_ops_func()안에서direct_mutex문맥으로 즉시 소비되는 동안에는, teardown ordering이 그 borrowed pointer의 생존을 보강한다.
실험/재현 결과
이번 글에는 reproducer, KASAN, lockdep 같은 동적 검증 결과를 넣지 않았다. 이 공백은 분명히 남는다.
그래서 이 글의 문장도 일부러 세게 쓰지 않는다. 여기서 말하는 것은 다음 정도다.
- 현재 저장소의 코드 경로를 따라간 정적 분석 범위에서는 False Positive로 보인다.
- 다만 동적 검증이 빠져 있으므로, 결론의 확신 수준은 높게 두더라도 주장 범위는 좁게 적는 편이 맞다.
그래서 이 섹션은 결과 보고가 아니라, 아직 비어 있는 검증 칸을 명시하는 용도로 남겨 둔다. 후속으로 붙일 만한 실험은 이미 뚜렷하다.
- attach/detach를 강하게 반복하는 stress 경로에서 KASAN 확인
- direct/IPMODIFY enable/disable 충돌 경로에서 lockdep 확인
- 마지막 put 직전 지점에 지연을 넣어 interleaving을 흔드는 재현기 작성
실험이 들어오면 글은 더 단단해질 것이다. 다만 지금 단계에서도 정적 근거만으로 not-a-bug 판단의 뼈대는 이미 보인다고 본다.
반론 처리
반론 1. lookup 뒤 unlock이면 여전히 UAF 아닌가
표면만 보면 그렇다. 하지만 이 경우에는 hash membership -> teardown 준비 -> hash 제거 -> free라는 순서가 있고, callback 소비 구간도 direct_mutex 문맥 안에 있다. 그래서 일반적인 "unlock 뒤 raw pointer"와 같은 강도로 보기는 어렵다.
반론 2. hash에 남아 있다는 것만으로 안전하다고 말할 수 있나
그렇게까지는 말하지 않겠다. hash membership 하나만으로 충분하다고 쓰면 과장이다. 더 정확한 표현은 이쪽이다. hash membership은 free 이전 단계를 보여 주는 강한 필요조건이고, unlock 이후 짧은 구간은 direct_mutex를 포함한 teardown ordering이 함께 닫아 준다.
반론 3. direct_mutex 보유가 모든 경로에서 항상 보장되나
이번 글에서 직접 확인한 핵심 peer callback 경로는 그렇다. 다만 다른 arch의 direct ftrace 세부 구현이나 내가 아직 보지 못한 주변 경로까지 전부 같은 성질이라고 단정할 단계는 아니다. 그래서 분석 범위를 x86_64와 현재 소스 트리로 제한한다.
반론 4. mutex_trylock()은 실제로 race 회피용 아닌가
코드 주석과 락 순서를 같이 보면 deadlock 회피로 읽는 편이 맞다. race가 아예 없다는 뜻은 아니지만, 적어도 이 줄 하나를 UAF의 자백처럼 읽는 것은 과하다.
반론 5. config가 다르면 이야기 자체가 달라지지 않나
맞다. 그래서 CONFIG_HAVE_SINGLE_FTRACE_DIRECT_OPS=n 경로를 대조군으로 같이 봤다. 이 경우 direct_ops_ip_lookup()은 ops->private을 반환하고, 지금 문제가 된 ip hash lookup 구조 자체가 등장하지 않는다. 이번 의혹은 특정 config 설계를 보편적인 UAF 패턴으로 오독한 쪽에 더 가깝다.
착시의 구조
이번 사례가 흥미로운 이유는 단순히 "AI가 틀렸다"에서 끝나지 않기 때문이다. 왜 틀리기 쉬운지도 꽤 선명하게 보인다.
mutex를 풀고 pointer를 반환하는 코드는 커널에서 늘 불안하게 읽힌다. 실제로 위험한 경우도 많다. 다만 이 패턴은 그 모양 자체만으로 bug 여부가 정해지지 않는다. 안전과 위험을 가르는 것은 대개 그 바깥의 lifetime 메커니즘이다.
이번 사례를 읽으며 남긴 기준은 두 가지다.
- 반환된 객체가 free되려면, 먼저 lookup 가능 상태를 벗어나야 하는가.
- 반환된 포인터가 owning reference가 아니더라도, 상위 호출 문맥의 lock과 ordering이 unlock 이후 소비 구간을 닫아 주는가.
두 질문에 모두 그렇다고 답할 수 있으면, unlock 뒤 pointer 반환은 곧바로 UAF가 아니다. 반대로 둘 중 하나라도 비면 실제 bug 가능성이 커진다.
결론
현재 확인한 범위에서는 direct_ops_ip_lookup() 관련 UAF 의혹을 False Positive로 판단한다. 확신 수준은 높게 보지만, 정적 분석 범위 안에서만 그렇게 적는다.
핵심 이유는 세 가지다.
direct_ops_ip_lookup()이 돌려주는 값은 owning reference가 아니라 callback 문맥 안에서 짧게 소비되는 borrowed pointer다.bpf_trampoline_put()이 실제 free까지 가려면 refcount, program unlink, hash 제거가 모두 선행돼야 한다.- UAF 의혹과 직접 맞닿아 있는 PEER callback은
direct_mutex문맥 안에서 실행되고, teardown도 같은 ordering에 묶여 있어lookup 직후 free라는 단순 interleaving이 그대로 성립하지 않는다.
다만 이 결론은 정적 분석 범위에 한정한다. 동적 실험과 다른 arch 검증이 들어오면 글은 더 단단해질 것이고, 그 전까지는 확신 수준을 높게 두더라도 표현은 조심하는 편이 맞다.
남은 리스크와 후속 조치
x86_64밖의 direct ftrace 세부 구현은 아직 보지 않았다.- 동적 실험이 없으므로 최종 증거 꾸러미는 아직 덜 완성됐다.
- 필요하다면
trampoline.c주석을 보강하는 작은 문서화 패치를 따로 검토할 수 있다. - 같은 착시를 만드는 다른
unlock -> borrowed pointer패턴도 추가로 찾아볼 만하다.