揭开x86程序在x86-64架构下运行机制的神秘面纱。

注1:本系列文章完全按照笔者学习路线记录。

注2:本系列文章假定读者拥有和笔者相近或远超笔者的知识。

注3:本系列文章基于Intel架构。

0x0 起因

半年前做了NU1L CTF的一个逆向题目,大致的意思是32位程序里做了一个切换到64位的操作,使得大部分调试器都起不了作用(除了强大的WinDbg),当时因为水平不太行没有思路去深入了解题目背后的机制。近日做windows进程在用户态和内核态切换的研究时再次遇到了这个技术,经过手搓内核的洗礼(还没写完),这一次分析起来虽饱含艰辛,却有星辰指路。

0x1 初见 retf

野生的汇编指令retf出现了!

平日里做逆向时,最常见的返回上一栈帧的指令是ret/retn,retf使用频率很少,但是如果有跟踪系统函数相关的经历,就会经常看到这个汇编指令了。解释如下:

ret retf
用栈中数据改IP内容,近转移。拆开表示为:pop ip 用栈中数据同时改CS,IP,远转移。拆开表示为:pop ip,pop cs

那么retf除了修改ip之外,还修改了段寄存器cs,那么架构切换一定和cs寄存器有关系。经过查阅资料和实际调试,环境为Windows10 1903 x64;进程环境为32位时,cs寄存器的值为0x23;进程环境为64为时,cs寄存器的值为0x33。也就是说,当前32位进程想要切换到x64位环境时,需要利用retf把修改为0x33。

0x2.1 线索1 Windows进程代码段映射规律

Windows x64在初始化进程的时候会把代码段映射到两个位置,便于切换。而实际在调试的时候笔者发现这两个位置其实是重合的,并没有所谓的在一份虚拟空间里找到两个相同的代码段。

0x2.2 线索2 段寄存器,全局描述符表,段描述符

说起段寄存器,有过一些汇编基础的人会想当然认为地址 = 段寄存器«4 + ip。但是进一步学习操作系统会发现,前者只适用于实模式下的寻址;开启保护模式后的操作系统,段寄存器的实际长度位96(32+32+16+16)位,前80位在processor内部不可见,只显示最后的16位。因此“16位的段寄存器”中的数据不再负责记录偏移用作寻址,而是作为选择子,充当GDT的index。(实际结构笔者并未在intel手册上查找到记载,在亲自逆向或查阅后会更新内容)

笔者查阅intel的手册后发现:在保护模式下段寄存器实际不止16位,还有一部分在processor内部,外部不可见,这部分不可见的部分有时会被作为段描述符的缓存。最后可见的16位作为Selector充当GDT的index。

注:保护模式下用户态每个进程的CS,DS等的BaseAddr都是0。

下图给出了16位Selector的结构细节以及作用:

这里需要解释一下,Windows只采用GDT而不采用LDT,GDT全局可见,因此TI始终为0。对于低2位而言,如果是CS,则为CPL。CPL,RPL,DPL的联系和区别

例如CS分别为0x23,0x33时,二进制来看就是:0b100011和0b110011。

介绍完低3位之后,就剩下Index没有说了,作用详见下图。

上图就是段描述符,它作为元素存在GDT中。每个段可以根据Selector在GDT中寻找并填充关于它的运行权限、状态等。

a9c5eb645939b9afb7eba350184083af.png

0x3 神秘的段寄存器值 0x23 0x33

经过0x2的讲解,相信读者已经复习了一遍保护模式下段寄存器的结构和作用,Selector和GDT的关系。接下来就是探究为什么从32-bit切换到64-bit,CS的值要从0x23切换到0x33。将hex转换为bin或者dec更易理解。

arch 32-bit 64-bit
hex 0x23 0x33
bin 100011 110011
index 100 110

当进程环境是32-bit时,Selector的值是4,也就是GDT的第五项;

当进程环境是64-bit时,Selector的值是6,也就是GDT的第七项。

0x4 追踪GDT,读取内存

那么接下来的工作就是找到GDT的地址,读取段描述符,对比CS值为0x23、0x33的差异。因为在R3调试无法看到gdtr寄存器,查看GDT内容时选择了双机内核调试的策略,可以让WinDbg调试虚拟机的内核,读到R3看不到的数据。

实验环境:宿主机 Windows10 1909 x64 , 虚拟机 Windows8.1 x64 ,调试工具 VirtualKD、Windbg。

1.使用简单Windows API VirtualAllocEx()切入x64

这个步骤我们需要的就是在x64环境下编译一个32位程序,32位程序中调用一个方便跟踪的函数,笔者选择了VirtualAllocEx(),代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include <iostream>
#include <cstdio>
#include <cstring>
#include <Windows.h>


int main(int argc, char** argv)
{
	auto hd = GetModuleHandle(NULL);
	int a = 1;
	auto p = VirtualAllocEx(hd,0,100,0,0);
	return 0;
}

在VS2019环境下编译,编译选项是x86 Release。接着用64位的WinDbg调试二进制文件。

跟进VirtualAllocEx函数

使用PCHunter查看进程加载的模块

负责x86切换到x64的dll组件:

  1. Wow64.dll,通往Windows NT内核的核心接口,它转换32位与64位调用,包括指针调用栈操作。
  2. Wow64win.dll,为32位应用程序提供适当的入口点。
  3. Wow64cpu.dll,负责解决进程从32位切换到64位模式。

同时我们也注意到了进程模块还加载了位于system32和syswow64下的ntdll,众所周知ntdll是处理用户态和内核态的媒介。但上述dll的内部细节并不打算在本次文章详解,不妨留到后面。我们的任务仍然是探索CS改变背后的原理。

继续Step In,跟进ntdll里面。

在NtAllocateVirtualMemory里,我们终于看到进程环境切换的far jmp语句:

这句话会把ip修改为0x774E6009,cs由0x23修改为0x33。接着看:

进程环境已经由32-bit切换为64-bit,连寄存器显示的名字都改变了。不妨我们就此打住,把进一步调试的内容留给下一篇文章。

2.查看GDT以及段描述符表

前文已经提到过:

  1. cs值作为Selector
  2. R3无法查看GDTR,只能将权限提升至R0

因此以下的WinDbg调试环境为:Windows 10 1909 x64为宿主机,WinDbg x64为宿主机上的调试器,调试虚拟机Win8.1 x64

WinDbg:

r gdtr //查看gdtr寄存器

dq gdtr+0x10*index //查看GDT附近内存

dg [Selector] //由WinDbg解析GDT表项

段描述符的长度大小为64位,换算成16进制也就是一个长度为16的数字,显然Selector 0x23和0x33 分别对应图中的00cffb00 0000ffff0020fb00 00000000,由WinDbg解析之后的数据如下:

32-bit 64-bit
Flags(HEX) 0xcfb 0x2fb
Flags(BIN) 110011111011 001011111011

我们只需要注意前四位,他们的意义分别是:G D/B L AVL。下图来自Intel手册

根据Windows的规定,32-bit环境下G=1,D/B=1,L=0,AVL=0;64-bit环境下G=0,D/B=0,L=1,AVL=0。同时我们也注意到,CS的Base为0,这也验证了保护模式下各个段的布局。

看看DS段:

0x5 总结

通过一些调试手法我们终于窥探到wow64的一隅,但是想要深入理解wow64的运行机制,以及是否会有漏洞利用,还需进一步努力。