Networking Fundamentals · beginner · ~10 min
- Describe what a **MAC address** is, how it differs from an IP address, and why both are needed to deliver a packet. - Trace the full **ARP request/reply** exchange, step by step, from an unanswered IP to a filled-in **ARP cache** entry. - Read the fields of an **ARP frame** (opcode, sender/target hardware and protocol addresses) and tell a request from a reply. - Explain why ARP is **trust-based** and how that single design gap makes **ARP spoofing** and man-in-the-middle attacks possible. - Name the common defensive measures (dynamic ARP inspection, static entries, segmentation) and where each fits.
When two computers on the same local network talk, the data does not travel by IP address the way most people imagine. IP addresses are how the internet routes packets between networks, but the actual wire — Ethernet or Wi-Fi — only knows how to deliver a frame to a hardware address burned into a network card. That hardware address is the MAC address, and the small protocol that figures out which MAC address goes with a given IP is the Address Resolution Protocol (ARP).
In plain language: your computer knows the IP of the machine it wants to reach (say the router at 192.168.1.1), but to put a frame on the wire it must know that IP's MAC address. ARP is how it asks the local network "who owns this IP?" and remembers the answer.
This lesson builds directly on The TCP/IP and OSI models. There you learned that networking is organized in layers, with the link layer (layer 2) sitting below the internet/IP layer (layer 3). ARP is the glue between those two layers: it lets a layer-3 IP address be translated into a layer-2 MAC address so the frame can actually be sent. If the layered model told you what the layers are, this lesson shows you how two adjacent layers cooperate on a real LAN.
Once the plain idea is clear, the terminology is easy: MAC address (48-bit hardware address), ARP request (a broadcast question), ARP reply (a unicast answer), and ARP cache (the short-lived table of answers).
ARP runs constantly, silently, under almost every connection you make on a LAN — every web request, every file copy, every video call starts with an ARP lookup for the next hop. If ARP is broken or slow, nothing else works, so understanding it is fundamental to diagnosing "I have an IP but I can't reach anything" problems.
It matters even more for security. ARP was designed in an era of trusted networks and has no authentication whatsoever — any machine on the LAN can claim to own any IP, and everyone believes it. This is the root of ARP spoofing / ARP poisoning, the classic technique for putting an attacker between two hosts as a man-in-the-middle (MITM) who can read or alter traffic. Many real LAN attacks, and the defenses built into managed switches, exist precisely because of this gap.
ARP is also where practical packet analysis begins. Open any capture of local traffic and the first frames you see are usually ARP. Being able to recognize an ARP request versus a reply, and spot two different MACs claiming the same IP, is an everyday skill for network engineers and defenders alike.
Definition. A MAC (Media Access Control) address is a 48-bit (6-byte) identifier assigned to a network interface, usually written as six hex pairs like 00:1a:2b:3c:4d:5e. It operates at the link layer.
Plain explanation. Think of the IP address as a mailing address (it can change when you move networks) and the MAC address as a serial number on the mailbox hardware itself. On a single LAN, frames are delivered to MAC addresses, not IPs.
How it works internally. The first 3 bytes are the OUI (Organizationally Unique Identifier), assigned to the hardware vendor; the last 3 bytes are chosen by the vendor per device. A special value, ff:ff:ff:ff:ff:ff, is the broadcast address — a frame sent there is delivered to every interface on the LAN. That broadcast ability is exactly what ARP uses to ask its question.
When it matters / when it doesn't. MAC addresses matter only within a single LAN segment. The moment a packet crosses a router into another network, the source and destination MAC are rewritten for the next hop — the IP addresses stay the same end-to-end, but the MACs are hop-by-hop.
Common pitfall. Believing a MAC address is globally unique and permanent. In practice MACs can be spoofed in software (ip link set dev eth0 address ...), and many devices randomize their MAC for privacy. Never treat a MAC as a trustworthy identity.
48-bit MAC address: 00:1a:2b : 3c:4d:5e
\_______/ \_______/
OUI device
(vendor) (per-NIC)
Special: ff:ff:ff:ff:ff:ff = broadcast (everyone on the LAN)
Knowledge check: Two laptops are on the same Wi-Fi network. Laptop A sends a file to Laptop B. Which address does the Wi-Fi frame carrying the data use as its destination — B's IP or B's MAC? Why?
Definition. IP addresses are layer-3 logical addresses used to route between networks; MAC addresses are layer-2 physical addresses used to deliver within one network.
Plain explanation. IP gets a packet to the right neighborhood (network); MAC gets it to the right house (interface) once it has arrived. You need both because the internet is a network of networks — routing across it needs a hierarchical, changeable address (IP), while the final local delivery needs a fixed hardware handle (MAC).
| Aspect | IP address | MAC address |
|---|---|---|
| Layer | 3 (internet) | 2 (link) |
| Scope | Global / across networks | Local LAN segment |
| Size (IPv4) | 32 bits | 48 bits |
| Changes when you move networks | Yes | No (burned in, though spoofable) |
| Used for | Routing between networks | Delivery within one network |
| Rewritten at each router hop | No (stays end-to-end) | Yes (per hop) |
Common pitfall. Thinking a router "forwards by MAC." Routers forward by IP and rewrite the MACs for the next hop; switches forward by MAC within a LAN. Confusing the two makes ARP's job impossible to understand.
Knowledge check (explain in your own words): Why does the destination MAC address of a frame change at every router hop, while the destination IP address stays the same the whole way?
Definition. ARP resolves an IPv4 address to a MAC address using a two-message exchange: a broadcast request and a unicast reply.
Plain explanation. The sender shouts to the whole LAN, "Who has 192.168.1.20? Tell me your MAC." Only the owner of that IP answers, privately: "192.168.1.20 is at 00:1a:2b:3c:4d:5e." Everyone else ignores the question.
How it works internally. The request is an Ethernet frame with destination MAC ff:ff:ff:ff:ff:ff (broadcast) carrying an ARP payload whose opcode = 1 (request). It fills in the sender's own IP+MAC and the target IP, leaving the target MAC blank. The owner responds with an ARP frame, opcode = 2 (reply), sent unicast straight back to the requester with its MAC filled in.
Host A (192.168.1.10) wants to reach 192.168.1.20
1) REQUEST (broadcast, opcode 1)
A --> ff:ff:ff:ff:ff:ff : "Who has 192.168.1.20? Tell 192.168.1.10"
|
+-------+-------+-------+ (every host on the LAN receives it)
v v v
.11 ignores .20 MATCH .30 ignores
2) REPLY (unicast, opcode 2)
.20 --> A : "192.168.1.20 is at 00:1a:2b:3c:4d:5e"
3) A stores the pair in its ARP cache and sends the real data.
When to use / when NOT. ARP is used only for IPv4 on a shared-medium LAN. It is not used across routers (you ARP for your gateway's MAC, then the gateway ARPs onward on the next network), and it does not exist in IPv6 — IPv6 uses Neighbor Discovery Protocol (NDP) instead.
Common pitfall. Assuming the reply is verified. Nothing checks that the replier actually owns the IP; the first believable answer wins. Hold that thought for concept 5.
Knowledge check (predict the output): You run a packet capture and see a frame with destination MAC ff:ff:ff:ff:ff:ff and ARP opcode 1. Is this a request or a reply, and roughly what question is it asking?
Definition. The ARP cache (or ARP table) is a short-lived, in-memory table of recently resolved IP-to-MAC mappings, one per active neighbor.
Plain explanation. Asking the whole LAN every single time would be wasteful, so each host remembers answers for a while (typically seconds to a couple of minutes) and reuses them.
How it works internally. Entries expire on a timer so that stale mappings (a device that moved or changed its MAC) get re-resolved. Entries can be dynamic (learned via ARP) or static (pinned manually). You can view the cache with arp -a or ip neigh.
$ ip neigh
192.168.1.1 dev eth0 lladdr 00:1a:2b:3c:4d:5e REACHABLE
192.168.1.20 dev eth0 lladdr 3c:22:fb:88:11:aa STALE
When to use static entries. Pinning a static entry for a critical host (like the default gateway) is a lightweight hardening step: a spoofed reply can no longer overwrite it.
Common pitfall. Forgetting that dynamic entries can be overwritten by an unsolicited reply on many systems (a gratuitous ARP). That overwrite behavior is the mechanism ARP poisoning abuses.
Knowledge check (find the bug in reasoning): A student says, "My ARP cache proves who's on the network — if an IP maps to a MAC there, that's definitely that machine." What's wrong with that claim?
Definition. ARP spoofing / poisoning is sending forged ARP replies that map a victim's IP (often the gateway) to the attacker's MAC, so the victim's traffic is delivered to the attacker instead.
Plain explanation. Because any host may answer and answers aren't verified, an attacker on the LAN can repeatedly announce "the gateway's IP is at my MAC." Victims cache that lie and send their outbound traffic to the attacker, who forwards it on — becoming an invisible man-in-the-middle.
How it works internally (conceptually, for defense). The attacker sends unsolicited (gratuitous) ARP replies to the victim and, to intercept both directions, also poisons the gateway's cache with the victim's IP. Enabling IP forwarding lets traffic still flow, so the victim notices nothing.
Before: Victim <-----------> Gateway <---> Internet
Poisoned: Victim ---> Attacker ---> Gateway <---> Internet
(victim's cache says
gateway-IP = attacker-MAC)
Defenses (the point of learning this):
| Defense | What it does | Where it fits |
|---|---|---|
| Dynamic ARP Inspection (DAI) | Managed switch validates ARP against a trusted DHCP-snooping table and drops forgeries | Enterprise switched networks |
| Static ARP entries | Pin critical mappings (e.g. gateway) so replies can't overwrite them | A few high-value hosts |
| Network segmentation / VLANs | Shrinks the broadcast domain so fewer machines can even attempt it | Network design |
| Encryption (TLS, SSH, VPN) | Even if intercepted, traffic stays confidential and tamper-evident | Every application |
| ARP monitoring (e.g. arpwatch) | Alerts when an IP's MAC suddenly changes | Detection / SOC |
Common pitfall. Relying on a single defense. Encryption protects the content but not the fact of interception or metadata; DAI needs a managed switch. Defense in depth is the professional answer.
Knowledge check (concept): Why does encrypting traffic with TLS reduce the impact of ARP spoofing even though it doesn't stop the spoofing itself?
ARP is a protocol, not code, so the "syntax" here is the layout of an ARP-over-Ethernet frame. Recognizing these fields is what a classifier reads.
Ethernet header (14 bytes)
[ dst MAC 6 ][ src MAC 6 ][ ethertype 2 = 0x0806 for ARP ]
ARP payload (28 bytes for IPv4-over-Ethernet)
offset
0 Hardware type (2) = 0x0001 (Ethernet)
2 Protocol type (2) = 0x0800 (IPv4)
4 HW addr len (1) = 6
5 Proto addr len (1) = 4
6 Opcode (2) = 1 request, 2 reply <-- the key field
8 Sender MAC (6)
14 Sender IP (4)
18 Target MAC (6) (all zeros in a request)
24 Target IP (4)
The opcode at offset 6-7 (big-endian, i.e. network byte order) is what tells a request (0x0001) from a reply (0x0002). Everything numeric on the wire is big-endian, so byte 6 is the high byte and byte 7 is the low byte: opcode = frame[6] << 8 | frame[7].
On a single LAN, hosts reach each other by MAC address (a 48-bit hardware address like 00:1a:2b:3c:4d:5e), not by IP.
The link layer does not understand IP. To send a frame, it needs the MAC address of the next hop.
ARP (Address Resolution Protocol) bridges layer 3 (IP) and layer 2 (the link layer).
Suppose a host wants to send to 192.168.1.20. It broadcasts a question to the whole LAN:
"Who has 192.168.1.20? Tell me your MAC."
The owner of that IP replies with its MAC address. The sender stores the result in its ARP table (also called the ARP cache) so it does not have to ask again right away.
You can inspect that table:
$ arp -a
? (192.168.1.1) at 00:1a:2b:3c:4d:5e [ether] on eth0
ARP has no authentication. Any host can claim, "I have that IP," and the reply is believed.
This is ARP spoofing (also called ARP poisoning). In authorized testing, it is used to place an attacker in the middle of LAN traffic as a man-in-the-middle.
Common defences include:
Recognising ARP frames is also a first step in reading packet captures.
Below is a small, complete C11 program that reads the opcode out of a raw ARP frame buffer and reports whether it is a request or a reply — the same first step an ARP-spoofing detector performs before it compares sender MAC/IP pairs. It parses two hard-coded sample frames so it runs with no network access.
#include <stdio.h>
#include <stdint.h>
#include <stddef.h>
/* Offsets within an ARP-over-Ethernet frame (14-byte Ethernet header + ARP). */
#define ETH_HEADER_LEN 14
#define ETH_TYPE_OFFSET 12
#define ETHERTYPE_ARP 0x0806
#define ARP_OPCODE_OFFSET (ETH_HEADER_LEN + 6) /* opcode is 6 bytes into ARP */
#define ARP_FRAME_MIN (ETH_HEADER_LEN + 28) /* 14 + 28 for IPv4/Ethernet */
#define ARP_REQUEST 1
#define ARP_REPLY 2
/* Read a big-endian (network byte order) 16-bit value at frame[pos]. */
static uint16_t read_be16(const uint8_t *frame, size_t pos) {
return (uint16_t)((frame[pos] << 8) | frame[pos + 1]);
}
/* Return the ARP opcode, or 0 if the buffer is not a valid ARP frame. */
int arp_opcode(const uint8_t *frame, size_t n) {
if (frame == NULL || n < ARP_FRAME_MIN) /* guard: too short to parse */
return 0;
if (read_be16(frame, ETH_TYPE_OFFSET) != ETHERTYPE_ARP) /* not ARP */
return 0;
return read_be16(frame, ARP_OPCODE_OFFSET);
}
static void classify(const uint8_t *frame, size_t n, const char *label) {
int op = arp_opcode(frame, n);
switch (op) {
case ARP_REQUEST: printf("%s: ARP request (opcode 1)\n", label); break;
case ARP_REPLY: printf("%s: ARP reply (opcode 2)\n", label); break;
default: printf("%s: not a valid ARP frame\n", label); break;
}
}
int main(void) {
/* A broadcast ARP request: dst = ff:ff:ff:ff:ff:ff, ethertype 0806, opcode 0001. */
uint8_t request[ARP_FRAME_MIN] = {
0xff,0xff,0xff,0xff,0xff,0xff, /* dst MAC (broadcast) */
0x00,0x1a,0x2b,0x3c,0x4d,0x5e, /* src MAC */
0x08,0x06, /* ethertype = ARP */
0x00,0x01, 0x08,0x00, 0x06, 0x04, /* HW/proto type + lengths */
0x00,0x01, /* opcode = 1 (request) */
/* remaining ARP address fields left as zero for this demo */
};
/* A unicast ARP reply: same header shape, opcode 0002. */
uint8_t reply[ARP_FRAME_MIN] = {
0x00,0x1a,0x2b,0x3c,0x4d,0x5e, /* dst MAC (the requester) */
0x3c,0x22,0xfb,0x88,0x11,0xaa, /* src MAC (the answerer) */
0x08,0x06,
0x00,0x01, 0x08,0x00, 0x06, 0x04,
0x00,0x02, /* opcode = 2 (reply) */
};
classify(request, sizeof request, "frame #1");
classify(reply, sizeof reply, "frame #2");
uint8_t truncated[10] = {0}; /* too short to be ARP */
classify(truncated, sizeof truncated, "frame #3");
return 0;
}
What it does. arp_opcode first rejects anything too short or whose ethertype isn't 0x0806, then returns the 16-bit opcode read in network byte order. main builds one request frame, one reply frame, and one deliberately truncated buffer, then classifies each.
Expected output:
frame #1: ARP request (opcode 1)
frame #2: ARP reply (opcode 2)
frame #3: not a valid ARP frame
Key edge cases. A buffer shorter than 42 bytes can't hold a full ARP frame, so it's rejected before any out-of-bounds read. A frame whose ethertype isn't ARP returns 0 even if the bytes at the opcode offset happen to look like 1 or 2. Opcodes other than 1 and 2 exist (RARP, etc.) and here fall through to "not a valid ARP frame," which is fine for a request/reply classifier.
We'll trace classify(request, 42, "frame #1").
classify calls arp_opcode(request, 42).frame is non-NULL and n (42) is >= ARP_FRAME_MIN (42), so we continue. Had n been 10, we'd return 0 immediately — this is the bounds check that prevents reading past the buffer.read_be16(frame, 12) reads bytes 12 and 13, which are 0x08 and 0x06. It computes (0x08 << 8) | 0x06 = 0x0806, which equals ETHERTYPE_ARP, so we don't bail out.ARP_OPCODE_OFFSET is 14 + 6 = 20. read_be16(frame, 20) reads bytes 20 and 21: 0x00 and 0x01, giving (0x00 << 8) | 0x01 = 1. That's returned.classify, op == 1 == ARP_REQUEST, so the case ARP_REQUEST branch prints frame #1: ARP request (opcode 1).For frame #2, step 4 reads bytes 20-21 = 0x00, 0x02, yielding 2, so the reply branch fires. For frame #3, n is 10, which is < 42, so step 2 returns 0 and the default branch prints "not a valid ARP frame."
| Frame | n | ethertype@12 | bytes@20-21 | opcode | branch |
|---|---|---|---|---|---|
| #1 request | 42 | 0x0806 | 00 01 | 1 | request |
| #2 reply | 42 | 0x0806 | 00 02 | 2 | reply |
| #3 short | 10 | (not read) | (not read) | 0 | not valid |
The key idea: validate length first, then type, then read the field — each guard protects the read that follows it.
Mistake 1 — Reading the opcode without a length check.
/* WRONG: assumes the buffer is long enough */
int arp_opcode(const uint8_t *frame) {
return (frame[20] << 8) | frame[21]; /* reads offsets 20,21 blindly */
}
Why it's wrong: if the caller passes a 10-byte buffer, frame[20] reads 10 bytes past the end — undefined behavior that may crash or leak stack memory. Fix: pass the length and check it first (as in the lesson code). Recognize it when a fuzzer or a short real-world frame causes a crash.
Mistake 2 — Ignoring byte order.
/* WRONG on a little-endian machine */
uint16_t op = *(uint16_t *)&frame[20]; /* reads bytes as host order */
On x86 this yields 0x0100 for a request instead of 0x0001. Network data is big-endian; always assemble it byte-by-byte (hi << 8 | lo) or use ntohs. Recognize it when values look byte-swapped (256 instead of 1).
Mistake 3 — Confusing IP delivery with MAC delivery. Learners often say "the packet is sent to the destination IP's MAC." On a routed path the frame's destination MAC is the next hop (your gateway), not the final host. Correct mental model: IP is end-to-end, MAC is hop-by-hop. Catch it by remembering that a far-away server's MAC never appears in your local ARP cache — only your gateway's does.
Mistake 4 — Trusting the ARP cache as identity. Assuming arp -a proves who's who. A spoofer can make any IP map to their MAC. Never authenticate a peer by its MAC or ARP entry; use cryptographic identity (TLS certs, SSH keys) instead.
Mistake 5 — Forgetting IPv6 doesn't use ARP. Debugging IPv6 connectivity by looking for ARP traffic finds nothing, because IPv6 uses NDP (ICMPv6 Neighbor Solicitation/Advertisement). Look at ip -6 neigh instead.
Compiler errors (for the C example):
printf → you forgot #include <stdio.h>. For uint8_t/uint16_t include <stdint.h>; for size_t include <stddef.h>.n (a size_t) with a small int → compare against a size_t-typed constant like ARP_FRAME_MIN (fine here) or cast deliberately.Runtime errors:
n and the raw bytes with %02x before parsing.0x0806); a common slip is forgetting the 14-byte Ethernet header when computing the opcode offset.Logic / network errors (real ARP):
ip neigh; a FAILED or missing entry for the gateway means ARP isn't resolving (cabling, VLAN, or the gateway is down).Questions to ask when it doesn't work:
This is a concept lesson, but the sample parser is C, and parsing untrusted network bytes is one of the most dangerous things C code does — so the memory-safety discipline is the real lesson.
n >= ARP_FRAME_MIN (and NULL) before indexing. Every field read must be provably within n. Reading frame[20] on a 10-byte buffer is an out-of-bounds read (undefined behavior) that can crash or leak memory.&frame[20] to uint16_t *: it assumes host byte order and can also violate alignment on some platforms. Assemble bytes explicitly with shifts, as read_be16 does.uint8_t promotes to int, so (frame[pos] << 8) is safe here; but be wary of shifts near 32 bits and of signed overflow when hand-rolling parsers.Security framing (defensive, lab-only). ARP's lack of authentication is a design vulnerability, clearly labeled: any host can forge replies, enabling MITM. The defensive pairing: validate at the switch (Dynamic ARP Inspection backed by DHCP snooping), pin static entries for critical hosts, segment the network to shrink the attack surface, encrypt so intercepted traffic stays confidential, and monitor (arpwatch) for sudden MAC changes. Practice interception only in an isolated lab you own — never on networks or targets you aren't authorized to test.
Where ARP shows up in real systems.
classify-arp-frame exercise mirrors the very first step such a detector performs.Professional best-practice habits.
| Beginner | Advanced | |
|---|---|---|
| Parsing frames | Length-check, then read fixed offsets | Validate every field (hw/proto type, lengths) and reject malformed frames; fuzz the parser |
| Naming | #define offsets instead of magic numbers |
Model the frame as a documented struct/spec with byte-order helpers |
| Error handling | Return 0 / a sentinel on bad input | Distinguish "not ARP" from "malformed ARP" and log for monitoring |
| Security | Encrypt traffic; don't trust MACs | Deploy DAI + DHCP snooping, static gateway entries, VLAN segmentation, ARP monitoring |
| Cleanup/robustness | Handle truncated input gracefully | Handle VLAN-tagged (802.1Q) frames, IPv6/NDP equivalents, and gratuitous ARP |
Good habits: never treat a MAC as identity, always account for the Ethernet header in offsets, read multi-byte fields in network byte order, and pair any "here's how the attack works" understanding with "here's the deployed defense."
Beginner 1 — Classify by opcode. Write const char *arp_kind(int opcode) that returns "request" for 1, "reply" for 2, and "other" otherwise. Requirements: no I/O, pure function. Example: arp_kind(2) → "reply". Hint: a switch is cleanest. Concepts: opcode field, request vs reply.
Beginner 2 — Print a MAC address. Write void print_mac(const uint8_t mac[6]) that prints six hex pairs separated by colons, e.g. 00:1a:2b:3c:4d:5e. Requirements: use %02x; lowercase hex. Input {0,0x1a,0x2b,0x3c,0x4d,0x5e} → output 00:1a:2b:3c:4d:5e. Hint: loop 0..5, print a colon before every pair except the first. Concepts: MAC layout, byte formatting.
Intermediate 1 — Extract sender IP and MAC. Extend the lesson parser with int arp_sender(const uint8_t *frame, size_t n, uint8_t out_mac[6], uint8_t out_ip[4]) that, for a valid ARP frame, copies the sender hardware address (offset 22) and sender protocol address (offset 28) into the outputs and returns 1; returns 0 for invalid/too-short frames. Requirements: bounds-check before copying. Constraint: no dynamic allocation. Hint: reuse the ethertype/length guards from arp_opcode. Concepts: ARP field offsets, bounds checking.
Intermediate 2 — Detect a suspicious mapping change. Write a function that, given a stream of (ip, mac) pairs (as arrays), reports the first time a previously-seen IP appears with a different MAC — the core signal an ARP-monitoring tool raises. Requirements: store seen pairs in a small fixed array; compare 6-byte MACs with memcmp. Example: seeing 192.168.1.1→AA.. then later 192.168.1.1→BB.. prints an alert. Hint: linear search is fine for small tables. Concepts: ARP cache semantics, spoofing detection (defensive).
Challenge — A tiny ARP frame validator. Write int arp_validate(const uint8_t *frame, size_t n) that returns 1 only if: n >= 42; ethertype at 12 is 0x0806; hardware type is 0x0001; protocol type is 0x0800; hardware length is 6; protocol length is 4; and opcode is 1 or 2. Otherwise return 0. Requirements: reject at the first failed check; no out-of-bounds reads for any n. Add a main that runs your validator against at least three crafted frames (valid, wrong-ethertype, truncated) and prints pass/fail. Hint: build a helper read_be16 and validate fields in offset order. Concepts: full-frame validation, defensive parsing, byte order, bounds safety. Do not just check the opcode — validate the whole header shape.
ff:ff:ff:ff:ff:ff) asks "who has this IP?", the owner sends a unicast reply (opcode 2), and the result is cached in the ARP cache for a short time.