SOCKET PROGRAMMING ON UNIX - TCP SYN PORT SCANNING(PART 3/3)

2020-06-16
In the previous part of this tutorial, we saw what raw sockets actually are and how to use them to build something useful(i.e. a tcpdump clone). In the last part of this guide, we will extend our knowledge about raw sockets by writing a simple TCP SYN port scanner. Before getting into the actual code, let us understand how does a port scanner work and what are the main techniques to perform port scanning.
>
A port scanner is a tool designed to look for open ports on a remote server. These tools are mainly used by system administrators and security researchers to get an idea of which network services are being executed on a given host.

A quick overview of Nmap

The most used and accurate port scanner available on UNIX systems is nmap. Nmap comes with a lot of options and a lot of different scanning techniques; you can install it on your computer, and you can try to perform a port scan on one of your server. If you do not have any server available, you can use the nmap playground: a testing environment provided by nmap.org itself that allow you to legally test their software. Go ahead and try it out:
                
$> nmap -p0- -v -A -T4 scanme.nmap.org 
Starting Nmap 7.91 ( https://nmap.org ) at 2021-06-09 12:26 CEST
NSE: Loaded 153 scripts for scanning.
NSE: Script Pre-scanning.
Initiating NSE at 12:26
Completed NSE at 12:26, 0.00s elapsed
Initiating NSE at 12:26
Completed NSE at 12:26, 0.00s elapsed
Initiating NSE at 12:26
Completed NSE at 12:26, 0.00s elapsed
Initiating Ping Scan at 12:26
Scanning scanme.nmap.org (45.33.32.156) [2 ports]
Completed Ping Scan at 12:26, 0.17s elapsed (1 total hosts)
Initiating Parallel DNS resolution of 1 host. at 12:26
Completed Parallel DNS resolution of 1 host. at 12:26, 0.35s elapsed
Initiating Connect Scan at 12:26
Scanning scanme.nmap.org (45.33.32.156) [65536 ports]
Discovered open port 80/tcp on 45.33.32.156
Discovered open port 22/tcp on 45.33.32.156
Connect Scan Timing: About 8.18% done; ETC: 12:33 (0:05:48 remaining)
Stats: 0:00:57 elapsed; 0 hosts completed (1 up), 1 undergoing Connect Scan
Connect Scan Timing: About 14.86% done; ETC: 12:33 (0:05:27 remaining)
Connect Scan Timing: About 25.51% done; ETC: 12:33 (0:05:07 remaining)
Connect Scan Timing: About 33.58% done; ETC: 12:33 (0:04:27 remaining)
Connect Scan Timing: About 41.16% done; ETC: 12:33 (0:03:56 remaining)
Connect Scan Timing: About 49.22% done; ETC: 12:33 (0:03:21 remaining)
Connect Scan Timing: About 58.11% done; ETC: 12:33 (0:02:42 remaining)
Connect Scan Timing: About 66.19% done; ETC: 12:33 (0:02:10 remaining)
Connect Scan Timing: About 74.02% done; ETC: 12:33 (0:01:40 remaining)
Discovered open port 9929/tcp on 45.33.32.156
Connect Scan Timing: About 81.40% done; ETC: 12:33 (0:01:12 remaining)
Discovered open port 31337/tcp on 45.33.32.156
Connect Scan Timing: About 89.89% done; ETC: 12:33 (0:00:39 remaining)
Completed Connect Scan at 12:33, 386.55s elapsed (65536 total ports)
Initiating Service scan at 12:33
Scanning 4 services on scanme.nmap.org (45.33.32.156)
Completed Service scan at 12:33, 6.37s elapsed (4 services on 1 host)
NSE: Script scanning 45.33.32.156.
Initiating NSE at 12:33
Completed NSE at 12:33, 5.61s elapsed
Initiating NSE at 12:33
Completed NSE at 12:33, 0.71s elapsed
Initiating NSE at 12:33
Completed NSE at 12:33, 0.00s elapsed
Nmap scan report for scanme.nmap.org (45.33.32.156)
Host is up (0.17s latency).
Other addresses for scanme.nmap.org (not scanned): 2600:3c01::f03c:91ff:fe18:bb2f
Not shown: 65530 closed ports
PORT      STATE    SERVICE    VERSION
22/tcp    open     ssh        OpenSSH 6.6.1p1 Ubuntu 2ubuntu2.13 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   1024 ac:00:a0:1a:82:ff:cc:55:99:dc:67:2b:34:97:6b:75 (DSA)
|   2048 20:3d:2d:44:62:2a:b0:5a:9d:b5:b3:05:14:c2:a6:b2 (RSA)
|   256 96:02:bb:5e:57:54:1c:4e:45:2f:56:4c:4a:24:b2:57 (ECDSA)
|_  256 33:fa:91:0f:e0:e1:7b:1f:6d:05:a2:b0:f1:54:41:56 (ED25519)
25/tcp    filtered smtp
80/tcp    open     http       Apache httpd 2.4.7 ((Ubuntu))
|_http-favicon: Nmap Project
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: Apache/2.4.7 (Ubuntu)
|_http-title: Go ahead and ScanMe!
9929/tcp  open     nping-echo Nping echo
11211/tcp filtered memcache
31337/tcp open     tcpwrapped
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

NSE: Script Post-scanning.
Initiating NSE at 12:33
Completed NSE at 12:33, 0.00s elapsed
Initiating NSE at 12:33
Completed NSE at 12:33, 0.00s elapsed
Initiating NSE at 12:33
Completed NSE at 12:33, 0.00s elapsed
Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 400.47 seconds
                
            
In this example we told nmap to scan every possible TCP port(-p0-), to enable OS/service detection(-A), to be verbose(-v) and to be as quick as possible(-T4). This gave us a lot of information about our target machine!

Port scanning techniques

The port scanner we are going to build will only cover one of these scanning methods(i.e. the most simple) but still, it is relevant to understand the main differences between these approaches.

ICMP scan

Ping scan(or ICMP scan) is a type of scan used to make sure a host is up and running. It works by sending a ICMP echo request to the target; if you get any response, then the host is alive. The main downfall is that nowadays the majority of firewalls are configured to drop any ICMP packets, so you cannot rely on this approach to check if a host is alive.

Half-Open TCP SYN scan

This is probably the most common(and the fastest) port scanning technique. Before explaining how does it work, we need to make a step back and understand how a TCP connection is established:

Three-way handshake process

The RFC793 standard defines how a TCP connection is made. The process is usually referred as three-way handshake process since it consists in three different steps. three way handshake diagram
  1. The client requests to establish a new TCP connection by sending a SYN(synchronized) packet to the remote host;
  2. The server acknowledges the request by sending a SYN-ACK packet back to the client;
  3. The client sends an ACK packet to complete the connection process.
The interesting thing about this process is that if the port is closed, then the server sends us an RST packet, which confirms that the selected port is not open. If we don't receive any packet at all(nor SYN-ACK nor RST), then it means that the network is filtered by a firewall. Since our scope is to check if a given port is open or not, we do not have to complete the whole handshake process; we can just send a SYN packet and wait for SYN-ACK response or for an RST/timeout. That's the reason why it is called “half-open” scan.

TCP Connect scan

This is type of scan is basically like the previous one, except for the fact that we complete whole handshake process. This is a much slower process(since we need to send more packets for each port we intend to scan) but is indeed more stealthy from the previous one. In fact, the Half-Open SYN scan could be easily detected from an experienced sysadmin since it creates a lot of “interrupted” attempts of connections. Needless to say, neither the TCP connect scan is the most silent method of scanning a network: this type of scan logs a lot of successful attempts of establish a TCP connection without any exchange of data. Again, an experienced sysadmin can easily spot this kind of requests between a bunch of legit ones.

Stealthy scanning techniques

So far we saw what are the basic port scanning techniques used by the majority of port scanner such as nmap. They all have, however, a big downfall: they are not stealthy at all. They are usually being logged and are easily spottable by a system administrator. To avoid this, other types of port scanning techniques has been developed by exploiting the characteristics of the TCP RFC to differentiate between open and closed ports. Another aspects of stealthy scans is that they typically do not require the handshaking process since they exploit some loophole of the RFC standard.

NULL scan

In the previous part of this tutorial, we developed a simple packet sniffer which printed out some information about the captured packets such as the IP header, the TCP header and the actual payload. The TCP header contained exactly 6 flags(URGENT, ACKNOWLEDGE, PUSH, RESET, SYNCHRONIZE, FINISH), below a brief explanation about their meaning: A NULL scan exploits a particular loophole of the RFC793 standard that force the server to respond with a RST packet to any illegal request. In this way, if we get a RST packet we know that the port is closed, if we do not receive any response, then the port is open(or filtered). The main advantages of this type of scan is that it's very fast (since it does not have to establish any connection) and very furtive(it sneaks through firewalls and ACL filters without being spotted).

FIN scan

A FIN scan sends a TCP packet with only the FIN flag(Finish flag, used to request the termination of the connection) set. Again, since this is an illegal request for the RFC793 standard, the server must send us an RST packet if the port is closed and nothing if the port is either open or filtered.

XMAS scan

An XMAS scan is another stealthy port scanning technique that sends a TCP packet with URG, PSH and FIN flags set to 1. The reason behind its name is that, if you try to analyze the TCP header of a TCP packet with these flags set to 1, it looks like a Christmas tree: XMAS scan diagram Again, since this kind of packets are considered illegal by the RFC793 standard, the server must send back a RST response on closed port and nothing on open/filtered port.

The major disadvantage about these approaches is that not all operating systems implements RFC793 standard equally. Some systems(such as Microsoft Windows), send an RST response to any invalid packet even if the port is open(in the previous sections we assumed that open/filtered ports would have just ignored the illegal packet). This leads to the situation where all ports are being marked as closed. However, this downfall could be used to determine the host's operating system: if you find at least one open port using this method, then it must not be Microsoft Windows.

Let's build a TCP SYN port scanner

Ok enough theory for today, let's dive into the logic behind the port scanner:
  1. Take an IP address/hostname and a list of ports from argv array;
  2. For each port, start to sniff for SYN-ACK packets on another thread;
  3. For each port, send a SYN packet;
  4. Wait for a response(either open or closed port) from the child thread;
As you can see, in this port scanner, we do not scan the whole range of TCP ports(i.e., 0-65535); instead, we ask the user for a list of ports, and we check whether they are open or not.

The code is heavily commented and is based on the code from the two previous parts of this tutorial, therefore you should be able to understand it by yourself.

main.c

                
/* SPS:
 * A SYN TCP "Half-Open" port scanner written in C
 * This port scanner is made for educational purposes
 * and should not be used on production
 * environments. Refer to the following link for 
 * further information: https://blog.marcocetica.com/posts/socket_tutorial_part3/
 * 
 * Developed by Marco Cetica <ceticamarco@gmail.com> 2021
 */


#include <stdio.h> // printf, puts
#include <getopt.h> // getopt_long
#include <string.h> // memset
#include <stdlib.h> // malloc, atoi
#include <unistd.h> // close syscall
#include <ctype.h> // isdigit
#include <sys/socket.h> // socket APIs
#include <arpa/inet.h> // inet_ntoa
#include <netinet/tcp.h> // TCP header
#include <netinet/ip.h> // IP header
#include <netdb.h> // gethostbyname
#include <time.h> // for localtime
#include "src/scanner.h"
#define HELPER_MSG(name) printf("Try \"%s --help\" for more information.\n", name)
#define MAX_PORTBUF_SIZE 1024
#define BUF_SIZE 65536
#define DNS_SERVER "1.1.1.1"
#define DNS_SERVER_PORT 53
#define VERSION "0.0.1"
typedef enum {false, true} bool; // Implement bool type
// Private methods
static unsigned int parse_ports_list(char *port_list, int *formatted_port_list);
static const char *resolve_hostname(const char *address);
static void get_client_ip(char *ip_addr);

void helper() {
    puts("SPS is a SYN TCP port scanner for GNU/Linux systems\n"
         "-s, --hostname HOST           | Set hostname to scan\n"
         "-p, --ports <PORT1,PORT2,...> | Check if port is open\n"
         "-h, --help                    | Print this helper\n"
         "-a, --about                   | About this tool\n"
         "Example: ./sps -s scanme.nmap.org -p 22,80\n"
    );
}

int main(int argc, char **argv) {
    // Compute execution time
    double duration = 0.0;
    clock_t begin = clock();

    if(argc < 2) { // Check argument count
        HELPER_MSG(argv[0]);
        return 1;
    }

    int sock_fd = 0; // Raw socket file descriptor
    struct in_addr server_ip;
    int ports[MAX_PORTBUF_SIZE] = {0}; // Port to be scanned
    unsigned int p_count = 0;
    const char *host; // Target host
    char ip_addr[MAX_PORTBUF_SIZE]; // Local IP address
    int opt = 0;
    const char *short_opts = "p:s:ha";
    bool is_hostopt_enable = false, is_portopt_enable = false;
    struct option long_opts[] = {
        {"hostname", required_argument, NULL, 's'},
        {"ports", required_argument, NULL, 'p'},
        {"help", no_argument, NULL, 'h'},
        {"about", no_argument, NULL, 'a'},
        {NULL, 0, NULL, 0}
    };

    // parse command line parameters
    while((opt = getopt_long(argc, argv, short_opts, long_opts, NULL)) != -1) {
        switch (opt) {
        case 's': {
                // Check if host is null
                if(optarg[0] == '\0') {
                    printf("Error: \"-s\" parameter requires exactly one value.\n");
                    return 1;
                }
                // Save host parameter
                host = optarg, is_hostopt_enable = true;
            }
            break;
        case 'p': {
                // Check if port list is empty
                if(optarg[0] == '\0') {
                    printf("Error: \"-p\" parameter requires at least one value.\n");
                    return 1;
                }
                // Parse port list
                p_count = parse_ports_list(optarg, ports);
                is_portopt_enable = true;
            }
            break;
        case 'a':
            #ifdef __STDC_VERSION__
                printf("SRS - a SYN TCP port scanner for GNU/Linux systems.\n\
            Developed by Marco Cetica 2021\n\
                STDC_VERSION: %ld\n", __STDC_VERSION__);
            #else
                puts("SRS - a SYN TCP port scanner for GNU/Linux systems.\n\
                    Developed by Marco Cetica 2021\n");
            #endif
            return 0;
        
        case 'h':
            helper();
            return 0;
            
        case ':':
        case '?':
        default:
            return 1;
        }
    }

    // Check if both host and port options are specified
    if(is_hostopt_enable && is_portopt_enable) {
        // If host is a IPv4 address, store it
        if(inet_addr(host) != (in_addr_t)-1)
            server_ip.s_addr = inet_addr(host);
        else { // Otherwise, parse it first
            if(resolve_hostname(host) != NULL)
                server_ip.s_addr = inet_addr(resolve_hostname(host));
            else {
                printf("Unable to resolve host \"%s\"\n", host);
                return 1;
            }
        }

        // Open raw socket
        sock_fd = socket(AF_INET, SOCK_RAW, IPPROTO_RAW);
        if(sock_fd < 0) {
            perror("Unable to create socket");
            return 1;
        }

        // Prepare TCP/IP Header
        char datagram[DATAGRAM_BUF_SIZE];
        struct iphdr *ip_head = (struct iphdr*)datagram; // IP header
        struct tcphdr *tcp_head = (struct tcphdr*)(datagram + sizeof(struct ip)); // TCP header
        get_client_ip(ip_addr);
        setup_datagram(datagram, server_ip, ip_addr, ip_head, tcp_head);

        // Print some info on the terminal
        time_t t = time(NULL);
        struct tm tm_s = *localtime(&t);
        printf("Starting SPS %s at %d-%02d-%02d %02d:%02d CEST\n", 
                VERSION, 
                (tm_s.tm_year + 1900),
                (tm_s.tm_mon + 1),
                (tm_s.tm_mday),
                (tm_s.tm_hour),
                (tm_s.tm_min)
              );
        printf("PORT\t\tSTATE\n");

        // For each port, set header's parameters and send SYN packet to host
        for(size_t current_port = 0; current_port < p_count; current_port++) {
           if(scan_port(sock_fd, datagram, server_ip, ip_addr, tcp_head, ports[current_port]) != 0) {
               perror("Unable to send SYN packet");
               return 1;
           }
        }
        // Finally, close raw socket
        close(sock_fd); 
    } else {
        printf("Error: both \"-p\" and \"-s\" must be specified.\n");
        HELPER_MSG(argv[0]);
        return 1;
    }

    clock_t end = clock();
    duration += (double)(end - begin) / CLOCKS_PER_SEC;
    printf("\nSPS done: %d ports scanned in %f seconds.\n", p_count, duration);

    return 0;
}

// Breaks "<PORT1,PORT2,...,PORTn>" into an array of integers
unsigned int parse_ports_list(char *port_list, int *formatted_port_list) {
    char *token, *next;
    const char *separator = ",";
    unsigned int count = 0;

    // Start parsing string
    token = strtok(port_list, separator);
    while(token != NULL) {
        // Check if token is a number
        strtol(token, &next, 10);
        if((next == token) || (*next != '\0')) {
            printf("Error: port parameter must contains numeric values only.\n");
            exit(1);
        } else 
            formatted_port_list[count++] = atoi(token);
        // Get next value
        token = strtok(NULL, separator);
    }

    // Return number of ports for later usage
    return count;
}

// Convert a domain name into an IP address(IPv4).
const char *resolve_hostname(const char *address) {
    struct hostent *host;

    if((host=gethostbyname(address)) == NULL) // Get host struct
        return NULL;

    return inet_ntoa(*((struct in_addr*)host->h_addr));
}

// Retrieve IP address of client
void get_client_ip(char *ip_addr) {
    // To get local ip address, we can send a UDP packet
    // to a DNS server, wait for its reply and then 
    // read the "sin_addr" field from the header of 
    // the packet.
    int sock = socket(AF_INET, SOCK_DGRAM, 0);
    if(sock < 1) {
        perror("Unable to create UDP socket");
        exit(1);
    }
    
    struct sockaddr_in ip_client, name;
    memset(&ip_client, 0, sizeof(ip_client));

    // Configure the header
    ip_client.sin_addr.s_addr = inet_addr(DNS_SERVER);
    ip_client.sin_port = htons(DNS_SERVER_PORT);
    ip_client.sin_family = AF_INET;

    // Establish a new connection
    int res = connect(sock, (struct sockaddr*)&ip_client, sizeof(ip_client));
    if(res < 0) {
        perror("Unable to connect to remote host");
        exit(1);
    }

    socklen_t name_len = sizeof(name);
    res = getsockname(sock, (struct sockaddr*)&name, &name_len);
    if(inet_ntop(AF_INET, &name.sin_addr, ip_addr, INET_ADDRSTRLEN) == NULL) {
        perror("Unable to retrieve IP address of the client");
        exit(1);
    }

    // Close socket
    close(sock);
}
                
            

scanner.h

                
#ifndef SCANNER_H
#define SCANNER_H
#pragma once
#include <stdio.h> // printf, puts
#include <string.h> // memset
#include <stdlib.h> // malloc, atoi
#include <unistd.h> // close syscall
#include <sys/socket.h> // socket APIs
#include <arpa/inet.h> // inet_ntoa
#include <netinet/tcp.h> // TCP header
#include <netinet/ip.h> // IP header
#include <pthread.h> // pthread_{create,join}
#include "sniffer.h"
#define DATAGRAM_BUF_SIZE 4096
typedef enum {false, true} bool; // Implement bool type
// Needed for checksum computation
struct pseudo_header {
    unsigned int source_addr;
    unsigned int dest_addr;
    unsigned char plc;
    unsigned char prt;
    unsigned short tcp_len;
    struct tcphdr tcp;
};

struct target_header {
    struct in_addr target_ip;
    unsigned int target_port;
};

struct datagram_header {
    char datagram[DATAGRAM_BUF_SIZE];
    struct iphdr *ip_head;
    struct tcphdr *tcp_head;
};

void setup_datagram(char *datagram, struct in_addr server_ip, const char *client_ip, struct iphdr *ip_head,  struct tcphdr *tcp_head);
unsigned short scan_port(int sock_fd, char *datagram, struct in_addr server_ip, const char *client_ip, struct tcphdr *tcp_head, unsigned int target_port);
unsigned short compute_checksum(unsigned short *dgm, int bytes); 


#endif
                
            

scanner.c

                
#include "scanner.h"
void setup_datagram(char *datagram, struct in_addr server_ip, const char *client_ip, struct iphdr *ip_head,  struct tcphdr *tcp_head) {
    // CLear datagram buffer
    memset(datagram, 0, DATAGRAM_BUF_SIZE);

    // Setup IP header
    ip_head->ihl = 5; // HELEN
    ip_head->version = 4;
    ip_head->tos = 0; // Type of service
    ip_head->tot_len = (sizeof(struct ip) + sizeof(struct tcphdr));
    ip_head->id = htons(36521);
    ip_head->frag_off = htons(16384);
    ip_head->ttl = 64;
    ip_head->protocol = IPPROTO_TCP;
    ip_head->check = 0;
    ip_head->saddr = inet_addr(client_ip);
    ip_head->daddr = server_ip.s_addr;
    ip_head->check = compute_checksum((unsigned short*)datagram, ip_head->tot_len >> 1);

    // Setup TCP header
    tcp_head->source = htons(46300); // Source port
    tcp_head->dest = htons(80);
    tcp_head->seq = htonl(1105024978);
    tcp_head->ack_seq = 0;
    tcp_head->doff = (sizeof(struct tcphdr) / 4);
    tcp_head->fin = 0;
    tcp_head->syn = 1; // Set SYN flag
    tcp_head->rst = 0;
    tcp_head->psh = 0;
    tcp_head->ack = 0;
    tcp_head->urg = 0;
    tcp_head->window = htons(14600); // Maximum window size
    tcp_head->check = 0;
    tcp_head->urg_ptr = 0;
}

unsigned short scan_port(int sock_fd, char *datagram, struct in_addr server_ip, const char *client_ip, struct tcphdr *tcp_head, unsigned int target_port) {
    struct sockaddr_in ip_dest;
    struct pseudo_header psh;

    // Create new thread
    pthread_t sniff_th;
    struct target_header th;
    if(pthread_create(&sniff_th, NULL, sniffer_thread_callback, &th) < 0) {
        perror("Unable to create sniffer thread");
        return 1;
    }

    // Save target IP and port for later usage
    th.target_ip = server_ip;
    th.target_port = target_port;

    // Setup packet info
    ip_dest.sin_family = AF_INET;
    ip_dest.sin_addr.s_addr = server_ip.s_addr;

    // Setup TCP header
    tcp_head->dest = htons(target_port); // Set target port
    tcp_head->check = 0;

    // Configure pseudo header(needed for checksum)
    psh.source_addr = inet_addr(client_ip);
    psh.dest_addr = ip_dest.sin_addr.s_addr;
    psh.plc = 0;
    psh.prt = IPPROTO_TCP;
    psh.tcp_len = htons(sizeof(struct tcphdr));

    // Copy TCP header into our pseudo header
    memcpy(&psh.tcp, tcp_head, sizeof(struct tcphdr));
    tcp_head->check = compute_checksum((unsigned short*)&psh, sizeof(struct pseudo_header));

    // Send packet to target
    if(sendto(sock_fd, datagram, sizeof(struct iphdr) + sizeof(struct tcphdr), 0, (struct sockaddr*)&ip_dest, sizeof(ip_dest)) < 0) {
        perror("Unable to send SYN packet");
        return 1;
    }

    // Wait for sniffer thread to receive a response
    pthread_join(sniff_th, NULL);

    return 0;
}

// Compute checksum of IP header
// Refer to https://www.ietf.org/rfc/rfc793.txt for reference
unsigned short compute_checksum(unsigned short *dgm, int bytes) {
    register long sum = 0;
    register short answer;
    unsigned int odd_byte;

    while(bytes > 1) {
        sum += *dgm++;
        bytes -= 2;
    }

    if(bytes == 1) {
        odd_byte = 0;
        *((unsigned char*)&odd_byte) = *(unsigned char*)dgm;
        sum += odd_byte;
    }

    sum = (sum >> 16) + (sum & 0xFFFF);
    sum += (sum >> 16);
    answer = (short)~sum;

    return answer;
}
                
            

sniffer.h

                
#ifndef SNIFFER_H
#define SNIFFER_H
#pragma once
#include <stdio.h> // printf, puts
#include <string.h> // memset
#include <stdlib.h> // malloc, atoi
#include <unistd.h> // close syscall
#include <sys/socket.h> // socket APIs
#include <arpa/inet.h> // inet_ntoa
#include <netinet/tcp.h> // TCP header
#include <netinet/ip.h> // IP header
#include "scanner.h"
#define BUF_SIZE 65536
enum status {
    CLOSED,
    OPEN
};

void *sniffer_thread_callback(void *ptr);


#endif
                
            

sniffer.c

                
#include "sniffer.h"
static void sniff_network(struct in_addr server_ip, const unsigned int port);
static void print_result(unsigned int port, enum status st);

void *sniffer_thread_callback(void *ptr) {
    struct target_header *th = ptr;

    sniff_network(th->target_ip, th->target_port);

    return (void*)NULL;
}

void sniff_network(struct in_addr server_ip, const unsigned int port) {
    int sock_raw;
    int saddr_size, data_size;
    struct sockaddr saddr;
    unsigned char *buf = (unsigned char*)malloc(BUF_SIZE);

    // Create new raw socket
    sock_raw = socket(AF_INET, SOCK_RAW, IPPROTO_TCP);
    if(sock_raw < 0) {
        perror("Unable to create socket");
        exit(1);
    }

    saddr_size = sizeof(saddr);

    // Start receiving packets
    data_size = recvfrom(sock_raw, buf, BUF_SIZE, 0, (struct sockaddr*)&saddr, (socklen_t*)&saddr_size);
    if(data_size < 0) {
        perror("Unable to receive packets");
        exit(1);
    }

    // Process data
    struct iphdr *ip_head = (struct iphdr*)buf;
    struct sockaddr_in source;
    unsigned short ip_head_len = ip_head->ihl*4;
    struct tcphdr *tcp_head = (struct tcphdr*)(buf + ip_head_len);
    memset(&source, 0, sizeof(source));
    source.sin_addr.s_addr = ip_head->saddr;

    if(ip_head->protocol == IPPROTO_TCP) {
        // Now check whether it's a SYN-ACK packet or not
        if(tcp_head->syn == 1 && tcp_head->ack == 1 && source.sin_addr.s_addr == server_ip.s_addr)
            print_result(port, OPEN);
        else
            print_result(port, CLOSED);
    }
    free(buf);
}

// Print the scan result just like Nmap
void print_result(unsigned int port, enum status st) {
    printf("%d/tcp\t\t%s\n", port, (st == OPEN ? "open" : "closed"));
}
                
            

Makefile

                
TARGET = sps
CC = gcc
CFLAGS = -Wall -Wextra -Werror -pedantic-errors -std=gnu11
LFLAGS = -lpthread

all: $(TARGET)

$(TARGET): main.o scanner.a sniffer.a
	$(CC) $(CFLAGS) $(LFLAGS) $^ -o $@

main.o: main.c
	$(CC) $(CFLAGS) -c $< -o $@

sniffer.a: sniffer.o
	ar rcs $@ $^

scanner.a: scanner.o
	ar rcs $@ $^

scanner.o: src/scanner.c src/scanner.h
	$(CC) $(CFLAGS) -c -o $@ $<

sniffer.o: src/sniffer.c src/sniffer.h
	$(CC) $(CFLAGS) -c -o $@ $<

clean:
	rm -f *.o *.a $(TARGET)
                
            
Below there's the project structure:
                
├── main.c
├── Makefile
└── src
├── scanner.c
├── scanner.h
├── sniffer.c
└── sniffer.h
                
            
Finally, let's compile the whole thing with
                
$> make clean all
                
            
Let's try our tool to scan scanme.nmap.org:
                
$> sudo ./sps -s scanme.nmap.org -p 80,22,9929,11211,31337
Starting SPS 0.0.1 at 2021-06-16 23:21 CEST
PORT		      STATE
80/tcp		    open
22/tcp		    open
9929/tcp        open
11211/tcp	    closed
31337/tcp	    open

SPS done: 5 ports scanned in 0.004033 seconds.
                
            
We did it! We got the same result(more or less) as the previous step.