Network debugging tools

tcpdump, ss, mtr, nmap, iperf.

Advanced14 min · lesson 15 of 17

A network that feels slow, or that fails once in every twenty tries, is one of the hardest things to debug. The evidence isn't on your screen. It's out on the wire, in the gap between two machines, and by the time your application prints 'connection timed out' the useful details are already gone. Think of a package that never showed up. Did it ever leave your building? Did it get lost somewhere on the highway? Or did it arrive at a door that was locked? You can't fix the delivery until you know which of those happened. Five tools answer those questions, and each looks at a different part of the trip. ss checks who's connected on this machine. tcpdump watches the actual packets leave and come back. mtr walks the road one hop at a time. nmap knocks on the far end's doors. iperf measures how fast the road really is. The skill is reaching for the one that matches your question instead of guessing.

Which tool answers your question
'The connection is weird.' What exactly do you want to know?
Is anything listening / reachable?
nmap
open vs closed vs filtered ports on the far host
Did my packet leave and get a reply?
tcpdump
watch the outgoing handshake, resets, and retransmits on the wire
Where on the path does it die?
mtr
per-hop packet loss and latency
What sockets does this host have?
ss
LISTEN, SYN-SENT, CLOSE-WAIT states
How fast is the link, really?
iperf3
raw throughput vs a slow application

ss: take inventory of the sockets

Before you touch the wire, look at what your own machine already knows. ss (short for socket statistics, the modern replacement for the older netstat command) is the guest list at the door. It prints every socket, which is the operating system's record of one end of a network conversation: 'this program is talking to that address on that port.' The flags you'll reach for most are -t for TCP (Transmission Control Protocol, the reliable connection type most services use), -u for UDP (User Datagram Protocol, the fire-and-forget type), -l for listening sockets, -n to print raw numbers instead of resolving names (much faster), and -p to show which process owns each socket. That last one needs root.

~/secopslog — bash
$ sudo ss -tulpn
Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process udp UNCONN 0 0 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=612,fd=13)) tcp LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=612,fd=14)) tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1023,fd=3)) tcp LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=1450,fd=6)) tcp LISTEN 0 511 0.0.0.0:6379 0.0.0.0:* users:(("redis-server",pid=1588,fd=6))

Read that list the way a bouncer reads a guest list: every open door is something an attacker can try. sshd on port 22 (the SSH service, short for Secure Shell, which is how you log into the box remotely) and nginx on port 80 (a web server) belong there. The line that should stop you is redis-server listening on 0.0.0.0:6379. The address 0.0.0.0 means 'every network interface, reachable from anywhere,' and Redis with no password on a public interface is one of the most-scanned, most-exploited misconfigurations on the internet. This one command is how you catch a service that was supposed to bind to localhost and didn't. Running ss -tulpn on a box you own and being able to explain every line is a real hardening check. One more detail worth knowing on a LISTEN socket: Recv-Q is the number of finished connections waiting for the application to pick them up, and Send-Q is the size of that waiting room, the accept backlog. A Recv-Q sitting right at the Send-Q limit means the application isn't calling accept() fast enough, so completed connections pile up and the kernel (the core of the operating system that manages hardware and processes) starts dropping new ones. That is what an overloaded server looks like from the inside. A SYN flood is a different animal. An attacker sends the opening packet of the TCP handshake (the SYN, short for synchronize) over and over and never finishes it, so those half-open connections never complete and never reach this accept queue. They pile up in a separate half-open queue, which you'd see as a heap of sockets in the SYN-RECV state, not as a full Recv-Q here.

~/secopslog — bash
$ ss -tan state syn-sent ss -tan state close-wait | wc -l
State Recv-Q Send-Q Local Address:Port Peer Address:Port SYN-SENT 0 1 10.0.1.5:51234 10.0.2.1:443 734

Two states are worth memorizing because each points straight at a cause. SYN-SENT means your machine fired off the first packet of the handshake (the SYN) and heard nothing back. The far end is unreachable, or a firewall is dropping you silently. A pile of sockets stuck in SYN-SENT is your side failing to reach the peer, not the peer rejecting you, because a rejection comes back fast as a reset. CLOSE-WAIT is the opposite tell. It means the other end hung up, but your own application never called close() to release the socket. A few are normal. Hundreds that keep climbing are a file-descriptor leak (a file descriptor is the small numbered handle the kernel gives a program for each open socket or file), and when the process runs out of them it stops accepting new connections and falls over. A count in the hundreds like this one is a bug in your app, not a problem with the network.

tcpdump: watch the actual packets

When ss says a socket is stuck but can't tell you why, go to the wire. tcpdump is a wiretap on a network interface: it copies packets as they pass and prints them, filtered so you don't drown in traffic. It answers the most basic questions in a network fight, the ones every other tool is only guessing at. Is my request actually leaving this box? Is anything coming back? Is the reply a real answer or a reset (a RST packet, the network's way of saying 'go away, nothing is here')? The filter language is where the power is. The command below reads as 'on interface eth0, don't resolve names or ports (-nn, which keeps it fast and honest), and show me only traffic to or from host 10.0.2.1 on port 443.'

~/secopslog — bash
$ sudo tcpdump -i eth0 -nn host 10.0.2.1 and port 443
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes 14:02:11.310457 IP 10.0.1.5.51234 > 10.0.2.1.443: Flags [S], seq 2846612032, win 64240, options [mss 1460,sackOK,TS val 8831201 ecr 0,nop,wscale 7], length 0 14:02:12.312881 IP 10.0.1.5.51234 > 10.0.2.1.443: Flags [S], seq 2846612032, win 64240, options [mss 1460,sackOK,TS val 8832203 ecr 0,nop,wscale 7], length 0 14:02:14.316902 IP 10.0.1.5.51234 > 10.0.2.1.443: Flags [S], seq 2846612032, win 64240, options [mss 1460,sackOK,TS val 8834207 ecr 0,nop,wscale 7], length 0 ^C 3 packets captured 3 packets received by filter 0 packets dropped by kernel

Three packets, and the story is already clear. Each line is a SYN (Flags [S]) leaving your host, and nothing ever comes back. Look at two details. The sequence number is identical every time, so this is the same SYN being retransmitted, not three fresh attempts. And the gaps grow: one second, then two. That doubling is TCP's exponential backoff, the kernel waiting longer and longer for a reply that never arrives. That pattern, a SYN going out with no SYN-ACK (the acknowledging reply that would complete the handshake) coming back, means a firewall is dropping your packets or nothing is listening on the far side. If the far end were actively refusing you, a RST would come back instantly instead. When you need to keep the evidence, add -w capture.pcap to write a capture file, then read it later with tcpdump -r capture.pcap, or open it in Wireshark for a full protocol decode.

A packet capture is a bag of other people's secrets
tcpdump copies real traffic, and on any unencrypted protocol that traffic includes the payload in the clear: passwords, session tokens, API keys, personal data. A .pcap file is therefore one of the most sensitive things on a box, and a stolen capture is a breach on its own. Capturing needs root or the CAP_NET_RAW capability (the specific kernel permission for reading raw packets), so treat that access as privileged. Use the tightest filter that answers your question, cap the volume with -c or a smaller snapshot length, store captures encrypted, delete them when you're done, and never capture on a system you aren't authorized to touch. The tool that lets you see the bug also lets you see everyone's data on the wire.

mtr: find where on the path it dies

tcpdump proves a packet left and got no answer. It can't tell you where on the road it died. mtr (short for My Traceroute) can. It folds two old tools, ping and traceroute, into one live table. It sends packets with a deliberately short lifespan (the TTL, or Time To Live, a counter that every router along the way decrements by one) so that each router in turn is forced to send back an ICMP (Internet Control Message Protocol, the small control-and-error messages that routers exchange) 'time exceeded' reply, which reveals its address. Then it repeats that over and over and tracks how many replies it lost at each hop. The result is a map of the path with a loss percentage at every step. The command below runs ten cycles in report mode (-r), in wide format (-w) so long hostnames don't get cut off.

~/secopslog — bash
$ mtr -rwc 10 10.0.2.1
Start: 2026-07-17T14:05:03+0000 HOST: web-01 Loss% Snt Last Avg Best Wrst StDev 1.|-- 10.0.1.1 0.0% 10 0.4 0.5 0.3 0.9 0.2 2.|-- 100.64.0.1 0.0% 10 1.1 1.3 1.0 2.1 0.3 3.|-- 203.0.113.9 0.0% 10 5.2 5.4 5.0 6.1 0.3 4.|-- ??? 100.0% 10 0.0 0.0 0.0 0.0 0.0 5.|-- 10.0.2.1 40.0% 10 82.4 79.1 70.2 95.3 8.1

This is the report people misread most, so read it slowly. Hop 4 shows 100% loss, which looks alarming. It isn't. If hop 4 were really dropping everything, hop 5 and the destination couldn't answer at all, and they answer fine. What's happening is that the router at hop 4 deprioritizes the 'time exceeded' replies mtr measures with, while still forwarding real traffic normally. That is ordinary ICMP rate limiting, not a fault. Loss that shows up at one middle hop and clears at the next is a measurement artifact. The loss that matters is the kind that starts at a hop and continues all the way to the destination. Here hop 5 (the destination, 10.0.2.1) shows a real 40% loss, with latency jumping to about 80 milliseconds. That is where your connection is actually dying, and now you have something concrete to hand the network team instead of 'the internet is broken.'

nmap: which doors are actually open

mtr found where packets die along the path. nmap answers a different question: on the machine at the far end, which doors are actually open? nmap (short for Network Mapper) knocks on ports and reports back what it finds. Attackers run it first, before anything else, to map what you've exposed. You should run it against your own hosts for the same reason, from outside the box, so you see what the world sees. The scan below skips the initial ping check (-Pn, handy when a host is set not to answer pings), uses a SYN scan (-sS, which needs root and is fast because it never finishes the handshake), asks for service and version detection (-sV), and checks all 65535 ports (-p-).

~/secopslog — bash
$ sudo nmap -Pn -sS -sV -p- 10.0.2.1
Starting Nmap 7.94 ( https://nmap.org ) at 2026-07-17 14:10 UTC Nmap scan report for 10.0.2.1 Host is up (0.0021s latency). Not shown: 65532 closed tcp ports (reset) PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.6 (Ubuntu Linux; protocol 2.0) 80/tcp open http nginx 1.18.0 (Ubuntu) 5432/tcp filtered postgresql Service detection performed. Please report any incorrect results at https://nmap.org/submit/ . Nmap done: 1 IP address (1 host up) scanned in 8.42 seconds

nmap's three port states are the whole point, and they line up exactly with what tcpdump showed earlier. 'open' means something is listening and answered. 'closed' means the port is reachable but nothing is listening, so the host sent back a reset. 'filtered' means nmap got no answer at all, which is the fingerprint of a firewall silently dropping the packet, the same silent drop you watched happen packet by packet with tcpdump. Notice that port 5432, PostgreSQL, is filtered here: a firewall is keeping the database off the outside, which is what you want. The defensive use is direct. Scan your own host from an untrusted network and compare the open ports against what you meant to expose. Any surprise 'open' line is either a misconfiguration or something that shouldn't be there. The version strings are a second gift, and a double-edged one: they tell you, and any attacker, exactly which software versions to check against lists of known vulnerabilities.

Only scan machines you're allowed to scan
A port scan is reconnaissance, and against a host you don't own or have written permission to test, it can be a crime in many places, not merely rude. It also lights up every intrusion-detection system in the path, so you may be the reason someone's pager goes off at 3am. Scan your own infrastructure, or systems you have explicit authorization to assess, and nothing else. On the defensive side, remember the reverse: the scans hitting your own hosts in the logs are attackers doing exactly this to you, and that traffic is worth alerting on.

iperf: is it the network or the app

The last question is the one everyone argues about: is the network slow, or is the application slow? iperf3 settles it by measuring the raw speed of the link with nothing else in the way. Think of it as renting a moving truck and driving it flat-out down the road to find out how fast the road really allows, instead of guessing from how long your last grocery delivery took. You run a server on one host and a client on the other, and it pushes data as hard as it can for a fixed time. Start iperf3 -s on the receiving host, then point the client at it with a ten-second test.

~/secopslog — bash
$ # on the receiving host: iperf3 -s # on the sending host: iperf3 -c 10.0.2.1 -t 10
Connecting to host 10.0.2.1, port 5201 [ 5] local 10.0.1.5 port 51234 connected to 10.0.2.1 port 5201 [ ID] Interval Transfer Bitrate Retr Cwnd [ 5] 0.00-1.00 sec 1.10 GBytes 9.44 Gbits/sec 0 3.14 MBytes [ 5] 1.00-2.00 sec 1.09 GBytes 9.41 Gbits/sec 0 3.14 MBytes [ 5] 9.00-10.00 sec 1.09 GBytes 9.41 Gbits/sec 0 3.14 MBytes - - - - - - - - - - - - - - - - - - - - - - - - - [ ID] Interval Transfer Bitrate Retr [ 5] 0.00-10.00 sec 11.0 GBytes 9.42 Gbits/sec 0 sender [ 5] 0.00-10.00 sec 11.0 GBytes 9.42 Gbits/sec receiver

Two things to read. The Bitrate column sits at 9.42 gigabits per second, which is essentially line rate for a 10-gigabit link, so the path between these two hosts is healthy. The Retr column (short for retransmits) is zero, meaning TCP never had to resend a packet. A climbing Retr count is the signature of loss or congestion on the path, and it lines up with what mtr would show you. Now the payoff for operators: if iperf reports full speed but your application still crawls, the network is not your problem and you can stop digging there. The bug is above the network, in the app, the database, or a slow dependency it waits on. For real-time traffic like voice or video (VoIP, short for Voice over Internet Protocol), add -u to test with UDP instead, which reports jitter and packet loss rather than throughput, the numbers that actually decide whether a call sounds fine or breaks up.

Quick check
01An mtr report run with -r and -w over ten cycles shows 100% loss at hop 4, while hop 5 and the destination each come back with 0%. What do you take from that?
Incorrect — If that router threw away everything, the two hops behind it would have nothing to answer with, yet both reply cleanly.
Correct — Routers commonly push real traffic through at full speed while rationing the control replies mtr builds its table from, so the column reads high for no operational reason.
Incorrect — Every reply from hop 5 and beyond had to travel through hop 4 first, so those rows are proof the hop is passing packets, not evidence of a detour.
Incorrect — More cycles give you the same picture here, and the hops past 4 already answer the question without another run.
02ss -tnp shows hundreds of sockets sitting in CLOSE-WAIT on your app server, and the count climbs every time you look. What is going on?
Incorrect — That failure parks sockets in SYN-SENT, which tells you your own machine could not get through to the far end.
Incorrect — You would catch that on the listening socket, where Recv-Q climbs to the Send-Q limit and the kernel starts turning away new arrivals.
Incorrect — Handshakes that never complete wait in a separate half-open queue and appear as SYN-RECV, so a flood never produces this state.
Correct — Only your application calling close() clears a socket the far side already shut, so a count that only grows points straight at code that forgot to.
03tcpdump -nn -i eth0 catches the same SYN leaving three times with an unchanged sequence number, the gaps stretching from one second to two, and nothing ever comes back. What does that pattern mean?
Correct — A silent discard and a dead listener look identical from your side: the SYN goes out and no acknowledging reply ever returns to finish the handshake.
Incorrect — A deliberate refusal answers straight away with a reset, so you would be reading RST packets in this capture instead of watching silence.
Incorrect — The sequence number never moves and the wait keeps doubling, which is one handshake being retried rather than chatter on a working connection.
Incorrect — tcpdump read those frames on their way out of eth0, which is exactly the evidence that they did leave the box.

Try this

Work through “iperf: is it the network or the app” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: a packet capture is a bag of other people's secrets. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related