Ikoct的饮冰室

你愿意和我学一辈子二进制吗?

0%

intelVT学习笔记-hvpp框架学习

这篇博客将记录通过hvpp框架对intelVT学习的过程

如果对VT技术一点了解都没有可以先看看B站上周壑老师的VT入门视频, 感觉讲的还是很好的, 至少让我听懂了个大概(

VT的基本思想

实际上和之前写过的Debug-Block非常相像, VT启动后提供一个虚拟CPU来执行后续的所有指令 这个环境下触发任何异常都会检查VMCS中相应异常的vmexit使能位, 如果使能位激活那么VT驱动会接管这个异常进行处理从而实现全面监控客机的行为.

hvpp项目学习

目前项目源码中的src/hvpp/hvpp/vmexit/vmexit_passthrough.cpp有明显语法错误 L29处的find_if圆括号未闭合:

QQ_1755179635356

其次当前VS2022自带的C++编译工具链(V143)会在std::find处报外部符号解析错误, 如果不想长考可以将skip_prefixes直接替换为以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static const uint8_t* skip_prefixes(const uint8_t* first, const uint8_t* last) noexcept
{
//
// Return the first byte of the opcode that is not a prefix.
//
return std::find_if(first, last, [&](uint8_t byte)
{
//
// List of prefix types that should be skipped, LOCK is not included as it'd #UD.
//
static constexpr uint8_t skip_table[] = {
0xF2, 0xF3, 0x2E, 0x36, 0x3E, 0x26, 0x64, 0x65, 0x2E, 0x3E, 0x66, 0x67
};
bool is_prefix = false;
for (const auto& prefix_byte : skip_table) {
if (prefix_byte == byte) {
is_prefix = true;
break;
}
}
return !is_prefix;
//return std::find(std::begin(skip_table), std::end(skip_table), byte) == std::end(skip_table);
});
}

项目结构

QQ_1755180250651

hvpp-lib中就是VT框架代码, 下面用到的部分会进行解读.

hvppctrl的编译目标是可执行文件, 用于对VT框架进行功能测试以及VT框架使用示例.

hvppdrv的编译目标是驱动, 作为VT框架的载体可以用于和ring3程序进行通信以及实现用户自定义vmexit handler

hvppdrv_c为hvppdrv的C版本

后续将会基于hvppctrl与VT框架的互动来对hvpp的VT框架进行学习, 以应用加深对VT的理解

hvppctrl

一共进行了3个测试:

1
2
3
4
5
6
7
8
int main()
{
TestCpuid();
TestHook();
TestIoControl();

return 0;
}

下面一个个进行解读

TestCpuid

1
2
3
4
5
6
7
8
9
10
void TestCpuid()
{
//
// See vmexit_custom_handler::handle_execute_cpuid().
//
uint32_t CpuInfo[4];
ia32_asm_cpuid(CpuInfo, 'ppvh');

printf("CPUID: '%s'\n\n", (const char*)CpuInfo);
}

cpuid作为一条硬件信息获取指令, VCPU在执行时会触发vmexit, 接下来看看VT框架的反应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void vmexit_custom_handler::handle_execute_cpuid(vcpu_t& vp) noexcept
{
if (vp.context().eax == 'ppvh')
{
//
// "hello from hvpp\0"
//
vp.context().rax = 'lleh';
vp.context().rbx = 'rf o';
vp.context().rcx = 'h mo';
vp.context().rdx = 'ppv';
}
else
{
base_type::handle_execute_cpuid(vp);
}
}

当执行cpuid且客机eax值为’ppvh’时, 驱动会将客机的几个寄存器设定为预设值, 这几个寄存器对应的就是CpuInfo, 也就是客机的cpuid指令返回的信息可以被VT驱动修改为任意值, 凭这点可以拦截软件对硬件信息的访问以绕过某些限制.

另外从这个handler可以看出, hvpp框架不需要用户实现每一种vmexit的对应handler, vmexit_custom_handler扩展自vmexit_passthrough_handler, vmexit_passthrough.cpp中实现了各种vmexit相当于无事发生的handler供使用, 如果用户需要自定义vmexit handler只需要在vmexit_custom.h中编辑vmexit_custom_handler对默认handler进行重载即可:

QQ_1755182400901

更进一步地, hvpp框架实现了vmexit handler执行结束后到vmresume前的所有善后措施, 包括调整客机RIP寄存器, 若不进行调整就会无限执行会触发vmexit的指令.

TestHook

该测试展示了VT对内存的虚拟化的运用, 也就是EPThook.

首先函数获取了待hook函数ZwClose的虚拟地址并获取了其所在的页(4kb)的起始地址为后续备份hook前的页做准备.

完成备份后使用detours库进行inline hook, 也就是修改函数头为jmp指令以执行用户代码, 此时读取ZwClose处的内存会发现已经被修改, 然后程序进一步进行了hide操作(通过lambda表达式实现):

1
2
3
4
5
6
7
8
9
10
auto Hide = [](void* PageRead, void* PageExecute)
{
struct CONTEXT { void* PageRead; void* PageExecute; } Context { PageRead, PageExecute };
ForEachLogicalCore([](void* ContextPtr) {
CONTEXT* Context = (CONTEXT*)ContextPtr;
ia32_asm_vmx_vmcall(0xc1, (uint64_t)Context->PageRead, (uint64_t)Context->PageExecute, 0);
}, &Context);
};
// ...
Hide(OriginalFunctionBackupAligned, OriginalFunctionAligned);

其中Hide第一个参数指向客机读到的页, 第二个参数指向实际执行的页, 然后带着这两个参数执行了vmcall.

vmcall指令是guest主动和host通信最常用的方式, 会直接触发vmexit, 来看看host的对应处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
void vmexit_custom_handler::handle_execute_vmcall(vcpu_t& vp) noexcept
{
auto& data = user_data(vp);

switch (vp.context().rcx)
{
case 0xc1:
{
cr3_guard _{ vp.guest_cr3() };

data.page_read = pa_t::from_va(vp.context().rdx_as_pointer);
data.page_exec = pa_t::from_va(vp.context().r8_as_pointer);
}

hvpp_trace("vmcall (hook) EXEC: 0x%p READ: 0x%p", data.page_exec.value(), data.page_read.value());

//
// Split the 2MB page where the code we want to hook resides.
//
vp.ept().split_2mb_to_4kb(data.page_exec & ept_pd_t::mask, data.page_exec & ept_pd_t::mask);

//
// Set execute-only access on the page we want to hook.
//
vp.ept().map_4kb(data.page_exec, data.page_exec, epte_t::access_type::execute);

//
// We've changed EPT structure - mappings derived from EPT need to be
// invalidated.
//
vmx::invept_single_context(vp.ept().ept_pointer());
break;

case 0xc2:
hvpp_trace("vmcall (unhook)");

//
// Merge the 4kb pages back to the original 2MB large page.
// Note that this will also automatically set the access
// rights to read_write_execute.
//
vp.ept().join_4kb_to_2mb(data.page_exec & ept_pd_t::mask, data.page_exec & ept_pd_t::mask);

//
// We've changed EPT structure - mappings derived from EPT
// need to be invalidated.
//
vmx::invept_single_context(vp.ept().ept_pointer());
break;

default:
base_type::handle_execute_vmcall(vp);
return;
}
}

guest呼叫host时的第一个参数为0xC1时, host先是计算了两个页的物理地址, 然后进行了对性能开销很重要的一步 – 重建guest的EPT结构(对于EPT可以参考科普下VT EPT).

虽然EPT会根据GPA进行4级页表直到检索到最小粒度4kb, 总共覆盖了512 * 512 * 512 * 512 * 4kb内存, 但是实际上因为每次guest涉及到内存的操作都需要计算两次转化(linear addr -> GPA -> HPA), 设计EPT结构时出于性能考量会减少一级页表, 但是总共能够覆盖的范围实际不会变, 也就是512 * 512 * 512 * 2mb, 此时EPT的粒度为2mb, 下称大页.

而VT框架中执行的split_2mb_to_4kb()为待EPThook的大页新建了一张映射表, 通过512项的指针指向这个大页中的每一个页(2mb / 512 = 4kb), 后续使用map_4kb来建立只可执行页从GPA到HPA的映射, 当任何guest试图访问ZwClose所在的页都会触发ept violation.

随后执行的invept_single_context用于清除掉先前CPU缓存的EPT地址防止不经过运算直接从缓存中得到了旧的映射.

接下来看看host对ept violation的处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
void vmexit_custom_handler::handle_ept_violation(vcpu_t& vp) noexcept
{
auto exit_qualification = vp.exit_qualification().ept_violation;
auto guest_pa = vp.exit_guest_physical_address();
auto guest_va = vp.exit_guest_linear_address();

auto& data = user_data(vp);

if (exit_qualification.data_read || exit_qualification.data_write)
{
//
// Someone requested read or write access to the guest_pa,
// but the page has execute-only access. Map the page with
// the "data.page_read" we've saved before in the VMCALL
// handler and set the access to RW.
//
hvpp_trace("data_read LA: 0x%p PA: 0x%p", guest_va.value(), guest_pa.value());

vp.ept().map_4kb(data.page_exec, data.page_read, epte_t::access_type::read_write);
}
else if (exit_qualification.data_execute)
{
//
// Someone requested execute access to the guest_pa, but
// the page has only read-write access. Map the page with
// the "data.page_execute" we've saved before in the VMCALL
// handler and set the access to execute-only.
//
hvpp_trace("data_execute LA: 0x%p PA: 0x%p", guest_va.value(), guest_pa.value());

vp.ept().map_4kb(data.page_exec, data.page_exec, epte_t::access_type::execute);
}

//
// An EPT violation invalidates any guest-physical mappings
// (associated with the current EP4TA) that would be used to
// translate the guest-physical address that caused the EPT
// violation. If that guest-physical address was the translation
// of a linear address, the EPT violation also invalidates
// any combined mappings for that linear address associated
// with the current PCID, the current VPID and the current EP4TA.
// (ref: Vol3C[28.3.3.1(Operations that Invalidate Cached Mappings)])
//
//
// TL;DR:
// We don't need to call INVEPT (nor INVVPID) here, because
// CPU invalidates mappings for the accessed linear address
// for us.
//
// Note1:
// In the paragraph above, "EP4TA" is the value of bits
// 51:12 of EPTP. These 40 bits contain the address of
// the EPT-PML4-table (the notation EP4TA refers to those
// 40 bits).
//
// Note2:
// If we would change any other EPT structure, INVEPT or
// INVVPID might be needed.
//

//
// Make the instruction which fetched the memory to be executed
// again (this time without EPT violation).
//
vp.suppress_rip_adjust();
}

可以看到当退出理由为试图读写只可执行的页时会通过map_4kb来重建GPA到HPA的映射, 实际读到和写到的是备份的内存, 最后很重要的一步是执行suppress_rip_adjust, 上面说过hvpp框架实现了自动调整guest的RIP的功能, 执行这个函数就是告诉框架不需要调整, 因为实际指令还没有执行, 还需要guest再执行一遍指令读到host重建映射后的虚假页.

至此简单以例子说明了EPThook技术的原理和实现