记一次x86 kvm虚机缺失 tlb flush 引发的 CVE 漏洞

文章目录

      • 1 背景介绍
      • 2 vcpu 延迟远程 tlb flush 机制及原理
      • 3 tlb flush 缺失及 CVE 漏洞触发

1 背景介绍

linux 5.5 版本以下才会触发,之后的版本已经修复该问题。

触发现象:
在x86 intel 芯片上的 kvm 虚机环境中:当有多个进程或者多个线程在频繁或者多次执行 malloc,write,read,free 操作时,有概率触发任务程序崩溃产生 core dump 或者程序 abort。

该问题触发对应 CVE-2019-3016,并且社区给出了对应的修复 patch:Merge branch ‘cve-2019-3016’ into kvm-next-5.6。
首先描述一下该 CVE 漏洞原文:

上述崩溃触发原因是缺少 TLB flush,这可能使运行 KVM 客户机中的进程访问到它不应该访问的客户机中的内存位置。
(解释一下上面的现象:对于一个正常程序来说,如果触发该问题,那么可能读取到的数据并不是原来程序所期望的数据,如原来内存中保存的是一个可访问的指针,在问题触发后,数据不再是可访问的指针,而是一个非法地址,当程序按照原有意图访问指针内容时,则有可能触发 data abort,导致程序被操作系统 kill,并产生 core dump。如果程序内部有一些 assert,比如 glibc 库中检测相关元数据,如果不是原有数据则可能调用 abort() 退出程序。
这是一些正常情况,如果该程序故意利用该漏洞,那么极有可能构建出特定环境,访问到其他程序使用的内存数据,从而执行一些恶意操作,因此该问题被定义为 CVE 漏洞)
这个问题仅限于 host 内核 4.10 以后版本,客户机运行 4.16 以后内核,并且客户机启用半虚拟化支持。该问题主要影响 AMD 处理器,但不能排除 intel cpu 也有该问题(因为从触发原理上来看,intel 也会触发)。

再介绍一下修复补丁的内容:

当远程 vcpu (这里指从当前 vcpu 角度来看,处于其他物理 cpu 的 vcpu 被称为远程 vcpu)不运行时,KVM 管理程序可以为客户机提供延迟远程 tlb flush 能力(该能力用于提高vcpu 运行性能)。当使用此特性时,tlb flush 只会在远程 vcpu 计划再次运行(再次运行指调度到该 vcpu 运行)时发生,这操作可以避免不必要的和昂贵的 ipi 操作(提供性能的关键就是避免 ipi 操作,后续介绍原理)。
在某些情况下,当客户机发起远程延迟 tlb flush 操作时,管理机可能会错过该请求。也有可能客户机错误地认为它已经将远程 vcpu 标记为需要刷新,而实际上该请求已经被管理机意外清除。
在这两种情况下,这都将导致 vcpu 中出现无效的 tlb 转换,可能允许访问客户机地址空间中不应该访问的内存位置。

上面的描述可能很抽象,接下来先详细了解下什么是延迟远程 tlb flush 操作,以及何时会执行远程 tlb flush 操作。

2 vcpu 延迟远程 tlb flush 机制及原理

TLB 全称是 Translation Lookaside Buffer。在开启 mmu 处理器上,当处理器取指或者访问内存时都需要进行地址翻译,即把虚拟地址翻译成物理地址。而翻译过程对于 cpu 来说是一个漫长的过程,需要经历从 pgd 到几个 level 的 translation table,最终找到实际的物理页,从而产生性能开销。为了提升性能,mmu 新增了 tlb 单元,同 cache 类似,tlb 把翻译后缓存条目保存在高速缓存中,当访问地址时,首先从 tlb cache 中寻找翻译后的条目使用,从而避免了翻译过程。当程序修改了地址映射关系,访问属性等页表时,则对应的 tlb 条目需要刷新或者无效化,以便后续能正确访问更新后的映射关系。

tlb 一致性问题:
tlb 也是一种 cache,因此当在多处理器系统中,tlb 条目的副本可能缓存在不同处理器的缓存中,因此存在一致性问题,一般而言处理器不维护该一致性(尤其是 x86,arm64 可以通过 inner share 属性来保证 tlb 缓存在不同处理器之间的一致性)。
因此一旦修改了页表属性或者映射关系,需要操作系统将所有使用该页表的 cpu 对应的 tlb cache 进行 tlb flush 操作,使其缓存 tlb 无效化,并在再次访问修改后的地址时重新进行地址翻译工作。

介绍了上面两个概念,接下来看看操作系统是如何来执行 tlb flush 的。针对该问题,我们不讨论内核自身的 tlb flush,主要讨论用户程序对内核发起的相关请求。

当用户程序执行 malloc,free,brk,mmap。munmap,mprotoct等申请内存,释放内存,修改映射属性等操作时将会触发 tlb flush 路径。接下来以 munmap 系统调用为例:
当用户程序使用 free 释放内存时,对于 glibc 在一定条件下会执行 munmap 释放一大块内存(这里不展开),最终会通过 sys_munmap 系统调用接触相关内存映射,arm64/x86 有如下路径:

SYSCALL_DEFINE2(munmap, unsigned long, addr, size_t, len)
  -> do_munmap
    -> unmap_region
      -> unmap_vmas
      -> free_pgtables
      -> tlb_finish_mmu
        -> arch_tlb_finish_mmu(tlb, start, end, force);
          -> tlb_flush_mmu
            -> tlb_flush_mmu_tlbonly
              -> tlb_flush
                -> __flush_tlb_range (arm64)
                -> flush_tlb_mm_range (x86)

可以看到相关任务在解除某段地址映射最后会调用 tlb_flush 来刷新相关缓存 tlb 以此来保证一致性。
对于 arm64 有如下代码:

static inline void __flush_tlb_range(struct vm_area_struct *vma,
				     unsigned long start, unsigned long end,
				     unsigned long stride, bool last_level)
{
...
  	start = __TLBI_VADDR(start, asid);
	end = __TLBI_VADDR(end, asid);

	dsb(ishst);
	for (addr = start; addr < end; addr += stride) {
		if (last_level) {
			__tlbi(vale1is, addr);
			__tlbi_user(vale1is, addr);
		} else {
			__tlbi(vae1is, addr);
			__tlbi_user(vae1is, addr);
		}
	}
	dsb(ish);
}

#define __TLBI_0(op, arg) asm ("tlbi " #op "\n"				       \
		   ALTERNATIVE("nop\n			nop",		       \
			       "dsb ish\n		tlbi " #op,	       \
			       ARM64_WORKAROUND_REPEAT_TLBI,		       \
			       CONFIG_QCOM_FALKOR_ERRATUM_1009)		       \
			    : : )

#define __TLBI_1(op, arg) asm ("tlbi " #op ", %0\n"			       \
		   ALTERNATIVE("nop\n			nop",		       \
			       "dsb ish\n		tlbi " #op ", %0",     \
			       ARM64_WORKAROUND_REPEAT_TLBI,		       \
			       CONFIG_QCOM_FALKOR_ERRATUM_1009)		       \
			    : : "r" (arg))

#define __TLBI_N(op, arg, n, ...) __TLBI_##n(op, arg)

#define __tlbi(op, ...)		__TLBI_N(op, ##__VA_ARGS__, 1, 0)

arm64 最终使用 tlbi op 来无效化相关 tlb 缓存,其中/opvale1is,is 代表 inner shareable,这里意思就是无效化内部可共享相关的所有 tlb 缓存。换句话说就是,不仅是本地 cpu 对应的 tlb 缓存需要无效化,其他 cpu 对应的只要是 inner shareable 属性页表也会有 tlb 无效化操作,这是由硬件来保证的。如果op不带is则只会无效化本地 cpu 的 tlb 缓存。

再来看一下对于一个 x86 的代码,x86的flush_tlb_mm_range分为 host os 和 guest os 实现:

void flush_tlb_mm_range(struct mm_struct *mm, unsigned long start,
				unsigned long end, unsigned long vmflag)
{
...
	if (mm == this_cpu_read(cpu_tlbstate.loaded_mm)) {
		VM_WARN_ON(irqs_disabled());
		local_irq_disable();
		flush_tlb_func_local(&info, TLB_LOCAL_MM_SHOOTDOWN);
		local_irq_enable();
	}

	if (cpumask_any_but(mm_cpumask(mm), cpu) < nr_cpu_ids)
		flush_tlb_others(mm_cpumask(mm), &info);
...
}

static inline void flush_tlb_others(const struct cpumask *cpumask,
				    const struct flush_tlb_info *info)
{
	PVOP_VCALL2(mmu.flush_tlb_others, cpumask, info);
}
对于 host os 和 guest os flush_tlb_others 实现是不一样的:
host os 如下:
mmu.flush_tlb_others = native_flush_tlb_others

而对于 guest os,有前面 steal time 机制分析可以知道,guest os 启动时可以知道自己是虚机运行,
因此可以执行额外的针对虚机的赋值操作,这里就会针对虚拟化类型 xen,kvm,虚拟化平台 intel,amd 进行初始化,
当检测到是 intel/amd kvm 并且支持 KVM_FEATURE_PV_TLB_FLUSH 特性时,有:
mmu.flush_tlb_others = kvm_flush_tlb_others

首先看看 host os tlb flush 对应的 native_flush_tlb_others 操作:

void native_flush_tlb_others(const struct cpumask *cpumask,
			     const struct flush_tlb_info *info)
{
...
...
	if (info->freed_tables)
		smp_call_function_many(cpumask, flush_tlb_func_remote,
			       (void *)info, 1);
	else
		on_each_cpu_cond_mask(tlb_is_not_lazy, flush_tlb_func_remote,
				(void *)info, 1, GFP_ATOMIC, cpumask);
}

flush_tlb_func_remote
	-> flush_tlb_func_common
static void flush_tlb_func_common(const struct flush_tlb_info *f,
				  bool local, enum tlb_flush_reason reason)
{
...
...
	if (f->end != TLB_FLUSH_ALL &&
...
	} else {
		/* Full flush. */
		local_flush_tlb();
		if (local)
			count_vm_tlb_event(NR_TLB_LOCAL_FLUSH_ALL);
		trace_tlb_flush(reason, TLB_FLUSH_ALL);
	}
...
}

可以看到 host os 的 tlb flush 操作在 x86 上会通过 on_each_cpu_cond_mask 发送 ipi 到该任务所有运行过的 cpu 上执行 local_flush_tlb。因此 x86 的一次 tlb flush 操作代价是很昂贵的因为它每执行一次 tlb 都需要向所有运行过的 cpu 发送 ipi,然后在远端执行 tlb flush,以此保证一致性。
对比 arm64 可以看到 arm64 在这里的实现更为强大,通过一个 tlbi vale1is 就可以完成相关 tlb 的刷新,少了很多软件操作。

回过头来看,也就马上知道为什么 x86 针对 host 和 guest 有不同的 tlb flush 实现了,因为 x86 针对 tlb flush 这种昂贵的 ipi 操作针对虚拟机运行可以进行一个特殊优化,该优化机制就是延迟远程 tlb 刷新操作

在之前关于 kvm 的 vcpu entry 分析时知道,vcpu 在进入 guest os 运行时会有一个加载 vcpu 上下文的操作,包括加载 guest 寄存器内容,注入中断,检查一些额外状态,steal time 更新等。
利用该机制,那么就可以针对 tlb flush 有一个额外优化。
当guest os 中发生 tlb flush 后,除了刷新本地 tlb,还需要刷新其他 cpu 的tlb,对于该任务使用过的其他 vcpu对应的物理cpu,如果正在运行 vcpu,那么没有选择,还是需要发送 ipi 使其同步执行 tlb flush 操作,然而对于该任务对应的其他 vcpu 如果没有处于运行状态,那么此时发送 ipi 时没有必要的,我们只需要在其对应使用的数据结构中标记需要执行 tlb flush 即可,等到该 vcpu 即将运行,在 vcpu 预备阶段我们可以读取该请求,并主动去执行 tlb flush 操作即可,这样可以避免一次远程 vcpu 的 ipi 操作,也可以在vcpu运行时刷新 tlb 保证一致性,一举两得,可以看到大佬为了优化提升性能真的用心良苦,当然该机制也就被称为了延迟远程 tlb 刷新。OK,现在看看虚拟化的 tlb flush怎么实现的:

static void kvm_flush_tlb_others(const struct cpumask *cpumask,
			const struct flush_tlb_info *info)
{
	u8 state;
	int cpu;
	struct kvm_steal_time *src;
	struct cpumask *flushmask = this_cpu_cpumask_var_ptr(__pv_tlb_mask);

	// cpumask 记录了该任务所运行过的所有 cpu
	cpumask_copy(flushmask, cpumask);
	/*
	 * We have to call flush only on online vCPUs. And
	 * queue flush_on_enter for pre-empted vCPUs
	 */
	for_each_cpu(cpu, flushmask) {
		src = &per_cpu(steal_time, cpu);
		state = READ_ONCE(src->preempted);
		if ((state & KVM_VCPU_PREEMPTED)) {
			if (try_cmpxchg(&src->preempted, &state,
					state | KVM_VCPU_FLUSH_TLB))
				__cpumask_clear_cpu(cpu, flushmask);
		}
	}

	native_flush_tlb_others(flushmask, info);
}

首先,本地 cpu 还是会在最后执行 tlb flush。但是针对远程 cpu,通过percpu(&steal_time)->preempted 判断对应 cpu 是否有KVM_VCPU_PREEMPTED标记,如果有则在 preempted 基础上添加KVM_VCPU_FLUSH_TLB标记。
steal_time 之前分享过是记录窃取时间的机制,其中的 preempted 是记录该cpu是否被抢占的标记,该标记在 vcpu exit 时被设置,在 vcpu entry 时被清除,因此上面通过 KVM_VCPU_PREEMPTED 标记是否存在可以知道 vcpu 是否在运行,所以如果有了该标记,那么 vcpu 没有运行,我们就附加上KVM_VCPU_FLUSH_TLB标记请求,该请求在 vcpu entry 时除了清除 KVM_VCPU_PREEMPTED 还会检测是否有 KVM_VCPU_FLUSH_TLB 标记,如果有则调用平台对应的 tlb_flush 回调执行 tlb 刷新操作,接着再去清除掉 KVM_VCPU_FLUSH_TLB 标记。

接下来看看相关代码实现:
qemu 通过 ioctl 开始启动 kvm(KVM_RUN) 后的代码流程(只罗列我们关心的部分):

kvm_vcpu_ioctl
  -> case KVM_RUN
    -> kvm_arch_vcpu_ioctl_run
      -> vcpu_load
        -> kvm_arch_vcpu_load
          -> kvm_make_request(KVM_REQ_STEAL_UPDATE, vcpu); (标记 vcpu KVM_REQ_STEAL_UPDATE)
      -> vcpu_run
        -> for (;;)
            -> vcpu_enter_guest
                // 在进入 guestOS 之前会有一些列的请求判断, 
                // 做完所有请求后才会进入 guestOS。
                -> if (kvm_check_request(KVM_REQ_STEAL_UPDATE, vcpu))
					record_steal_time(vcpu);
				// 进入 guestOS 执行 guestOS 代码,
				// 回调根据 intel 或者 amd 芯片不同调用不同运行代码。
				-> kvm_x86_ops->run(vcpu);
				// 根据退出原因调用处理函数。
				-> r = kvm_x86_ops->handle_exit(vcpu);
            -> 如果需要调度则执行调度
            -> 中断,信号等原因则会退出该循环
      -> vcpu_put
        -> kvm_arch_vcpu_put
          -> kvm_steal_time_set_preempted

开始执行 kvm 时,首先调用 vcpu_load 预处理一些 vcpu 事务,包括为 vcpu 添加 KVM_REQ_STEAL_UPDATE 标记。

接着在 vcpu_run 中是一个无限循环,意味着没有特殊的情况,vcpu 将会一直执行,此时调用 vcpu_enter_guest 进入 guestOS 执行 guest 代码。但在切换到 vmm non-root 模式之前,还需要根据 kvm_check_request 检测 vcpu 是否有请求处理,比如我们关心的KVM_REQ_STEAL_UPDATE ,KVM_REQ_STEAL_UPDATE 中的处理函数record_steal_time中一部分能力是将会根据是否存在 KVM_VCPU_FLUSH_TLB 标记来执行延迟的 tlb flush 操作。

然后才是调用 kvm_x86_ops->run(vcpu); 进入非根模式执行 guestOS 代码。如果遇到中断,异常,IO 等则会退出该回调,接着在 kvm_x86_ops->handle_exit(vcpu); 中对退出的原因进行相应处理,不是所有处理都可以在这里完成,比如有用户的 IO 信号,中断注入需要在 kvm 和 qemu 中才能处理。
最后当 vcpu 无法继续执行,必须交由 kvm 或者 qemu 来进行进一步异常处理。vcpu_put 中调用 kvm_steal_time_set_preempted 处理 steal_time 相关的逻辑。

这里贴一下 steal time 和 vcpu 使用的数据结构:

struct kvm_vcpu_arch {
	struct {
		u64 msr_val;
		u64 last_steal;
		struct gfn_to_hva_cache stime;
		struct kvm_steal_time steal;
	} st;
}

struct kvm_steal_time {
	__u64 steal;
	__u32 version;
	__u32 flags;
	__u8  preempted;
	__u8  u8_pad[3];
	__u32 pad[11];
};

steal time 机制不详述,可以看之前的 steal time 机制分析。

当进入 vcpu 之前记录 steal_time:

// guest os 共享 struct kvm_steal_time 结构体给 host os,通过 MSR_KVM_STEAL_TIME 将
// percpu 的 steal_time 首地址传递给了 host os并保存在 struct kvm_vcpu_arch 中。
static DEFINE_PER_CPU_DECRYPTED(struct kvm_steal_time, steal_time) __aligned(64);

static void kvm_register_steal_time(void)
{
	int cpu = smp_processor_id();
	struct kvm_steal_time *st = &per_cpu(steal_time, cpu);

	if (!has_steal_clock)
		return;

	wrmsrl(MSR_KVM_STEAL_TIME, (slow_virt_to_phys(st) | KVM_MSR_ENABLED));
	pr_info("kvm-stealtime: cpu %d, msr %llx\n",
		cpu, (unsigned long long) slow_virt_to_phys(st));
}


/// vcpu load
static void record_steal_time(struct kvm_vcpu *vcpu)
{
	if (!(vcpu->arch.st.msr_val & KVM_MSR_ENABLED))
		return;

    // 首先从 kvm 的 guestOS(根据 slot hva 偏移) 中读取出 struct kvm_steal_time,
    // 并存放到 vcpu->arch.st.steal 中。
	if (unlikely(kvm_read_guest_cached(vcpu->kvm, &vcpu->arch.st.stime,
		&vcpu->arch.st.steal, sizeof(struct kvm_steal_time))))
		return;

	/*
	 * Doing a TLB flush here, on the guest's behalf, can avoid
	 * expensive IPIs.
	 */
	// 取出 arch.st.steal.preempted 值并清零,如果取出值包含 KVM_VCPU_FLUSH_TLB,
	// 则去做 tlb flush。这里通过注释也可以看到,延迟 tlb 刷新操作,可以避免代价昂贵的
	// ipi 操作。
	if (xchg(&vcpu->arch.st.steal.preempted, 0) & KVM_VCPU_FLUSH_TLB)
		kvm_vcpu_flush_tlb(vcpu, false);

    // 后面则是将 struct kvm_steal_time steal; 相关信息更新并回写到 kvm guestOS 中。
	if (vcpu->arch.st.steal.version & 1)
		vcpu->arch.st.steal.version += 1;  /* first time write, random junk */

	vcpu->arch.st.steal.version += 1;

	kvm_write_guest_cached(vcpu->kvm, &vcpu->arch.st.stime,
		&vcpu->arch.st.steal, sizeof(struct kvm_steal_time));

	smp_wmb();

	vcpu->arch.st.steal.steal += current->sched_info.run_delay -
		vcpu->arch.st.last_steal;
	vcpu->arch.st.last_steal = current->sched_info.run_delay;

	kvm_write_guest_cached(vcpu->kvm, &vcpu->arch.st.stime,
		&vcpu->arch.st.steal, sizeof(struct kvm_steal_time));

	smp_wmb();

	vcpu->arch.st.steal.version += 1;

	kvm_write_guest_cached(vcpu->kvm, &vcpu->arch.st.stime,
		&vcpu->arch.st.steal, sizeof(struct kvm_steal_time));
}

kvm_write_guest_cached, kvm_read_guest_cached 是通过 steal time 中介绍的机制实现的,通过 KVM_MSR_ENABLED 判断是否支持 msr 特性,支持了那么就可以通过msr 写过来的地址保存在 struct kvm_vcpu_arch,然后通过kvm维护的 GPA 到 HVA 的转换关系可以从 host 读写 guest 的内存地址空间。因此 guest os 在 struct kvm_steal_time 中设置的 KVM_VCPU_FLUSH_TLB 标记,host 在此时可以通过 kvm_read_guest_cached 读取到,并处理。

接着当 vcpu 退出时,会执行下面的代码:

static void kvm_steal_time_set_preempted(struct kvm_vcpu *vcpu)
{
	if (!(vcpu->arch.st.msr_val & KVM_MSR_ENABLED))
		return;

    // 退出 vcpu, vcpu 将完全停止运行被调度出去,那么标记 vcpu 被抢占。
	vcpu->arch.st.steal.preempted = KVM_VCPU_PREEMPTED;

    // 将 arch.st.steal.preempted 信息更新到 guestOS 中。
	kvm_write_guest_offset_cached(vcpu->kvm, &vcpu->arch.st.stime,
			&vcpu->arch.st.steal.preempted,
			offsetof(struct kvm_steal_time, preempted),
			sizeof(vcpu->arch.st.steal.preempted));
}

总结一下:vcpu 退出时标记 vcpu 被抢占,然后同步该信息到 vcpu 内存空间,接着 guest os 内的其他 vcpu 根据被抢占标记 tlb flush,然后当对应 vcpu 即将运行时, host 在进入 vcpu load 阶段,读取 guest os 内存中的数据,并处理 tlb flush 标记,执行 tlb flush,清除标记,同步状态,最后再次进入 vcpu 运行。
OK,到这里看一切都很完美,原理和机制也都理清楚了。接下来看看为什么会触发 CVE 漏洞。

3 tlb flush 缺失及 CVE 漏洞触发

首先漏洞触发有两个问题,先看第一个问题对应的 patch:

kvm_steal_time_set_preempted() may accidentally clear KVM_VCPU_FLUSH_TLB
bit if it is called more than once while VCPU is preempted.

diff --git a/arch/x86/kvm/x86.c b/arch/x86/kvm/x86.c
index cf917139de6ba..8c9369151e9f3 100644
--- a/arch/x86/kvm/x86.c
+++ b/arch/x86/kvm/x86.c
@@ -3504,6 +3504,9 @@ static void kvm_steal_time_set_preempted(struct kvm_vcpu *vcpu)
 	if (!(vcpu->arch.st.msr_val & KVM_MSR_ENABLED))
 		return;
 
+	if (vcpu->arch.st.steal.preempted)
+		return;
+
 	vcpu->arch.st.steal.preempted = KVM_VCPU_PREEMPTED;
 
 	kvm_write_guest_offset_cached(vcpu->kvm, &vcpu->arch.st.stime,

逻辑很简单,在 vcpu exit 中的 kvm_steal_time_set_preempted 中,不再直接赋值被抢占标记,而是判断 preempted 是否有值,有值则跳过。

每当访问 vcpu 的数据时,都有 vcpu_load 和 vcpu_put 操作,以获取正确引用和同步 vcpu 数据访问。然而 kvm_steal_time_set_preempted 是在 vcpu_put 中调用的。kvm_steal_time_set_preempted中则会有vcpu->arch.st.steal.preempted = KVM_VCPU_PREEMPTED;。也就是说每执行一次 vcpu_load/vcpu_put 都会触发设置 preempted 操作。但是执行 vcpu_load/vcpu_put 不是 vcpu run ioctl 独有的,当我们访问 vcpu 数据时都有这个操作,那么这就存在一个问题,如果在我们 vcpu load ---- vcpu put 期间,对应 vcpu 正在运行,并且执行了 tlb flush,那么对应的 preempted 就会附加上 KVM_VCPU_FLUSH_TLB,然而此时由于外部执行 vcpu put 也刚好执行,那么对应的 KVM_VCPU_FLUSH_TLB 标记则很有可能在本次 vcpu put 操作中被覆盖掉,从而对应的远程 vcpu 错失一次 tlb flush 操作。因此该 patch 通过检测 preempted 是否有值来判断是否已经被赋值,以避免覆盖掉 vcpu load 和 vcpu put 期间的 KVM_VCPU_FLUSH_TLB 请求。因为进入 vcpu 是会清 0 preempted 字段的,那么只要 preempted 有值,则一定是被抢占或者附加了 KVM_VCPU_FLUSH_TLB 标记,所以 kvm_steal_time_set_preempted 中可以通过判断 preempted 是否有值来直接返回。

接下来看第二部分 patch,第二部分涉及多个patch,这里只贴最重要那一个:

There is a potential race in record_steal_time() between setting
host-local vcpu->arch.st.steal.preempted to zero (i.e. clearing
KVM_VCPU_PREEMPTED) and propagating this value to the guest with
kvm_write_guest_cached(). Between those two events the guest may
still see KVM_VCPU_PREEMPTED in its copy of kvm_steal_time, set
KVM_VCPU_FLUSH_TLB and assume that hypervisor will do the right
thing. Which it won't.

Instad of copying, we should map kvm_steal_time and that will
guarantee atomicity of accesses to @preempted.

diff --git a/arch/x86/kvm/x86.c b/arch/x86/kvm/x86.c
index 0795bc876abcc..f1845df7e7c32 100644
--- a/arch/x86/kvm/x86.c
+++ b/arch/x86/kvm/x86.c
@@ -2581,45 +2581,47 @@ static void kvm_vcpu_flush_tlb(struct kvm_vcpu *vcpu, bool invalidate_gpa)
 
 static void record_steal_time(struct kvm_vcpu *vcpu)
 {
+	struct kvm_host_map map;
+	struct kvm_steal_time *st;
+
 	if (!(vcpu->arch.st.msr_val & KVM_MSR_ENABLED))
 		return;
 
-	if (unlikely(kvm_read_guest_cached(vcpu->kvm, &vcpu->arch.st.stime,
-		&vcpu->arch.st.steal, sizeof(struct kvm_steal_time))))
+	/* -EAGAIN is returned in atomic context so we can just return. */
+	if (kvm_map_gfn(vcpu, vcpu->arch.st.msr_val >> PAGE_SHIFT,
+			&map, &vcpu->arch.st.cache, false))
 		return;
 
+	st = map.hva +
+		offset_in_page(vcpu->arch.st.msr_val & KVM_STEAL_VALID_BITS);
+
 	/*
 	 * Doing a TLB flush here, on the guest's behalf, can avoid
 	 * expensive IPIs.
 	 */
 	trace_kvm_pv_tlb_flush(vcpu->vcpu_id,
-		vcpu->arch.st.steal.preempted & KVM_VCPU_FLUSH_TLB);
-	if (xchg(&vcpu->arch.st.steal.preempted, 0) & KVM_VCPU_FLUSH_TLB)
+		st->preempted & KVM_VCPU_FLUSH_TLB);
+	if (xchg(&st->preempted, 0) & KVM_VCPU_FLUSH_TLB)
 		kvm_vcpu_flush_tlb(vcpu, false);
 
-	if (vcpu->arch.st.steal.version & 1)
-		vcpu->arch.st.steal.version += 1;  /* first time write, random junk */
+	vcpu->arch.st.steal.preempted = 0;
 
-	vcpu->arch.st.steal.version += 1;
+	if (st->version & 1)
+		st->version += 1;  /* first time write, random junk */
 
-	kvm_write_guest_cached(vcpu->kvm, &vcpu->arch.st.stime,
-		&vcpu->arch.st.steal, sizeof(struct kvm_steal_time));
+	st->version += 1;
 
 	smp_wmb();
 
-	vcpu->arch.st.steal.steal += current->sched_info.run_delay -
+	st->steal += current->sched_info.run_delay -
 		vcpu->arch.st.last_steal;
 	vcpu->arch.st.last_steal = current->sched_info.run_delay;
 
-	kvm_write_guest_cached(vcpu->kvm, &vcpu->arch.st.stime,
-		&vcpu->arch.st.steal, sizeof(struct kvm_steal_time));
-
 	smp_wmb();
 
-	vcpu->arch.st.steal.version += 1;
+	st->version += 1;
 
-	kvm_write_guest_cached(vcpu->kvm, &vcpu->arch.st.stime,
-		&vcpu->arch.st.steal, sizeof(struct kvm_steal_time));
+	kvm_unmap_gfn(vcpu, &map, &vcpu->arch.st.cache, true, false);
 }
 
 int kvm_set_msr_common(struct kvm_vcpu *vcpu, struct msr_data *msr_info)
@@ -3501,18 +3503,25 @@ void kvm_arch_vcpu_load(struct kvm_vcpu *vcpu, int cpu)
 
 static void kvm_steal_time_set_preempted(struct kvm_vcpu *vcpu)
 {
+	struct kvm_host_map map;
+	struct kvm_steal_time *st;
+
 	if (!(vcpu->arch.st.msr_val & KVM_MSR_ENABLED))
 		return;
 
 	if (vcpu->arch.st.steal.preempted)
 		return;
 
-	vcpu->arch.st.steal.preempted = KVM_VCPU_PREEMPTED;
+	if (kvm_map_gfn(vcpu, vcpu->arch.st.msr_val >> PAGE_SHIFT, &map,
+			&vcpu->arch.st.cache, true))
+		return;
+
+	st = map.hva +
+		offset_in_page(vcpu->arch.st.msr_val & KVM_STEAL_VALID_BITS);
+
+	st->preempted = vcpu->arch.st.steal.preempted = KVM_VCPU_PREEMPTED;
 
-	kvm_write_guest_offset_cached(vcpu->kvm, &vcpu->arch.st.stime,
-			&vcpu->arch.st.steal.preempted,
-			offsetof(struct kvm_steal_time, preempted),
-			sizeof(vcpu->arch.st.steal.preempted));
+	kvm_unmap_gfn(vcpu, &map, &vcpu->arch.st.cache, true, true);
 }
 
 void kvm_arch_vcpu_put(struct kvm_vcpu *vcpu)

可以看到该 patch 将进入 vcpu 之前的 record_steal_time 函数以及 kvm_steal_time_set_preempted 中的所有kvm_write_guest_cached和 kvm_read_guest_cached修改成了 kvm_map_gfn 操作。

什么意思呢?之前 kvm_write_guest_cached/kvm_read_guest_cached是通过去取 guest os 得到副本数据,然后修改副本数据,再同步回 guest os 的方式更新共享的数据。现在改为了将guest os内存 map 到 host 来访问,这样可以保证原子访问数据,guest os 拿到的永远都是最新的数据。

既然这样修改,那么原来的逻辑又有怎样的问题呢?
设想一个场景:当host 从guest读取了struct kvm_steal_time 的副本到host,并开始处理,
而正在host读取了副本数据以后,vcpu在其他 cpu 上发生了 tlb flush,并在本 cpu 的原始 struct kvm_steal_time 结构上附加了 KVM_VCPU_FLUSH_TLB 标记,vcpu 认为你会正确的处理该 KVM_VCPU_FLUSH_TLB 请求。而实际情况是,host 此时访问的是该struct kvm_steal_time 的副本,并且按照自己的逻辑给 preempted 清零,完成处理后,将struct kvm_steal_time回写回 guest,此时问题发生了,再此期间 vcpu 标记的KVM_VCPU_FLUSH_TLB 被 host 的回写操作覆盖,意外的清除了 KVM_VCPU_FLUSH_TLB。那么此时 vcpu 也会错过一次 tlb flush 操作。通过 map 操作,数据之间为原子访问,我们可以处理掉上面描述的竞争状态,从而不会意外清除 tlb flush 请求。

通过上述两个 patch,我们可以清晰的看到 tlb flush 是如何丢失的,而 patch 又是如何解决问题,不得不说内核开发人员的心细。

那么接下来还剩下最后一个问题,丢失 tlb flush,对于应用程序到底意味着什么,为什么就变成了一个 CVE 漏洞呢?
同样的,试想下面一个场景:

  1. task 1 通过 malloc 申请了一块 A 地址内存数据,并正常的读写了 A 地址
  2. task 1 在完成处理后,释放了 A 地址。
  3. 由于一些临界条件,free 操作触发了 munmap 操作,A 地址被返回给了操作系统
  4. 操作系统调用 tlb flush 刷新 A 地址的 tlb 缓存并且释放了对应的 C 物理地址回系统中,如果由于上面逻辑触发,本次 tlb flush 丢失,没有刷新到 A 地址的 tlb 缓存,访问A地址还是能访问到 C 物理地址。
  5. 紧接着 task 1 又进行了 malloc 申请内存,本次申请到的地址还是 A 地址。
  6. 随后 task 2 通过 malloc 同样申请了一块内存,并且申请到 B 地址,并且该 B 地址是 glibc 通过 mmap 进行映射分配给 task 2 的。
  7. task 2 随后访问 B 地址,通过 page fault 机制,内核给 task 2 B 地址分配实际物理地址,而此时正好分配到的时 task 1 A地址使用过的 C 物理地址。
  8. 我们知道 mmap 不会实际建立地址映射关系,而是通过 page fault 来实际分配物理内存。所以此时 task 1 去访问 A 地址,本意上如果没有缺失 tlb flush 操作,会触发 page fault 来申请新的物理地址。
  9. 但是此时由于 A 地址对应的 tlb 缓存并没有失效,因此不会触发 page fault,而是访问了原来的 tlb 缓存,即 task 1 通过 A 地址又可以继续访问 C 物理地址。
  10. 而实际上 C 物理地址是被分配给了 task2 使用,那么此时 task1 就可以随意访问 task2 中 B 地址中的任意数据。从而访问了不属于自己地址空间的内存位置,这即是一个 CVE 漏洞。

你可能感兴趣的:(linux,linux,kvm,tlb,flush)