DEV Community

Al
Al

Posted on • Edited on

Building a Syn Scanner in Rust

Note:
Port scanning on hosts that don't like it when people probe them. They could actually bring legal consequences for port scanning them. Scan at your own risk. That being said, I've never heard of anyone getting arrested for port scanning.
i had to update some stuff bear with me

Thanks to chinoto for helping me.

Prereqs: having Rust installed

tldr : code

I'll be as brief as I can, and I hope to do a good write-up.

We'll be making a syn scanner in Rust. I love Rust, its a great language and I've had a lot of fun programming in it. You might have issues understanding whats going on if you're a beginner (or maybe as a pro if you think my code is janky). This code doesn't handle potentially filtered ports.

Lets start at what a syn scan should do.
The TCP handshake is the initial packet is a Syn packet which is like a request to connect. From there if the port is open receiver sends back a Syn/Ack. Then the sender sends an Ack packet. From there the connection starts.

In the syn scan, we send a Syn packet, and if the port is open we receive a Syn/Ack as a response. We never send back the last Ack packet to confirm connect. Instead we then send back an Rst packet otherwise the receiver of our Syn packet will continually send Syn/Ack to a timeout. We don't want to waste bandwidth.

Now that sounds super simple, and on paper it is. However to implement it is a tad bit more complicated.

Let's start out by making the new project. In the directory you have Rust projects in run

cargo new <Project Name>
Enter fullscreen mode Exit fullscreen mode

replace project name for whatever you want to name it. I named it "syn-scan-practice"

Now navigate to the project folder and open up the Cargo.toml file and past the following dependencies in it:

pnet = "0.34.0"
rand = "0.8.5"
pnet_packet = "0.34.0"
Enter fullscreen mode Exit fullscreen mode

As of 15/Dec/2023 these are the latest versions of the dependencies.

We are going to section this out in blocks, and I want it so we can do this with all the correct using statements right off the bat. So in main.rs paste the following usings at the top:

use pnet::{
    datalink::{Channel, DataLinkReceiver, NetworkInterface},
    packet::{
        ethernet::{EtherTypes, EthernetPacket},
        ip::IpNextHeaderProtocols,
        ipv4::Ipv4Packet,
        ipv6::Ipv6Packet,
        tcp::{ipv4_checksum, ipv6_checksum, MutableTcpPacket, TcpFlags, TcpOption, TcpPacket},
        Packet,
    },
    transport::{self, TransportChannelType, TransportProtocol, TransportSender},
};
use rand::{thread_rng, Rng};
use std::{
    net::IpAddr,
    sync::{
        atomic::{AtomicBool, Ordering},
        Arc,
    },
    thread,
    time::{Duration, Instant},
};
Enter fullscreen mode Exit fullscreen mode

We will use all of these, and its a good amount of stuff to perform what on paper is a simple task.

Now lets get the consts out of the way, in main.rs copy paste the following into it under the using statements:

pub const IPV4_HEADER_LEN: usize = 20;
pub const IPV6_HEADER_LEN: usize = 40;
pub const ETHERNET_HEADER_LEN: usize = 14;
Enter fullscreen mode Exit fullscreen mode

The length of the IPv4 header is 20, the IPv6 is double that. This gets added to the Ethernet header length which is 14. This all goes into the raw packet slice for building the packet.

Now lets set up the config struct, in main.rs copypaste this into it under the usings:

pub struct Config {
    interface_ip: IpAddr,
    source_port: u16,
    destination_ip: IpAddr,
    ports_to_scan: Vec<u16>,
    wait_after_send: Duration,
    timeout: Duration,
    all_sent: Arc<AtomicBool>,
}
Enter fullscreen mode Exit fullscreen mode

The config will be mostly reused variables in a struct, this will later go into an Arc so we can pass it between threads. The meaning behind this is to potentially incorporate it in a larger project.

next, get the number generator up, this will be used for ephemeral the ephemeral port.

fn generate_random_port(min: u32, max: u32) -> u32 {
    let mut rng = rand::thread_rng();
    rng.gen_range(min..max)
}
Enter fullscreen mode Exit fullscreen mode

Next we will want to implement new method for config, so in main.rs copypaste the following code:

impl Config {
    pub fn new(
        destination_ip: IpAddr,
        ports_to_scan: Vec<u16>,
        interface_ip: IpAddr,
        timeout: u64,
    ) -> Self {
        //get ephemeral port to use
        let source_port: u16 = generate_random_port(10024, 65535) as u16;
        Self {
            interface_ip,
            source_port,
            destination_ip,
            //set the kill flag, giving every syn packet a 500 millisecond roundtrip
            wait_after_send: Duration::from_millis(500 * ports_to_scan.len() as u64),
            ports_to_scan,
            //timeout just in case we need it.
            timeout: Duration::from_secs(timeout),
            //kill flag
            all_sent: Arc::new(AtomicBool::new(false)),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This is taking the destination IP and the ports to scan, and timeout just in case something happens receiving (something funky).

This is also setting up a source port to receive packets on a port that is Ephemeral. This also encompasses the "timeout" or the wait_afte_send (since it really does sleep the thread after send, but acts like a timeout).

Lastly, it has the "kill" flag, which is really the "all packets are sent" bool.

now lets set up the ability to get an interface:

//get the interface to establish a datalink channel
fn get_interface(interface_name: Option<String>) -> (NetworkInterface, IpAddr) {
    let interfaces = pnet::datalink::interfaces();

    //checks if interface exists, and if not it lists out available interfaces
    let Some(interface_name) = interface_name else {
        println!("Interface names available:");
        { interfaces.iter() }.for_each(|iface| println!("{}", iface.name));
        std::process::exit(1);
    };

    let interfaces_name_match = |interface: &NetworkInterface| interface.name == interface_name;

    if let Some(interface) = interfaces.into_iter().find(interfaces_name_match) {
        match interface.ips.first() {
            Some(ip_network) => {
                let interface_ip = ip_network.ip();
                (interface, interface_ip)
            }
            None => {
                panic!(
                    "cant get ip for interface \n {} \n interface: \n {}",
                    interface_name, interface,
                );
            }
        }
    } else {
        panic!("no interface named {}", interface_name);
    }
}
Enter fullscreen mode Exit fullscreen mode

This will later be used and the arg to it is a command line arg at the start. This will also return the ip of that interface that will be used later.

Now lets set up the ability to get a socket to send the Rst and Syn packets:

fn get_socket(destination: IpAddr) -> Result<TransportSender, String> {
    match destination {
        IpAddr::V4(_) => {
            match transport::transport_channel(
                4096,
                TransportChannelType::Layer4(TransportProtocol::Ipv4(IpNextHeaderProtocols::Tcp)),
            ) {
                Ok((ts, _)) => Ok(ts),
                Err(e) => Err(format!("{}", e)),
            }
        }
        IpAddr::V6(_) => {
            match transport::transport_channel(
                4096,
                TransportChannelType::Layer4(TransportProtocol::Ipv6(IpNextHeaderProtocols::Tcp)),
            ) {
                Ok((ts, _)) => Ok(ts),
                Err(e) => Err(format!("{}", e)),
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This takes in the destination Ip to return a socket (or a TransportSender). You can play around with buffer sizes if you so like.

Now lets build out the build_packet function.

//build rst or syn packet
fn build_packet(
    tcp_packet: &mut MutableTcpPacket,
    source_ip: IpAddr,
    dest_ip: IpAddr,
    source_port: u16,
    dest_port: u16,
    syn: bool,
) {
    tcp_packet.set_source(source_port); //ephemeral port
    tcp_packet.set_destination(dest_port); //port being probed
    tcp_packet.set_sequence(0);
    tcp_packet.set_window(64240);
    tcp_packet.set_data_offset(8);

    //if syn, set syn flag, if rst set rst flag
    if syn {
        tcp_packet.set_flags(TcpFlags::SYN);
        tcp_packet.set_options(&[
            TcpOption::mss(1460),
            TcpOption::sack_perm(),
            TcpOption::nop(),
            TcpOption::nop(),
            TcpOption::wscale(7),
        ]);
        tcp_packet.set_urgent_ptr(0);
    } else {
        tcp_packet.set_flags(TcpFlags::RST);
    }

    let checksum = match (source_ip, dest_ip) {
        (IpAddr::V4(src), IpAddr::V4(dest)) => {
            ipv4_checksum(&tcp_packet.to_immutable(), &src, &dest)
        }
        (IpAddr::V6(src), IpAddr::V6(dest)) => {
            ipv6_checksum(&tcp_packet.to_immutable(), &src, &dest)
        }
        _ => panic!("cant create socket in get_socket"),
    };
    tcp_packet.set_checksum(checksum);
}
Enter fullscreen mode Exit fullscreen mode

You are free to read up on the Tcp field values, the ones on this are tested and work, at least for me. If you have questions as to what they do, I suggest looking over this TCP header explanation.

now for the get_buffer function

fn get_buffer(config: &Config) -> Vec<u8> {
    let header_length = match config.destination_ip {
        IpAddr::V4(_) => IPV4_HEADER_LEN,
        IpAddr::V6(_) => IPV6_HEADER_LEN,
    };
    let vec: Vec<u8> = vec![0; ETHERNET_HEADER_LEN + header_length + 86];
    vec
}
Enter fullscreen mode Exit fullscreen mode

this is to reduce redundancy, it builds out a buffer in case of a IPv4 packet or an IPv6 packet

Now lets build out the functionality to handle the tcp packets:

fn handle_tcp<'a>(
    ip_payload: &[u8],
    config: &Config,
    ip_addr: IpAddr,
    buffer: &'a mut [u8],
) -> Result<Option<MutableTcpPacket<'a>>, String> {
    let tcp_packet =
        TcpPacket::new(ip_payload).ok_or_else(|| "Failed to create TCP packet".to_string())?;

    let mut rst_packet = MutableTcpPacket::new(buffer)
        .ok_or_else(|| "Failed to create mutable TCP packet".to_string())?;

    if tcp_packet.get_destination() != config.source_port {
        return Ok(None);
    }

    if tcp_packet.get_flags() == TcpFlags::SYN | TcpFlags::ACK {
        println!("port {} open on host {}", tcp_packet.get_source(), ip_addr);
        build_packet(
            &mut rst_packet,
            config.interface_ip,
            config.destination_ip,
            config.source_port,
            tcp_packet.get_source(),
            false,
        );
        Ok(Some(rst_packet))
    } else if tcp_packet.get_flags() == TcpFlags::RST {
        Err(format!(
            "misc flag {} on port {}",
            tcp_packet.get_flags(),
            tcp_packet.get_source()
        ))
    } else {
        Ok(None)
    }
}
Enter fullscreen mode Exit fullscreen mode

This incorporates a lifetime tied to the input buffer as the output packet contains a reference to the input buffer

Now lets build out the send_packets function:

fn send_packets(config: &Config, mut sender: TransportSender) {
    for destination_port in config.ports_to_scan.iter() {
        //get buffer to send
        let mut vec: Vec<u8> = get_buffer(config);
        //create packet
        let mut tcp_packet =
            MutableTcpPacket::new(&mut vec[..]).expect("Failed to create mutable TCP packet");
        //build packet
        build_packet(
            &mut tcp_packet,
            config.interface_ip,
            config.destination_ip,
            config.source_port,
            *destination_port,
            true,
        );

        //send packet
        if let Err(e) = sender.send_to(tcp_packet.to_immutable(), config.destination_ip) {
            eprintln!("Error sending packet: {}", e);
        } else {
            println!(
                "sent syn packet to port {} on host {}",
                destination_port, config.destination_ip
            );
        }
    }
    //sleep to change the all_sent flag
    thread::sleep(config.wait_after_send);
    //mark to true to kill receive_packets loop
    config.all_sent.store(true, Ordering::SeqCst);
}
Enter fullscreen mode Exit fullscreen mode

This takes the config struct and a transport sender to send the packets. It builds a syn packet and sends it using the socket or the TransportSender. Look over the code and I will do my best to comment the source code on github to explain things that may not be apparent. Feel free to leave a comment if you don't understand whats going on in it.

Now the tough part, receive_packets function.

fn receive_packets(
    config: &Config,
    mut sender: TransportSender,
    mut rx: Box<dyn DataLinkReceiver>,
) {
    let start = Instant::now();
    //loops over received packets, may not be all our packets
    loop {
        //we got a packet
        match rx.next() {
            //we got a packet
            Ok(packet) => {
                //Build from bottom up the stack, starting with ethernet
                let eth_packet = EthernetPacket::new(packet).unwrap();
                //get the type of packet riding on ethernet packet
                match eth_packet.get_ethertype() {
                    //type of IPv4, we want this
                    EtherTypes::Ipv4 => {
                        let ipv4_packet = Ipv4Packet::new(eth_packet.payload()).unwrap();
                        //if next layer up isn't tcp, we don't care
                        if ipv4_packet.get_next_level_protocol() == IpNextHeaderProtocols::Tcp {
                            //get buffer to have a lifetime outside the function, done for ownership reasons
                            let mut buffer = get_buffer(config);
                            //develop rst packet to send
                            let rst_packet = handle_tcp(
                                ipv4_packet.payload(),
                                config,
                                ipv4_packet.get_source().into(),
                                &mut buffer,
                            );
                            //rst_packet is a Result<Option> so this unwraps it all
                            match rst_packet {
                                Ok(rst_packet) => {
                                    if let Some(rst_packet) = rst_packet {
                                        let _ = sender.send_to(rst_packet, config.destination_ip);
                                    }
                                }
                                Err(e) => {
                                    //prints too much
                                    println!("error {:?}", e)
                                }
                            }
                        }
                    }
                    //type of IPv6, we want this
                    EtherTypes::Ipv6 => {
                        let ipv6_packet = Ipv6Packet::new(eth_packet.payload()).unwrap();

                        //if next layer up isn't tcp, we don't care
                        if ipv6_packet.get_next_header() == IpNextHeaderProtocols::Tcp {
                            //get buffer to have a lifetime outside the function, done for ownership reasons
                            let mut buffer = get_buffer(config);
                            let rst_packet = handle_tcp(
                                ipv6_packet.payload(),
                                config,
                                ipv6_packet.get_source().into(),
                                &mut buffer,
                            );
                            match rst_packet {
                                Ok(rst_packet) => {
                                    if let Some(rst_packet) = rst_packet {
                                        println!("{:?}", rst_packet.get_source());
                                        println!("{:?}", config.destination_ip);
                                        let _ = sender.send_to(rst_packet, config.destination_ip);
                                    }
                                }
                                Err(e) => {
                                    //oops
                                    println!("error: {:?}", e);
                                }
                            }
                        }
                    }
                    //we dont care, do nothing so we can check breaking condition
                    _ => {}
                }
            }
            //print out if there is an error receiving a packet
            Err(e) => {
                println!("error while receiving packet: {:?}", e)
            }
        }
        //check if all_sent flag is true
        if config.all_sent.load(Ordering::SeqCst)
            //check if timeout is hit
            || Instant::now().duration_since(start) > config.timeout
        {
            break;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

I commented a lot on the code to clarify some things, let me know if it is unclear.

Now set up the main

 fn main() {
    //setting up values for new config and an interface to send into receive_packets
    let interface_name = std::env::args().nth(1);
    //set up var to use, make sure its in scope
    let destination_ip: IpAddr;

    if let Some(ip) = std::env::args().nth(2) {
        match ip.parse::<IpAddr>() {
            Ok(ip) => destination_ip = ip,
            Err(err) => {
                panic!("error parsing ip address {}\n error: \n {}", ip, err);
            }
        }
    } else {
        panic!("no ipv4 or ipv6 address provided")
    }

    //change to desired ports
    let ports_to_scan: Vec<u16> = vec![443];

    //get interface and the interface_ip
    let (interface, interface_ip): (NetworkInterface, IpAddr) = get_interface(interface_name);

    //timeout value if op isnt working correctly, going to be in secs.
    let timeout: u64 = 10;

    //config
    let config: Config = Config::new(destination_ip, ports_to_scan, interface_ip, timeout);

    //create the arcs to send among other threads
    let config_arc = Arc::new(config);
    let config_arc_clone = config_arc.clone();

    let rx_builder = thread::Builder::new().name("receive_thread".to_string());
    let tx_builder = thread::Builder::new().name("send_thread".to_string());

    //set up senders for sender syn packets (sender) and rst packets to close the connection (receiver)
    let syn_sender: TransportSender =
        get_socket(destination_ip).expect("Failed to create tx_sender");
    let rst_sender: TransportSender =
        get_socket(destination_ip).expect("Failed to create rx_sender");

    //get interface to receive syn packet responses
    let rx = match pnet::datalink::channel(&interface, Default::default()) {
        Ok(Channel::Ethernet(_, rx)) => rx,
        Ok(_) => panic!("Unknown channel type"),
        Err(e) => panic!("Error happened {}", e),
    };

    //receiving packet thread
    let rx_thread = rx_builder.spawn(move || {
        receive_packets(&config_arc, rst_sender, rx);
    });

    //sending packet thread
    let tx_thread = tx_builder.spawn(move || {
        send_packets(&config_arc_clone, syn_sender);
    });

    //joining the handles
    let _ = rx_thread.expect("receive failed").join();
    let _ = tx_thread.expect("send failed").join();
}
Enter fullscreen mode Exit fullscreen mode

Again, I've attempted to comment it out to make it more clear. Some things you can change are the destination_ip or the ports_to_scan.

So now do a cargo check to make sure if it is all there.
And if that's all okay, run it
(for linux/mac)

sudo cargo run <Interface Name> <IP>
Enter fullscreen mode Exit fullscreen mode

Replace with the name of the interface you want to use, same with IP.
If you run
(for linux/mac)

sudo cargo run
Enter fullscreen mode Exit fullscreen mode

you will get a list of interfaces available

If your on windows, I think you can run

cargo build
Enter fullscreen mode Exit fullscreen mode

and right click the binary and "run as administrator" it. If that doesnt work let me know, I'm not a windows user so I can't trouble shoot issues on it at the moment.

Let me know if you have issues, I ran through the tutorial quick and I came up with no issues. But I can miss stuff.

This is how to build a syn scanner in Rust, if you have any ideas, link up on the comments or github.

Some ideas for future are some things like figuring out how to reuse the buffer, adding the ability to input ip from command line (can take v4 and v6), take in custom port ranges || subnets || single ports, etc.

Thanks

Top comments (0)