Using nmap or Burp for a while doesn’t teach you how they work. So I wrote two small tools that do a fraction of what the real ones do, in code short enough to read in one sitting:
- portscout: an async TCP port scanner in Python, built to learn how nmap decides a port is open, closed or filtered.
- jwt-autopsy: a Go CLI that takes JSON Web Tokens apart and explains what’s risky about them.
Neither one replaces the real tool. Both are written to be read.
portscout
Open, closed, filtered
A TCP connection starts with a three-way handshake. A port scanner sends the first packet and classifies the port by what comes back:
scanner --SYN--> target "can I connect to port 80?"
scanner <--SYN/ACK-- target "yes, port 80 is listening" (open)
<--RST-- "no, nothing here" (closed)
(silence) (filtered)
portscout does a connect scan (nmap’s -sT). It asks the OS to connect() normally and looks at how that turns out. That means no raw sockets and no root, and since you end up with a real connection you can read a banner off it. The downside is noise: every open port costs a full handshake, and the service will probably log it. nmap’s default when run as root is a SYN scan (-sS), which sends the SYN itself and never finishes the handshake. That’s faster and quieter, but it needs raw sockets.
In Python, the three outcomes map almost directly onto exceptions:
try:
# open_connection() performs the full handshake. If it returns, the
# port accepted our connection.
reader, writer = await asyncio.wait_for(asyncio.open_connection(target.ip, port), timeout)
except ConnectionRefusedError:
# The target's TCP stack replied with RST: host reachable, port closed.
return result(PortState.CLOSED, elapsed_ms())
except TimeoutError:
# Silence. Our SYN (or their SYN/ACK) was dropped somewhere.
return result(PortState.FILTERED)
except OSError:
# e.g. "host/network unreachable": usually an ICMP error from a router
# or firewall. nmap also reports these as filtered.
return result(PortState.FILTERED)
A closed port is actually useful to a scanner, because the RST proves the host is up. Filtered means nothing came back, usually because a firewall dropped the packet on purpose so you’d have nothing to work with.
Running it
python -m venv .venv && source .venv/bin/activatepip install -e ".[dev]"portscout 127.0.0.1 --top 20 --bannersThat run was on my Windows machine, so the two open ports are the RPC endpoint mapper and SMB. Your results will be different. Add --json out.json to save every result, or -p 1-1024 to pick ports yourself.
Why asyncio fits
A scan spends nearly all its time waiting for packets. With asyncio, one thread can start hundreds of connects and the event loop resumes each one when its socket answers. The one thing you have to add is a limit, because every connection holds a socket and too many at once hit OS limits or look like a flood. portscout uses a semaphore, and acquires it before creating each task, so a huge CIDR scan never has more than -c tasks sitting in memory.
You can try the pattern here. This cell fakes the network with asyncio.sleep (nothing leaves your browser), so change CONCURRENCY and watch how the total time changes:
Set CONCURRENCY = 1 and it’s a plain sequential scan. Set it to 20 and all probes run at once, so the total time drops to roughly the slowest single probe.
jwt-autopsy
Base64 is not encryption
A signed JWT is three base64url segments joined by dots: header, payload and signature. The first two are plain JSON that anyone holding the token can read. The signature is the only thing that makes the token trustworthy, and it only helps if the server picks the algorithm and key itself instead of believing what the header says.
This cell decodes a token and runs a few of jwt-autopsy’s checks. Paste your own token in (one you’re allowed to look at) or edit the header:
The real tool
jwt-autopsy has three subcommands: decode pretty-prints the token, inspect explains what’s risky, and verify checks the signature against a key you supply. It uses only the Go standard library, so every check fits in a few lines of crypto/* and encoding/*.
Here’s inspect on the same unsigned token:
./bin/jwt-autopsy inspect eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhbGljZSIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTc1ODgwMDAwMH0.There are 27 checks in total. Besides the claim checks there’s a kid containing ../ or quotes (path traversal or SQL injection if the server trusts it), jku/x5u headers that point at a remote key, an embedded jwk, RSA signatures that imply a key under 2048 bits, and timestamps in milliseconds instead of seconds. The last one is a surprisingly common bug. It pushes exp past the year 3000, so the token effectively never expires.
verify against the well-known example token from jwt.io:
echo -n 'your-256-bit-secret' > secret.txt./bin/jwt-autopsy verify eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c --secret-file secret.txtThe signature checks out, and the tool still points out that a 19-byte secret is too short. Anyone holding one token signed with a short or dictionary-word secret can brute-force it offline.
Algorithm confusion
verify refuses to use a key for any algorithm other than its own, and this is the most important design choice in the tool. The attack it prevents goes like this:
- The server signs tokens with RS256. It holds an RSA private key and publishes the public key.
- The attacker changes the header to
alg: HS256and signs the token with HMAC, using the public key bytes as the secret. Anyone can do this, because the key is public. - A naive verifier reads
algfrom the header, sees HS256, and runs HMAC with the only key it has, the RSA public key. The signature matches, so the forged token is accepted.
how do you fix it?
Bind every key to one algorithm, and never let the token pick. jwt-autopsy refuses to use a PEM public key for HS*,
or a secret for RS*/ES*/EdDSA, and reports key type does not match algorithm instead of trying. Your JWT
library should be configured the same way, with an explicit allow-list of algorithms.
What I took away
- Writing a small version of a tool is the fastest way to learn the real one. After portscout, nmap’s
filteredstate and its-sT/-sSflags made sense to me. - Explaining matters more than detecting. Every jwt-autopsy finding says why it matters and what a correct verifier does. A finding is an observation about the token. Whether it’s exploitable depends on the server that receives it.
- Tests shouldn’t touch the network. portscout’s tests start local asyncio servers (an echo server, a fake SSH greeting, a fake HTTP server) and scan those. jwt-autopsy’s tests mint their own signed tokens with a test-only helper.
Both repos are on my GitHub: portscout and jwt-autopsy. The READMEs go deeper, and jwt-autopsy’s has a reference table for every finding code.