Networks
Step 80. Introduction to Scapy — The Handmade Packet Workshop
Level 1 · Difficulty ★★★★☆ · Plan about four hours; individual time varies
Prerequisites: Python sockets and TCP connections from Steps77–79, and a Linux terminal. Bring your own Linux lab, Python3 and Scapy2.7.0. Offline assembly and response classification also work on Windows.
A socket’s connect() asks the operating system to establish a connection. Here you assemble IP and TCP headers and decide exactly what the reply supports. Your deliverables are syn_scan.py and an observation record. Live exercises target your own computer at 127.0.0.1.
1. Learning goals
- Assemble IP/TCP/data and distinguish uncalculated fields from serialized values.
- Distinguish SYN-ACK, RST-ACK, ICMP and no response to a SYN.
- Check the outer protocol, addresses, ports and ACK before classifying.
- Separate duplicate loopback capture records from requests sent.
- Explain raw-socket permission, source-field changes and the limits of log observations.
2. Packets and interpretation
2-1. What stacking layers means
Ether()/IP()/TCP()/Raw(b"hello") puts IP inside an Ethernet frame, TCP inside IP and data inside TCP. Ethernet handles delivery on a link; IP handles addressing and routing; TCP handles ports, ordering, acknowledgments and retransmission. Not every link is Ethernet. An Ethernet-shaped record in a loopback capture is not evidence that a physical cable carried it.
IP()/TCP() starts at the IP layer. Scapy’s / combines layers and determines some protocol fields. A None in show() can mean a length or checksum has not yet been calculated. Serialize with bytes(packet), then parse it with IP(...) to inspect the calculated values.
2-2. Connection versus observation
Initiator Receiver
SYN -------->
<-------- SYN-ACK
ACK --------> Connection established
A usual closed-port reply: RST-ACK in response to SYN
This SYN observer does not send the final ACK to establish a full connection. Linux may send a RST when it receives a SYN-ACK for a connection it did not create. This exercise does not alter firewall rules to suppress that behavior.
| Observation | Output and interpretation |
|---|---|
| SYN-ACK with matching addresses, ports and ACK | OPEN: observed a reply accepting this SYN |
| RST-ACK with the same matching checks | CLOSED: observed a reset reply to this probe |
| ICMP | ICMP: record type/code; do not read its quoted TCP header as the server’s TCP response |
| No matching response before timeout | NO_RESPONSE: undetermined cause; investigate filtering, loss, routing, permission/capture configuration and timing separately |
| Wrong addresses, ports, ACK or other flags | UNMATCHED/OTHER: do not force an open/closed verdict |
No response alone proves neither filtering nor a powered-off host. SYN probes can appear in firewalls, captures and IDS records. Application access logs depend on what that application chooses to log.
3. Run the exercises
3-1. Get the files and prepare Python
Extract the lab ZIP and run from that folder. It contains runnable code, a reproducer, bilingual instructions, environment-specific results and file hashes. Individual files: syn_scan.py, packet_lab.py, reproduce.py, English instructions.
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
.venv/bin/python -c "import scapy; print(scapy.__version__)"
.venv/bin/python packet_lab.py build
.venv/bin/python reproduce.py --offline
The package pins the actually tested Scapy2.7.0; this is not a claim that it is always the latest version. An externally-managed-environment error can occur when installing into system Python. Use a virtual environment; if the venv module is missing, install your distribution’s Python venv package. Do not modify system Python with sudo pip install.
Live transmission requires raw-socket permission. The sudo commands below run these lab files with the already prepared virtual environment’s Python. sr1() in an ordinary unprivileged interpreter may fail. In a container, being named root is insufficient without the NET_RAW capability.
On Windows, use py -m venv .venv and .venv\Scripts\python.exe -m pip install -r requirements.txt, then that Python for build and –offline. Windows transmission requires Npcap and suitable permissions/interface configuration. One interface error does not by itself prove that Npcap is missing. This package’s live tests were performed on Linux; it does not claim verified Windows or WSL live transmission.
3-2. Assemble and inspect fields
from scapy.all import IP, TCP, Raw
packet = IP(src="127.0.0.1", dst="127.0.0.1")/TCP(sport=40000, dport=9999, flags="S")/Raw(b"hello")
packet.show()
decoded = IP(bytes(packet))
print(decoded.len, decoded[TCP].flags, decoded[Raw].load)
This is an assembly exercise; it sends nothing. The provided build observed an initial IP length of None, a serialized length of 45 bytes, the payload hello and calculated IP/TCP checksums. FlagValue is Scapy’s flag object, and string comparison works in the tested 2.7.0. Do not turn a successful comparison into a fabricated error example. The reference uses bit masks for SYN/ACK/RST/FIN combinations and does not misclassify conflicting SAR flags as OPEN.
3-3. Compare an open port with a stopped server
Start the server in Linux terminal A and scan in terminal B in the same environment. Do not assume WSL loopback and the Windows host behave identically. If 9999 is occupied, identify its owner and use another port consistently with –ports for the manual exercise.
# Terminal A
python3 -m http.server 9999 --bind 127.0.0.1
# Terminal B, in the lab directory
sudo .venv/bin/python syn_scan.py --ports 9999
sudo .venv/bin/python syn_scan.py --ports 21,22,80,443,9999 --json
With the server running, the measured output was 9999 OPEN TCP flags=SA. Stop the server with Ctrl+C in terminal A and repeat: 9999 CLOSED TCP flags=RA. Other ports depend on your computer. Do not assume that any particular port number is always closed.
3-4. Verify an ICMP echo reply
sudo .venv/bin/python packet_lab.py echo
The observed matchingEchoReply was true. The code checks the outer ICMP type=0 and code=0, addresses, id and seq. It does not print “alive” merely because some packet arrived. A failed echo is not conclusive evidence that the host is off.
3-5. Capture records versus requests
sudo .venv/bin/python packet_lab.py sniff
After capture is ready, the program sends three requests with a unique ICMP id. It uses lo and selects that id and the loopback addresses. This Linux capture produced 3 requests → 12 raw records → 6 distinct (type,id,seq) combinations. Each request and reply was captured twice. If the first four rows are request/request/reply/reply, inspect their seq values before treating them as separate exchanges.
Duplicate counts can vary by capture method and environment. Grouping this experiment by id and seq helps interpret our three requests; it is not a general rule for deleting retransmissions. Opening an external webpage does not necessarily generate ICMP on lo.
3-6. Change the source field
sudo .venv/bin/python packet_lab.py source
The program actually sends IP(src="127.0.0.2", dst="127.0.0.1")/ICMP(...). Both addresses are loopback. In this environment the reply destination was 127.0.0.2 and matchingEchoReply was true. Changing a source field does not always make replies disappear. Nor can an arbitrary private address be called nonexistent without evidence. Routing and filtering must be considered separately on other networks.
Distinguish an ordinary SOCK_STREAM connection API from raw sockets. Scapy itself uses operating-system sockets, so “impossible with sockets” is inaccurate. The difference here is explicitly constructing headers that an ordinary TCP connection API delegates to the kernel.
3-7. Connect and application logs
Restart the server, then compare a SYN probe, a connection without an HTTP request, and an HTTP request.
sudo .venv/bin/python syn_scan.py --ports 9999
.venv/bin/python -c "import socket; s=socket.create_connection(('127.0.0.1',9999)); s.close()"
.venv/bin/python -c "import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:9999/').status)"
The reproducer’s HTTP server had zero access-log entries after SYN, still zero after a connection alone, and one after GET. TCP establishment and HTTP request logs are different observations. Do not extrapolate this to an absence of logs in other services or network monitoring devices.
4. Mission and exercises
Before reading the reference, separate the fixed target, port list, response classification and reporting into functions. Compare port9999 before/after stopping the server and with Step79’s connect result. Record ICMP, no response and unexpected flags separately. Keep your environment, actual output, failures and shutdown instructions in a README.
- What are the layers in IP()/TCP()/Raw(b”hello”), and when are lengths/checksums calculated?
- Why check the outer protocol and ACK before interpreting SYN-ACK or RST-ACK?
- Does no response prove filtering? Name two conditions to check next.
- If three echo requests produce twelve capture records, which fields should you compare?
- How do a source-field change, response delivery, ordinary connection APIs and raw sockets relate?
- Must a successful connect produce an HTTP access-log entry?
5. Reference implementation and measurements
The complete code below matches the downloadable file. In addition to sr1()’s answer matching, it checks a direct TCP reply’s addresses, ports and ACK. This is an educational IPv4 SYN observer, not a definitive classifier for every firewall.
"""Step080: a small IPv4 SYN observation exercise, fixed to local loopback."""
import argparse
import json
import secrets
import sys
from scapy.all import IP, TCP, ICMP, conf, sr1
TARGET = "127.0.0.1"
def configure_linux():
if not sys.platform.startswith("linux"):
raise RuntimeError("Run live exercises in a Linux lab; offline packet construction works elsewhere.")
from scapy.supersocket import L3RawSocket
conf.use_pcap = False
conf.L3socket = L3RawSocket
def classify(answer, sent):
"""Inspect the OUTER response, never a TCP header quoted inside ICMP."""
if answer is None:
return {"state": "NO_RESPONSE", "detail": "No matching reply before timeout; cause undetermined"}
if not isinstance(answer, IP):
return {"state": "OTHER", "detail": "Not an IPv4 response"}
if isinstance(answer.payload, ICMP):
return {"state": "ICMP", "detail": f"type={answer[ICMP].type} code={answer[ICMP].code}; not a TCP verdict"}
if not isinstance(answer.payload, TCP):
return {"state": "OTHER", "detail": "Not a direct TCP response"}
tcp = answer[TCP]
if (answer.src, answer.dst, tcp.sport, tcp.dport) != (
sent.dst, sent.src, sent[TCP].dport, sent[TCP].sport
) or not (int(tcp.flags) & 0x10) or tcp.ack != ((sent[TCP].seq + 1) & 0xffffffff):
return {"state": "UNMATCHED", "detail": "Addresses, ports or SYN acknowledgment do not match"}
# Ignore ECN flags here, but reject conflicting SYN/RST/FIN combinations.
control = int(tcp.flags) & 0x17
state = "OPEN" if control == 0x12 else "CLOSED" if control == 0x14 else "OTHER"
return {"state": state, "detail": f"TCP flags={tcp.flags}"}
def observe(port, timeout=1.0):
configure_linux()
sent = IP(src=TARGET, dst=TARGET) / TCP(
sport=secrets.randbelow(20000) + 40000,
dport=port, flags="S", seq=secrets.randbits(32)
)
answer = sr1(sent, timeout=timeout, verbose=0)
return {"port": port, **classify(answer, sent)}
def parse_ports(value):
try:
ports = sorted(set(int(p) for p in value.split(",")))
except ValueError as exc:
raise argparse.ArgumentTypeError("Use comma-separated integer ports") from exc
if not ports or len(ports) > 32 or not all(1 <= p <= 65535 for p in ports):
raise argparse.ArgumentTypeError("Choose 1 to 32 distinct ports, each 1..65535")
return ports
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--ports", type=parse_ports, default=parse_ports("21,22,80,443,9999"))
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
try:
result = {"target": TARGET, "observations": [observe(port) for port in args.ports]}
except (PermissionError, OSError, RuntimeError) as exc:
print(f"Cannot run packet exercise: {exc}. Use the lab venv Python with raw-socket permission.", file=sys.stderr)
return 1
if args.json:
print(json.dumps(result))
else:
print(f"Target: {TARGET}")
for row in result["observations"]:
print(f"{row['port']:5} {row['state']:12} {row['detail']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
The complete assembly/echo/capture/source examples and reproducer are also supplied. Stop your manual server to free9999 before running this. The reproducer creates temporary servers/sockets and closes them on exit.
sudo .venv/bin/python reproduce.py
On2026-09-25 with Scapy2.7.0, Windows Python3.12.10 passed packet assembly and ten constructed-response cases. Linux Python3.14.7 passed those plus actual loopback SYN/RST, ICMP, source changes, capture, HTTP logs and six invalid inputs in a container with no external network. A separate run without NET_RAW returned permission error and exit1. Windows offline results · Linux live results · Permission-denial result.
ICMP quotations, wrong ACKs and conflicting flags were tested with constructed packets. A firewall dropping SYN was not reproduced. The real no-reply observation involved a local RST that elicited no answer; it is separate from evidence of SYN filtering. These results do not verify Windows/WSL transmission or every OS and capture configuration.
Exercise answers
- TCP and data are inside IP. Unspecified lengths/checksums are calculated during serialization and can be inspected by parsing the bytes.
- An ICMP error may quote an original TCP header. Mistaking that for the server’s direct reply produces a wrong verdict. Check that the ACK equals this SYN’s seq+1.
- No. Check a known local server, timeout, permission, interface/socket method, routing and filters.
- Compare type, id and seq. This capture observed each request/reply twice; it did not send twelve requests.
- An ordinary connection API delegates TCP headers to the kernel. Scapy uses raw sockets to specify a source field; reply delivery depends on addressing, routing and filtering.
- No. This HTTP server records requests, so a connection alone and a GET produced different log results.
6. Troubleshooting
- Operation not permitted: check the virtual environment’s Python and raw-socket permission. Do not hide failure to send as NO_RESPONSE.
- ModuleNotFoundError: verify that pip and Python belong to the same environment. Use the specified .venv/bin/python rather than sudo python.
- Loopback timeout: the provided Linux code explicitly uses L3RawSocket instead of the libpcap path. Check the server, port and environment. Do not begin by disabling the entire firewall.
- 9999 occupied: do not terminate an unknown existing process. Choose another port for manual work; stop your own manual server before running the reproducer.
- No capture or a different count: inspect lo, permission, generated id/seq and capture readiness. Do not memorize duplicate count as a universal constant.
- Windows interface error: check installation, permissions and the actual interface list separately. This package’s live commands target Linux.
7. Completion criteria
Finish when you can assemble packets, explain the actual server start/stop results, connect echo requests to capture records, and explain uncertainty, duplicates and logs. A successful scanner exit alone is insufficient: compare all six answers with your observations. Stop your lab server with Ctrl+C and keep your records.
Official references: Scapy installation · Layering and usage · Loopback, sockets and kernel RST. The version of the latest documentation may differ from this package’s pinned, tested version.
ONE STEP FURTHER
Finished this lesson?
Check the completion criteria, then mark your progress.