
Uma prova de conceito de exploit para o PyInstaller CVE-2019-16783
Esta é minha POC para a vulnerabilidade do Windows PyInstaller versão < 3.6 que existe para a opção --onefile. Um atacante poderia alcançar execução de comandos e possível LPE sequestrando uma DLL importada pela DLL do interpretador Python usada pelo binário do PyInstaller. Uma breve explicação da vulnerabilidade e do processo de exploração será apresentada. Meus agradecimentos à Alter Solutions por encontrar a vulnerabilidade e escrever sobre sua descoberta no PagedOut #3 (página 55 do pdf).
A vulnerabilidade foi causada por uma criação fraca de um diretório usado pelo PyInstaller em tempo de execução do binário executado. O PyInstaller constrói um diretório _MEIPIDX no diretório temporário do usuário e coloca diferentes coisas nele, como a DLL do interpretador Python usada para executar o código Python empacotado em um executável PE.
O problema com esse processo era que o diretório construído para NT AUTHORITY\SYSTEM era C:\Windows\Temp, o que permitia que alguém tanto o adivinhasse quanto escrevesse dentro dele. Assim, um sequestro de DLL seria possível, por exemplo, quando o interpretador Python fosse executado. Aqui está o commit corrigindo a vulnerabilidade. Em vez de depender apenas de algumas funções padrão da API para criar o diretório, os desenvolvedores implementaram sua própria função para ter mais controle sobre a criação do diretório.
Como esta foi minha primeira POC, discutirei alguns problemas que encontrei pelo caminho.
Primeiramente, configurar um ambiente para esta POC não foi particularmente difícil, já que tudo que você precisava era da versão correta do pacote. No entanto, quando o instalei, ele travava
Traceback (most recent call last):
File "c:\users\ckrielle\appdata\local\programs\python\python38\lib\runpy.py", line 194, in _run_module_as_main
return _run_code(code, main_globals, None,
...
File "c:\users\ckrielle\appdata\local\programs\python\python38\lib\site-packages\PyInstaller\building\utils.py", line 653, in <genexpr>
strip_paths_in_code(const_co, new_filename)
File "c:\users\ckrielle\appdata\local\programs\python\python38\lib\site-packages\PyInstaller\building\utils.py", line 660, in strip_paths_in_code
return code_func(co.co_argcount, co.co_kwonlyargcount, co.co_nlocals, co.co_stacksize,
TypeError: an integer is required (got type bytes)
Depois de pesquisar um pouco, percebi que minha versão do Python era a culpada (estou usando 3.8.10). Minha versão do Python dificultou a instalação de qualquer outra versão 3.8.x, pois na instalação o instalador encontrava minha versão Python38 e retornava um erro. Também tentei compilar outra versão 3.8, mas não consegui porque precisava do Visual Studio 2015, enquanto tenho o 2022. Por fim, escolhi baixar o Python 3.7.5, que funcionou perfeitamente. Então criei um ambiente virtual para a versão 3.7.
Entendi que configurar o ambiente pode variar desde já estar pronto até potencialmente levar muito tempo para configurar corretamente. Embora seja importante, poderia potencialmente tirar a diversão de realmente explorar nosso alvo.
Há duas etapas em nosso processo de exploração: encontrar o diretório do processo empacotado pelo PyInstaller e escrever nossa DLL de exploit lá. Para a primeira parte, podemos encontrar o PID através das funções WINAPI padrão (CreateToolhelp32Snapshot, Process32First, Process32Next). Isso funcionou sem problemas. Depois, precisamos encontrar o último número do diretório _MEI. O exploit original sugere usar apenas a função GetFileAttributesA e verificar se o código de status retornado é FILE_ATTRIBUTE_DIRECTORY. No entanto, isso não funcionou para mim. Minha solução foi criar um arquivo em cada diretório candidato e, se o arquivo fosse criado com sucesso, então o diretório existia. Por algum motivo, o status retornado para o arquivo de GetFileAttributesA era FILE_ATTRIBUTE_ARCHIVE, então verifico isso. Antes de prosseguir, o loop durante a busca do PID é porque queremos injetar nossas DLLs quando o processo alvo é executado, para tê-las prontas antes do carregamento.
Para a DLL, precisamos sequestrar uma DLL que o interpretador Python (python37.dll) importa. Para obter as DLLs que o interpretador importa, poderíamos abrir a DLL com o PE-Bear. Uma das DLLs do sistema importadas é version.dll. Então podemos criar nossa própria DLL e abusar da ordem de busca do carregador do Windows colocando-a no diretório temporário do processo empacotado pelo PyInstaller. Dessa forma, quando ele quiser importar a DLL, importará nossa DLL maliciosa, em vez da correta.

No entanto, fazer isso não funcionará. A razão é que a importação da função que o interpretador Python chama de version.dll (especificamente VerQueryValueW da imagem) não é resolvida. Então o programa trava. Para resolver esse problema, precisamos fazer proxy de DLL. Em resumo, configuramos nossa DLL maliciosa para exportar as funções da DLL original e trazemos a DLL original renomeada, para que ela possa carregá-la e chamar a função a partir dela. Então, para nosso exploit, compilaremos uma DLL maliciosa com um DllMain que nos permitirá alcançar nossa execução de código. Exportaremos todas as funções da version.dll e copiaremos a version2.dll original do sistema e a colocaremos no mesmo diretório. Dessa forma, version.dll pode encaminhar as chamadas de função para version2.dll. E uma vez que tenhamos essas DLLs, tudo que precisamos fazer é copiá-las no diretório do processo e aguardar a execução de código. Para uma explicação melhor sobre DLL Proxying/Hijacking, leia o artigo vinculado.

Mesmo que, em retrospecto, esses passos sejam todos fáceis e diretos, não foi assim durante o desenvolvimento da POC. Antes, demorou um pouco para entender o processo do exploit e, assim que comecei a codificar, comecei a entendê-lo mais. Quanto à DLL, tentei compilá-la eu mesmo da maneira que foi sugerida no repositório da POC da Alter Solutions. Eles têm uma DLL separada para execução de código e outra diferente para proxy, que carregava a payload.dll em tempo de execução (DllMain é chamado no momento do carregamento). No entanto, não consegui compilá-la corretamente. No final, depois de ler o artigo acima, decidi usar o repositório DLLProxyProject, que compilou a DLL que eu queria e pode ser usado genericamente para produzir uma DLL para fins de proxy. Tentei copiar o arquivo DLLMain.cpp e compilá-lo com o arquivo de cabeçalho exports.h. E embora tenha executado, produziu um erro:
Fatal Python error: init_sys_streams: can't initialize sys standard streams
OSError: [WinError 6] The handle is invalid
Current thread 0x00002ea8 (most recent call first):
Esse erro poderia ser evitado com o arquivo Utils.cpp, então decidi manter aquele projeto para minha compilação de DLL (embora teria sido melhor se eu tivesse feito completamente sozinho).
Depois de configurar o ambiente (usei psexec para obter um shell NT AUTHORITY\SYSTEM), executei o exploit, executei o binário alvo e obtive execução de código como administrador.

Esta foi uma experiência muito boa, e estou feliz por ter passado por ela. Realmente quero produzir mais POCs para outras CVEs, e este foi um primeiro alvo perfeito. Ler a descrição da vulnerabilidade foi interessante, pois entendi que a principal dificuldade de implementar uma POC por conta própria é preencher as lacunas para coisas que o autor não explicou (seja de propósito ou não) e apenas entender o que você está lendo. Foi uma vulnerabilidade simples, então não me esforcei muito para entendê-la. Num futuro próximo, depois de fazer mais algumas, tentarei fazer uma POC para uma vulnerabilidade de corrupção de memória.