Information Security 23 min read

Scapy Host Discovery & Port Scanning: TCP SYN Ping, Traceroute & More

This guide explains how to use Scapy for host discovery (TCP SYN/ACK, UDP, ARP, ICMP pings), various port‑scanning techniques (SYN, FIN, NULL, Xmas, UDP) and traceroute methods (ICMP, TCP, DNS), providing complete code examples and practical insights.

Ops Development Stories
Ops Development Stories
Ops Development Stories
Scapy Host Discovery & Port Scanning: TCP SYN Ping, Traceroute & More

Host Discovery

TCP SYN Ping

Send an empty TCP packet with only the SYN flag set.

A SYN/ACK or RST response indicates the host is up and running.

<code>>> ans,unans=sr(IP(dst="60.205.177.0/28")/TCP(dport=80,flags="S"))
Begin emission:
Finished sending 16 packets.
Received 92 packets, got 9 answers, remaining 7 packets
>>> ans.summary(lambda s:s[1].sprintf("%IP.src% is alive"))
60.205.177.1 is alive
60.205.177.2 is alive
60.205.177.4 is alive
60.205.177.6 is alive
60.205.177.7 is alive
60.205.177.8 is alive
60.205.177.11 is alive
60.205.177.12 is alive
60.205.177.14 is alive</code>

TCP ACK Ping

Send an empty TCP packet with only the ACK flag set.

An unsolicited ACK should be answered with RST, indicating a live host.

Both SYN‑ping and ACK‑ping are useful because most stateless firewalls do not filter unsolicited ACK packets.

<code>>> ans, unans = sr(IP(dst='60.205.177.90-105')/TCP(dport=80, flags='A'))
Begin emission:
Finished sending 16 packets.
Received 173 packets, got 7 answers, remaining 9 packets
>>> ans.summary(lambda s:s[1].sprintf("{IP: %IP.src% is alive}"))
 60.205.177.91 is alive
 60.205.177.94 is alive
 60.205.177.95 is alive
 60.205.177.97 is alive
 60.205.177.100 is alive
 60.205.177.101 is alive
 60.205.177.102 is alive</code>

UDP Ping

Send a UDP packet to a chosen port (payload optional); protocol‑specific payloads improve effectiveness.

Select ports that are likely closed; open UDP ports may ignore empty packets.

An ICMP "port unreachable" response shows the host is up.

<code>>> ans, unans = sr(IP(dst='60.205.177.100-254')/UDP(dport=90),timeout=0.1)
Begin emission:
Finished sending 155 packets.
Received 18 packets, got 11 answers, remaining 144 packets
>>> ans.summary(lambda s:s[1].sprintf("%IP.src% is unreachable"))
60.205.177.106 is unreachable
60.205.177.108 is unreachable
60.205.177.107 is unreachable
60.205.177.111 is unreachable
60.205.177.125 is unreachable
60.205.177.172 is unreachable
60.205.177.191 is unreachable
60.205.177.203 is unreachable
60.205.177.224 is unreachable
60.205.177.242 is unreachable
60.205.177.244 is unreachable</code>

ARP Ping

Use ARP Ping to discover live hosts on the same LAN.

Faster and more reliable because it operates at layer 2.

ARP is the backbone protocol for all layer‑2 communications.

In IPv6, ARP is replaced by NDP, which provides address resolution and neighbor discovery.
<code>>> ans,unans=srp(Ether(dst="ff:ff:ff:ff:ff:ff")/ARP(pdst="172.17.51.0/24"),timeout=2)
Begin emission:
Finished sending 256 packets.
Received 190 packets, got 162 answers, remaining 94 packets
>>> ans.summary(lambda r: r[0].sprintf("%Ether.src% %ARP.pdst%"))
00:16:3e:0c:d1:ad 172.17.51.0
00:16:3e:0c:d1:ad 172.17.51.1
00:16:3e:0c:d1:ad 172.17.51.2
00:16:3e:0c:d1:ad 172.17.51.3
00:16:3e:0c:d1:ad 172.17.51.4
00:16:3e:0c:d1:ad 172.17.51.5
00:16:3e:0c:d1:ad 172.17.51.6
00:16:3e:0c:d1:ad 172.17.51.7</code>

ICMP Ping

ICMP scanning uses the ubiquitous _ping_ program to send standard packets.

Send an ICMP type 8 (echo request); a type 0 (echo reply) indicates the host is alive.

Many hosts and firewalls block these packets, making basic ICMP scans unreliable.

ICMP also supports timestamp and address‑mask requests to reveal host availability.

<code>>> ans,unans=sr(IP(dst="60.205.177.168-180")/ICMP())
>>> ans.summary(lambda s:s[0].sprintf("{IP: %IP.dst% is alive}"))
 60.205.177.168 is alive
 60.205.177.169 is alive
 60.205.177.171 is alive
 60.205.177.172 is alive
 60.205.177.175 is alive
 60.205.177.174 is alive
 60.205.177.176 is alive
 60.205.177.179 is alive
 60.205.177.178 is alive
 60.205.177.180 is alive</code>

Service Discovery (Port Scanning)

TCP Connect Scan

Example of a captured three‑way handshake:

<code>192.168.2.1.35555 > 192.168.2.12.4444: Flags [S] seq=12345
192.168.2.12.4444 > 192.168.2.1.35555: Flags [S.], seq=9998 ack=12346
192.168.2.1.35555 > 192.168.2.12.4444: Flags [.] seq=12346 ack=9999</code>

IP and port numbers are separated by a dot; '.' denotes ACK, 'S' denotes SYN, and '[S.]' denotes SYN‑ACK.

Crafting a Three‑Way Handshake in Scapy

Step 1 – Send SYN from client to listening server

Create an IP header with source and destination addresses.

Create a TCP header with a random source port, the server’s listening port, SYN flag, and an initial sequence number.

<code>ip=IP(src="192.168.2.53", dst="60.205.177.168")
syn_packet = TCP(sport=1500, dport=80, flags="S", seq=100)</code>

Step 2 – Listen for the server’s SYN‑ACK response

Store the server’s response.

Extract the server’s TCP sequence number and add 1.

<code>synack_packet = sr1(ip/syn_packet)
my_ack = synack_packet.seq + 1</code>

Step 3 – Send ACK to confirm the connection

The IP header uses the same source and destination as the initial SYN.

The TCP header mirrors the SYN’s source and destination ports, sets the ACK flag, and uses the incremented sequence number.

<code>ack_packet = TCP(sport=1500, dport=80, flags="A", seq=101, ack=my_ack)
send(ip/ack_packet)</code>

Full script:

<code>#!/usr/bin/python

from scapy.all import *
# Build payload
get='GET / HTTP/1.0\n\n'
# Set source and destination IPs
ip=IP(src="192.168.2.53", dst="60.205.177.168")
# Random source port
port=RandNum(1024,65535)
# Build SYN packet
SYN=ip/TCP(sport=port, dport=80, flags="S", seq=42)
# Send SYN and receive SYN‑ACK
SYNACK=sr1(SYN)
# Build ACK packet with payload
ACK=ip/TCP(sport=SYNACK.dport, dport=80, flags="A", seq=SYNACK.ack, ack=SYNACK.seq+1)/get
# Send ACK and print response
reply,error=sr(ACK)
print(reply.show())</code>

SYN Scan

SYN scanning (half‑open scan) determines port state without completing the TCP handshake. An open port replies with SYN‑ACK, after which the scanner sends RST. A closed port replies with RST directly. Sending many SYNs without completing connections can cause a SYN flood attack.

Using Scapy to perform a SYN scan on a single host and port:

<code>>> syn_packet = IP(dst='60.205.177.168')/TCP(dport=22,flags='S')
>>> rsp=sr1(syn_packet)
Begin emission:
Finished sending 1 packets.
Received 3 packets, got 1 answers, remaining 0 packets
>>> rsp.sprintf("%IP.src%  %TCP.sport%  %TCP.flags%")
'60.205.177.168  ssh  SA'</code>

SYN Scan on Multiple Ports

<code>>> ans,unans=sr(IP(dst="60.205.177.168")/TCP(dport=(20,22),flags="S"))
Begin emission:
Finished sending 3 packets.
Received 7 packets, got 3 answers, remaining 0 packets
>>> ans.summary(lambda s:s[1].sprintf("%TCP.sport%  %TCP.flags%" ))
ftp_data  RA
ftp  RA
ssh  SA</code>

SYN Scan on Multiple Hosts and Ports

Use

make_table

to build a matrix where the X‑axis is target IP, Y‑axis is target port, and the cell contains the TCP flag.

<code>>> ans,unans = sr(IP(dst=["60.205.177.168-170"])/TCP(dport=[20,22,80],flags="S"))
Begin emission:
Finished sending 9 packets.
Received 251 packets, got 4 answers, remaining 5 packets
>>> ans.make_table(lambda s: (s[0].dst, s[0].dport, s[1].sprintf("%TCP.flags%")))
   60.205.177.168 60.205.177.169
20  RA               -
22  SA               -
80  SA               SA</code>

FIN Scan

The client sends a TCP packet with the FIN flag. No response indicates an open port; a RST response indicates a closed port.

<code>>> fin_packet = IP(dst='60.205.177.168')/TCP(dport=4444,flags='F')
>>> resp = sr1(fin_packet)
Begin emission:
Finished to send 1 packets.
Received 0 packets, got 0 answers, remaining 1 packets</code>

Closed port example:

<code>>> fin_packet = IP(dst='60.205.177.168')/TCP(dport=4399,flags='F')
>>> resp = sr1(fin_packet)
>>> resp.sprintf('%TCP.flags%')
'RA'</code>

NULL Scan

A NULL scan sends a TCP packet with no flags set. An RST response means the port is closed; no response means the port is open. ICMP type 3 codes 1,2,3,9,10,13 indicate the port is filtered.

<code>>> null_scan_resp = sr1(IP(dst="60.205.177.168")/TCP(dport=4399,flags=""),timeout=1)
>>> null_scan_resp.sprintf('%TCP.flags%')
'RA'</code>

Xmas Scan

An Xmas scan sends a TCP packet with FIN, URG, and PUSH flags. No response indicates an open port; an RST indicates a closed port. ICMP type 3 codes 1,2,3,9,10,13 indicate filtering.

<code>>> xmas_scan_resp=sr1(IP(dst="60.205.177.168")/TCP(dport=4399,flags="FPU"),timeout=1)
Begin emission:
Finished sending 1 packets.
Received 2 packets, got 1 answers, remaining 0 packets
>>> xmas_scan_resp.sprintf('%TCP.flags%')
'RA'</code>

UDP Scan

UDP scanning is common for detecting DNS, SNMP, and DHCP services. An open port replies with a UDP packet; an ICMP "port unreachable" (type 3, code 3) indicates a closed port.

<code>>> udp_scan=sr1(IP(dst="60.205.177.168")/UDP(dport=53),timeout=1)
</code>

Traceroute

Traceroute relies on the IP TTL field. Each router decrements TTL; when TTL reaches zero, the router returns an ICMP "time exceeded" message.

Unix tools use UDP, Windows

tracert

uses ICMP, and Linux

tcptraceroute

uses TCP.

Traceroute with ICMP

<code>>> ans,unans=sr(IP(dst="49.232.152.189",ttl=(1,10))/ICMP())
Begin emission:
Finished sending 10 packets.
Received 112 packets, got 7 answers, remaining 3 packets
>>> ans.summary(lambda s:s[1].sprintf("%IP.src%"))
10.36.76.142
10.54.138.21
10.36.76.13
45.112.216.134
103.216.40.18
9.102.250.221
10.102.251.214</code>

Traceroute with TCP

<code>>> ans,unans=sr(IP(dst="baidu.com",ttl=(1,10))/TCP(dport=53,flags="S"))
Begin emission:
Finished sending 10 packets.
Received 31 packets, got 9 answers, remaining 1 packets
>>> ans.summary(lambda s:s[1].sprintf("%IP.src% {ICMP:%ICMP.type%}"))
10.36.76.142 time-exceeded
10.36.76.13 time-exceeded
10.102.252.130 time-exceeded
117.49.35.150 time-exceeded
10.102.34.237 time-exceeded
111.13.123.150 time-exceeded
218.206.88.22 time-exceeded
39.156.67.73 time-exceeded
39.156.27.1 time-exceeded</code>

Scapy also provides a built‑in

traceroute()

function that combines these techniques.

<code>>> traceroute("baidu.com")
Begin emission:
Finished sending 30 packets.
Received 24 packets, got 24 answers, remaining 6 packets
   220.181.38.148:tcp80
2  10.36.76.13      11
3  10.102.252.34    11
4  117.49.35.138    11
5  116.251.112.185  11
6  36.110.217.9     11
7  36.110.246.201   11
8  220.181.17.150   11
14 220.181.38.148   SA
15 220.181.38.148   SA
... (continues)</code>

Traceroute with DNS

Specify a full packet in the

l4

argument to perform DNS‑based traceroute.

<code>>> ans,unans=traceroute("60.205.177.168",l4=UDP(sport=RandShort())/DNS(qd=DNSQR(qname="thesprawl.org")))
Begin emission:
Finished sending 30 packets.
Received 21 packets, got 4 answers, remaining 26 packets
  60.205.177.168:udp53
1 10.2.0.1        11
2 114.242.29.1    11
4 125.33.185.114  11
5 61.49.143.2     11</code>
Pythontraceroutenetwork securityport scanningScapyhost discovery
Ops Development Stories
Written by

Ops Development Stories

Maintained by a like‑minded team, covering both operations and development. Topics span Linux ops, DevOps toolchain, Kubernetes containerization, monitoring, log collection, network security, and Python or Go development. Team members: Qiao Ke, wanger, Dong Ge, Su Xin, Hua Zai, Zheng Ge, Teacher Xia.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.