{"id":1349,"date":"2026-05-21T14:47:41","date_gmt":"2026-05-21T12:47:41","guid":{"rendered":"https:\/\/simon-frey.com\/blog\/?p=1349"},"modified":"2026-05-21T16:53:59","modified_gmt":"2026-05-21T14:53:59","slug":"nmap-through-ssh-pivot","status":"publish","type":"post","link":"https:\/\/simon-frey.com\/blog\/nmap-through-ssh-pivot\/","title":{"rendered":"Nmap Through SSH Pivot: Why Proxychains and sshuttle Fail"},"content":{"rendered":"\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p class=\"wp-block-paragraph\">If you&#8217;ve tried pivoting nmap through an SSH jump host, you&#8217;ve probably hit one of two outcomes: every port comes back as <strong>filtered<\/strong>, or every port comes back as <strong>open<\/strong>. Both are wrong and in this post I will show you how I got to a satisifying result. (Spoiler: Using ligolo-ng)<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Scenario: You&#8217;ve got an attack host, a pivot you can SSH into, and a target on an internal subnet that&#8217;s only reachable from the pivot:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Attack Host (10.10.14.12)  --&gt;  Pivot (10.129.229.129)  --&gt;  Target (172.16.5.35)\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The target lives on <code>172.16.5.0\/24<\/code>. You want to scan it from your attack host and get real results.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Failed approach 1: SSH dynamic forward + proxychains<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The textbook answer (or rather the one I learned in HTB Academy) Open a SOCKS proxy with SSH, point proxychains at it, run nmap.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>> ssh -D 9050 -i ~\/.ssh\/id_rsa user@pivot-host\n\n> proxychains nmap -sT -Pn -n 172.16.5.35\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">nmap result:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>PORT     STATE    SERVICE\n22\/tcp   filtered ssh\n135\/tcp  filtered msrpc\n445\/tcp  filtered microsoft-ds\n3389\/tcp filtered ms-wbt-server\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Checking with netcat I figured, the tunnel is fine, but the combination of the SOCKS proxy and Nmap is the problem.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why does nmap show all ports as filtered through proxychains?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Three things compound. First, proxychains forces nmap&#8217;s non-blocking sockets into blocking mode. Nmap&#8217;s whole scan engine is built around firing off <code>connect()<\/code> in non-blocking mode and using <code>select()<\/code>\/<code>poll()<\/code> to track dozens of probes in flight. Proxychains intercepts those calls and makes them blocking, so probes that should return instantly hang instead. Nmap&#8217;s reads the hang as &#8220;no response&#8221; and labels the port filtered.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I have tried <code>-n<\/code>, <code>--max-parallelism 1<\/code>, <code>--max-rtt-timeout 5000ms<\/code>, bumping <code>tcp_read_time_out<\/code> to 30000, or swapping <code>socks4<\/code> for <code>socks5<\/code>&#8230; none of that fixes it. The socket-level mismatch between nmap and proxychains is just how those two tools interact.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What really made me wonder: Even Nmap&#8217;s own <code>--proxies socks4:\/\/127.0.0.1:9050<\/code> flag has the same problem. Apparent the scan engine was never designed to run through a SOCKS proxy.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Failed approach 2: sshuttle<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">sshuttle uses iptables rules to redirect outbound TCP into a local Python process that forwards over SSH.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>> sudo sshuttle -r user@pivot-host 172.16.5.0\/24 --ssh-cmd \"ssh -i ~\/.ssh\/id_rsa\"\n\n> nmap -sT -Pn -n 172.16.5.35\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">What you get: all 1000 default ports open. The first time I saw that I thought I&#8217;d found a goldmine :&#8217;D<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why does nmap show all ports as open through sshuttle?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">sshuttle&#8217;s iptables <code>REDIRECT<\/code> rule funnels every outbound TCP connection into its local listener \u2014 and that listener accepts the connection immediately, before it has any idea whether the real target is reachable. From nmap&#8217;s perspective the three-way handshake completes on every port, because it&#8217;s completing against the local Python process, not the target. Nmap sees a successful <code>connect()<\/code> and dutifully reports &#8220;open.&#8221;<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Approach 3: just run nmap on the pivot<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">You can skip the proxy problem entirely:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ssh -i ~\/.ssh\/id_rsa user@pivot-host \"nmap -sT -Pn 172.16.5.35\"\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Accurate, fast, done \u2014 if nmap is on the pivot. On real engagements and most CTF labs, it isn&#8217;t, and uploading a static binary adds operational noise you might not want. Worth knowing about, though.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Approach 4: ligolo-ng &#8211; the fix<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/github.com\/nicocha30\/ligolo-ng\">ligolo-ng<\/a> creates a real <strong>tun interface<\/strong> on your attack host and tunnels raw IP packets through a small agent running on the pivot. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That&#8217;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)<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Setup<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Download proxy and agent binary from ligolo-ng release page: <a href=\"https:\/\/github.com\/nicocha30\/ligolo-ng\/releases\">https:\/\/github.com\/nicocha30\/ligolo-ng\/releases<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">On your attack host, build the tun interface and start the proxy:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo ip tuntap add user $(whoami) mode tun ligolo\nsudo ip link set ligolo up\nsudo ip route add 172.16.5.0\/24 dev ligolo\nsudo .\/proxy -selfcert -laddr 0.0.0.0:11601\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Push the agent to the pivot and run it:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>scp -i ~\/.ssh\/id_rsa .\/agent user@pivot-host:\/tmp\/agent\nssh -i ~\/.ssh\/id_rsa user@pivot-host \"\/tmp\/agent -connect YOUR_ATTACK_IP:11601 -ignore-cert\"\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">(There&#8217;s a Windows agent too, if your pivot is a compromised Windows host. Same idea, just <code>agent.exe<\/code>.)<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In the ligolo proxy console (on your attack host):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ligolo-ng &gt;&gt; session\nligolo-ng &gt;&gt; start\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Now you can run nmap like the pivot isn&#8217;t even there:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>nmap 172.16.5.35\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Results:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>PORT     STATE SERVICE\n22\/tcp   open  ssh\n135\/tcp  open  msrpc\n139\/tcp  open  netbios-ssn\n445\/tcp  open  microsoft-ds\n3389\/tcp open  ms-wbt-server\n\nNmap done: 1 IP address (1 host up) scanned in 1.83 seconds\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Five open, 995 correctly closed, 1.83 seconds. Compare that to the multi-minute proxychains scan that was faulty.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How the tools differ<\/h3>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Tool<\/th><th>How it tunnels<\/th><th>What breaks<\/th><\/tr><\/thead><tbody><tr><td>proxychains<\/td><td>Intercepts <code>connect()<\/code>, proxies through SOCKS<\/td><td>Forces non-blocking sockets to blocking; nmap&#8217;s timing engine reads delays as &#8220;filtered&#8221;<\/td><\/tr><tr><td>sshuttle<\/td><td>iptables REDIRECT to a local Python process over SSH<\/td><td>Local listener accepts every connection before forwarding, so nmap sees every port as &#8220;open&#8221;<\/td><\/tr><tr><td>ligolo-ng<\/td><td>Kernel tun interface, raw IP packets<\/td><td>Nothing. There&#8217;s no proxy layer to break.<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">What about Chisel?<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Chisel came up a lot in my research on this issue and it&#8217;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&#8217;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&#8217;t scale to a nmap scan, but might be relevant on other task during your pentest.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">A note on SYN scans<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">If you&#8217;ve been wondering why <code>nmap -sS<\/code> 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 (<code>-sU<\/code>) 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.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Why nmap returns all &#8216;filtered&#8217; through proxychains and all &#8216;open&#8217; through sshuttle when pivoting via SSH, and how to get accurate scans.<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":"","_links_to":"","_links_to_target":""},"categories":[377],"tags":[68],"class_list":["post-1349","post","type-post","status-publish","format-standard","hentry","category-pentesting","tag-security"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v27.6 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>Nmap Through SSH Pivot: Why Proxychains and sshuttle Fail - Blog by Simon Frey<\/title>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/simon-frey.com\/blog\/nmap-through-ssh-pivot\/\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"Simon Frey\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"4 minutes\" \/>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"Nmap Through SSH Pivot: Why Proxychains and sshuttle Fail - Blog by Simon Frey","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/simon-frey.com\/blog\/nmap-through-ssh-pivot\/","twitter_misc":{"Written by":"Simon Frey","Est. reading time":"4 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/simon-frey.com\/blog\/nmap-through-ssh-pivot\/#article","isPartOf":{"@id":"https:\/\/simon-frey.com\/blog\/nmap-through-ssh-pivot\/"},"author":{"name":"Simon Frey","@id":"https:\/\/simon-frey.com\/blog\/#\/schema\/person\/34753982b648415636ee7a079f3e19a3"},"headline":"Nmap Through SSH Pivot: Why Proxychains and sshuttle Fail","datePublished":"2026-05-21T12:47:41+00:00","dateModified":"2026-05-21T14:53:59+00:00","mainEntityOfPage":{"@id":"https:\/\/simon-frey.com\/blog\/nmap-through-ssh-pivot\/"},"wordCount":872,"publisher":{"@id":"https:\/\/simon-frey.com\/blog\/#\/schema\/person\/34753982b648415636ee7a079f3e19a3"},"keywords":["security"],"articleSection":["pentesting"],"inLanguage":"en-US"},{"@type":"WebPage","@id":"https:\/\/simon-frey.com\/blog\/nmap-through-ssh-pivot\/","url":"https:\/\/simon-frey.com\/blog\/nmap-through-ssh-pivot\/","name":"Nmap Through SSH Pivot: Why Proxychains and sshuttle Fail - Blog by Simon Frey","isPartOf":{"@id":"https:\/\/simon-frey.com\/blog\/#website"},"datePublished":"2026-05-21T12:47:41+00:00","dateModified":"2026-05-21T14:53:59+00:00","breadcrumb":{"@id":"https:\/\/simon-frey.com\/blog\/nmap-through-ssh-pivot\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/simon-frey.com\/blog\/nmap-through-ssh-pivot\/"]}]},{"@type":"BreadcrumbList","@id":"https:\/\/simon-frey.com\/blog\/nmap-through-ssh-pivot\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/simon-frey.com\/blog\/"},{"@type":"ListItem","position":2,"name":"Nmap Through SSH Pivot: Why Proxychains and sshuttle Fail"}]},{"@type":"WebSite","@id":"https:\/\/simon-frey.com\/blog\/#website","url":"https:\/\/simon-frey.com\/blog\/","name":"Blog by Simon Frey","description":"","publisher":{"@id":"https:\/\/simon-frey.com\/blog\/#\/schema\/person\/34753982b648415636ee7a079f3e19a3"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/simon-frey.com\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":["Person","Organization"],"@id":"https:\/\/simon-frey.com\/blog\/#\/schema\/person\/34753982b648415636ee7a079f3e19a3","name":"Simon Frey","logo":{"@id":"https:\/\/simon-frey.com\/blog\/#\/schema\/person\/image\/"},"sameAs":["https:\/\/simon-frey.com","https:\/\/www.linkedin.com\/in\/simonfrey\/","https:\/\/x.com\/eu_frey"]}]}},"_links":{"self":[{"href":"https:\/\/simon-frey.com\/blog\/wp-json\/wp\/v2\/posts\/1349","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/simon-frey.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/simon-frey.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/simon-frey.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/simon-frey.com\/blog\/wp-json\/wp\/v2\/comments?post=1349"}],"version-history":[{"count":1,"href":"https:\/\/simon-frey.com\/blog\/wp-json\/wp\/v2\/posts\/1349\/revisions"}],"predecessor-version":[{"id":1350,"href":"https:\/\/simon-frey.com\/blog\/wp-json\/wp\/v2\/posts\/1349\/revisions\/1350"}],"wp:attachment":[{"href":"https:\/\/simon-frey.com\/blog\/wp-json\/wp\/v2\/media?parent=1349"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/simon-frey.com\/blog\/wp-json\/wp\/v2\/categories?post=1349"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/simon-frey.com\/blog\/wp-json\/wp\/v2\/tags?post=1349"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}