Localizando subdomínios de um domínio
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.
🔍 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:
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:
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:
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! 🌈🌟