Detect, Restrict, and Respond to Unauthorized SSH Tunnels
Unauthorized forwarding is an access incident even when no shell login appears in the first alert. A compromised key can use -R, -L, -D, Unix-domain forwarding, or a long-lived carrier to move data and reach services. Detect the process, the SSH session, the listening socket, and the resulting egress together. Then contain the identity and the path without destroying evidence.
Prevent broad forwarding by default
Ordinary users should not receive forwarding merely because they can authenticate. Apply a global deny and create narrowly reviewed Match exceptions:
AllowTcpForwarding no
GatewayPorts no
DisableForwarding yes
X11Forwarding no
AllowAgentForwarding no
Match User tunnel
AllowTcpForwarding remote
PermitListen 127.0.0.1:2222
Check the installed OpenSSH manual because available directives and precedence depend on version. DisableForwarding yes is a convenient deny-all forwarding control and must not be combined accidentally with the exception. For keys that need a remote forward, use individual authorized_keys restrictions:
no-pty,no-agent-forwarding,no-X11-forwarding,no-user-rc,command="/bin/false",permitlisten="127.0.0.1:2222" ssh-ed25519 [approved-public-key-recorded-in-key-management] approved-tunnel
The bracketed public-key marker is not installable data. Do not put private keys in source control, units, environment variables, tickets, or shell history. Do not use restrict for this forwarding exception unless the required port-forwarding behavior is separately supported and tested; its no-port-forwarding component commonly conflicts with -R.
Find listeners and carrier processes
Run these commands with the privileges needed to see process ownership, and adapt service names to the distribution:
sudo ss -lntup
sudo ss -ntp
sudo lsof -nP -iTCP -sTCP:LISTEN
pgrep -a -f '(^|/)(ssh|sshd|autossh)( |$)'
ps -eo user,pid,ppid,etime,args --forest | grep -E '[s]sh|[a]utossh'
Pay special attention to an SSH client with -R, -L, -D, -W, or -w, an unexpected parent process, a listener owned by a service account, and an SSH socket bound outside loopback. A process list can expose usernames and paths; restrict and redact incident copies.
On systemd hosts, inventory persistence as well as the current process:
systemctl list-units --type=service --all | grep -Ei 'ssh|tunnel|auto'
systemctl list-timers --all
sudo find /etc/systemd /usr/lib/systemd /var/lib/systemd \
-type f \( -name '*.service' -o -name '*.timer' \) -print 2>/dev/null
sudo crontab -l 2>/dev/null
sudo find /etc/cron* -maxdepth 2 -type f -print 2>/dev/null
Also inspect user services, authorized keys, shell startup files, configuration-management state, and container or orchestration manifests. Treat a newly installed supervisor or a key with an unexpected command= and permitlisten option as evidence requiring review.
Correlate authentication, forwarding, and egress
sudo journalctl _COMM=sshd --since "2 hours ago" --no-pager
sudo journalctl -u ssh --since "2 hours ago" --no-pager 2>/dev/null \
|| sudo journalctl -u sshd --since "2 hours ago" --no-pager
sudo journalctl -u ssh-reverse-tunnel.service --since "2 hours ago" --no-pager
sudo ss -ntp
ip route get 198.51.100.44
On systems using file-based auth logs, use the distribution’s documented location and access controls. Correlate successful public-key authentication with the source address, account, key fingerprint or certificate identity, forwarding request, process owner, destination socket, and firewall connection. The absence of a shell command does not make a tunnel benign.
- Alert on new
-R,-L,-D,-W, or tunnel interfaces outside the approved inventory. - Alert on bastion listeners that are not
127.0.0.1or explicitly approved private addresses. - Watch for persistent outbound SSH to unapproved hosts, repeated reconnects, unusual long-lived byte counts, DNS lookups to unapproved resolvers, and DMZ attempts toward trusted, management, storage, or backup networks.
- Use host and firewall rate limits. SSH
MaxStartupsand any version-specific per-source setting protect authentication capacity; they do not constrain traffic inside an established forwarding channel.
Contain without erasing the trail
- Record the time, host, account, source, destination, PID, parent, command-line metadata, listener address, key fingerprint, current connections, and relevant firewall counters. Preserve logs and volatile evidence under the incident policy.
- Contain the smallest boundary first: stop the unauthorized service or SSH client, block the specific egress destination, remove the unauthorized listener, and terminate the associated SSH session. Keep a trusted administrative session and tested console path open.
- Revoke the identity: remove or quarantine the affected authorized-key line, revoke the certificate serial through the configured KRL or CA process, expire the access grant, and rotate any credential that may have been exposed. Removing a key does not terminate an already-established session, so verify and terminate that session.
- Inspect the private host and bastion for persistence, new users, modified SSH configuration, scheduled jobs, service units, containers, changed binaries, and lateral connections. Check whether the tunnel reached secrets or management services.
- Patch the vulnerable component, restore the least-privilege forwarding policy, and test
sshd -tbefore a reload. Do not re-enable forwarding globally to recover convenience. - Document root cause, scope, evidence hashes where applicable, actions, owner, and follow-up tests. Notify the independent bastion operator and affected service owners.
Use a safe emergency policy
If the bastion is under active abuse, set the forwarding identity to a deny state through the approved change or incident process, revoke its key, terminate its carrier, and block its specific source or destination. A temporary global DisableForwarding yes may be appropriate when the host is actively compromised, but validate the impact on recovery and record the rollback owner. Never assume changing authorized_keys alone disconnects existing channels.
# Validate before applying a reviewed emergency drop-in.
sudo sshd -t
sudo sshd -T -C user=tunnel,host=bastion.example.net,addr=198.51.100.20 \
| grep -E '^(allowtcpforwarding|gatewayports|permitlisten|disableforwarding)'
sudo systemctl reload ssh 2>/dev/null || sudo systemctl reload sshd
Keep an out-of-band console or trusted recovery host available. After containment, inspect ss -lntup, process ancestry, logs, firewall counters, and key inventories again; then repeat after reboot to catch persistence.
Test the detection program
| Exercise | Expected result |
|---|---|
Ordinary account requests -R, -L, or -D | Authentication may succeed, but forwarding is denied and the event is logged. |
| Approved tunnel requests another listener or wildcard bind | PermitListen/GatewayPorts rejects it; no public socket appears. |
| Forwarding key is revoked while connected | New login fails; the active session is explicitly terminated and its listener disappears. |
| Unauthorized outbound SSH from a DMZ host | Egress rule blocks or alerts, with source, destination, owner, and counter evidence. |
| Reboot after cleanup | No unit, timer, cron job, user service, or container recreates the tunnel. |
Run exercises in an approved window using non-production test identities. Keep Tailscale, Cloudflare Tunnel, and the documented console recovery path distinct from reverse SSH evidence so one system’s health does not hide another system’s compromise.
Return to Operate and Recover SSH on the Raspberry Pi 5 for the broader Pi recovery runbook.
Sequence navigation
Previous: Use a Bastion Host for Reverse SSH Access Behind CGNAT · Next: Operate and Recover SSH on the Raspberry Pi 5
dispelled