Node.js: checando as portas abertas de um IP
Olá meus Unicórnios! 🦄✨
Tudo começou com uma pergunta simples: "o servidor está com a porta
3306 aberta para fora?". Eu podia abrir um nmap, claro. Mas
eu queria isso dentro de uma ferramenta minha, rodando no
navegador, sem depender de um binário instalado no servidor. 🤔
Achei que seria meia hora de trabalho. Afinal, é só tentar conectar, né? Pois é. 😅 O código que funciona sai em quinze minutos mesmo — o problema é que ele mente para você de três jeitos diferentes, e eu só descobri isso depois de encanar com resultados que não batiam.
Neste artigo eu conto o caminho inteiro: como sondar uma porta com o
módulo net, por que "fechada" e "filtrada" são coisas
diferentes (e como o relógio prova isso), por que o banner do serviço
às vezes não vem, e — o mais importante — o guarda que impede a sua
ferramenta de virar arma na mão dos outros. 🔐
🔌 Sondando uma porta: o mínimo que funciona
A ideia é velha e simples: se eu consigo completar um handshake
TCP com ip:porta, tem alguém escutando ali. Não
precisa de privilégio de root, não precisa de pacote cru — é a mesma
conexão que qualquer cliente faz.
Em Node isso é o módulo net, que já vem instalado:
import net from 'node:net';
const soquete = new net.Socket();
soquete.setTimeout(2000);
soquete.once('connect', () => console.log('ABERTA'));
soquete.once('error', () => console.log('fechada?'));
soquete.connect(80, 'exemplo.com');
Funciona. E é exatamente aqui que mora a primeira
mentira, porque esse 'fechada?' com interrogação está mais
certo do que eu imaginava.
🚦 As três respostas de uma porta (não duas)
Eu escrevi o código acima pensando em dois resultados: abre ou não abre. Está errado. Uma porta responde de três jeitos, e o terceiro é o que interessa:
- Aberta — o handshake completou. Tem serviço ali.
- Fechada — o host respondeu
RST(ECONNREFUSED). Repare: ele respondeu. A máquina existe, está de pé e disse "aqui não". - Filtrada — silêncio. Um firewall comeu o pacote e não mandou nem a recusa. Você fica esperando até o tempo limite estourar.
Juntar "fechada" e "filtrada" no mesmo balde de "não aberta" joga fora a informação mais interessante da varredura: a diferença entre "não tem serviço nessa porta" e "tem um firewall aqui na frente".
E o melhor: não precisa acreditar em mim, o relógio conta. Rodei os três casos na minha máquina — uma porta que eu mesma abri, a porta 1 (fechada) e um IP de documentação que descarta pacote em silêncio:
aberta -> { estado: 'aberta', ms: 3, codigo: null }
fechada -> { estado: 'fechada', ms: 1, codigo: 'ECONNREFUSED' }
filtrada -> { estado: 'filtrada', ms: 2010, codigo: null }
🤐 O banner: nem todo serviço fala primeiro
Quando a porta abre, dá para ir além e descobrir o que está escutando. Muitos serviços se apresentam sozinhos assim que você conecta — esse texto é o banner.
Foi aqui que eu tomei o segundo susto. Meu primeiro código só marcava a porta como aberta depois de receber o banner. Resultado: o SSH aparecia lindamente e toda porta HTTP sumia da lista. 😳
O motivo é óbvio depois que você vê: o SSH se apresenta ao conectar, mas o HTTP fica calado esperando você pedir alguma coisa. Ele não vai falar primeiro nunca.
A solução é dar uma janela curta para o banner e seguir a vida se ele não
vier. Este é o sondarPorta() inteiro, do repositório:
/**
* Tenta abrir uma conexao TCP. NUNCA rejeita: porta fechada e um resultado
* normal desta funcao, nao um erro do programa.
*
* Devolve { estado, ms, codigo, banner } com estado em:
* 'aberta' - o handshake TCP completou
* 'fechada' - o host respondeu RST (ECONNREFUSED). Ha alguem ali, so nao
* nessa porta.
* 'filtrada' - ninguem respondeu ate o tempo limite. Firewall descartando
* o pacote em silencio. NAO e a mesma coisa que fechada.
*/
export function sondarPorta(ip, porta, tempoLimiteMs = TEMPO_LIMITE_MS, { lerBanner = false } = {}) {
return new Promise((resolve) => {
const inicio = Date.now();
const soquete = new net.Socket();
let banner = '';
let timerBanner = null;
// 'connect', 'data', 'timeout' e 'error' podem disparar quase juntos.
// Sem esta trava, o mesmo Promise resolveria duas vezes (a segunda e
// silenciosamente ignorada) e o soquete ficaria aberto vazando descritor.
let encerrado = false;
const encerrar = (estado, codigo = null) => {
if (encerrado) return;
encerrado = true;
if (timerBanner) clearTimeout(timerBanner);
soquete.destroy();
resolve({ estado, ms: Date.now() - inicio, codigo, banner: banner.trim() || null });
};
soquete.setTimeout(tempoLimiteMs);
soquete.once('connect', () => {
if (!lerBanner) return encerrar('aberta');
// Conectou: a porta ja esta aberta, o banner e bonus. Se o servico nao
// falar primeiro (HTTP nao fala), o timer fecha como aberta do mesmo jeito.
timerBanner = setTimeout(() => encerrar('aberta'), ESPERA_BANNER_MS);
});
soquete.on('data', (pedaco) => {
if (banner.length < BANNER_MAX_BYTES) {
banner += pedaco.toString('latin1').slice(0, BANNER_MAX_BYTES - banner.length);
}
encerrar('aberta');
});
soquete.once('timeout', () => encerrar('filtrada'));
soquete.once('error', (erro) => {
encerrar(erro.code === 'ECONNREFUSED' ? 'fechada' : 'erro', erro.code || null);
});
soquete.connect(porta, ip);
});
}
Três detalhes valem o olhar:
- A função nunca rejeita. Porta fechada é um
resultado normal, não uma exceção do programa. Se ela lançasse, uma
varredura de 1024 portas seria 1000
try/catch. - A trava
encerradonão é paranoia. Os eventosconnect,data,timeouteerrordisparam quase juntos; sem ela o mesmo Promise resolveria duas vezes — a segunda é ignorada em silêncio, mas o soquete fica aberto vazando descritor. - Aquele
timerBanneré o conserto do bug do HTTP: conectou, a porta já está aberta; o banner é bônus.
🛡️ O guarda: sua ferramenta não pode ser arma dos outros
Se você só vai rodar isso no terminal da sua máquina, pode pular. Mas se essa varredura virar um endpoint — um formulário na web, como o do repositório — preste atenção nesta seção. 🙏
Um servidor que aceita "me diga quais portas estão abertas em X"
é um servidor que faz conexão para onde o desconhecido mandar. Se eu digitar
127.0.0.1, ele varre a própria máquina de vocês
e me conta o que achou: o Redis sem senha, o banco que só escuta local, o
painel interno. Isso tem nome — SSRF — e a sua ferramenta
gentil virou o scanner interno do atacante.
A defesa é recusar endereço interno. E aqui vem o detalhe cruel:
A lista de faixas internas é maior do que a gente lembra de cabeça:
/** true se o IP for interno: loopback, rede privada, link-local ou multicast. */
export function ehIpPrivado(ip) {
if (typeof ip !== 'string' || !ip) return true;
let endereco = ip.trim().toLowerCase();
// IPv6 que embrulha um IPv4 (::ffff:127.0.0.1) precisa ser avaliado como IPv4,
// senao o loopback entra disfarcado.
const embrulhado = endereco.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
if (embrulhado) endereco = embrulhado[1];
const inteiro = ipParaInteiro(endereco);
if (inteiro !== null) {
return (
dentroDaFaixa(inteiro, '0.0.0.0', 8) ||
dentroDaFaixa(inteiro, '10.0.0.0', 8) ||
dentroDaFaixa(inteiro, '127.0.0.0', 8) || // loopback
dentroDaFaixa(inteiro, '169.254.0.0', 16) || // link-local: inclui 169.254.169.254
dentroDaFaixa(inteiro, '172.16.0.0', 12) ||
dentroDaFaixa(inteiro, '192.168.0.0', 16) ||
dentroDaFaixa(inteiro, '100.64.0.0', 10) || // CGNAT
dentroDaFaixa(inteiro, '224.0.0.0', 4) // multicast
);
}
if (endereco === '::1' || endereco === '::') return true;
if (endereco.startsWith('fe80:')) return true; // link-local
if (endereco.startsWith('fc') || endereco.startsWith('fd')) return true; // fc00::/7
if (endereco.startsWith('ff')) return true; // multicast
// Nao parseou como IPv6: na duvida, trata como privado e recusa.
return !/^[0-9a-f:]+$/.test(endereco);
}
Repare no 169.254.0.0/16 com o comentário. Não está ali por
capricho: 169.254.169.254 é o endereço de metadados das
nuvens. Numa VM da AWS, do Google ou da Azure, é dele que saem as
credenciais temporárias da máquina. É o primeiro lugar onde um SSRF bate.
E o ::ffff:127.0.0.1 no começo da função? É o loopback
entrando disfarçado de IPv6. Sem desembrulhar, ele escapa da checagem de
IPv4 inteirinho.
Com as faixas na mão, o guarda fica assim:
export async function alvoPublico(host) {
const h = typeof host === 'string' ? host.trim().toLowerCase() : '';
if (h === 'localhost') throw erro400('Alvo nao permitido (endereco interno).');
if (!ehHostValido(h)) throw erro400('Informe um host ou IP valido.');
let enderecos;
try {
const resolvido = await dns.promises.lookup(h, { all: true });
enderecos = resolvido.map((r) => r.address);
} catch {
throw erro400('Nao foi possivel resolver o host.');
}
// `some` e nao `every`: se QUALQUER um dos IPs for interno, recusa. Um host
// com varios registros A poderia colar um interno no meio.
if (!enderecos.length || enderecos.some(ehIpPrivado)) {
throw erro400('Alvo nao permitido (endereco interno).');
}
return enderecos;
}
Duas escolhas de propósito: o some() — e não
every() — porque se qualquer um dos IPs for interno,
recusa (um host pode ter vários registros A, com um interno no
meio); e o retorno da lista de IPs, para a varredura usar o mesmo endereço
que acabou de ser aprovado. Resolver o DNS de novo lá na frente abriria
espaço para o alvo trocar de IP entre a checagem e o uso.
Rodando os quatro casos que interessam:
node cli.js localhost 80 # Alvo nao permitido (endereco interno).
node cli.js 127.0.0.1 80 # Alvo nao permitido (endereco interno).
node cli.js 192.168.1.1 80 # Alvo nao permitido (endereco interno).
node cli.js 169.254.169.254 80 # Alvo nao permitido (endereco interno).
⚡ Varrendo 1024 portas sem derrubar a própria máquina
Uma porta é fácil. Mil é outra conversa. Minha primeira versão foi a que todo mundo escreve:
// NAO faca isso
const resultados = await Promise.all(
portas.map((p) => sondarPorta(ip, p)),
);
Isso abre 1024 soquetes ao mesmo tempo. O sistema
operacional recusa (EMFILE, "too many open files") ou o
roteador doméstico engasga — e aí vem o resultado que me deixou maluca:
porta aberta aparecendo como filtrada. Não era firewall
nenhum, era a minha própria máquina não dando conta de responder a tempo. Eu
estava medindo o meu gargalo, não o servidor. 🤦♀️
O conserto é um punhado de trabalhadores puxando de uma fila comum:
export async function varrerPortas(host, portas, opcoes = {}) {
const {
concorrencia = 20,
tempoLimiteMs = 1500,
aoAbrir,
devoParar,
} = opcoes;
const [ip] = await alvoPublico(host);
const lista = [...new Set(portas.filter(ehPortaValida))].slice(0, MAX_PORTAS);
const abertas = [];
let indice = 0;
let varridas = 0;
async function trabalhador() {
while (indice < lista.length) {
if (devoParar && devoParar()) return;
const porta = lista[indice++];
const r = await sondarPorta(ip, porta, tempoLimiteMs, { lerBanner: true });
varridas++;
if (r.estado === 'aberta') {
abertas.push({ porta, ms: r.ms, banner: r.banner, servico: SERVICOS[porta] || null });
if (aoAbrir) aoAbrir({ porta, ip, ms: r.ms, banner: r.banner, servico: SERVICOS[porta] || null });
}
}
}
await Promise.all(
Array.from({ length: Math.min(concorrencia, lista.length) }, trabalhador),
);
abertas.sort((a, b) => a.porta - b.porta);
return { ip, abertas, varridas };
}
O indice++ compartilhado entre os trabalhadores parece coisa
de quem esqueceu o mutex, mas está certo: JavaScript é
single-thread, e o incremento acontece inteiro antes de qualquer
await devolver o controle. Dois trabalhadores nunca pegam a
mesma porta.
O devoParar existe para a versão web: se o usuário fecha a
aba no meio de uma varredura de 1024 portas, os trabalhadores param na
próxima volta em vez de gastar soquete para ninguém ver.
📡 Mostrando o resultado ao vivo com NDJSON
Uma varredura das portas comuns leva alguns segundos; de 1024 portas, bem mais. Devolver um JSON gigante no fim é condenar quem usa a olhar para uma tela parada — e eu acho isso péssimo. 😬
A saída é o NDJSON: uma linha JSON por evento, escrita
na resposta conforme as coisas acontecem. Sem WebSocket, sem SSE, sem
biblioteca — é o mesmo res.write() de sempre:
async function rotaVarrer(req, res) {
const { host, portas: portasTexto } = await lerCorpo(req);
const portas = interpretarPortas(portasTexto);
if (!host) {
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ error: 'Informe um host.' }));
}
// NDJSON: uma linha JSON por evento. A varredura pode levar dezenas de
// segundos - o navegador mostra cada porta aberta na hora, em vez de olhar
// para uma tela parada esperando um JSON gigante no fim.
res.writeHead(200, {
'Content-Type': 'application/x-ndjson; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
// Sem isto, um proxy reverso (nginx) segura o corpo em buffer e o
// streaming vira um bloco unico no final - o efeito ao vivo some.
'X-Accel-Buffering': 'no',
});
// Se o usuario fecha a aba, `shouldStop` para os trabalhadores. Sem isso a
// varredura continua ate o fim, gastando soquete para ninguem ver.
let abortado = false;
req.on('close', () => { abortado = true; });
const linha = (obj) => {
if (abortado || res.writableEnded) return;
res.write(JSON.stringify(obj) + '\n');
};
try {
const total = Math.min(portas.length, MAX_PORTAS);
linha({ tipo: 'inicio', total });
const { ip, abertas, varridas } = await varrerPortas(host, portas, {
concorrencia: 20,
tempoLimiteMs: 1500,
devoParar: () => abortado,
aoAbrir: (p) => linha({ tipo: 'aberta', ...p }),
});
if (abortado) return;
linha({ tipo: 'fim', ip, abertas, varridas });
res.end();
} catch (erro) {
// O guarda so roda depois de o cabecalho 200 ter saido, entao o erro nao
// pode virar status HTTP: vai como uma linha do proprio stream.
linha({ tipo: 'erro', erro: erro.message });
if (!res.writableEnded) res.end();
}
}
Dois detalhes que só aparecem quando isso vai para o ar:
X-Accel-Buffering: no— sem esse cabeçalho, um nginx na frente segura o corpo em buffer e entrega tudo de uma vez no final. Seu streaming funciona lindamente emlocalhoste morre em produção.- O erro vira linha, não status HTTP. O guarda roda
depois de o cabeçalho
200já ter saído — não dá mais para mandar400. Por isso ocatchescreve{'{'} tipo: 'erro' {'}'}dentro do próprio stream.
🖥️ O resultado, rodando
O repositório traz uma página que consome esse stream e mostra cada porta
no instante em que ela é encontrada. Rodando contra o
scanme.nmap.org — o host que o próprio projeto Nmap mantém
para testes:
Varrendo scanme.nmap.org - 11 portas...
ABERTA 22 (SSH) 406 ms
banner: SSH-2.0-OpenSSH_6.6.1p1 Ubuntu-2ubuntu2.13
ABERTA 80 (HTTP) 907 ms
ABERTA 31337 907 ms
IP resolvido: 2600:3c01::f03c:91ff:fe18:bb2f
Varridas: 11 portas em 1.7s
Abertas: 22, 80, 31337
Essa saída é real, e ela prova sozinha duas coisas que o artigo contou. 🎯
Primeira: a porta 22 veio com banner
(SSH-2.0-OpenSSH_6.6.1p1) e a 80 veio sem — o
HTTP calado, exatamente como previsto. Segunda: repare que a 80 e a 31337
marcaram 907 ms enquanto a 22 marcou 406 ms. Não é rede
lenta: são os 700 ms da janela de banner que elas
esperaram até a função desistir e declarar a porta aberta assim mesmo.
E tem um detalhe que eu não tinha planejado: o IP resolvido é
IPv6. O dns.lookup devolveu o registro AAAA primeiro e
a varredura inteira foi por IPv6, sem eu pedir. Bom lembrete de que "o IP do
servidor" pode não ser o que você tem na cabeça — por isso a função devolve
o endereço que realmente usou.
No navegador, a mesma coisa com o banner em destaque:
🧰 O script do terminal, inteiro
Para quem só quer rodar e ver, o cli.js completo — 50 linhas
que amarram tudo:
// Varredura pela linha de comando.
//
// node cli.js scanme.nmap.org
// node cli.js scanme.nmap.org 20-100
// node cli.js exemplo.com 22,80,443
import { varrerPortas, interpretarPortas, MAX_PORTAS } from './scanner.js';
const [host, portasTexto] = process.argv.slice(2);
if (!host) {
console.log('Uso: node cli.js <host> [portas]');
console.log(' portas: "comuns" (padrao), "1-1024" ou "22,80,443"');
process.exit(1);
}
const portas = interpretarPortas(portasTexto);
if (!portas.length) {
console.error('Nenhuma porta valida na lista informada.');
process.exit(1);
}
const total = Math.min(portas.length, MAX_PORTAS);
console.log(`Varrendo ${host} - ${total} portas...\n`);
const inicio = Date.now();
try {
const { ip, abertas, varridas } = await varrerPortas(host, portas, {
concorrencia: 20,
tempoLimiteMs: 1500,
aoAbrir: ({ porta, ms, servico, banner }) => {
const nome = servico ? ` (${servico})` : '';
console.log(` ABERTA ${String(porta).padStart(5)}${nome} ${ms} ms`);
if (banner) console.log(` banner: ${banner.split('\n')[0].slice(0, 70)}`);
},
});
const segundos = ((Date.now() - inicio) / 1000).toFixed(1);
console.log(`\nIP resolvido: ${ip}`);
console.log(`Varridas: ${varridas} portas em ${segundos}s`);
console.log(
abertas.length
? `Abertas: ${abertas.map((a) => a.porta).join(', ')}`
: 'Abertas: nenhuma',
);
} catch (erro) {
console.error(`Erro: ${erro.message}`);
process.exit(1);
}
A lista de portas aceita três formatos, porque digitar
22,80,443 é mais rápido que lembrar da lista de comuns:
export function interpretarPortas(texto) {
const t = String(texto || '').trim().toLowerCase();
if (!t || t === 'comuns') return PORTAS_COMUNS;
const portas = [];
for (const pedaco of t.split(',')) {
const faixa = pedaco.trim().match(/^(\d+)\s*-\s*(\d+)$/);
if (faixa) {
const de = Number(faixa[1]);
const ate = Number(faixa[2]);
// Faixa invertida ("443-80") daria lista vazia em silencio: normaliza.
for (let p = Math.min(de, ate); p <= Math.max(de, ate); p++) portas.push(p);
} else if (/^\d+$/.test(pedaco.trim())) {
portas.push(Number(pedaco.trim()));
}
}
return portas.filter(ehPortaValida);
}
Aquele Math.min/Math.max na faixa é um mimo
para quem digita 443-80 de trás para frente: sem ele o
for não roda nenhuma vez e você recebe zero portas em
silêncio, achando que o alvo está todo fechado.
🎁 O código completo
Está tudo no repositório, pronto para clonar e rodar. Não tem
npm install — não tem uma dependência sequer, só os módulos que
já vêm no Node. 🙌
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Leia também
Node.js: alterando o plano de energia do Windows
Como ler e trocar o plano de energia do Windows com Node.js e powercfg: por que resolver por GUID, e o acento que o TextDecoder nao decodifica.
Node.js: exportando grandes volumes do Elasticsearch
Como exportar milhões de registros do Elasticsearch para CSV com Node.js: a parede dos 10.000, a Scroll API e o scroll que fica aberto.
Node.js: gerando certificado SSL e instalando no IIS
Como emitir um certificado gratuito do Let's Encrypt com Node.js e instalar no IIS do Windows, com as armadilhas que travaram tudo.