| 항목 | 내용 |
|---|---|
| CVE ID | CVE-2026-46113 |
| 영향 소프트웨어 | Linux kernel KVM/x86 shadow MMU |
| 영향 버전 | kernel.org CNA 기준 2.6.20 이후, 수정 커밋 반영 전 (6.1.175/6.6.140/6.12.88/6.18.30/7.0.7/7.1 미만) |
| CVSS v3.1 | 8.8 (High) — AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H (kernel.org CNA) |
| 공개일 | 2026-05-28 (NVD) |
| 취약점 유형 | CWE-416 Use-After-Free; stale reverse-mapping(rmap) 항목이 해제된 kvm_mmu_page의 SPTE를 가리킴 |
| 수정 커밋 | 0cb2af2ea66ad8ff195c156ea690f11216285bdf (mainline), stable 백포트 다수 |
| 수정 파일 | arch/x86/kvm/mmu/mmu.c의 kvm_mmu_get_child_sp() / __link_shadow_page() |
KVM/x86의 shadow MMU는 게스트 페이지 테이블 워크 결과를 호스트 커널 안의 kvm_mmu_page
트리로 미러링한다. 게스트가 2MiB huge-page를 매핑하면 KVM은 그 영역을 4KiB 단위 512개의
SPTE로 쪼갠 "direct" shadow page를 만들고, 각 엔트리의 GFN(Guest Frame Number)을 매번
저장하지 않고 sp->gfn + index 공식으로 계산한다. 이 공식은 "이 shadow page가 대표하는
게스트 매핑이 처음 만들어졌을 때와 똑같이 유지된다"는 가정 위에 서 있다.
CVE-2026-46113은 이 가정이 깨지는 경로를 다룬다. 게스트 페이지 테이블(PDE)이 VM 진입
사이에 외부 요인으로 바뀌면, 기존 shadow page를 채우는 SPTE 워크 코드(kvm_mmu_get_child_sp)
가 GFN을 전혀 재검증하지 않고 이전 shadow page를 그대로 재사용한다. 이후 실제로 설치되는
leaf SPTE는 (바뀐 매핑을 반영한) 올바른 GFN을 쓰지만, 그 SPTE를 나중에 정리할 때는 여전히
sp->gfn + index 공식으로 GFN을 계산하므로 서로 다른 GFN을 가리키게 된다. 그 결과 삭제되지
않은 rmap 항목이 해제된 kvm_mmu_page 안의 SPTE 주소를 계속 가리키게 되고, dirty logging이나
MMU notifier가 그 rmap을 나중에 순회하면 해제된 메모리를 역참조하는 Use-After-Free가 발생한다.
가상머신 안의 게스트 OS는 자신만의 페이지 테이블로 가상 주소(GVA)를 게스트 물리
주소(GPA)로 변환한다. 하드웨어가 2단계 주소 변환(Intel EPT, AMD NPT)을 지원하면 CPU가
GPA→HPA 변환을 직접 처리하지만, 이 기능이 없거나 비활성화된 경우(오래된 CPU, 중첩
가상화의 내부 계층 등) KVM은 게스트 페이지 테이블을 직접 해석해서 GVA→HPA로 바로 가는
"shadow page table"을 소프트웨어로 구성해야 한다. 이 소프트웨어 워크 로직을 shadow paging이라
부르며, 관련 코드는 arch/x86/kvm/mmu/paging_tmpl.h의 FNAME(fetch)와 arch/x86/kvm/mmu/mmu.c에
있다.
shadow page table의 한 레벨(예: PDE 레벨)은 kvm_mmu_page 구조체 하나로 표현된다. 이
구조체는 다음 정보를 갖는다.
gfn: 이 shadow page가 대표하는 게스트 프레임의 시작 번호role.direct: 하위 512개 엔트리의 GFN이gfn + index공식으로 규칙적으로 계산되는지 여부shadowed_translation:direct == 0일 때만 쓰이는 배열로, 엔트리별 실제 GFN·접근 권한을 개별 저장한다
게스트가 2MiB 페이지처럼 연속된 huge mapping을 쓰면, 그 영역을 4KiB 단위로 쪼갠 shadow
page는 "direct"로 표시된다. 512개 엔트리의 GFN이 전부 sp->gfn에서부터 순서대로 이어지기
때문에 굳이 엔트리마다 GFN을 저장할 필요가 없다는 최적화다. 실제 GFN 조회는 다음 헬퍼가
담당한다.
static gfn_t kvm_mmu_page_get_gfn(struct kvm_mmu_page *sp, int index)
{
if (sp->role.passthrough)
return sp->gfn;
if (sp->shadowed_translation)
return sp->shadowed_translation[index] >> PAGE_SHIFT;
return sp->gfn + (index << ((sp->role.level - 1) * SPTE_LEVEL_BITS));
}shadowed_translation이 없는(= direct) shadow page라면 마지막 줄, 즉 sp->gfn + index
계산만으로 GFN을 구한다. 이 함수는 "SPTE가 실제로 어떤 GFN을 가리키는지"를 저장된 값이
아니라 계산으로 추론한다 — direct shadow page가 만들어질 당시의 게스트 매핑이 이후에도
그대로라는 전제가 없으면 이 계산은 틀릴 수 있다.
KVM은 memslot을 삭제하거나 dirty logging/MMU notifier 무효화를 처리할 때 "이 GFN을 가리키는 SPTE가 어디 있는가"를 반대 방향으로 찾아야 한다. 매번 페이지 테이블 전체를 훑는 대신, GFN별로 그 GFN을 가리키는 SPTE 주소들의 연결 리스트(rmap)를 유지한다.
static void rmap_remove(struct kvm *kvm, u64 *spte)
{
struct kvm_mmu_page *sp = sptep_to_sp(spte);
gfn_t gfn = kvm_mmu_page_get_gfn(sp, spte_index(spte));
...
rmap_head = gfn_to_rmap(gfn, sp->role.level, slot);
pte_list_remove(kvm, spte, rmap_head);
}rmap_remove()가 어떤 rmap 리스트에서 이 SPTE를 지울지 결정할 때도 kvm_mmu_page_get_gfn()을
그대로 쓴다. 즉 SPTE를 설치할 때 쓴 GFN과, 나중에 그 SPTE를 rmap에서 지울 때 계산하는
GFN은 서로 다른 코드 경로에서 나온다. 설치 시점(leaf SPTE를 채우는 mmu_set_spte류
경로)은 그 순간의 실제 페이지 테이블 워크 결과(진짜 GFN)를 쓰고, 삭제 시점(rmap_remove)은
부모 sp->gfn + index 공식을 쓴다. 두 값이 같다는 보장은 "direct shadow page가 대표하는
매핑이 생성 이후 바뀌지 않았다"는 가정에서만 나온다.
게스트 페이지 테이블을 워크하며 다음 레벨로 내려갈 때, KVM은 현재 SPTE가 이미 유효한
하위 shadow page를 가리키고 있으면 새로 만들지 않고 그 shadow page를 재사용한다. 이
판단을 하는 함수가 kvm_mmu_get_child_sp()이고, 재사용하지 않기로 하면 __link_shadow_page()가
새 shadow page를 SPTE에 연결한다.
static struct kvm_mmu_page *kvm_mmu_get_child_sp(struct kvm_vcpu *vcpu,
u64 *sptep, gfn_t gfn, bool direct, unsigned int access)
{
union kvm_mmu_page_role role;
if (is_shadow_present_pte(*sptep) && !is_large_pte(*sptep))
return ERR_PTR(-EEXIST);
role = kvm_mmu_child_role(sptep, direct, access);
return kvm_mmu_get_shadow_page(vcpu, gfn, role);
}조건은 "이 SPTE가 이미 present이고, large(2MiB 이상 leaf)가 아니다"뿐이다. 요청받은
gfn 인자는 아예 비교에 쓰이지 않는다. 즉 이 SPTE가 가리키는 기존 child shadow page가
지금 워크 중인 게스트 주소와 실제로 같은 GFN을 대표하는지는 전혀 확인하지 않고, "이미
뭔가 채워져 있으니 그걸 쓰자"고 판단해 곧장 EEXIST를 반환한다.
__link_shadow_page()도 같은 가정을 코드 주석으로 명시하고 있었다.
/*
* If an SPTE is present already, it must be a leaf and therefore
* a large one. Drop it, and flush the TLB if needed, before
* installing sp.
*/
if (is_shadow_present_pte(*sptep))
drop_large_spte(kvm, sptep, flush);drop_large_spte() 내부에도 WARN_ON_ONCE(sp->role.level == PG_LEVEL_4K)가 있어, "이미
SPTE가 present라면 그건 huge-page가 나중에 쪼개지면서 남은 large leaf뿐"이라는 단일
시나리오만 가정하고 있었다. 즉 코드 전체가 "child shadow page의 GFN이 틀릴 수 있다"는
가능성 자체를 설계에 넣지 않았다.
커밋 메시지가 설명하는 재현 경로를 정리하면 다음과 같다.
┌────────────────────────────────────────────────────────────────────┐
│ 패치 전 KVM shadow MMU │
├────────────────────────────────────────────────────────────────────┤
│ 1. 게스트 PDE = 2MiB huge mapping (GFN 범위: G ~ G+511) │
│ → 접근 시 KVM이 direct shadow page S 생성 (S->gfn = G) │
│ → FNAME(fetch)가 huge page를 4KiB 512개로 쪼개며 S를 direct 표시 │
│ │
│ 2. 게스트 밖(다른 vCPU, 에뮬레이션 등)에서 이 PDE를 변경 │
│ → 이제 같은 GVA 범위는 더 이상 G~G+511에 연속 대응하지 않음 │
│ → 하지만 부모 SPTE는 여전히 S를 가리키고 있음 │
│ │
│ 3. 게스트가 같은 2MiB 영역 내 "다른" 주소를 접근 │
│ → kvm_mmu_get_child_sp(): is_shadow_present && !large → GFN 비교 │
│ 없이 곧장 EEXIST → 기존 S를 그대로 재사용 │
│ → 워크는 계속 진행되어 leaf SPTE를 설치 │
│ → leaf 설치 시점에는 "진짜" GFN(바뀐 PDE가 가리키는 값) 사용 │
│ → 이 GFN은 [G, G+511] 범위 밖 → rmap은 이 진짜 GFN 아래 기록됨 │
│ │
│ 4. 해당 2MiB 영역을 덮는 memslot 삭제 │
│ → 이제 무효가 된 GPA에 대해 S를 zap │
│ → 정리 루틴은 kvm_mmu_page_get_gfn(S, index) = S->gfn + index로 │
│ 각 엔트리 GFN을 "계산" → 진짜 GFN이 아니라 [G, G+511] 범위만 봄 │
│ → 3에서 만든 rmap 항목(실제 GFN 아래)을 찾지 못하고 지나침 │
│ → S는 해제되지만, 그 안의 SPTE 주소를 가리키는 rmap 항목은 잔존 │
│ │
│ 5. dirty logging 또는 MMU notifier(MADV_DONTNEED 등)가 │
│ 3에서 실제로 쓰인 GFN에 대해 rmap을 순회 │
│ → 잔존 rmap 항목이 가리키는, 이미 해제된 S 내부 SPTE를 역참조 │
│ → Use-After-Free │
└────────────────────────────────────────────────────────────────────┘
커밋 메시지는 "GFN이 다를 수 있다는 가정 자체는 KVM 최초 버전부터 없었다"고 설명한다.
다만 과거에는 direct shadow page도 엔트리별 GFN을 배열에 저장했기 때문에 sp->gfn + index
계산으로 인한 오차가 단순한 부정확함(imprecision)에 그쳤다. 이후 direct shadow page의
메모리 사용량을 줄이기 위해 shadowed_translation 배열 자체를 만들지 않고 계산으로
대체한 최적화(2032a93d66fa, "KVM: MMU: Don't allocate gfns page for direct mmu pages")가
들어오면서, 계산된 GFN과 실제 rmap에 기록된 GFN의 불일치가 곧바로 잘못된 rmap 리스트
접근으로 이어지게 됐다. 즉 이 UAF는 최적화가 기존의 암묵적 가정을 검증 없이 노출시킨
전형적인 사례다.
CWE-416 Use-After-Free로 분류되며, CVSS 벡터는 로컬 접근(AV:L)과 낮은 권한(PR:L)만
요구하고 Scope Changed(S:C)로 게스트 동작이 호스트 커널 상태를 오염시킬 수 있음을
반영한다. 가장 직접적인 결과는 호스트 커널 패닉을 통한 서비스 거부이며, 해제된 객체를
공격자가 원하는 내용으로 재점유(heap grooming)할 수 있는 경우 권한 상승·호스트 탈출로
이어질 가능성도 이론적으로 존재한다. 이 보고서는 소스와 패치, kernel.org CNA 설명만으로
확인되는 메모리 수명 위반까지만 단정하며, 실제 안정적인 익스플로잇 가능성은 별도 검증이
필요하다.
패치는 두 지점을 함께 고친다.
static struct kvm_mmu_page *kvm_mmu_get_child_sp(struct kvm_vcpu *vcpu,
u64 *sptep, gfn_t gfn, bool direct, unsigned int access)
{
union kvm_mmu_page_role role;
- if (is_shadow_present_pte(*sptep) && !is_large_pte(*sptep))
+ if (is_shadow_present_pte(*sptep) && !is_large_pte(*sptep) &&
+ spte_to_child_sp(*sptep) && spte_to_child_sp(*sptep)->gfn == gfn)
return ERR_PTR(-EEXIST);
role = kvm_mmu_child_role(sptep, direct, access);
return kvm_mmu_get_shadow_page(vcpu, gfn, role);
}이제 기존 child shadow page를 재사용하려면 GFN이 요청과 실제로 같아야 한다. GFN이
다르면 EEXIST를 반환하지 않고 그대로 통과해 kvm_mmu_get_shadow_page(vcpu, gfn, role)을
호출한다. 이 함수는 해시 테이블에서 (gfn, role)이 일치하는 shadow page를 찾고, 없으면
새로 만든다 — 결과적으로 stale한 기존 S 대신 올바른 GFN을 대표하는 새 shadow page를
돌려준다.
1번 변경만으로는 부족하다. kvm_mmu_get_shadow_page()가 새 shadow page를 돌려줘도, 부모
SPTE 슬롯에는 여전히 stale한 S가 연결되어 있다. 이를 새 shadow page로 교체하는
__link_shadow_page()는 기존에 "present SPTE는 항상 large leaf"라고 가정한
drop_large_spte()만 호출했는데, 이제는 present SPTE가 stale한 non-leaf child를
가리키는 경우도 생긴다.
- /*
- * If an SPTE is present already, it must be a leaf and therefore
- * a large one. Drop it, and flush the TLB if needed, before
- * installing sp.
- */
- if (is_shadow_present_pte(*sptep))
- drop_large_spte(kvm, sptep, flush);
+ if (is_shadow_present_pte(*sptep)) {
+ struct kvm_mmu_page *parent_sp;
+ LIST_HEAD(invalid_list);
+
+ parent_sp = sptep_to_sp(sptep);
+ WARN_ON_ONCE(parent_sp->role.level == PG_LEVEL_4K);
+
+ mmu_page_zap_pte(kvm, parent_sp, sptep, &invalid_list);
+ kvm_mmu_remote_flush_or_zap(kvm, &invalid_list, true);
+ }mmu_page_zap_pte()는 기존에 이미 존재하던 범용 정리 함수로, SPTE가 leaf든 non-leaf든
알맞게 처리한다.
static int mmu_page_zap_pte(struct kvm *kvm, struct kvm_mmu_page *sp,
u64 *spte, struct list_head *invalid_list)
{
u64 pte = *spte;
struct kvm_mmu_page *child;
if (is_shadow_present_pte(pte)) {
if (is_last_spte(pte, sp->role.level)) {
drop_spte(kvm, spte); /* leaf: rmap_remove 포함 */
} else {
child = spte_to_child_sp(pte);
drop_parent_pte(kvm, child, spte); /* non-leaf: child 연결 해제 */
...
}
}
return 0;
}drop_large_spte()는 "large leaf"라는 단일 케이스만 처리했지만, mmu_page_zap_pte()는
leaf/non-leaf를 실제로 분기해서 올바른 정리 경로(rmap 제거 또는 부모-자식 연결 해제)를
탄다. 이렇게 하면 stale child S가 새 shadow page로 교체되기 전에 확실히 rmap과 부모
포인터에서 제거된다.
┌──────────────────────────────────────────────────────────────────┐
│ 패치 후 shadow page 재사용/교체 │
├──────────────────────────────────────────────────────────────────┤
│ 워크 중 요청: gfn = G', role = {direct=0, ...} │
│ │ │
│ ▼ │
│ kvm_mmu_get_child_sp(): 기존 S->gfn(G) == G'? NO │
│ │ │
│ └─ EEXIST 반환 안 함 │
│ → kvm_mmu_get_shadow_page(G', role) │
│ → 새 shadow page S' 확보/생성 │
│ │ │
│ ▼ │
│ __link_shadow_page(parent_sptep, S'): │
│ parent_sptep가 이미 present(=S를 가리킴) │
│ → mmu_page_zap_pte(parent_sp, parent_sptep, ...) │
│ S가 non-leaf child면 drop_parent_pte로 연결 해제 │
│ S 내부 leaf SPTE는 각각 drop_spte → rmap_remove로 정리 │
│ → S에 대한 rmap이 이 시점에 전부 제거됨 │
│ parent_sptep에 S' 연결 │
│ │ │
│ ▼ │
│ memslot 삭제 시점에는 이미 S가 아니라 S'만 유효 │
│ → S'의 GFN 계산과 실제 rmap 기록이 일치 │
│ → 잔존 rmap 없음 → UAF 경로 차단 │
└──────────────────────────────────────────────────────────────────┘
운영자는 실행 중인 KVM 호스트의 커널이 위 수정 커밋 또는 배포판 보안 업데이트를 포함하는지 확인하고 커널을 업데이트한 뒤 재부팅해야 한다. NVD의 kernel.org CNA 데이터는 mainline 수정 커밋과 다수의 stable 커밋을 함께 참조하므로, 배포판별 고정 버전은 동일한 upstream 버전 번호가 아닐 수 있다. 신뢰하지 않는 게스트에 shadow paging 경로가 노출되는 환경(구형 CPU, 중첩 가상화 내부 계층 등)이라면 업데이트 전까지 해당 워크로드를 재검토할 수 있지만, 이는 공식 패치를 대체하지 않는다.
- NVD는 kernel.org CNA의 영향 범위를 Git 커밋 범위와 일부 stable 버전으로 제시한다. 실제 운영 커널이 영향을 받는지는 배포판의 backport 여부를 별도로 확인해야 한다.
- 이 취약점은 shadow paging(비-TDP) 경로에서만 발생한다. 최신 Intel/AMD CPU에서 EPT/NPT가 활성화된 일반적인 KVM 게스트는 이 경로를 거치지 않지만, 중첩 가상화의 L1 하이퍼바이저가 L2 게스트를 위해 shadow MMU를 쓰는 경우 등은 영향을 받을 수 있다. 정확히 어떤 설정 조합이 이 경로를 타는지는 이 보고서의 정적 분석 범위를 벗어난다.
- 공개된 실제 익스플로잇 재현 코드는 확인하지 못했다(공개 PoC 유무 자체가 이 보고서 조사 시점 기준 불명확). 따라서 이 문서는 소스·패치·1차 자료로 확인되는 메모리 수명 위반 경로까지만 다루며, 별도의 "PoC 관련 언급" 섹션은 두지 않는다.
- CVE-2026-46113 — NVD — kernel.org CNA 설명, CVSS, 영향 버전 범위
- KVM: x86: Fix shadow paging use-after-free due to unexpected GFN — torvalds/linux — mainline 수정 커밋 원문과 diff
- kernel.org stable 백포트 (6.1.y) — stable 트리 백포트 커밋
- CVE-2026-53359 — KVM shadow paging role UAF 분석 — 같은
kvm_mmu_get_child_sp()에 대해 이 커밋이 추가한 GFN 검사만으로는role불일치까지는 못 잡는다는 후속 수정 사례 (이 문서의 저장소 내 관련 리포트)