If you’ve tried pivoting nmap through an SSH jump host, you’ve probably hit one of two outcomes: every port comes back as filtered, or every port comes back as open. Both are wrong and in this post I will show you how I got to a satisifying result. (Spoiler: Using ligolo-ng)
Scenario: You’ve got an attack host, a pivot you can SSH into, and a target on an internal subnet that’s only reachable from the pivot:
Attack Host (10.10.14.12) --> Pivot (10.129.229.129) --> Target (172.16.5.35)
The target lives on 172.16.5.0/24. You want to scan it from your attack host and get real results.
Failed approach 1: SSH dynamic forward + proxychains
The textbook answer (or rather the one I learned in HTB Academy) Open a SOCKS proxy with SSH, point proxychains at it, run nmap.
> ssh -D 9050 -i ~/.ssh/id_rsa user@pivot-host
> proxychains nmap -sT -Pn -n 172.16.5.35
nmap result:
PORT STATE SERVICE
22/tcp filtered ssh
135/tcp filtered msrpc
445/tcp filtered microsoft-ds
3389/tcp filtered ms-wbt-server
Checking with netcat I figured, the tunnel is fine, but the combination of the SOCKS proxy and Nmap is the problem.
Why does nmap show all ports as filtered through proxychains?
Three things compound. First, proxychains forces nmap’s non-blocking sockets into blocking mode. Nmap’s whole scan engine is built around firing off connect() in non-blocking mode and using select()/poll() to track dozens of probes in flight. Proxychains intercepts those calls and makes them blocking, so probes that should return instantly hang instead. Nmap’s reads the hang as “no response” and labels the port filtered.
I have tried -n, --max-parallelism 1, --max-rtt-timeout 5000ms, bumping tcp_read_time_out to 30000, or swapping socks4 for socks5… none of that fixes it. The socket-level mismatch between nmap and proxychains is just how those two tools interact.
What really made me wonder: Even Nmap’s own --proxies socks4://127.0.0.1:9050 flag has the same problem. Apparent the scan engine was never designed to run through a SOCKS proxy.
Failed approach 2: sshuttle
sshuttle uses iptables rules to redirect outbound TCP into a local Python process that forwards over SSH.
> sudo sshuttle -r user@pivot-host 172.16.5.0/24 --ssh-cmd "ssh -i ~/.ssh/id_rsa"
> nmap -sT -Pn -n 172.16.5.35
What you get: all 1000 default ports open. The first time I saw that I thought I’d found a goldmine :’D
Why does nmap show all ports as open through sshuttle?
sshuttle’s iptables REDIRECT rule funnels every outbound TCP connection into its local listener — and that listener accepts the connection immediately, before it has any idea whether the real target is reachable. From nmap’s perspective the three-way handshake completes on every port, because it’s completing against the local Python process, not the target. Nmap sees a successful connect() and dutifully reports “open.”
Approach 3: just run nmap on the pivot
You can skip the proxy problem entirely:
ssh -i ~/.ssh/id_rsa user@pivot-host "nmap -sT -Pn 172.16.5.35"
Accurate, fast, done — if nmap is on the pivot. On real engagements and most CTF labs, it isn’t, and uploading a static binary adds operational noise you might not want. Worth knowing about, though.
Approach 4: ligolo-ng – the fix
ligolo-ng creates a real tun interface on your attack host and tunnels raw IP packets through a small agent running on the pivot.
That’s the key detail. Your kernel sees the internal subnet as routable through a normal network interface. No SOCKS. No iptables redirect, everything works the way it does on a directly connected network, because as far as the OS is concerned, it is one. You also need to upload a static binary to the pivot, though. (So you might as well go with nmap on pivot, I prefer the ligolo-ng approach still)
Setup
Download proxy and agent binary from ligolo-ng release page: https://github.com/nicocha30/ligolo-ng/releases
On your attack host, build the tun interface and start the proxy:
sudo ip tuntap add user $(whoami) mode tun ligolo
sudo ip link set ligolo up
sudo ip route add 172.16.5.0/24 dev ligolo
sudo ./proxy -selfcert -laddr 0.0.0.0:11601
Push the agent to the pivot and run it:
scp -i ~/.ssh/id_rsa ./agent user@pivot-host:/tmp/agent
ssh -i ~/.ssh/id_rsa user@pivot-host "/tmp/agent -connect YOUR_ATTACK_IP:11601 -ignore-cert"
(There’s a Windows agent too, if your pivot is a compromised Windows host. Same idea, just agent.exe.)
In the ligolo proxy console (on your attack host):
ligolo-ng >> session
ligolo-ng >> start
Now you can run nmap like the pivot isn’t even there:
nmap 172.16.5.35
Results:
PORT STATE SERVICE
22/tcp open ssh
135/tcp open msrpc
139/tcp open netbios-ssn
445/tcp open microsoft-ds
3389/tcp open ms-wbt-server
Nmap done: 1 IP address (1 host up) scanned in 1.83 seconds
Five open, 995 correctly closed, 1.83 seconds. Compare that to the multi-minute proxychains scan that was faulty.
How the tools differ
| Tool | How it tunnels | What breaks |
|---|---|---|
| proxychains | Intercepts connect(), proxies through SOCKS | Forces non-blocking sockets to blocking; nmap’s timing engine reads delays as “filtered” |
| sshuttle | iptables REDIRECT to a local Python process over SSH | Local listener accepts every connection before forwarding, so nmap sees every port as “open” |
| ligolo-ng | Kernel tun interface, raw IP packets | Nothing. There’s no proxy layer to break. |
What about Chisel?
Chisel came up a lot in my research on this issue and it’s a legitimate option. It tunnels TCP over HTTP and can do SOCKS or reverse port forwards. The catch: if you use its SOCKS mode you’re back to proxychains territory and the same nmap problem. If you use reverse port forwards you have to set them up per-port, which doesn’t scale to a nmap scan, but might be relevant on other task during your pentest.
A note on SYN scans
If you’ve been wondering why nmap -sS fails through every SSH-based tunnel: SYN scans need raw sockets, which no userland proxy passes through. proxychains, sshuttle, Chisel SOCKS none of them can carry a half-open SYN. ligolo-ng can, because the tun interface lets nmap craft and send actual IP packets. UDP scans (-sU) work for the same reason. If you need anything beyond a connect scan over a pivot, ligolo-ng right now seems to me like the only option.