← All write-ups
Networking

SSH Forwarding Through a Jump Host

SSH patterns for opening remote Nessus and RDP services, proxying a browser through Kali, and forwarding one service through a jump host.

I normally use SSH forwarding for one of three things during an internal assessment:

  1. Opening the Nessus interface running on a jump host, Kali, or another remote SSH host.
  2. Sending a browser or another proxy-aware tool into the internal network.
  3. Reaching one internal application or Windows test machine without proxying anything else.

The commands look similar, but they solve different problems. The right choice depends on where the service is running, which host has the internal route, and whether I need one destination or several.

Choose the tunnel by what you need

What I need SSH feature Final SSH host Why I use it
Open Nessus on a VPS or remote host ↘ Local forward (-L) The host running Nessus Makes only the Nessus UI available on my Mac. The scanner remains on the remote host and scans from that host’s network position.
Open one internal web application ↘ Local forward (-L) Kali Simple, narrow, and no browser-wide proxy configuration.
RDP to a Windows test machine ↘ Local forward (-L) Kali Carries one RDP connection without exposing TCP 3389 beyond the test network.
Browse several internal applications ↘ Dynamic forward (-D) Kali One SOCKS listener can reach different hosts and ports without creating a tunnel for each one.
Use the jump host without Kali ↘ Local or dynamic forward (-L or -D) Jump host Removes the second hop when the jump host already has the route I need.
Open a shell on Kali ↘ ProxyJump (-J) Kali Reaches Kali through the jump host without creating a local service tunnel.
Copy a file from Kali ↘ scp -J Kali Transfers the file through the jump host without creating a separate tunnel.

-L is the better default when I know the exact service I need. -D is more flexible when I am moving between several web applications, but only applications configured to use the SOCKS proxy will follow that route. It is not a system-wide VPN.

The layout used in these examples

Operator laptop ──SSH──> Jump host ──SSH──> Kali host ──> Internal network

The jump host is the reachable entry point. Kali is the testing host connected to the client network. Some environments need only the jump host; others need both hops.

The most important detail is the host at the end of the ssh command. That final SSH host opens the connection named in -L or handles the destinations sent through -D.

One hop:  Laptop -> jump host -> service
Two hops: Laptop -> jump host -> Kali -> service

If Kali is the only host with the client-network route, the SSH command must end at Kali. Logging in to the jump host and creating a forward there will not somehow use Kali’s routes.

What the SSH options mean

  • -L maps one port on the laptop to one destination reachable from the final SSH host.
  • -D creates a local SOCKS proxy and lets a proxy-aware application choose the destination.
  • -J reaches the final SSH host through a jump host.
  • -N creates the forwarding session without opening a remote shell.
  • ExitOnForwardFailure=yes stops SSH when it cannot create the requested local listener.

ExitOnForwardFailure does not prove that the final application is reachable. The SSH session can create the listener successfully and still encounter a routing, DNS, or service error when I use it.

Read this command from the outside in:

ssh -N \
  -L '127.0.0.1:8443:<INTERNAL_HOST>:443' \
  -J '<JUMP_USER>@<JUMP_HOST>' \
  '<KALI_USER>@<KALI_HOST>'

The laptop listens on 127.0.0.1:8443. SSH reaches Kali through the jump host, and Kali connects to <INTERNAL_HOST>:443.

Open Nessus on a VPS or remote host

There are two common versions of this setup. In both cases, I open https://127.0.0.1:4350/ in the browser on my Mac. The difference is where the SSH command ends.

Case A: Nessus runs on the VPS or jump box

Example: Nessus is installed on scanner.example.test, a VPS I can SSH into directly from my Mac.

Mac browser -> 127.0.0.1:4350 -> SSH -> VPS 127.0.0.1:8834

The VPS is both the SSH destination and the machine running Nessus. No second SSH host is involved:

ssh -N \
  -o ExitOnForwardFailure=yes \
  -L 127.0.0.1:4350:127.0.0.1:8834 \
  '<VPS_USER>@<VPS_HOST>'

I then open https://127.0.0.1:4350/ on the Mac. Nessus stays on the VPS, and its scan traffic leaves from the VPS.

Case B: Nessus runs on another machine behind the jump box

Example: Nessus runs on kali.internal.test, which my Mac can reach only through jump.example.test.

Mac browser -> 127.0.0.1:4350 -> jump box -> Nessus host 127.0.0.1:8834

The command ends at the machine running Nessus and uses the jump box only as the route:

ssh -N \
  -o ExitOnForwardFailure=yes \
  -L 127.0.0.1:4350:127.0.0.1:8834 \
  -J '<JUMP_USER>@<JUMP_HOST>' \
  '<NESSUS_USER>@<NESSUS_HOST>'

Again, I open https://127.0.0.1:4350/ on the Mac. Nessus and its scan traffic stay on the final SSH host.

The two loopback addresses refer to different machines:

  • 127.0.0.1:4350 is the temporary listener on my Mac.
  • 127.0.0.1:8834 is resolved on the final SSH host—the VPS in Case A or the Nessus host in Case B.

This exposes the Nessus interface only through the SSH session. It does not move the scanner to the laptop. Nessus still sends scan traffic from the machine where it is installed.

The browser may show a certificate-name warning because I am opening 127.0.0.1 while Nessus presents a certificate issued for another hostname. The tunnel changes the network path, not the certificate Nessus serves.

The rule is simple: if the destination is 127.0.0.1:8834, the SSH command must end at the machine running Nessus. If Nessus listens on a different host that the final SSH server can reach, replace the second loopback address with that host’s address.

Proxy a browser into the internal network

Example: I need to move between https://admin.internal.test/ and https://192.0.2.40/. One SOCKS listener lets the browser reach both through Kali.

A dynamic forward is useful when Kali can reach several internal applications and I do not want to create a separate local port for every one:

ssh -N \
  -o ExitOnForwardFailure=yes \
  -D 127.0.0.1:9090 \
  -J '<JUMP_USER>@<JUMP_HOST>' \
  '<KALI_USER>@<KALI_HOST>'

The route is:

Browser -> SOCKS5 127.0.0.1:9090 -> jump host -> Kali -> selected internal host

I configure the browser or testing tool to use SOCKS5 at 127.0.0.1:9090. When internal hostnames resolve only from Kali, the client must proxy DNS as well. Browsers often describe this as proxying DNS through SOCKS; command-line tools may use the socks5h scheme.

The SOCKS listener accepts only connections from the laptop because it is bound to 127.0.0.1. Closing the SSH process removes the route.

Forward one internal application through Kali

Example: The application is 192.0.2.40:443. I map it to 127.0.0.1:8443 and use that local address without changing the rest of the browser’s traffic.

When I need only one internal service, a local forward is easier to reason about than a SOCKS proxy:

ssh -N \
  -o ExitOnForwardFailure=yes \
  -L '127.0.0.1:8443:<INTERNAL_HOST>:443' \
  -J '<JUMP_USER>@<JUMP_HOST>' \
  '<KALI_USER>@<KALI_HOST>'

I open https://127.0.0.1:8443/ locally. Kali makes the connection to <INTERNAL_HOST>:443, while the jump host only carries the SSH transport to Kali.

This is a good fit for an administrative interface, an API, or another single TCP service. A local forward maps one TCP destination; it does not provide general access to the internal network.

If the application depends on a particular hostname for TLS or virtual-host routing, I still need to request it with that hostname. A tunnel changes where the TCP connection travels; it does not rewrite the HTTP Host header or TLS server name.

RDP to a Windows test machine

Example: Kali can reach an authorized Windows test machine at <WINDOWS_HOST>:3389, but the operator laptop cannot route to it directly.

RDP is another single TCP service, so it uses the same local-forward pattern:

ssh -N \
  -o ExitOnForwardFailure=yes \
  -L '127.0.0.1:3390:<WINDOWS_HOST>:3389' \
  -J '<JUMP_USER>@<JUMP_HOST>' \
  '<KALI_USER>@<KALI_HOST>'
RDP client -> 127.0.0.1:3390 -> jump host -> Kali -> <WINDOWS_HOST>:3389

The local port does not need to be 3389. Using 3390 avoids a collision with any local RDP listener and makes the local and remote sides easy to distinguish.

SSH local forwarding carries TCP only. RDP can also use UDP 3389 for performance, but this route does not forward that UDP transport. The session uses TCP and may perform differently from a direct connection.

On macOS, Windows App replaced Microsoft Remote Desktop. Under Devices, select +, choose Add PC, and enter 127.0.0.1:3390 as the PC name. Windows App accepts an explicit port and does not require an app sign-in to add a direct remote-PC connection. The credentials are for the Windows test machine, not the jump host or Kali.

From a Windows operator machine, the built-in Remote Desktop Connection client accepts the same local endpoint:

mstsc.exe /v:127.0.0.1:3390

The destination must support incoming RDP, have Remote Desktop enabled, permit the selected account, and allow TCP 3389 through Windows Firewall from Kali’s network position. Windows Professional, Enterprise, Education, and Server editions can host RDP; Windows Home cannot.

The RDP client may warn because it connects to 127.0.0.1 while the Windows host presents a certificate for another name. The SSH tunnel changes the route, not the certificate identity. Verify the expected host and certificate through the approved process before accepting that warning.

Equivalent nested-shell form

The two-command form is valid when an interactive shell on the jump host is useful. On the laptop, the first command opens a shell on the jump host and maps local port 3390 to port 3391 on that host:

ssh \
  -o ExitOnForwardFailure=yes \
  -L 127.0.0.1:3390:127.0.0.1:3391 \
  '<JUMP_USER>@<JUMP_HOST>'

Inside that jump-host shell, the second command maps the jump host’s loopback port 3391 through Kali to the Windows machine:

ssh -N \
  -o ExitOnForwardFailure=yes \
  -L '127.0.0.1:3391:<WINDOWS_HOST>:3389' \
  '<KALI_USER>@<KALI_HOST>'

Both SSH processes must remain open, and port 3391 must be unused on the jump host. The original pattern is therefore correct, including variants that use local port 3393 and intermediate port 3395, but the single ProxyJump command is easier to reproduce and troubleshoot.

Use the jump host directly

Example: jump.example.test can already reach 192.0.2.40:443, so I end the SSH command at the jump host and skip Kali entirely.

The second hop is unnecessary when the jump host itself can reach the service.

To open one internal HTTPS service:

ssh -N \
  -o ExitOnForwardFailure=yes \
  -L '127.0.0.1:8443:<INTERNAL_HOST>:443' \
  '<JUMP_USER>@<JUMP_HOST>'
Browser -> 127.0.0.1:8443 -> jump host -> <INTERNAL_HOST>:443

To create a SOCKS proxy whose connections leave from the jump host:

ssh -N \
  -o ExitOnForwardFailure=yes \
  -D 127.0.0.1:1080 \
  '<JUMP_USER>@<JUMP_HOST>'

These commands work only for destinations the jump host can reach. If the client route exists only on Kali, use the two-hop versions instead.

Open a shell on Kali through the jump host

Example: Kali’s SSH service at 192.0.2.30:22 is private, but jump.example.test can reach it. ProxyJump carries my SSH connection through the jump host and opens the shell on Kali.

ProxyJump is also useful without forwarding:

ssh -J '<JUMP_USER>@<JUMP_HOST>' '<KALI_USER>@<KALI_HOST>'

That opens a normal shell on Kali. It does not create a local web route unless -L or -D is also present.

Copy a file from Kali

Example: I need to pull /home/tester/evidence.zip from Kali to the current directory on my laptop without first copying it onto the jump host.

scp accepts the same ProxyJump route:

scp -J '<JUMP_USER>@<JUMP_HOST>' \
  '<KALI_USER>@<KALI_HOST>:<REMOTE_FILE>' \
  '<LOCAL_DESTINATION>'

The file moves from Kali to the laptop through the jump host. For collected evidence, I calculate a SHA-256 hash before and after the transfer and compare the values.

Use separate keys for the jump host and Kali

SSH config is clearer than a long ProxyCommand when each host uses a different key:

Host assessment-jump
    HostName <JUMP_HOST>
    User <JUMP_USER>
    IdentityFile <JUMP_KEY>
    IdentitiesOnly yes

Host assessment-kali
    HostName <KALI_HOST>
    User <KALI_USER>
    IdentityFile <KALI_KEY>
    IdentitiesOnly yes
    ProxyJump assessment-jump

The two-hop SOCKS command then becomes:

ssh -N \
  -o ExitOnForwardFailure=yes \
  -D 127.0.0.1:9090 \
  assessment-kali

ProxyCommand with ssh -W %h:%p can create the same transport on older clients, but ProxyJump shows the route more clearly and is easier to troubleshoot.

Troubleshoot the path in order

Add -vvv to the command that is failing:

ssh -vvv -N \
  -o ExitOnForwardFailure=yes \
  -L '127.0.0.1:8443:<INTERNAL_HOST>:443' \
  -J '<JUMP_USER>@<JUMP_HOST>' \
  '<KALI_USER>@<KALI_HOST>'

Then check each part of the path:

  1. The laptop can authenticate to the jump host.
  2. The jump host can reach Kali’s SSH service.
  3. Kali can resolve and reach the requested internal destination.
  4. The service is listening on the address and port used in -L.
  5. The selected local port is not already in use.
  6. The browser or other client is using the local listener or SOCKS proxy.

Successful SSH authentication proves only that the SSH path works. The final service can still fail because of the destination address, name resolution, routing, or the service listener.

I keep the SSH process in the foreground while using the tunnel and stop it with Ctrl-C when I am done.