Skip to content

Latest commit

 

History

History
344 lines (286 loc) · 20.9 KB

File metadata and controls

344 lines (286 loc) · 20.9 KB

KVM shadow paging의 GFN 재검증 누락으로 인한 Use-After-Free (CVE-2026-46113)

메타데이터

항목 내용
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.ckvm_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가 발생한다.

사전 지식

KVM은 왜 "shadow" 페이지 테이블을 만드는가

가상머신 안의 게스트 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.hFNAME(fetch)arch/x86/kvm/mmu/mmu.c에 있다.

kvm_mmu_page와 direct / indirect 구분

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가 만들어질 당시의 게스트 매핑이 이후에도 그대로라는 전제가 없으면 이 계산은 틀릴 수 있다.

rmap: GFN에서 SPTE로 가는 역방향 인덱스

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가 대표하는 매핑이 생성 이후 바뀌지 않았다"는 가정에서만 나온다.

shadow page 재사용과 kvm_mmu_get_child_sp()

게스트 페이지 테이블을 워크하며 다음 레벨로 내려갈 때, KVM은 현재 SPTE가 이미 유효한 하위 shadow page를 가리키고 있으면 새로 만들지 않고 그 shadow page를 재사용한다. 이 판단을 하는 함수가 kvm_mmu_get_child_sp()이고, 재사용하지 않기로 하면 __link_shadow_page()가 새 shadow page를 SPTE에 연결한다.

취약점 분석

패치 전: 존재 여부만 보고 재사용을 결정한 kvm_mmu_get_child_sp()

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 설명만으로 확인되는 메모리 수명 위반까지만 단정하며, 실제 안정적인 익스플로잇 가능성은 별도 검증이 필요하다.

수정 방법

패치는 두 지점을 함께 고친다.

1) kvm_mmu_get_child_sp()에 GFN 재검증 추가

 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를 돌려준다.

2) __link_shadow_page()가 stale child를 안전하게 정리하도록 일반화

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 관련 언급" 섹션은 두지 않는다.

참고 자료