
SSH tunneling, fully explained: local, remote, and dynamic SOCKS forwards
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.
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
-Ntells SSH not to run a remote command; you only want the forward.-fbackgrounds 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 startssh -Nyourself and keep its PID (which is what the helper below does).-o ServerAliveInterval=30 -o ServerAliveCountMax=3keeps the session from dying silently behind a NAT or idle timeout and tears it down promptly when the link really drops.-o ExitOnForwardFailure=yesmakes 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 noon an sshd that only needs to give shells kills-L,-R, and-Doutright. Where some forwarding is legitimate, scope it:AllowTcpForwarding local(orremote),AllowStreamLocalForwarding no,GatewayPorts no,PermitTunnel no, and aMatch User/Match Groupblock 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-forwardinginauthorized_keys,ForceCommand, and least-privilege service accounts stop a compromised login from becoming a general-purpose relay. - Control egress. A
-Rcallback and a deep-Dpivot 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.