Pular para o conteúdo
NodeJS

Localizando subdomínios de um domínio

Paloma Macetko
Unicórnio detetive com uma lupa mágica sobre um mapa de castelos ligados por linhas de luz, alguns brilhando e outros em ruínas

Olá meus Unicórnios! 🦄✨

Sabe quando você precisa saber tudo que um domínio tem no ar? 😅 Não só o www, mas o api, o lab, aquele homologacao que alguém subiu em 2019 e esqueceu. A pergunta parece simples, mas ela tem um detalhe cruel no meio: descobrir que um subdomínio existiu não é a mesma coisa que descobrir que ele está de pé.

Neste artigo eu mostro como localizar os subdomínios de um domínio usando os certificados públicos — a fonte mais generosa que existe — e, principalmente, como separar o que ainda responde do que virou ruína. É Node.js puro, sem dependência nenhuma para a parte que importa.

Exemplos_LocalizarSubDominios no GitHub O código completo deste artigo: linha de comando, servidor e uma página para testar no navegador. github.com

🔍 De onde vem a lista de nomes

A tentação é fazer força bruta: pegar uma lista de mil palavras (www, mail, dev…) e testar uma por uma. Funciona, é lento e só acha o que já estava na sua lista.

Existe um caminho muito melhor, e ele é público por projeto: o Certificate Transparency. Toda vez que uma autoridade certificadora emite um certificado HTTPS, ela é obrigada a publicar esse certificado em registros abertos e auditáveis. E o certificado carrega os nomes que ele cobre.

Ou seja: se um dia alguém subiu homologacao.seudominio.com.br com HTTPS, esse nome está registrado para sempre num log público. O crt.sh permite consultar esses logs, e o melhor: ele responde em JSON, sem cadastro e sem chave de API.

🧩 A primeira armadilha: um registro, vários nomes

A resposta do crt.sh é uma lista de certificados. Parece óbvio ler o campo name_value de cada um e pronto — só que um certificado cobre vários hosts ao mesmo tempo, e o crt.sh entrega todos eles empilhados num único campo, separados por quebra de linha.

Se você tratar name_value como um nome só, perde quase tudo. Por isso o split('\n') no meio do laço:

/**
 * Extrai os nomes do JSON do crt.sh: sem curingas, dentro do dominio, unicos.
 *
 * O campo name_value traz VARIOS nomes separados por \n num unico registro
 * (um certificado cobre varios hosts), por isso o split.
 */
export function extrairNomes(json, dominio) {
  if (!Array.isArray(json)) return [];
  const alvo = dominio.toLowerCase();
  const sufixo = '.' + alvo;
  const nomes = new Set();

  for (const registro of json) {
    const valor = registro && registro.name_value;
    if (!valor) continue;
    for (const bruto of String(valor).split('\n')) {
      const nome = bruto.trim().toLowerCase();
      if (!nome || nome.includes('*')) continue;
      if (nome === alvo || nome.endsWith(sufixo)) nomes.add(nome);
    }
  }
  return [...nomes];
}

Repare nos três filtros. O includes('*') descarta os curingas (*.exemplo.com não é um host, é uma autorização). O endsWith(sufixo) descarta nomes de outros domínios que aparecem no mesmo certificado. E o Set resolve a duplicação, que é enorme: cada renovação de certificado gera um registro novo com os mesmos nomes.

⚠️ A segunda armadilha, e essa dói

Se você só for ler um pedaço deste artigo, leia este. 🙏

O crt.sh é um serviço público e gratuito — e, como todo serviço público e gratuito, ele cai. Responde 502, 503, 429 com uma frequência que surpreende. E a forma ingênua de escrever essa consulta transforma essa queda em algo muito pior que um erro:

// NÃO faça assim
try {
  const resposta = await fetch(url);
  return await resposta.json();   // com 502, o corpo é HTML: estoura aqui
} catch {
  return [];                      // e a falha vira "não achei nada"
}

Percebeu o estrago? Quando o crt.sh responde 502, o corpo é uma página HTML de erro. O .json() estoura, o catch engole, a função devolve lista vazia — e a busca termina com sucesso e zero resultados. Do lado de fora, isso é idêntico a um domínio que realmente não tem subdomínio nenhum.

É o pior tipo de bug: silencioso, e que responde com uma mentira plausível. A correção é uma linha, e ela é o coração deste módulo:

export async function consultarCrtSh(dominio) {
  const url = `https://crt.sh/?q=%25.${encodeURIComponent(dominio)}&output=json`;

  let resposta;
  try {
    resposta = await fetch(url, { signal: AbortSignal.timeout(TEMPO_LIMITE_CRTSH) });
  } catch {
    const erro = new Error('Nao foi possivel consultar o crt.sh agora. Tente novamente.');
    erro.status = 502;
    throw erro;
  }

  // Este if e o coracao do modulo. Sem ele, um 502/503/429 do crt.sh cai no
  // r.json(), que estoura, o catch engole e a busca termina "com sucesso" e
  // zero resultados -- indistinguivel de um dominio que realmente nao tem
  // subdominio nenhum. O erro precisa subir.
  if (!resposta.ok) {
    const erro = new Error(`O crt.sh respondeu ${resposta.status}. Tente novamente em alguns minutos.`);
    erro.status = 502;
    throw erro;
  }

  try {
    return await resposta.json();
  } catch {
    const erro = new Error('O crt.sh devolveu uma resposta que nao e JSON.');
    erro.status = 502;
    throw erro;
  }
}

Eu não estou supondo que isso acontece. Enquanto eu escrevia este artigo e testava a página no navegador, o crt.sh caiu sozinho — sem eu forçar nada:

A página mostrando a mensagem em vermelho: O crt.sh respondeu 502. Tente novamente em alguns minutos.

Com a guarda, o leitor vê exatamente o que houve. Sem ela, veria uma tabela vazia e concluiria que o domínio não tem subdomínio nenhum. 😳

🏚️ Certificado que existiu não é host no ar

Agora o ponto que dá nome ao artigo. A lista do crt.sh é um registro histórico: ela guarda tudo que um dia teve certificado. Ambiente de teste desligado há cinco anos, migração abandonada, subdomínio de campanha que durou um mês — está tudo lá, com a mesma cara de quem está no ar.

Por isso a segunda metade do trabalho é confirmar por DNS quais daqueles nomes ainda resolvem. Um resolve4 por candidato, com concorrência limitada:

/**
 * Busca subdominios de um dominio e confirma quais realmente resolvem em DNS.
 *
 * @param {string} dominio
 * @param {{ aoEncontrar?: Function, deveParar?: Function }} opcoes
 */
export async function localizarSubdominios(dominio, opcoes = {}) {
  const { aoEncontrar, deveParar } = opcoes;

  if (!dominioValido(dominio)) {
    const erro = new Error('Informe um dominio valido.');
    erro.status = 400;
    throw erro;
  }

  const json = await consultarCrtSh(dominio);
  const candidatos = extrairNomes(json, dominio).slice(0, MAX_SUBDOMINIOS);

  const encontrados = [];
  let proximo = 0;

  async function trabalhador() {
    while (proximo < candidatos.length) {
      if (deveParar && deveParar()) return;
      const nome = candidatos[proximo++];
      try {
        const ips = await resolverIpv4(nome);
        if (ips.length) {
          encontrados.push({ nome, ip: ips[0] });
          if (aoEncontrar) aoEncontrar({ nome, ip: ips[0] });
        }
      } catch {
        // Nao resolve: o certificado existiu, o host nao esta no ar. Ignora.
      }
    }
  }

  await Promise.all(
    Array.from({ length: Math.min(CONSULTAS_SIMULTANEAS, candidatos.length) }, trabalhador),
  );

  encontrados.sort((a, b) => a.nome.localeCompare(b.nome));
  return { encontrados, candidatos: candidatos.length };
}

O catch vazio ali é proposital, e vale um comentário: um nome que não resolve não é erro, é a resposta. Significa "esse host não está mais no ar", que é exatamente o que a gente veio medir.

E a diferença entre as duas listas é maior do que se imagina. Rodando contra o github.com:

  enterprise.github.com                      140.82.114.21
  status.github.com                          140.82.113.17
  slack.github.com                           140.82.112.22
  brandguide.github.com                      185.199.109.153
  jobs.github.com                            0.0.0.0
  central.github.com                         140.82.114.21
  community.github.com                       140.82.114.17
  atom-installer.github.com                  185.199.109.133

Candidatos no crt.sh: 115
Confirmados por DNS:  33

115 candidatos, 33 confirmados. Dois terços da lista bruta eram fantasmas. Se eu tivesse publicado a saída do crt.sh direto, teria entregue um relatório com 70% de lixo — e com toda a cara de estar certo.

Repare também no jobs.github.com apontando para 0.0.0.0. O nome ainda resolve, mas para lugar nenhum: é um serviço desativado cujo registro DNS ficou para trás. O DNS filtra a maior parte do lixo, não todo ele.

🚦 Os tetos que não são negociáveis

Dois números no topo do arquivo merecem atenção, porque eles são o que impede a ferramenta de virar um problema:

// Teto rigido: crt.sh devolve milhares de nomes para dominios grandes e cada
// candidato vira uma consulta DNS. Sem o teto, uma busca sozinha derruba o app.
export const MAX_SUBDOMINIOS = 200;

const TEMPO_LIMITE_CRTSH = 20000;
const CONSULTAS_SIMULTANEAS = 20;

// Resolvedores publicos usados quando o resolvedor do sistema nao responde.

Domínios grandes devolvem milhares de nomes no crt.sh, e cada candidato vira uma consulta DNS. Sem o teto, uma única busca dispara alguns milhares de consultas simultâneas e derruba o seu próprio resolvedor — ou faz o provedor achar que você está fazendo algo que não deveria.

E note que eles são fixos no código, não parâmetros da função. Um teto que quem chama pode aumentar não é um teto.

🔁 Quando o resolvedor da máquina não coopera

Uma coisa que descobri na prática: dentro de container, o resolvedor do sistema muitas vezes não responde a consultas diretas — e aí tudo falha, dando a impressão de que nenhum subdomínio existe. A saída é ter um resolvedor público de reserva:


// Erros que indicam resolvedor fora do ar (vale tentar a reserva). ENOTFOUND e
// ENODATA NAO entram aqui: eles significam "o nome nao existe", que e a
// resposta certa, nao uma falha de infraestrutura.
const RESOLVEDOR_CAIU = new Set(['ECONNREFUSED', 'ETIMEOUT', 'ETIMEDOUT', 'ESERVFAIL', 'EREFUSED']);

let resolvedorReserva = null;

function obterResolvedorReserva() {
  if (!resolvedorReserva) {
    resolvedorReserva = new dns.promises.Resolver({ timeout: 5000, tries: 2 });
    resolvedorReserva.setServers(DNS_RESERVA);
  }
  return resolvedorReserva;
}

/** Resolve o IPv4 de um nome, caindo para um DNS publico se o do sistema falhar. */
export async function resolverIpv4(nome) {
  try {
    return await dns.promises.resolve4(nome);
  } catch (erro) {
    if (RESOLVEDOR_CAIU.has(erro && erro.code)) {
      return await obterResolvedorReserva().resolve4(nome);
    }
    throw erro;
  }
}

O detalhe que separa isso de funcionar de isso atrapalhar está na lista RESOLVEDOR_CAIU: ela tem ECONNREFUSED e os timeouts, mas não tem ENOTFOUND nem ENODATA. Esses dois significam "o nome não existe" — que é uma resposta correta, não uma falha. Se você repetir a consulta no DNS público toda vez que um nome não existe, e a maioria dos candidatos não existe mesmo, você acabou de dobrar o tempo da busca inteira para não descobrir nada.

📡 Mostrando resultado antes de terminar

Uma busca dessas leva de 2 a 30 segundos. Deixar a tela parada esse tempo todo é ruim, e tem uma solução simples que não precisa de WebSocket: NDJSON — uma linha de JSON por evento, escrita na resposta conforme os resultados aparecem.

app.post('/api/subdominios', async (req, res) => {
  const dominio = String((req.body && req.body.dominio) || '').trim();

  // Validar ANTES de abrir o stream: depois que a primeira linha sai, o status
  // HTTP ja foi enviado e nao da mais para responder 400.
  if (!dominio) return res.status(400).json({ erro: 'Informe um dominio.' });
  if (!dominioValido(dominio)) return res.status(400).json({ erro: 'Dominio invalido.' });

  res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
  res.setHeader('Cache-Control', 'no-cache, no-transform');
  res.setHeader('X-Accel-Buffering', 'no');

  // Tem de ser res.on('close'), NAO req.on('close'): o evento da requisicao
  // dispara assim que o corpo dela termina de ser lido -- o que acontece antes
  // de existir qualquer resultado. Com req, "abortado" ja era true na primeira
  // linha, todas eram descartadas e a resposta saia vazia com HTTP 200.
  let abortado = false;
  res.on('close', () => { abortado = true; });

  const escrever = (obj) => {
    if (abortado || res.writableEnded) return;
    res.write(JSON.stringify(obj) + '\n');
  };

  try {
    const { encontrados, candidatos } = await localizarSubdominios(dominio, {
      deveParar: () => abortado,
      aoEncontrar: ({ nome, ip }) => escrever({ tipo: 'encontrado', nome, ip }),
    });
    if (abortado) return;
    escrever({ tipo: 'fim', encontrados, candidatos });
  } catch (erro) {
    // O stream ja abriu com 200, entao o erro vai como linha, nunca como status.
    escrever({ tipo: 'erro', erro: erro.message || 'Falha ao buscar subdominios.' });
  }

  if (!res.writableEnded) res.end();

Duas coisas nesse trecho me custaram tempo, e as duas produzem o mesmo sintoma horrível: resposta vazia com HTTP 200.

A primeira é a validação. Ela tem que acontecer antes da primeira linha sair, porque depois disso o status HTTP já foi enviado — não dá mais para responder 400. Por isso o erro que acontece durante o streaming vai como linha ({ tipo: 'erro' }), nunca como status.

A segunda foi um bug meu, e bem bobo. 😳 Eu escrevi req.on('close') para detectar o visitante que fecha a aba no meio da busca. Parece certo — e a resposta saiu vazia, com HTTP 200, sempre. Fui atrás com um teste mínimo:

  [req close] disparou -> abortado=true
  [200ms depois] abortado = true
  corpo recebido: ""

O evento da requisição dispara assim que o corpo dela termina de ser lido — o que acontece antes de existir qualquer resultado. A flag já estava ligada na primeira linha, e todas foram descartadas. O evento certo é o da resposta:


  // Tem de ser res.on('close'), NAO req.on('close'): o evento da requisicao
  // dispara assim que o corpo dela termina de ser lido -- o que acontece antes
  // de existir qualquer resultado. Com req, "abortado" ja era true na primeira
  // linha, todas eram descartadas e a resposta saia vazia com HTTP 200.
  let abortado = false;

🖥️ A página para testar sem escrever código

O repositório traz uma página pronta em publico/, servida pelo próprio Express. Você digita o domínio e vê os subdomínios aparecendo um a um, conforme o DNS confirma cada um:

A página listando quatro subdomínios confirmados com seus IPs, e o resumo de 5 candidatos e 4 confirmados

Do lado do navegador, ler NDJSON tem um detalhe que quase todo mundo erra na primeira vez: um pedaço que chega pela rede não termina necessariamente numa quebra de linha. Você pode receber meia linha de JSON. Se tentar dar JSON.parse nela, quebra.

A solução é guardar o resto para o próximo pedaço:

restante += decodificador.decode(value, { stream: true });
const linhas = restante.split('\n');

// A ultima parte pode ser uma linha cortada no meio: guarda para o
// proximo pedaco em vez de tentar interpretar JSON incompleto.
restante = linhas.pop();

for (const linha of linhas) {
  if (!linha.trim()) continue;
  const evento = JSON.parse(linha);
  // ...
}

▶️ Rodando

Pela linha de comando, é só passar o domínio:

node buscar.js palomamacetko.com.br
Buscando subdominios de palomamacetko.com.br...

  becodosscripts.palomamacetko.com.br        64.29.17.65
  loja.palomamacetko.com.br                  172.67.130.52
  lab.palomamacetko.com.br                   216.198.79.1
  testespaloma.palomamacetko.com.br          199.36.158.100

Candidatos no crt.sh: 5
Confirmados por DNS:  4
Tempo:                2.2s

Ou npm start e abrir http://localhost:3009 para usar a página. Toda a saída deste artigo — inclusive o erro 502 lá em cima — é execução real, nada foi montado à mão.

🎁 O código completo

Está tudo no repositório, com o README trazendo as armadilhas resumidas e como reproduzir o erro do crt.sh de propósito:

Exemplos_LocalizarSubDominios no GitHub subdominios.js com a lógica, buscar.js para o terminal, servidor.js com a rota NDJSON e a página em publico/. github.com

O que eu levo deste código é uma frase curta: a fonte generosa e a fonte confiável raramente são a mesma. O crt.sh entrega tudo que um dia existiu, e é justamente por isso que ele sozinho não responde a pergunta. É o DNS, chato e silencioso, que separa o castelo iluminado da ruína.

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟