Fooling Port Scanners: Simulating Open Ports with eBPF and Rust

29 June 2024
Cover image

In our previous article, we explored the SYN and accept queues and their crucial role in the TCP three-way handshake. We learned that for a TCP connection to be fully established, the three-way handshake must be successfully completed. Let's recap this process:

  • The client initiates the connection by sending a SYN packet.
  • The server responds with a SYN-ACK packet.
  • The client then sends an ACK packet back to the server.

At this point, the client considers the connection established. However, it's important to note that from the server's perspective, the connection is only fully established when it receives and processes the final ACK from the client.

In this article, we will review the three-way handshake behavior and a related port scanning technique. We will also explore how to use Rust and eBPF to thwart curious individuals attempting to scan our machine using this technique.

Understanding the TCP Three-Way Handshake in Port Scanning

As we delve deeper into TCP connection management, it's crucial to understand the server's behavior when it receives connection requests. Let's break this down:

When a server has a socket listening on a specific port, it's ready to handle incoming connection requests. Upon receiving a SYN packet, the server begins tracking potential connections by allocating resources, such as space in the SYN queue. This behavior, while necessary for normal operation, can be exploited by malicious actors. For instance, the SYN flood attack takes advantage of this resource allocation to overwhelm the server.

If you're interested in learning more about the SYN flood attack, feel free to leave a comment. This attack exploits the TCP handshake process to overwhelm a server with incomplete connection requests. It works by sending a large number of SYN packets to a server. The server allocates resources for each connection request, occupying significant kernel memory, while the cost for the client is just one SYN packet per request, with no memory allocation on their end.

It's important to note the phrase "When a server has a socket listening on a ..." from our previous discussion. This condition is critical because it determines how the server responds to incoming SYN packets. Let's clarify the two scenarios:

Server with a listening socket on the targeted port

Flow:

  • Receives SYN packet
  • Responds with SYN-ACK
  • Allocates resources to track the potential connection
+--------+                      +-----------------------+
| Client |                      | Server with Listening |
|        |                      | Socket on Target Port |
+--------+                      +-----------------------+
|                                    |                   
|--- SYN --------------------------> |                   
|                                    |                   
|                                    |                   
| <--- SYN-ACK --------------------- |                   
|                                    |                   
|                                    |                   
|                                    |                   
|                                    |                   
+--------+                      +-----------------------+
| Client |                      | Allocates Resources to|
|        |                      | Track Potential Conn. |
+--------+                      +-----------------------+ 

Server without a listening socket on the targeted port

Flow:

  • Receives SYN packet
  • Responds with RST-ACK (Reset-Acknowledge)
  • No resources are allocated for connection tracking
+--------+                      +-----------------------+
|        |                      | Server without        |
| Client |                      | Listening Socket on   |
|        |                      | Target Port           |
+--------+                      +-----------------------+
|                                    |                   
|--- SYN --------------------------> |                   
|                                    |                   
|                                    |                   
| <--- RST-ACK --------------------- |                   
|                                    |                   
|                                    |                   
|                                    |                   
|                                    |                   
+--------+                      +------------------------+
| Client |                      | No Resources Allocated |
|        |                      | for Connection Tracking|
+--------+                      +------------------------+ 

The RST-ACK response in the second scenario is the server's way of saying, "There's no service listening on this port, so don't attempt to establish a connection." This behavior is a fundamental aspect of TCP/IP networking and plays a crucial role in network security and resource management.

As we can see, by sending a simple SYN packet to a server on a specific port, we can determine if the port is open or not. This is the basis for one of the most popular techniques for port scanning: the Stealth SYN Scan.

Indeed, the behavior we've discussed forms the basis for one of the most popular port scanning techniques: the Stealth SYN Scan, also known as a half-open scan. This technique is called a half-open scan because it doesn’t actually open a full TCP connection. Instead, a SYN scan only sends the initial SYN packet and examines the response. If a SYN/ACK packet is received, it indicates that the port is open and accepting connections. This is recorded, and an RST packet is sent to tear down the connection.

To recap: Stealth SYN Scan Process:

  • The scanner sends a SYN packet to a target port.
  • If the port is open (i.e., a service is listening):
    • The target responds with a SYN-ACK.
    • The scanner immediately sends an RST to terminate the connection.
  • If the port is closed:
    • The target responds with an RST-ACK.

Why it's called "Stealth":

  • The scan doesn't complete the full TCP handshake.
  • It's less likely to be logged by basic firewall configurations.
  • It can potentially bypass certain intrusion detection systems (IDS).

The SYN scan is popular for its speed, capable of scanning thousands of ports per second on a fast network. It's unobtrusive and stealthy, as it never completes TCP connections.

Nmap in Action: Demonstrating SYN Scans

As you may know, Nmap is a powerful network scanning and discovery tool widely used by security professionals and system administrators.

Using Nmap, a SYN scan can be performed with the command-line option -sS. The program must be run as root since it requires raw-packet privileges. This is the default TCP scan when such privileges are available. Therefore, if you run Nmap as root, this technique will be used by default, and you don't need to specify the -sS option.

So these commands are equivalent:

$ sudo nmap -p- 192.168.2.107
$ sudo nmap -sS -p- 192.168.2.107

Here, we are simply instructing Nmap to perform a SYN scan on the target 192.168.2.107 using -p- to scan all ports from 1 through 65535. However, we can also specify individual ports or a range of ports.

sudo nmap -sS -p9000-9500 192.168.2.107
Starting Nmap 7.95 ( https://nmap.org ) at 2024-06-29 14:56 EDT
Nmap scan report for dlm (192.168.2.107)
Host is up (0.0085s latency).
Not shown: 499 closed tcp ports (reset)
PORT     STATE SERVICE
9090/tcp open  ...
9100/tcp open  ...
...

From the output above, we can see that the target machine has two open ports in the specific range from 9000 to 9500. The remaining 499 ports are closed. This is because, as we know, Nmap received RST packets when it sent the initial SYN to those ports.

Crafting Our Defense: eBPF and Rust Implementation

In previous articles, I explained how to start projects with Rust-Aya, including using their scaffolding generator. If you need a refresher, feel free to revisit Harnessing eBPF and XDP for DDoS Mitigation and Uprobes Siblings - Capturing HTTPS Traffic, or check the Rust-Aya documentation.

Setting Up the eBPF Program

As we are going to use XDP to accomplish this trick, the initial part of the code is very similar to the example explained in the article Harnessing eBPF and XDP for DDoS Mitigation. In this code, we first validate if the packet is an IPv4 packet by examining the ether_type in the Ethernet header. If it's not IPv4, the packet is passed through without further processing. Then, we look at the IPv4 header to check if it's a TCP packet. Non-TCP packets are also allowed to pass. This way, our program focuses only on IPv4 TCP packets and then we store the TCP header in tcp_hdr.

fn try_syn_ack(ctx: XdpContext) -> Result<u32, ExecutionError> {
    // Use pointer arithmetic to obtain a raw pointer to the Ethernet header at the start of the XdpContext data.
    let eth_hdr: *mut EthHdr = get_mut_ptr_at(&ctx, 0)?;

    // Check the EtherType of the packet. If it's not an IPv4 packet, pass it along without further processing
    // We have to use unsafe here because we're dereferencing a raw pointer
    match unsafe { (*eth_hdr).ether_type } {
        EtherType::Ipv4 => {}
        _ => return Ok(xdp_action::XDP_PASS),
    }

    // Using Ethernet header length, obtain a pointer to the IPv4 header which immediately follows the Ethernet header
    let ip_hdr: *mut Ipv4Hdr = get_mut_ptr_at(&ctx, EthHdr::LEN)?;

    // Check the protocol of the IPv4 packet. If it's not TCP, pass it along without further processing
    match unsafe { (*ip_hdr).proto } {
        IpProto::Tcp => {}
        _ => return Ok(xdp_action::XDP_PASS),
    }

    // Using the IPv4 header length, obtain a pointer to the TCP header which immediately follows the IPv4 header
    let tcp_hdr: *mut TcpHdr = get_mut_ptr_at(&ctx, EthHdr::LEN + Ipv4Hdr::LEN)?;
    ...
}

Filtering Packets: Targeting Specific Ports

Now that we've confirmed the received packet is a TCP packet, let's implement a simple filter in our eBPF program. This filter will instruct the program to interact only with packets whose destination port falls within the range of 9000 to 9500.

It's important to note that this is a basic implementation. In a production environment, we'd want to implement a more robust and flexible solution. For instance, we could:

  • Use eBPF to listen for the bind syscall in our server.
  • Utilize eBPF maps to track all open ports on our server, avoiding interference with legitimate services.
  • Alternatively, use eBPF maps to maintain a list of ports we want to simulate as open.

Again, we're using unsafe throughout the code, as we're directly manipulating memory through raw pointers. This allows us to alter the packets or inspect them as needed.

For now, let's keep it simple and focus on our port range filter:

// Check the destination port of the TCP packet. If it's not in the range 9000-9500, pass it along without further processing
let port = unsafe { u16::from_be((*tcp_hdr).dest) };
match port {
	9000..=9500 => {}
	_ => return Ok(xdp_action::XDP_PASS),
}

This code snippet does the following:

  1. We extract the destination port from the TCP header, converting it from network byte order (big-endian) to host byte order.
  2. We use a match statement to check if the port falls within our target range.
  3. If the port is in the range 9000-9500, we continue processing.
  4. For any other port, we immediately return XDP_PASS, allowing the packet to continue its normal journey through the network stack.

This filter allows us to focus our eBPF program's attention on a specific range of ports, which could be useful for various network management and security tasks. For example, we could use this to implement a port knocking sequence, set up a honeypot, or monitor for potential port scan attempts within a specific range.

Identifying SYN Packets

Now that we've confirmed the packet is destined for one of our target ports, our next step is to determine if it's a SYN packet. Here's how we can check for a SYN packet:

// Check if it's a SYN packet
let is_syn_packet = unsafe {
	match ((*tcp_hdr).syn() != 0, (*tcp_hdr).ack() == 0) {
		(true, true) => true,
		_ => false,
	}
};

if !is_syn_packet {
	return Ok(xdp_action::XDP_PASS);
}

Let's break down this code:

  1. We define is_syn_packet by checking two conditions:
    • The SYN flag is set ((*tcp_hdr).syn() != 0)
    • The ACK flag is not set ((*tcp_hdr).ack() == 0)
  2. A valid SYN packet in the initial TCP handshake should have the SYN flag set and the ACK flag unset.
  3. If the packet is not a SYN packet, we immediately return XDP_PASS, allowing non-SYN packets to continue their normal path through the network stack.

Crafting the SYN-ACK Response

Now that we've identified a SYN packet targeting one of our ports of interest, we'll craft a SYN-ACK response. To do this efficiently, we'll modify the incoming packet in-place, transforming it into our response.

// Swap Ethernet addresses
unsafe { core::mem::swap(&mut (*eth_hdr).src_addr, &mut (*eth_hdr).dst_addr) }
// Swap IP addresses
unsafe {
	core::mem::swap(&mut (*ip_hdr).src_addr, &mut (*ip_hdr).dst_addr);
}

Here's what this code accomplishes:

  1. Ethernet Address Swap:
    • We exchange the source and destination MAC addresses in the Ethernet header.
    • This ensures our response packet will be routed back to the sender at the link layer.
  2. IP Address Swap:
    • Similarly, we swap the source and destination IP addresses in the IP header.
    • This directs our response to the original sender at the network layer.
  3. core::mem::swap:
    • This function efficiently exchanges the values of two mutable references without requiring a temporary variable.
    • It's particularly useful here as it keeps our code concise and performant.

By modifying the existing packet, we're essentially "reflecting" it back to the sender, but with crucial changes that we'll make in the next steps to transform it into a valid SYN-ACK response.

After swapping the Ethernet and IP addresses, we now need to modify the TCP header to transform our packet into a valid SYN-ACK response. This step is critical in simulating an open port and continuing the TCP handshake process. Here's how we accomplish this:

// Modify TCP header for SYN-ACK
unsafe {
	core::mem::swap(&mut (*tcp_hdr).source, &mut (*tcp_hdr).dest);
	(*tcp_hdr).set_ack(1);
	(*tcp_hdr).ack_seq = (*tcp_hdr).seq.to_be() + 1;
	(*tcp_hdr).seq = 1u32.to_be();
}

Let's break down these modifications:

  1. Port Swap:
    • We exchange the source and destination ports, ensuring our response goes back to the correct client port.
  2. Setting the ACK Flag:
    • We set the ACK flag using set_ack(1). This, combined with the existing SYN flag, creates a SYN-ACK packet.
  3. Acknowledgment Number:
    • We set the acknowledgment number to the incoming sequence number plus one.
    • This acknowledges the client's SYN and informs it of the next sequence number we expect.
    • Note the use of to_be() to handle endianness correctly.
  4. Sequence Number:
    • We set our initial sequence number to 1 (converted to network byte order).
    • In a real TCP stack, this would typically be a random number for security reasons.

It's important to note that in our eBPF program, we're modifying packet headers without recalculating the checksums. In a production environment, this approach could lead to issues as the modified packets might be dropped by network stacks that verify checksums. For the sake of simplicity and focusing on the core concepts, we've omitted checksum recalculation in this demonstration.

Nmap typically uses raw sockets for its SYN scans. Raw sockets bypass much of the normal network stack processing, including checksum verification in many cases.

Also, in a production environment, you might want to consider additional factors. This is just an oversimplified version that demonstrates the basic concept of tricking a simple SYN scan.

These modifications transform our incoming SYN packet into a SYN-ACK response, effectively simulating the behavior of an open port. This is a key step in our port scanning detection or simulation logic.

Sending the Modified Packet

After modifying our packet to create a valid SYN-ACK response, the final step is to send this packet back to the client. We accomplish this using the XDP_TX action. This action instructs the XDP framework to transmit the modified packet back out through the same network interface it arrived on.

This action is particularly useful for applications like our port scan simulation, load balancers, firewalls, and other scenarios where rapid packet inspection and modification are crucial.

Ok(xdp_action::XDP_TX)

By using XDP_TX, we're completing our port scanning response simulation in a highly efficient manner. This approach allows us to respond to SYN packets almost instantaneously, making our simulated open ports "virtually indistinguishable" from real ones in terms of response time.

It's worth noting that this approach, while effective for Syn Scan scenario, doesn't account for more sophisticated scanning techniques that might use non-standard flag combinations. In a production environment, you might want to implement more comprehensive checks to detect various types of port scans.

Putting It All Together: Running Our eBPF Program

Full code:


fn try_syn_ack(ctx: XdpContext) -> Result<u32, ExecutionError> {
    // Use pointer arithmetic to obtain a raw pointer to the Ethernet header at the start of the XdpContext data.
    let eth_hdr: *mut EthHdr = get_mut_ptr_at(&ctx, 0)?;

    // Check the EtherType of the packet. If it's not an IPv4 packet, pass it along without further processing
    // We have to use unsafe here because we're dereferencing a raw pointer
    match unsafe { (*eth_hdr).ether_type } {
        EtherType::Ipv4 => {}
        _ => return Ok(xdp_action::XDP_PASS),
    }

    // Using Ethernet header length, obtain a pointer to the IPv4 header which immediately follows the Ethernet header
    let ip_hdr: *mut Ipv4Hdr = get_mut_ptr_at(&ctx, EthHdr::LEN)?;

    // Check the protocol of the IPv4 packet. If it's not TCP, pass it along without further processing
    match unsafe { (*ip_hdr).proto } {
        IpProto::Tcp => {}
        _ => return Ok(xdp_action::XDP_PASS),
    }

    // Using the IPv4 header length, obtain a pointer to the TCP header which immediately follows the IPv4 header
    let tcp_hdr: *mut TcpHdr = get_mut_ptr_at(&ctx, EthHdr::LEN + Ipv4Hdr::LEN)?;

    // Check the destination port of the TCP packet. If it's not in the range 9000-9500, pass it along without further processing
    let port = unsafe { u16::from_be((*tcp_hdr).dest) };
    match port {
        9000..=9500 => {}
        _ => return Ok(xdp_action::XDP_PASS),
    }

    // Check if it's a SYN packet
    let is_syn_packet = unsafe {
        match ((*tcp_hdr).syn() != 0, (*tcp_hdr).ack() == 0) {
            (true, true) => true,
            _ => false,
        }
    };

    if !is_syn_packet {
        return Ok(xdp_action::XDP_PASS);
    }

    // Swap Ethernet addresses
    unsafe { core::mem::swap(&mut (*eth_hdr).src_addr, &mut (*eth_hdr).dst_addr) }

    // Swap IP addresses
    unsafe {
        core::mem::swap(&mut (*ip_hdr).src_addr, &mut (*ip_hdr).dst_addr);
    }

    // Modify TCP header for SYN-ACK
    unsafe {
        core::mem::swap(&mut (*tcp_hdr).source, &mut (*tcp_hdr).dest);
        (*tcp_hdr).set_ack(1);
        (*tcp_hdr).ack_seq = (*tcp_hdr).seq.to_be() + 1;
        (*tcp_hdr).seq = 1u32.to_be();
    }

    Ok(xdp_action::XDP_TX)
}

Now that we've implemented our eBPF program to simulate open ports within our chosen range, it's time to see it in action. This demonstration will showcase the power of eBPF in manipulating network behavior at a low level.

First, let's run our eBPF program. Open a terminal and execute the following command:

$ RUST_LOG=info cargo xtask run -- -i wlp5s0
[2024-06-29T19:57:33Z INFO  syn_ack] Waiting for Ctrl-C...

With our eBPF program running, let's perform a port scan using nmap to see the effects:

$ sudo nmap -sS -p9000-9500 192.168.2.107
Starting Nmap 7.95 ( https://nmap.org ) at 2024-06-29 15:57 EDT
Nmap scan report for dlm (192.168.2.107)
Host is up (0.0084s latency).

PORT     STATE SERVICE
9000/tcp open  cslistener
9001/tcp open  tor-orport
9002/tcp open  dynamid
9003/tcp open  unknown
9004/tcp open  unknown
9005/tcp open  golem
9006/tcp open  unknown
9007/tcp open  ogs-client
9008/tcp open  ogs-server
...
9491/tcp open  unknown
9492/tcp open  unknown
9493/tcp open  unknown
9494/tcp open  unknown
9495/tcp open  unknown
9496/tcp open  unknown
9497/tcp open  unknown
9498/tcp open  unknown
9499/tcp open  unknown
9500/tcp open  ismserver

As we can see, Nmap reports all ports in the range 9000-9500 as open. This is exactly what our eBPF program is designed to do: respond to SYN packets on these ports with SYN-ACK, simulating open ports.

In a production environment, you would want to implement additional features such as logging, more sophisticated decision-making logic, and possibly integration with other security systems. This example serves as a foundation for understanding how eBPF can be used to manipulate network traffic at a fundamental level.

For a deeper dive and hands-on experience, all the code discussed is available in my  repository. Feel free to explore, experiment, and comments !

To conclude

In this article, we've explored the intricacies of TCP handshakes and port scanning techniques, culminating in a practical demonstration of how eBPF and Rust can be used to manipulate network behavior at a fundamental level. By implementing a program that simulates open ports, we've showcased the power and flexibility of eBPF in network security applications. This approach not only provides a means to confuse potential attackers but also opens up possibilities for more advanced network management and security tools.

Thank you for reading along. This blog is a part of my learning journey and your feedback is highly valued. There's more to explore and share regarding eBPF, so stay tuned for upcoming posts. Your insights and experiences are welcome as we learn and grow together in this domain. Happy coding!

Share article