DEV Community 👩‍💻👨‍💻

moose
moose

Posted on • Updated on

Rust Host Lookup and Port Scanner

This is just the remnants of a project I started and am tabling. Its ANOTHER port scanner in rust. I was planning on mimicking nmap functionality.
tl;dr
https://github.com/salugi/port_scanner_demo

The project build is meant for building bigger pieces. The conductor is the actual actor. The project was going to be separated up into scans. Since it is just the TCP part, here is how it works.

We are going to structure the project using cargo workspaces. I decided to build it this way since it better illustrates how exactly to structure files in order for rust to see them all.

First, make a directory

mkdir port_scanner_demo
Enter fullscreen mode Exit fullscreen mode

in that directory make a Cargo.toml file and copypaste the following into it:

[workspace]
members = [
    "example_bin",
    "lib"
    ]
Enter fullscreen mode Exit fullscreen mode

This sets up the directory to handle multiple project files and makes it easier to break up files. Right here, we are basically saying "I have these two project folders". Now, since the toml file declares this, cargo knows not to make multiple target directories. Once you have the Cargo.toml file created, do the following in your project directory:
1 . Create the example_bin binary space:

cargo new --bin example_bin
Enter fullscreen mode Exit fullscreen mode

2 . Create the library to go along with it:

cargo new --lib lib
Enter fullscreen mode Exit fullscreen mode

3 . To make sure, in the project root directory do:

cargo run
Enter fullscreen mode Exit fullscreen mode

If it runs, things went right, you should have only one shared target directory because cargo found the root directory toml file and knew there were shared workspaces.

First in the ./lib/Cargo.toml file, change the dependencies to look like the following:

[dependencies]
dns-lookup = "1.0.2"
Enter fullscreen mode Exit fullscreen mode

Second, let us make a function to look up the ip of a given host.
In the lib directory create a file called conductor.rs

touch ./lib/conductor.rs
Enter fullscreen mode Exit fullscreen mode

The conductor will "conduct" the different port scan variation. This will be the main abstraction and is done because there are two components to the TCP scan that have to do with creating SocketAddr's for a timeout scan. This means we need to be able to take in a "google.com" or an "8.8.8.8" address.

In the ./lib/conductor.rs file, copy and paste the following:

use std::io;
use std::net::IpAddr;
use std::str::FromStr;
use dns_lookup::lookup_host;

pub fn conduct_host_lookup(addr:&str)->Result<Vec<String>, io::Error>{

    let ip_vecs : Vec<IpAddr> = lookup_host(addr).unwrap();
    let mut return_array: Vec<String> = vec![];

    for ip in ip_vecs {
        return_array.push(ip.to_string())
    }


    Ok(return_array)

}
Enter fullscreen mode Exit fullscreen mode

now, in the ./lib/lib.rs file, copy past the following:

pub mod conductor;
Enter fullscreen mode Exit fullscreen mode

next in the ./example_bin/Cargo.toml file, change the dependency section to look like the following:

[dependencies]
lib = {path = "../lib"}
Enter fullscreen mode Exit fullscreen mode

Now in the ./example_bin/main.rs file, import the conduct_host_lookup function by copypasting the following in:

use lib::conductor::{conduct_host_lookup};

fn main() {

    let hosts = conduct_host_lookup("google.com");
    println!("{:?}", hosts);

}
Enter fullscreen mode Exit fullscreen mode

in the root of the ./port_scanner_demo/ directory run it by

cargo run
Enter fullscreen mode Exit fullscreen mode

your output should yield a similar result:

Ok(["142.250.190.78"])
Enter fullscreen mode Exit fullscreen mode

The host lookup part is done. Now, lets start on the first tcp scan. In the ./lib/ directory create the ./lib/tcp_scans.rs file. Copy paste the following in:

use std::io;
use std::net::{SocketAddr, TcpStream};
use std::time::Duration;



/*

connect scan

 */
pub fn tcp_connect_scan(address:&str, ports:Vec<i32>)->Result<Vec<i32>, io::Error>{

    let mut open_port_vector:Vec<i32> = vec![];


    for port in ports {

        let addr_string = format!("{}:{}", address, port);
        let server_details:SocketAddr = addr_string.parse().expect("unable to parse");

        if TcpStream::connect(&server_details).is_ok() {

            open_port_vector.push(port);

        }

    }

    Ok(open_port_vector)

}
Enter fullscreen mode Exit fullscreen mode

Since the project revolved around records, we will create a data struct to have a consolidated way to transport the host as well as the open ports on the host.
In the ./lib/ directory, create the ./lib/records.rs file and copypaste the following into it:

pub struct TcpScanRecord {
    pub host : String,
    pub open_ports : Vec<i32>
}
Enter fullscreen mode Exit fullscreen mode

To make sure it is available to main, go to the ./lib/lib.rs file and change it to look like the following:

pub mod conductor;
mod tcp_scans;
pub mod records;
Enter fullscreen mode Exit fullscreen mode

Switch back over to the ./lib/conductor.rs file change it to the following

use crate::tcp_scans::{tcp_connect_scan};
use crate::records::TcpScanRecord;

use std::io;
use std::net::IpAddr;
use std::str::FromStr;
use dns_lookup::lookup_host;

pub fn conduct_connect_scan(addr:&str, ports:Vec<i32>) ->Result<TcpScanRecord,io::Error> {

    let sanitized_host = host_sanitizer(addr);
    let open_ports = tcp_connect_scan(&sanitized_host, ports)?;
    let record = TcpScanRecord {
        host : sanitized_host,
        open_ports
    };

    Ok(record)

}

pub fn conduct_host_lookup(addr:&str)->Result<Vec<String>, io::Error>{

    let ip_vecs : Vec<IpAddr> = lookup_host(addr).unwrap();
    let mut return_array: Vec<String> = vec![];

    for ip in ip_vecs {
        return_array.push(ip.to_string())
    }


    Ok(return_array)

}

fn host_sanitizer(addr:&str)->String{

    match IpAddr::from_str(addr) {

        Err(_) => {

            let ip_vecs : Vec<std::net::IpAddr> = lookup_host(addr).unwrap();

            ip_vecs[0].to_string()

        },

        Ok(IpAddr::V4(..)) => addr.to_string(),
        Ok(IpAddr::V6(..)) => addr.to_string(),

     }

}
Enter fullscreen mode Exit fullscreen mode

The host_sanitizer function allows us to take in a host string, an IPv4 address and an IPv6 address to feed to the the TCP connect scan.

To test, switch to the ./example_bin/main.rs file and change it to look like the following:


use lib::conductor::{conduct_connect_scan, conduct_host_lookup};

fn main() {

    let hosts = conduct_host_lookup("google.com");
    println!("{:?}", hosts);

    let record = conduct_connect_scan("google.com",vec![80]);
    println!("{:?}", record);
}
Enter fullscreen mode Exit fullscreen mode

To run, go back to the root folder and do a

cargo run
Enter fullscreen mode Exit fullscreen mode

Your output should be similar to the following:

Ok(["142.250.191.238"])
Ok(TcpScanRecord { host: "142.250.191.238", open_ports: [80]})
Enter fullscreen mode Exit fullscreen mode

Now, this is a very basic port scanner. The issue is, if we want to then scan a bunch of ports we would have to wait a long time if a port is not open. So we want a timeout. Luckily for us, there is a Rust function for that. Back in the ./lib/tcp_scans.rs, change it to look like the following:

use std::io;
use std::net::{SocketAddr, TcpStream};
use std::time::Duration;



/*

connect scan

 */
pub fn tcp_connect_scan(address:&str, ports:Vec<i32>)->Result<Vec<i32>, io::Error>{

    let mut open_port_vector:Vec<i32> = vec![];


    for port in ports {

        let addr_string = format!("{}:{}", address, port);
        let server_details:SocketAddr = addr_string.parse().expect("unable to parse");

        if TcpStream::connect(&server_details).is_ok() {

            open_port_vector.push(port);

        }

    }

    Ok(open_port_vector)

}


/*

connect timeout scan

 */
pub fn tcp_timeout_scan(address:&str, ports:Vec<i32>, timeout:u64)->Result<Vec<i32>, io::Error>{

    let mut open_port_vector:Vec<i32> = vec![];
    let duration = Duration::from_millis(timeout);

    for port in ports {

        let addr_string = format!("{}:{}", address, port);
        let server_details:SocketAddr = addr_string.parse().expect("unable to parse");

        if TcpStream::connect_timeout(&server_details,duration).is_ok() {

            open_port_vector.push(port);

        }

    }

    Ok(open_port_vector)

}
Enter fullscreen mode Exit fullscreen mode

This is basically saying "we are only willing to wait n number of milliseconds to be able to connect".

Switch back to the ./lib/conductor.rs file and modify it to look like the following:

use crate::tcp_scans::{tcp_connect_scan, tcp_timeout_scan};
use crate::records::TcpScanRecord;

use std::io;
use std::net::IpAddr;
use std::str::FromStr;
use dns_lookup::lookup_host;

pub fn conduct_connect_scan(addr:&str, ports:Vec<i32>) ->Result<TcpScanRecord,io::Error> {

    let sanitized_host = host_sanitizer(addr);
    let open_ports = tcp_connect_scan(&sanitized_host, ports)?;
    let record = TcpScanRecord {
        host : sanitized_host,
        open_ports
    };

    Ok(record)

}

pub fn conduct_timeout_scan(addr:&str, ports:Vec<i32>, timeout:u64) ->Result<TcpScanRecord,io::Error> {

    let sanitized_host = host_sanitizer(addr);
    let open_ports = tcp_timeout_scan(&sanitized_host, ports,timeout)?;
    let record = TcpScanRecord {
        host : sanitized_host,
        open_ports
    };

    Ok(record)

}

pub fn conduct_host_lookup(addr:&str)->Result<Vec<String>, io::Error>{

    let ip_vecs : Vec<IpAddr> = lookup_host(addr).unwrap();
    let mut return_array: Vec<String> = vec![];

    for ip in ip_vecs {
        return_array.push(ip.to_string())
    }


    Ok(return_array)

}

fn host_sanitizer(addr:&str)->String{

    match IpAddr::from_str(addr) {

        Err(_) => {

            let ip_vecs : Vec<std::net::IpAddr> = lookup_host(addr).unwrap();

            ip_vecs[0].to_string()

        },

        Ok(IpAddr::V4(..)) => addr.to_string(),
        Ok(IpAddr::V6(..)) => addr.to_string(),

     }

}
Enter fullscreen mode Exit fullscreen mode

To test, switch back to ./example_bin/main.rs and modify it to look like the following:


use lib::conductor::{conduct_timeout_scan, conduct_connect_scan, conduct_host_lookup};

fn main() {

    let hosts = conduct_host_lookup("google.com");
    println!("{:?}", hosts);

    let record = conduct_connect_scan("google.com",vec![80]);
    println!("{:?}", record);

    let record = conduct_timeout_scan("google.com",vec![80], 80);
    println!("{:?}", record);

}
Enter fullscreen mode Exit fullscreen mode

Run

cargo run
Enter fullscreen mode Exit fullscreen mode

The output should resemble the following:

Ok(["142.250.190.110"])
Ok(TcpScanRecord { host: "142.250.190.110", open_ports: [80]})
Ok(TcpScanRecord { host: "142.250.190.110", open_ports: [80]})
Enter fullscreen mode Exit fullscreen mode

Where to go from here? It is an interesting question. These types of scans can run independent of sudo/admin access. When getting into the ip layer, you will run into the need to have admin/sudo access. A lighter-weight is a syn scan.
This will require the user to build partial packets, but this could also be a way to trigger alarms at a target address because this is a symptom of a type of DDOS/DOS attack. ICMP or ping scans also require sudo/admin access.

These are scans I decided to not build out.

Another thing to build out is a way to analyze the OS. Techniques to do this are banner grabbing and open port OS analysis.

IF you wanted to send strings to try and do some banner grabbing, you could do something like

/*

tcp send string

 */
pub fn tcp_send_string(address:String, port:i32, msg:String) ->Result<String, io::Error>{


        let host_string = format!("{}:{}", address, port.to_string());
        let mut stream =  TcpStream::connect(host_string).expect("could not connect");
        let mut response_shell = String::new();
        let req_bytes =     msg.as_bytes();

        stream
            .write_all(req_bytes)
            .expect("couldnt write bytes");

        stream
            .read_to_string(&mut response_shell)
            .expect("could not read string");

    Ok(response_shell)
}
Enter fullscreen mode Exit fullscreen mode

its basically sending data and parsing the return strings or packet data to verify the service/protocol running on a given port.
You'd probably want to try a scraper to parse returned document data. Rust has great options for that.

If no banner is able to be grabbed, the open port analysis basically takes all the open ports and will attempt to match against know schemas of different systems. Basically if A, B, and C are open there is an X% chance of being linux.

Some things to consider:
Rate limiting: You can send a shit ton of data and throw a bunch of DOS flags because, you can DOS/DDOS a host by sending too many packets (especially partial packets like syn packets that don't get acknowledged.)
Gettin' Listed: you can get blocked by a number of hosts if you send them a bunch of weird traffic. Avoid unnecessary checks.
I wrote this to be able to pass core functionality strings. There are a lot of different types, and this was aiming to build basic API's with Deno for an intelligence platform.

Remember, when collecting intelligence slow and steady wins the race. If you get a response indicating a connection was reset: time to change IPs.

A port scanner is a perfect exercise to learn about protocols. It's a great opportunity to learn about concurrent programming and how to utilize multiple threads to optimize performance.

If you know how to build the tools used, you know how to benefit from their use.

If you have any issues, feel free to leave a comment. The working product is at
https://github.com/salugi/port_scanner_demo

peace

Top comments (0)

We are hiring! Do you want to be our Senior Platform Engineer? Forem is hiring a Senior Platform Engineer

If you're interested in ops and site reliability and capable of dipping in to our Linux stack, we'd love your help shoring up our systems!