SSH tunneling, fully explained: local, remote, and dynamic SOCKS forwards

  • networking
  • pivoting
  • ssh
  • post-exploitation
  • tooling

Proof of concept on GitHub

When you can SSH to a host, you can usually reach everything that host can reach, because an SSH connection is not limited to carrying a shell. It is a multiplexed transport: the single encrypted session carries many independent channels, and SSH will happily open a channel that relays a plain TCP connection to wherever you point it. That is all a tunnel is. Understand the three forward directions and you can turn one login on a reachable box into a route onto a network that is otherwise invisible to you.

How a forward actually works

A forward has two ends: a listener on one side of the SSH session, and a dialer on the other. When something connects to the listener, SSH opens a new channel over the existing encrypted session, the far end dials the real target, and bytes are relayed between the two. The confidentiality, integrity, and authentication are the SSH session's; the forwarded traffic rides inside it. Nothing on the wire between the two SSH endpoints sees anything but SSH. The three forms differ only in which side holds the listener and which side does the dialing.

The three SSH forward types Local forward listens on your box and the SSH host dials the target. Remote forward listens on the SSH host and relays back to a service on your box. Dynamic forward opens a SOCKS proxy on your box and the SSH host dials any destination. -L local forward: reach a remote service on a local port Your box SSH host Target listen :8445 ssh dial -R remote forward: expose your service on the far side Your box SSH host Far client to your local svc listen :p back connects -D dynamic SOCKS: one proxy to every host the SSH host can reach Your box SSH host any host SOCKS :1080 ssh dials any
The three forward directions. In -L and -D the listener is on your box and the SSH host does the dialing; in -R the listener is on the SSH host and the traffic is relayed back to a service on your side.

Local forward (-L): pull a remote service to a local port

ssh -L [bind_addr:]<local_port>:<target_host>:<target_port> user@ssh_host

The listener is on your machine. A connection to local_port is relayed through ssh_host and dialed to target_host:target_port from the SSH host's vantage point. The classic use is reaching an internal service through a jump box: you can SSH to a perimeter host, and from that host an internal server's SMB, RDP, or database port is reachable, but you cannot route to it directly.

# reach an internal SMB server (172.16.0.9:445) through a jump host you can SSH to
ssh -L 8445:172.16.0.9:445 kali@10.0.0.5
# now 127.0.0.1:8445 on your box IS that internal server's 445
smbclient -p 8445 //127.0.0.1/share

target_host is resolved and dialed by the SSH host, so 127.0.0.1 as the target means the SSH host itself. By default the local listener binds to loopback; add a bind_addr (or GatewayPorts) only if you deliberately want other machines to use your tunnel.

Remote forward (-R): push a local service onto the far network

ssh -R [bind_addr:]<remote_port>:<local_host>:<local_port> user@ssh_host

Now the listener is on the SSH server. A connection to remote_port on ssh_host is relayed back through the session and dialed to local_host:local_port on your side. This is how you expose something on your machine to a network only the SSH host can see: a payload handler, an internal tool, or a callback. On a pivot it is the direction that lets a deep host reach back to a listener you control without the deep host needing a route to you. By default the remote listener binds to the SSH server's loopback; reaching it from other hosts on that network needs GatewayPorts yes on the server, which is off by default for good reason.

Dynamic forward (-D): a SOCKS proxy over the session

ssh -D <local_port> user@ssh_host

Instead of one fixed target, -D opens a SOCKS5 proxy on local_port. Any client that speaks SOCKS hands the proxy a destination per connection, and SSH dials it from the SSH host. One tunnel then reaches every host and port the SSH host can, with no per-target setup. This is the workhorse for pivoting and for scanning an internal range through a single foothold.

ssh -D 1080 kali@10.0.0.5
# point SOCKS-aware tools at 127.0.0.1:1080
proxychains -q nmap -sT -Pn -p 445,3389 172.16.0.0/24
curl --socks5-hostname 127.0.0.1:1080 http://172.16.0.20/

Use --socks5-hostname (and proxychains proxy_dns) so name resolution happens at the SSH host, inside the target network, not on your box.

Running tunnels so they stay up

  • -N tells SSH not to run a remote command; you only want the forward. -f backgrounds the client after authenticating. Together, ssh -fN ... is the usual "set it and forget it," though if you want to track and kill the exact process later it is cleaner to start ssh -N yourself and keep its PID (which is what the helper below does).
  • -o ServerAliveInterval=30 -o ServerAliveCountMax=3 keeps the session from dying silently behind a NAT or idle timeout and tears it down promptly when the link really drops.
  • -o ExitOnForwardFailure=yes makes SSH fail loudly if the forward cannot bind, instead of giving you a live session with a dead tunnel.
  • -J user@hop1,user@hop2 (ProxyJump) chains through multiple hops, so you can forward through a second and third pivot without nesting commands. Each hop only needs to reach the next.

The helper: tunnelctl

Managing more than one or two of these by hand gets error-prone: you lose track of which local port maps where, and which ssh process is which. The companion tunnelctl is a small dependency-free Python CLI that builds the command interactively (it asks the mode, the SSH host, the ports, and the auth), starts it in the background, and keeps a local inventory so you can list, up, down, and rm tunnels across separate runs. It remembers the username and key path you used per host so you do not retype them, and deliberately never stores a password: password-auth hosts prompt each time and the secret is passed to sshpass through the environment, never written to disk or the process list. list shows each tunnel's route and whether it is actually up (its process is alive and, for -L/-D, the local port is listening).

Defense: stop SSH being a pivot

Tunneling is a feature, so the control is deciding who may use it and watching when they do.

  • Turn off forwarding where it is not needed. AllowTcpForwarding no on an sshd that only needs to give shells kills -L, -R, and -D outright. Where some forwarding is legitimate, scope it: AllowTcpForwarding local (or remote), AllowStreamLocalForwarding no, GatewayPorts no, PermitTunnel no, and a Match User/Match Group block so only the accounts that need it have it.
  • Make the bastion the only path and log it. Funnel administrative SSH through a bastion with LogLevel VERBOSE, which records the forwarding channel requests, and forward those logs off the host. A jump host that suddenly relays to many internal addresses is visible there.
  • Constrain the accounts. Keys with restrict/no-port-forwarding in authorized_keys, ForceCommand, and least-privilege service accounts stop a compromised login from becoming a general-purpose relay.
  • Control egress. A -R callback and a deep -D pivot both depend on the network letting the traffic flow; default-deny egress and segmentation limit how far a single foothold reaches.

Detection

Long-lived ssh processes carrying -L, -R, or -D, a host listening on unusual local ports owned by an ssh process, and sshd VERBOSE logs showing direct-tcpip (local/dynamic) or forwarded-tcpip (remote) channel openings are the direct signals. At the network layer, one internal host relaying connections to many others, SOCKS-shaped traffic, and DNS resolved at a jump host for names an endpoint would not normally look up all point at a pivot. Audit ssh invocations with forwarding flags on your own hosts, and alert on a bastion opening forwards outside change windows.

Use

Authorized and educational use only: your own hosts, a lab you control, or an engagement with written permission. Tunneling moves traffic you are allowed to move; run tunnelctl only against hosts you are authorized to reach. This pairs with the local network enumeration in living off the land on Linux, and the SSH keys that make it work are the kind of loot covered in backups, connection managers, and keyrings.

Proof of concept on GitHub

Back to all techniques