DevSecOps

12 jul, 2017

Port Scanner Chatbot – Parte 1: Construindo um Port Scanner com Java

Publicidade

Um Port Scanner é um software que detecta portas disponíveis em um servidor ou em um host. O software mais famoso com essa finalidade é o nmap, que dentre várias coisas, também possui uma funcionalidade de port scan. Para utilizar essa função do nmap, basta especificar o IP da máquina a ser escaneada. No exemplo abaixo, utilizamos localhost 127.0.0.1.

Esse tipo de software é útil quando precisamos ver quais são as portas disponíveis em um servidor. Apenas a título de curiosidade, port scanners também são muito utilizados em ataques DoS. Durante esse tipo de ataque, é importante saber quais são as portas disponíveis no servidor sob ataque, já que, para que o servidor seja sobrecarregado por excesso de requisições, essas requisições devem ser feitas às portas disponíveis.

Nesse artigo, vamos nos concentrar nos aspectos de performance do software, para que, futuramente, possamos aplicar esse scanner em um slackbot, que irá responder a perguntas sobre a disponibilidade de portas em um servidor.

Vejamos, então, como funciona um port scanner e algumas alternativas de implementação usando Java.

Como funciona um port scanner?

A lógica por traz de um software desse tipo é bem simples: executamos um ping em todas as portas possivelmente disponíveis em um IP, especificando um tempo máximo de espera de resposta (timeout). Caso exista uma resposta dentro do período de tempo esperado, sabemos que a porta está disponível e atende às requisições, se não houver resposta, sabemos que essa é uma porta inacessível.

Para testar a conexão com as portas, vamos utilizar um socket que vai tentar estabelecer um elo bidirecional de comunicação entre nosso host e um servidor qualquer. Vejamos a implementação do método que testa a conexão com uma porta:

public boolean portaEstaAberta(String ip, int porta, int timeout) {
    try {
        Socket socket = new Socket();
        socket.connect(new InetSocketAddress(ip, porta), timeout);
        socket.close();
        return true;
    } catch (Exception ex) {
        return false;
    }
}

Se o socket conseguir estabelecer a comunicação com o endereço que fornecermos, temos uma porta acessível. Agora basta invocarmos nosso método para todas as 65535 portas possivelmente disponíveis:

public static void main(String[] args) {
	final int timeout = 200;
	final String ip = "127.0.0.1";

	for (int porta = 1; porta <= 65535; porta++) {
		if(portaEstaAberta(ip, porta, timeout)) {
			System.out.println("porta " + porta + "aberta em " + ip);
		}
	}
}
	
public static boolean portaEstaAberta(String ip, int porta, int timeout) {
    try {
	Socket socket = new Socket();
	socket.connect(new InetSocketAddress(ip, porta), timeout);
	socket.close();
	return true;
    } catch (Exception ex) {
	return false;
    }
}

Problema à vista

Com essa implementação, precisamos de 200ms (valor que atribuímos à variável timeout) para cada uma das 65535 portas. Suponhamos que no pior dos casos, um firewall bloqueie o acesso a todas as portas. Nesse caso, nosso port scanner é obrigado a esperar o timeout para todas elas! Assim, seriam necessários 13 mil segundos até que o port scanner leia todas as portas, algo em torno de 3 horas; ou seja, nada bom.

Para resolver esse problema, temos duas alternativas:

  • Diminuir o valor do timeout;
  • Paralelizar o trabalho com várias threads.

Somente diminuir o timeout não resolve de fato nosso problema. Pode ser que isso nos prejudique, tendo em vista que algumas portas podem demorar pouco mais de 100ms para responder, por exemplo. Por esse motivo, a melhor alternativa é paralelizar o processamento.

Paralelizando o processamento

Vamos utilizar 20 threads para conseguirmos um tempo máximo de 10 minutos de processamento. O tempo de processamento era de 13000 segundos, dividindo esse valor por 20 (quantidade de threads), temos 650 segundos, o equivalente a 10 minutos.

Vejamos a nova implementação do método portaEstaAberta() utilizando processamento assíncrono:

public static Future<Boolean> portaEstaAberta(final ExecutorService es, final String ip, final int port,
			final int timeout) {
	return es.submit(new Callable<Boolean>() {
		@Override
		public Boolean call() {
			try {
				Socket socket = new Socket();
				socket.connect(new InetSocketAddress(ip, port), timeout);
				socket.close();
				return true;

			} catch (Exception ex) {
				return false;
			}
		}
	});
}

ExecutorService, que utilizamos como parâmetro, nos permite trabalhar com tasks assíncronas sem termos que criar threads manualmente como faríamos tradicionalmente: Thread thread = new Thread(runnable);. Este executor service é quem vai gerenciar o pool com as 20 threads que criaremos quando formos invocar o método portaEstaAberta().

No método submit() do executor service, usamos um Callable que é uma interface funcional. O Callable funciona exatamente como um Runnable, passado ao construtor de threads. A diferença é que Callables não são do tipo void, eles retornam valores. No nosso caso, retornamos true, caso o Socket estabeleça comunicação com a porta em questão no momento da execução da thread.

Callables podem ser submetidos a executor services, assim como runnables. Mas e o resultado do callabe? Sabendo que o método submit() não espera o fim da execução de uma task para iniciar o processamento de uma nova, o executor service não pode retornar o resultado do callable diretamente. Em vez disso, o executor service retorna um resultado especial do tipo Future, que é usado para recuperar o resultado atual em um momento posterior. Por isso, nosso método tem o retorno do tipo Future<Boolean>.

Vejamos a implementação completa com a chamada do nosso novo método:

public static void main(String[] args) throws InterruptedException, ExecutionException {

	final String ip = "127.0.0.1";
	final int timeout = 200;
	final ExecutorService executorService = Executors.newFixedThreadPool(20);
	final List<Future<Boolean>> futures = new ArrayList<>();

	for (int porta = 1; porta <= 65535; porta++) {
		futures.add(portaEstaAberta(executorService, ip, porta, timeout));
	}

	executorService.shutdown();
	int portasAbertas = 0;

	for (final Future<Boolean> future : futures) {
		if (future.get()) {
			portasAbertas++;
		}
	}
	System.out.println("Existem " + portasAbertas + " portas abertas nesse host " + ip);
}

public static Future<Boolean> portaEstaAberta(final ExecutorService es, final String ip, final int port,
		final int timeout) {
	return es.submit(new Callable<Boolean>() {
		@Override
		public Boolean call() {
			try {
				Socket socket = new Socket();
				socket.connect(new InetSocketAddress(ip, port), timeout);
				socket.close();
				return true;

			} catch (Exception ex) {
				return false;
			}
		}
	});
}
  • Primeiramente, criamos o executor service com as 20 threads e instanciamos uma lista de Futures do tipo Boolean, que receberão os valores truee false após a execução de todas as threads.
  • Passamos por todas as portas, exatamente como era feito anteriormente. A diferença é que aqui armazenamos os resultados na lista de Futures.
  • Executor services precisam ser parados explicitamente, caso contrário, nunca serão parados. Por esse motivo, invocamos o método executorService.shutdown(). Este método espera o fim da execução das tasks e para a execução do executor service logo em seguida.
  • Passamos por todos os itens da lista de Futures verificando a existência de valores verdadeiros. Quando encontramos, incrementamos a variável portasAbertas para contarmos quantas portas estão abertas no dado endereço.

Observação sobre o passo 4: o método future.get() bloqueia a thread em execução e espera até que o callable complete a execução da task corrente. Isso garante que o retorno do processamento atual já esteja presente no Future retornado pelo método portaEstaAberta().

  • Por fim, apenas imprimimos a quantidade de portas abertas no endereço especificado.

A estrutura do método main() pode ser alterada para exibirmos quais são as portas abertas, como fizemos na primeira versão do Port Scanner.

Esse foi o primeiro artigo da construção do nosso Port Scanner. Nos próximos, vamos adicionar mais valor ao scanner até que possamos perguntar a um bot do slack, quais são as portas disponíveis em um servidor e quais serviços estão sendo expostos nelas!

Na sequência iremos:

  • Expor um endpoint com Spring, para ser consumido pelo slack bot.
  • Criar o bot para consumir o endpoint e responder a solicitações.