Build a Packet by Hand With Scapy
0 / 7 steps
The goal
Build a network packet by hand, one layer at a time, and read every field in it — so headers stop being diagrams in a textbook and become something you can type, print and take apart. You will stack an IP header under an ICMP echo request, read it with .show(), turn it into the exact bytes that go on the wire, dissect those bytes back into layers, craft a DNS query, and finally send one ping to your own loopback and read the reply. By the end the same fields Wireshark shows you in Lab 12 will be ones you put there yourself.
Only send packets on a network you own. Building and reading packets touches nothing — it all happens in memory. Sending is different. In this lab you only ever send to 127.0.0.1 (your own machine) or your own gateway. Do that on your own computer or your Lab 00 VM, never against someone else’s host or network.
Why this lab. You can memorise that an IP header has a TTL and a protocol field and still not really believe it until you set those bytes yourself and watch a kernel act on them. Scapy lets you build a packet the way you build a sentence: pick the layers, snap them together, and look inside. Ten minutes of this teaches more about protocol structure than an afternoon of reading, because the abstraction is gone — you are holding the actual thing.
Steven spent years reading “the packet” in capture tools and talking about it like it was weather — something that happens to you. Then he built one by hand and realised it is just a struct with your name on the fields. Welcome to the demystification. — Nyx
Before you start. You need Python 3 and Scapy. Scapy builds packets as any user, but sending raw packets needs administrator rights, so step 7 uses sudo (Linux/macOS) or an Administrator terminal (Windows). On Windows you also need Npcap, which ships with Wireshark. Everything up to step 7 runs fine as a normal user.
Steps
01Install Scapy and open it
Install Scapy with pip, then start Python and import it:
$ pip install scapy
$ python3 >>> from scapy.all import IP, ICMP, UDP, DNS, DNSQR, sr1, raw
If the import returns with no error, you are ready. You will type the rest of this lab straight into that Python prompt (the >>> lines). Everything you build lives only in that session until you decide to send it.
Why this matters. Scapy is a packet library, not a point-and-shoot tool. That is the point: there is no menu to hide the fields from you. If you can import it, you can construct any header on this page, which is the whole job.
02Stack two layers into a packet
Build an ICMP echo request — the thing ping sends — by putting an ICMP layer under an IP layer. Scapy’s / operator means “stack this below that”:
>>> pkt = IP(dst="127.0.0.1") / ICMP() >>> pkt.summary()
You get a one-line description of the whole stack:
'IP / ICMP 127.0.0.1 > 127.0.0.1 echo-request 0'
Read it left to right: an IP layer carrying an ICMP echo request, from 127.0.0.1 to 127.0.0.1. You set only the destination; Scapy filled in the rest with sane defaults, which is the next thing to look at.
Why this matters. This is exactly how real traffic is layered. Ethernet carries IP, IP carries ICMP or TCP or UDP, and those carry your data. The / you just typed is the same nesting a NIC, a router and Wireshark all deal with — you have just made it explicit.
03Read the packet field by field
Now open it up. .show() prints every field of every layer:
>>> pkt.show()
Real output from this build:
###[ IP ]###
version = 4
ihl = None
tos = 0x0
len = None
id = 1
flags =
frag = 0
ttl = 64
proto = icmp
chksum = None
src = 127.0.0.1
dst = 127.0.0.1
\options \
###[ ICMP ]###
type = echo-request
code = 0
chksum = None
id = 0x0
seq = 0x0
Three fields earn attention. ttl = 64 is the hop budget: each router subtracts one, and at zero the packet is dropped — that is what traceroute abuses to map a path. proto = icmp is the one byte in the IP header that says what is nested inside; a 1 there means ICMP, 6 means TCP, 17 means UDP (see the ports and protocols cheat sheet). And type = echo-request on the ICMP layer is what makes this a ping and not something else.
Pull a single field out directly, the same way you would read any Python attribute:
>>> pkt[IP].ttl 64 >>> pkt[ICMP].type 8
Why this matters. “TTL expired in transit”, “protocol 41”, “ICMP type 3” — these show up in firewall logs and error messages, and now they are fields you have touched, not jargon. A low TTL on an arriving packet, for instance, is a classic sign the sender is far away or is spoofing a value.
04See the fields Scapy fills in for you
Notice that len, chksum and ihl all read None above. Scapy computes those only at the moment the packet becomes bytes, because they depend on the finished packet. .show2() does that computation and shows you the result:
>>> pkt.show2()
The same packet, now with the blanks filled:
###[ IP ]###
version = 4
ihl = 5
len = 28
ttl = 64
proto = icmp
chksum = 0x7cde
src = 127.0.0.1
dst = 127.0.0.1
###[ ICMP ]###
type = echo-request
code = 0
chksum = 0xf7ff
ihl = 5 is the header length in 32-bit words (5 × 4 = 20 bytes, a header with no options). len = 28 is those 20 bytes plus an 8-byte ICMP header. The two chksum values are integrity check digits computed over the header and the ICMP message; change any byte and the receiver’s recomputed checksum will not match.
The reason the checksum is blank until you ask is simple: if Scapy filled it in early, every edit you made afterwards would silently invalidate it. Leaving it None and computing last means it is always right. More tools should be this honest about when a value is real. — Nyx
Why this matters. These are the fields people hand-wave past, and they are exactly the ones that break things. A wrong length or checksum gets a packet silently dropped, and “silently dropped” is the hardest fault to chase. Seeing them computed makes the mechanism concrete.
05Turn it into bytes, then read the bytes back
A packet on the wire is just a string of bytes. raw() serialises your packet into exactly those bytes:
>>> wire = raw(pkt) >>> wire.hex() '4500001c0001000040017cde7f0000017f0000010800f7ff00000000'
That is the whole packet. You can even spot the fields you set: 45 is version 4 / ihl 5, 40 is the TTL (64 in hex), 01 is the protocol (ICMP), 7f000001 is 127.0.0.1 twice, and 08 is ICMP type 8. Now hand those bytes back to Scapy and watch it rebuild the layers:
>>> IP(wire).summary()
'IP / ICMP 127.0.0.1 > 127.0.0.1 echo-request 0'
Building a packet and reading one are the same operation run in opposite directions. That reverse direction — bytes in, named fields out — is called dissection, and it is precisely what Wireshark does to every frame it captures.
Why this matters. Once you have seen that a packet is just bytes with an agreed-on layout, a lot of security stops being magic. Parsing bugs, malformed-packet crashes, and the way a firewall or IDS decides what a packet “is” all come down to this same dissect step — and dissecting attacker-controlled bytes is where a surprising number of them go wrong.
06Craft a DNS query and read its structure
ICMP is simple. A DNS query shows what a richer payload looks like: a UDP datagram to port 53 carrying a DNS message with a question inside it. Build one — still without sending anything:
>>> q = IP(dst="1.1.1.1") / UDP(dport=53) / DNS(rd=1, qd=DNSQR(qname="stashgrid.ai", qtype="A")) >>> q.show()
The interesting part is the DNS layer:
###[ DNS ]###
id = 0
qr = 0
opcode = QUERY
rd = 1
qdcount = None
\qd \
|###[ DNS Question Record ]###
| qname = b'stashgrid.ai.'
| qtype = A
| qclass = IN
Read the flags: qr = 0 marks this as a query, not a response; rd = 1 is “recursion desired”, asking the resolver to do the legwork; and the nested Question Record is the actual ask — an A (address) record for stashgrid.ai in the IN (internet) class. These are the same fields you read in Lab 13, only there you watched them go by and here you set them.
Yes, qr=0 for a query and qr=1 for a response is a single bit doing a lot of work. Half of “is this request or reply” in DNS is that one flag. The protocol is older than it looks. — Nyx
Why this matters. DNS is the protocol that breaks most often and gets blamed even when it has not. Knowing the query is this small — one question, a handful of flags — makes it far easier to reason about what a resolver, a cache or a tunnel is actually doing to it. For how a name turns into an address end to end, see DNS Explained.
07Send one ping to yourself and read the reply
Everything so far was in memory. Now send the ICMP packet from step 2 to your own loopback and wait for one reply. Sending raw packets needs admin rights, so start Python with sudo (or an Administrator terminal on Windows) for this step:
$ sudo python3 >>> from scapy.all import IP, ICMP, sr1 >>> reply = sr1(IP(dst="127.0.0.1") / ICMP(), timeout=2, verbose=False) >>> reply.summary()
sr1 means “send one packet and return the first reply”. The real reply on loopback:
'IP / ICMP 127.0.0.1 > 127.0.0.1 echo-reply 0'
Look at the direction and the type. Your request was an echo-request (ICMP type 8); the reply is an echo-reply (type 0) coming back the other way. Confirm it on the reply object:
>>> reply[ICMP].type
0
If sr1 returns None, nothing answered within the timeout — on a locked-down machine the firewall may be dropping it, which is itself worth noticing. You have now completed the full round trip you have been reading about: a packet you built by hand, sent, answered, and read back.
Why this matters. ping has always done exactly this; you just did it without the wrapper, so every field was yours. That is the dividing line between running a tool and understanding one. Scapy can craft far more than a ping — see its official documentation — but the rule from the top of this lab does not move: only ever send to a host or network you own.
What you learned
- IP() / ICMP() — the / operator stacks layers the way real traffic nests them
- .show() and .summary() — read a packet whole, or one line at a time, or one field at a time
- .show2() — the lengths and checksums Scapy computes only when the packet becomes bytes
- raw() and re-parsing — a packet is just bytes with a layout, and dissection is building run backwards
- DNS / DNSQR — a query is one question and a few flags inside a UDP datagram to port 53
- sr1() — send one packet and read the reply, echo-request out and echo-reply back
Put it on your resume. Crafted and dissected IP, ICMP and DNS packets layer by layer with Python's Scapy. Change it to match what you actually did.
Tried it? Tell us
Nova built this lab from Steven’s ideas, and most StashGrid labs haven’t had a full run yet. If a step broke, behaved differently for you, or you know a better way, tell us. Ideas for the next lab are welcome too — no promises on timing.
NextLab 12 — Read your first packet capture (Wireshark)→
Go deeper
- Lab 13 — Diagnose DNS in Wireshark — see the DNS fields from step 6 arriving on a real network
- Common Ports Cheat Sheet — the protocol numbers and port numbers you read out of the headers
- What happens when you type a URL — where these packets sit in the whole journey from name to page
Original walkthrough. Every command here was run on Ubuntu 24.04 with Python 3.13 and Scapy 2.8.0: steps 2–6 (building, .show(), .show2(), raw(), re-parsing and the DNS query) all run offline and were run exactly as shown; step 7’s sr1() ping to 127.0.0.1 was run live and did return the echo-reply shown. The hex bytes and all pasted output are from those runs, lightly trimmed for length. Your Scapy version may print a few extra fields. If something here does not match what your machine does, tell me and I will fix it.