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 ofSYN
packets to a server. The server allocates resources for each connection request, occupying significant kernel memory, while the cost for the client is just oneSYN
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.
The Stealth SYN Scan: A Popular Port Scanning Technique
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.
- The target responds with a
- If the port is closed:
- The target responds with an
RST-ACK
.
- The target responds with an
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:
- We extract the destination port from the TCP header, converting it from network byte order (big-endian) to host byte order.
- We use a
match
statement to check if the port falls within our target range. - If the port is in the range 9000-9500, we continue processing.
- 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:
- 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
)
- The SYN flag is set (
- A valid SYN packet in the initial TCP handshake should have the SYN flag set and the ACK flag unset.
- 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:
- 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.
- 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.
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:
- Port Swap:
- We exchange the source and destination ports, ensuring our response goes back to the correct client port.
- Setting the ACK Flag:
- We set the ACK flag using
set_ack(1)
. This, combined with the existing SYN flag, creates aSYN-ACK
packet.
- We set the ACK flag using
- 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.
- 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!