← Volver a los writeups

Vendix — Writeup completo

pwnRet2winDifícil

Infinity CTF 2026 (Harpia Security + SENAC)· Publicado el · Autor: Niedson· 5 min de lectura

#ret2win#blind-exploitation#stack-canary-leak

Este writeup todavía no fue traducido a este idioma. Mostrando la versión en portugués.

Sobre o desafio

Plataforma: Infinity CTF 2026 (Harpia Security + SENAC) Categoria: Pwn (root) · Dificuldade: Hard · Pontos: 758 · 0-solve → first blood Vulnerabilidades: Vazamento de memória não inicializada (canário + ponteiro PIE) → ret2win cego por brute-force Flag: flag{infinity_ctf_2026_vendix_5cde77bff9} (fixa nesta instância — o formato varia por desafio)

Vendix foi o único desafio root deste CTF resolvido sem nunca ter acesso ao binário — nem publicado pela organização, nem extraído de alguma forma. Tudo o que existia era o serviço remoto e um punhado de bytes vazados por acidente. É o exemplo mais puro de “ret2win às cegas” do evento.


1. Contexto

O serviço pede um “apelido do dispositivo” e depois um “pacote de diagnóstico”. Sem binário, sem código-fonte — só esses dois prompts para investigar. O nome “Vendix” e a estrutura de “diagnóstico” sugerem algo tipo um sistema embarcado/IoT simulado.

2. Reconhecimento

Ao mandar o apelido do dispositivo, o servidor responde com um “pacote interno” de exatamente 80 bytes. Comparando várias respostas, uma parte desse pacote muda a cada conexão e outra parte é sempre igual — sinal clássico de que esse “pacote” é, na verdade, um buffer de memória mal-inicializado sendo devolvido praticamente cru: o programa alocou 80 bytes, escreveu só uma parte com dado de verdade, e devolveu o buffer inteiro sem zerar o resto antes.

Um buffer “quase certo” que vaza memória é ouro em CTF

Sempre que uma resposta parece ter dados “residuais” — bytes que não fazem sentido junto ao conteúdo esperado — vale a pena decompor byte a byte. Muitas vezes é literalmente pilha antiga do processo, sobrando de uma chamada de função anterior.

Decompondo os 80 bytes vazados, dava pra identificar:

  • Um qword terminando em 00 — característica de um canário de pilha (o glibc sempre zera o byte menos significativo do canário, justamente para que ele nunca seja tratado como parte de uma string C que termina em \0).
  • Vários valores no formato 0x7fff... — endereços típicos de pilha em processos x86-64 Linux.
  • Um valor no formato 0x5f91... — um endereço de código, mas numa faixa que muda pouco entre conexões, sugerindo que era um ponteiro PIE (executável com posição independente) cujos 12 bits menos significativos (0x485 nesta instância) são estáveis.

O comando de “pacote de diagnóstico”, por sua vez, tinha um overflow de pilha clássico — mandando entradas cada vez maiores, o servidor emitia *** stack smashing detected *** a partir de 72 bytes, confirmando: canário presente, no offset 72 (o mesmo padrão 64→72 desta família de binários), sem nenhuma variável lógica extra entre o buffer e o canário.

3. Encontrando a vulnerabilidade

Com canário confirmado no offset 72, e sem nenhuma lógica extra pra contornar, a exploração é puro ret2win: padding + canário + rbp salvo + endereço de retorno forjado. O problema é que, sem o binário, não existe nenhuma função “win” conhecida para apontar — não sabemos onde fica.

Ret2win cego funciona quando você tem os dois leaks certos

Um ret2win totalmente às cegas (sem binário) só é viável quando dá pra: (1) contornar o canário (aqui, porque ele mesmo vazou no “pacote interno”) e (2) ancorar o brute-force do endereço de retorno em algo já vazado — aqui, o ponteiro de código PIE. Sem esse segundo leak, o espaço de endereços possíveis é grande demais pra tentar um por um.

Como o ASLR do serviço é previsível (o servidor usa um modelo de fork por conexão, então a base do executável é a mesma em toda conexão nova daquela instância), o ponteiro de código vazado dá uma âncora: a função “win” real deveria estar relativamente perto dele no mapa de memória.

4. Exploração

O payload de overflow:

payload = b"B" * 72          # padding até o canário
        + canario_vazado     # 8 bytes, lido do "pacote interno"
        + rbp_qualquer        # 8 bytes — o rbp salvo não é checado, qualquer valor serve
        + p64(endereco_ret)   # candidato a testar

Como não existe um endereço de “win” conhecido, a solução foi fazer um brute-force organizado: testar candidatos de retorno numa janela ao redor do ponteiro de código vazado — de codeptr - 0x900 até codeptr + 0x200, em paralelo (40 threads) para varrer a janela rápido antes da instância expirar.

# esboço da lógica de brute-force (pseudocódigo)
for offset in range(-0x900, 0x200, passo):
    candidato = codeptr_vazado + offset
    payload = b"B"*72 + canario + b"C"*8 + p64(candidato)
    resposta = enviar(payload)
    if "RCTF_FLAG" in resposta or contem_flag(resposta):
        print("achou:", hex(candidato))
        break

O acerto veio em codeptr - 0x20c — um deslocamento de pouco mais de 500 bytes abaixo do endereço vazado, dentro da faixa testada.

Instâncias frágeis exigem lote pequeno

Nos desafios root deste CTF, instâncias tendiam a degradar depois de umas 100-125 conexões seguidas (o balanceador começa a devolver erro genérico). O brute-force precisou ser dosado — várias rodadas curtas em instâncias novas, em vez de uma varredura gigante numa instância só.

5. Capturando a flag

Ao acertar o endereço certo, o servidor executa a função de “win” (sem argumentos, como as outras desta família de desafios) e imprime a flag direto na resposta.

🚩 Flag

flag{infinity_ctf_2026_vendix_5cde77bff9}

6. Lições

  • Memória não-inicializada devolvida ao cliente é uma das falhas mais valiosas em pwn. Ela pode vazar exatamente as duas coisas que um ret2win cego precisa: proteção (canário) e âncora de endereço (ponteiro de código).
  • ASLR previsível (mesma base a cada conexão) transforma “impossível sem binário” em “viável com brute-force organizado”. Se a base muda a cada tentativa, a mesma técnica simplesmente não funcionaria.
  • Dosar o brute-force contra a fragilidade da infraestrutura importa tanto quanto a técnica em si — descobrir empiricamente “quantas conexões a instância aguenta” foi decisivo pra não desperdiçar tentativas.
  • Corrigir isso na origem: nunca devolver ao cliente um buffer que não foi 100% escrito pelo próprio programa (zerar antes de preencher), e habilitar PIE + ASLR de verdade (base diferente a cada processo, não só por conexão dentro do mesmo processo pai).

Writeup do desafio Vendix (Infinity CTF 2026 · Pwn · Hard).