Networks
Step 163. SSH Tunneling and Port Forwarding — Loading Other Roads onto an Encrypted Passage
Level 2 — Network Attacks and MITM | Difficulty ★★★★☆ | Estimated time: 4 hours
Prerequisites: Step 29 (remote SSH access) and Step 161 (firewalls and iptables) complete. Knowing socket basics (Steps 77–78) makes the principle exercises click better.
- What you need: a Linux machine with an SSH server running (lab VM or your own WSL), an ssh client (built into Windows PowerShell), Python 3.
- ⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime. Caution: port forwarding is a technology that effectively bypasses firewall policy, so using it at will on someone else’s network — like a company network — goes beyond policy violation and becomes intrusion. The Python forwarder outputs and the
ssh localhostfailure message in this chapter were measured on 2026-09-09; SSH session screens are environment-dependent and marked as output examples.
In Step 29 you used SSH as a "remote terminal." But an SSH connection is not a pipe that carries only commands — inside that encrypted passage you can load other programs’ traffic. This is port forwarding. It’s the technology for reaching "services blocked outside but open inside," for making your traffic exit from a different point, and even for reaching a hand back out from behind a firewall. The pivot of penetration testing (moving inward from a foothold) stands on this technology. Today you learn the three directions (-L, -R, -D) and prove the principle yourself with a 40-line Python forwarder.
1. Learning Objectives
By the end of this chapter, you will be able to:
- Explain the directional differences of local (-L), remote (-R), and dynamic (-D) forwarding with diagrams
- Pull a service behind a firewall to localhost with -L
- Turn an SSH server into a SOCKS proxy with -D
- Implement and explain the essence of port forwarding (socket relaying) in Python
- Know the constraints of -R such as GatewayPorts, and the defender’s perspective on detecting tunnels
2. Background Knowledge — Today’s Tools and Concepts
Today’s Tools at a Glance
| Category | Details |
|---|---|
| Language/environment | ssh client (Windows/Linux alike) + one SSH server + Python 3 (principle practice) |
| Today’s commands | ssh -L myport:destination:destport user@server, ssh -R remoteport:destination:destport user@server, ssh -D myport user@server |
| Concepts needed | ports (Step 29), listening and connecting, SOCKS proxies (Step 162), firewall inbound/outbound (Step 161) |
| Today’s artifact | one -L tunnel + a working Python forwarder + a three-direction comparison table |
2-1. What Is Port Forwarding — A Relay Race of Connections
The essence of port forwarding is simple. A program accepts connections on my port 8080 and relays that data as-is to another address:port. The program in the middle doesn’t need to understand the content — it pushes received bytes over there, and pushes the response back over here. SSH does this relaying inside its own encrypted connection. That’s why it’s called a "tunnel" — from the outside there’s just one SSH connection, but other traffic passes inside it.
2-2. -L (Local Forwarding) — The Door Opens on My Side
Here’s how to read ssh -L 8080:localhost:80 user@server: "When something connects to my computer’s 8080, send it to localhost:80 as seen from beyond the SSH server (server)." The door (listening socket) opens locally — hence local forwarding.
The core of its use: the server’s localhost:80 may be a service that never shows outside the firewall (an internal admin page, a DB, etc.). But if only SSH (port 22) is open, that one line makes the internal service appear on my 8080. This connects exactly to the story of the server whose firewall said "only 22 open" in Step 161 — even when the firewall blocks, you reach other doors through the open one (SSH).
2-3. -R (Remote Forwarding) — The Door Opens Over There
ssh -R 9000:localhost:22 user@server is the opposite. "When something connects to their (server’s) 9000, send it to my side’s localhost:22." The door opens on the remote side.
Why is this needed? When my computer sits behind NAT or a firewall so nobody can connect to me (Step 161’s BlockInbound), if I go out first to a server outside and leave a door there, the other party can climb back in through that door. A classic bypass exploiting the fact that outbound connections are usually free — and at the same time a legitimate remote-support technology.
2-4. -D (Dynamic Forwarding) — A Door for Everything
ssh -D 1080 user@server fixes no destination. A SOCKS proxy (Step 162) appears on my 1080, and at each connection you can designate "go over there this time." Since all traffic exits through the SSH server, it’s effectively a makeshift VPN. Write socks5 127.0.0.1 1080 into proxychains and any program rides this tunnel.
2-5. The Three Directions at a Glance
| Option | Where the door opens | Where data exits | Representative use |
|---|---|---|---|
-L |
My computer | The SSH server side | Bring a service inside the firewall to my localhost |
-R |
The SSH server | My computer’s side | Expose my service behind NAT to the outside |
-D |
My computer | The SSH server side (destination varies) | Makeshift VPN / proxy |
Memory aid: the letter is the door’s location. L opens a door on Local, R on Remote.
3. Follow Along
3-0. Checking Today’s Environment — Is There an SSH Server?
Before the main practice, confirm the server is alive (Step 29’s method).
ssh localhost
Output (measured 2026-09-09, WSL with no sshd installed):
ssh: connect to host localhost port 22: Connection refused
How to read it: "Connection refused" means you reached the address but the port-22 door is closed — there’s no SSH server (same as Step 29’s Wall 2). If openssh-server is running on your lab VM, proceed with 3-1 through 3-4 as-is. If not, read the command outputs as examples and secure the principle with 3-2’s Python exercise — because a tunnel’s essence is not SSH but relaying.
3-1. -L Practice — An Internal Service in My Hands
Lab scenario: inside the SSH server (192.168.56.103) there’s a web service (port 80) bound only to localhost.
Input (my computer):
ssh -L 8080:localhost:80 lee@192.168.56.103
Stay logged in, and open http://127.0.0.1:8080 in your browser.
Output example: the server’s internal page renders in my browser.
How to read it: a request that went into my 8080 rode inside the encrypted SSH connection to 192.168.56.103, then exited there toward localhost:80. The server’s firewall doesn’t need a port-80 rule — from the outside there was only one SSH connection to port 22. Change the sub-address (like -L 3306:db.internal:3306) and the same principle reaches an internal DB.
3-2. Proving the Principle — A 40-Line Python Forwarder
Let’s confirm with our hands that -L is "just relaying," possible even without SSH. Create the file below (it operates only inside my computer, not on any external network).
Input: port_forwarder.py
"""Mini port forwarder — a working model of SSH -L's principle."""
import socket, sys, threading
LISTEN_PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
TARGET_HOST = sys.argv[2] if len(sys.argv) > 2 else "127.0.0.1"
TARGET_PORT = int(sys.argv[3]) if len(sys.argv) > 3 else 8000
def relay(src, dst, tag):
try:
while True:
data = src.recv(4096)
if not data:
break
print(f"[{tag}] relayed {len(data)} bytes")
dst.sendall(data)
except OSError:
pass
finally:
src.close(); dst.close()
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", LISTEN_PORT))
server.listen(5)
print(f"[Forwarder started] 127.0.0.1:{LISTEN_PORT} -> {TARGET_HOST}:{TARGET_PORT}")
while True:
client, addr = server.accept()
print(f"[Connection accepted] {addr[0]}:{addr[1]}")
target = socket.create_connection((TARGET_HOST, TARGET_PORT))
threading.Thread(target=relay, args=(client, target, "cli->srv"), daemon=True).start()
threading.Thread(target=relay, args=(target, client, "srv->cli"), daemon=True).start()
You need three terminals.
# Terminal 1 — the "internal service" role (destination)
echo "<h1>secret internal page</h1>" > index.html
python -m http.server 8000
# Terminal 2 — the forwarder (the "tunnel" role)
python -u port_forwarder.py 8080 127.0.0.1 8000
# Terminal 3 — the user role
curl http://127.0.0.1:8080/
Output (measured 2026-09-09):
# Terminal 3
<h1>secret internal page</h1>
# Terminal 2 (forwarder log)
[Forwarder started] 127.0.0.1:8080 -> 127.0.0.1:8000
[Connection accepted] 127.0.0.1:50231
[cli->srv] relayed 78 bytes
[srv->cli] relayed 186 bytes
[srv->cli] relayed 30 bytes
How to read it: curl went to 8080, but the response came from the server on 8000. The forwarder’s log recorded every byte’s round trip — a 78-byte request went over, and 186+30 bytes of response came back. This program differs from SSH in exactly one thing: no encryption and no authentication. Understand ssh -L as running this relay loop inside an encrypted connection, and tunnels are no longer magic.
3-3. -D Practice — Turning an SSH Server into a SOCKS Proxy
Input:
ssh -D 1080 lee@192.168.56.103
Keeping the connection alive, check from the side using the proxy.
curl --proxy socks5://127.0.0.1:1080 http://internal-address/
Output example: a response comes back from an address visible only from the SSH server’s location.
How to read it: unlike -L, you didn’t write a destination on the command line — curl tells the proxy the destination each time. Add socks5 127.0.0.1 1080 to the end of proxychains’ config file, and you can send an arbitrary tool’s traffic through this tunnel, like proxychains nmap ... (the point where this connects to Step 162’s chains).
3-4. -R Practice — Making a Door Over There
Do this with two lab machines. From A in my lab (the internal role) to B outside (the SSH server role):
Input (on A):
ssh -R 9000:localhost:22 lee@B-address
Now on B, running ssh -p 9000 lee@127.0.0.1 arrives at A’s SSH.
Output example: A’s prompt appears on B’s console.
How to read it: A is behind a firewall so nobody can connect to A, but on top of the connection A made first, a door (9000) opened on B. By default this door binds only to B’s localhost — to let other computers knock on B’s 9000 too, B’s sshd_config needs GatewayPorts yes (the default is off for security — it means "don’t casually open doors to the outside world").
3-5. The Defender’s Eyes — How Does a Tunnel Look?
Flip it over. The firewall log shows only "one long SSH connection to port 22." What’s loaded inside is encrypted and unknown. So defense watches other signals — SSH sessions alive abnormally long, SSH logins by accounts that don’t connect, standing SSH connections from inside to outside. The habit of asking "so what remains in the logs?" after learning an attack technique turns today’s tool into defensive knowledge.
4. Missions & Exercises
Mission — Tunnel Design and Proving the Principle
- Run 3-2’s Python forwarder and capture the log (connection accepted + bidirectional byte relaying). Confirm it still works with 8000 changed to another port like 9000.
- If your lab has an SSH server, make a tunnel with
ssh -L 8080:localhost:80(or the port of a service you launched) and leave a record of reaching127.0.0.1:8080with a browser or curl. If not, write one line on "why it didn’t work" alongside 3-0’s refused message. - Scenario design: "The client’s firewall blocks all inbound SSH, but allows outbound 443 from inside. How would you design remote support for the internal server?" — write 3–5 lines from the perspective of -R and GatewayPorts.
- Redraw the three-direction comparison table (2-5) without looking.
Exercises
Exercise 1. In -L and -R, where does "the door (listening socket)" open in each? How does this difference create the difference in use?
Exercise 2. Give two differences between 3-2’s Python forwarder and ssh -L. And why do those differences connect to "why you mustn’t use it carelessly on someone else’s network"?
Exercise 3. Why is -D more flexible than -L? In exchange, what does the application side need to use -D?
Exercise 4. Explain, from a security perspective, why a door made with -R binds only to the other side’s localhost by default (GatewayPorts’ default).
Answers & completion criteria · expand/collapse
5. Model Answers & Completion Criteria
Mission Model Answer
#1: the measured log’s core is the three steps "connection accepted → cli->srv relay → srv->cli relay." The reason it works with a different port is that the forwarder uses port numbers only as configuration values and never interprets content.
Sample success record for #2: "While ssh -L 8080:localhost:8000 stayed connected, curl 127.0.0.1:8080 returned the page of the 8000 server." Sample failure record: "This environment has no sshd, so ssh: connect to host localhost port 22: Connection refused (measured 2026-09-09) — meaning the server isn’t listening on port 22."
Sample answer for #3: the internal server first makes an outbound SSH (-R) connection to the support company’s relay server and leaves a door there — outbound 443 (or 22) is allowed by the firewall, so it works. The supporter comes in through that door via the relay server. Turning GatewayPorts on exposes the door beyond the relay server’s localhost to the outside world, so it must be used together with access controls (limits on port, key, duration), and the tunnel must be torn down when the work ends.
How to verify: ① Did the forwarder log show bidirectional relaying? ② Was the -L attempt’s success or failure cause recorded? ③ Is the scenario answer designed as "using an allowed exit" rather than "piercing the firewall"? ④ Did you reproduce the table from a blank page?
Exercise Answers
Answer 1. -L opens the door on my computer; -R opens it on the SSH server (the other side). With the door on my side, it becomes a shape of "me fetching resources from their network" (-L); with it on their side, "people over there coming to my resources" (-R). The essence is not the direction but the door’s location.
Answer 2. The differences are encryption and authentication. The Python forwarder passes content in plaintext and accepts whoever connects, but ssh -L relays only inside an authenticated user’s encrypted passage. Even for the same "relaying," SSH leaves audit logs (who connected, when) and binds privileges — which is why an unauthorized tunnel becomes a target of detection and accountability tracking.
Answer 3. Because -L fixes the destination in the command, while -D lets you decide the destination per connection (SOCKS). In exchange, the application must know how to speak SOCKS proxy — curl’s --proxy, a browser’s proxy settings, or a tool like proxychains that speaks it on their behalf.
Answer 4. If an -R door is opened to the outside (GatewayPorts yes), anyone who can reach that server gets a passage into my network inside the tunnel. The default off restricts it to "a door used only from the computer of the person who made it" — a safety device preventing the accident of a tunnel immediately becoming a public gateway.
Completion Criteria Checklist
- [ ] I can explain where the door opens for each of -L/-R/-D
- [ ] I can read an -L command’s address interpretation (myport:destination-from-server’s-view:port)
- [ ] I can run the Python forwarder and interpret the relay log
- [ ] I can explain that port forwarding’s essence is "socket relaying" and that SSH adds encryption and authentication on top
- [ ] I know that -D creates a SOCKS proxy and that the application needs SOCKS support
- [ ] I can explain GatewayPorts’ default and its security reason
- [ ] I know how a tunnel looks in firewall logs (one long SSH session)
6. Common Pitfalls & Fixes
Wall 1. "ssh: connect to host … port 22: Connection refused"
Symptom: the connection is refused immediately (reproduced in this environment too on 2026-09-09).
Cause: you reached the address but the other side’s port-22 door is closed — the SSH server isn’t running. This is a problem of the SSH connection itself, before tunnels.
Fix: check on the other side with sudo systemctl status ssh and turn it on (Step 29 Wall 2). "refused" means "it’s not the tunnel that’s blocked — the front door is closed."
Wall 2. "I made a tunnel but 127.0.0.1:8080 won’t open"
Symptom: curl returns Connection refused.
Cause: one of two. ① The ssh session dropped — a tunnel exists only while the ssh connection is alive. ② You wrote -L’s destination from "my perspective" — localhost is always the SSH server’s localhost.
Fix: confirm the ssh session is alive, and re-read the destination from the server’s perspective. Using the -N (tunnel only, no terminal) and -f (background) options reduces accidental session closes.
Wall 3. "I keep mixing up -L and -R"
Symptom: you flip the direction every time you compose the command.
Cause: you’re trying to memorize by data flow. Flow is bidirectional, so it’s weak for memory.
Fix: memorize just one thing — "where does the door open": L opens a door on Local, R on Remote. And draw each one line yourself. Recall 3-2’s forwarder: where the bind (door) was is exactly the answer.
Wall 4. "I made a door with -R but other computers can’t come"
Symptom: it works from the server where the door was made, but third parties can’t attach to that port.
Cause: GatewayPorts’ default (off) binds the door only to that server’s localhost.
Fix: if the opening is intended, set GatewayPorts yes (or clientspecified) in the other side’s sshd_config and restart sshd. But turn it on only after confirming in 3-4 and Exercise 4 that the door immediately becomes a public gateway.
Wall 5. "My tunnel keeps dying"
Symptom: left alone for a while, the tunnel is dead.
Cause: a NAT/firewall in the middle cleans up (times out) quiet connections.
Fix: turn on keepalive — ServerAliveInterval 60 (a liveness signal every 60 seconds) in the client-side ~/.ssh/config. If you need automatic reconnection too, use a tool like autossh.
7. Summary
Today’s Concepts
| Concept | One-line explanation |
|---|---|
| Port forwarding | Technology that relays data received on one port to another address:port |
| SSH tunnel | Doing that relaying inside an authenticated, encrypted SSH connection |
| -L (local) | A door on my computer — their network’s resources in my hands |
| -R (remote) | A door on the SSH server — my resources behind a firewall, out to the world |
| -D (dynamic) | An all-purpose door (SOCKS) on my computer — destination chosen each time |
| GatewayPorts | The switch deciding whether an -R door opens beyond localhost to the outside (default off) |
| Pivot | A penetration technique that moves into the internal network from a captured foothold |
| ServerAliveInterval | A liveness signal that keeps a quiet tunnel from dying |
Today’s Commands
| Command | What it does |
|---|---|
ssh -L 8080:localhost:80 user@server |
My 8080 → localhost:80 from the server’s view |
ssh -R 9000:localhost:22 user@server |
The server’s 9000 → my localhost:22 |
ssh -D 1080 user@server |
Open a SOCKS proxy on my 1080 |
ssh -N -f -L ... |
Tunnel only, no terminal, in the background |
curl --proxy socks5://127.0.0.1:1080 address |
A request via the dynamic tunnel |
python port_forwarder.py 8080 127.0.0.1 8000 |
Run the working-model forwarder |
An Instinct More Important Than Commands
Today’s core is the moment tunnels stop being magic. Forty lines of Python did the same job as -L, and the only difference was encryption and authentication. That one notch is what divides "a legitimate administration tool" from "a covert intrusion passage" — and that division lies not in technology but in permission.
You can also see now that when the firewall you built in Step 161 answered "only 22 open," that one line is the entrance and exit of every tunnel you learned today. A firewall reduces the number of doors — but today you learned how much one open door can carry. A day when the technology of closing and the technology of opening landed in the same hand.
Once every box is checked, Step 163 is complete.
ONE STEP FURTHER
Finished this lesson?
Check the completion criteria, then mark your progress.