JA4SSH: SSH Client and Server Fingerprinting
Introduction
JA4SSH extends network fingerprinting to the SSH protocol, enabling identification and classification of SSH clients and servers based on their handshake properties. This technique is invaluable for detecting anomalies, recognizing known clients, identifying malicious tools, and tracking infrastructure.
Why SSH Fingerprinting Matters: SSH is ubiquitous in system administration, automated scripts, and remote access. Attackers leverage SSH for lateral movement, C2 communications, and data exfiltration. JA4SSH provides visibility into SSH behaviors that traditional logs miss.
Skill Level: Intermediate
Prerequisites:
- Understanding of SSH protocol basics
- Familiarity with network packet analysis
- Knowledge of JA4 fingerprinting concepts
- Basic cryptography understanding
Learning Objectives:
- Understand SSH handshake structure
- Extract SSH client/server capabilities
- Construct JA4SSH fingerprints
- Detect malicious SSH tools and behaviors
- Integrate JA4SSH into security monitoring
Why JA4SSH Matters
The SSH Security Challenge
SSH connections appear encrypted and "secure," but the handshake reveals:
- Client software and version
- Supported algorithms and preferences
- Implementation characteristics
- Tool signatures (OpenSSH vs. Paramiko vs. malware)
Real-World Use Cases
- Detect Malicious Tools: Identify Metasploit, Cobalt Strike, custom backdoors
- Track APT Activity: Recognize attacker SSH tools across incidents
- Unauthorized Access: Detect SSH clients not approved for your environment
- Automated Attacks: Identify SSH brute-force tools and scanners
- Insider Threats: Detect unusual SSH tools used by insiders
Understanding SSH Handshake
SSH Protocol Exchange
The SSH handshake consists of several phases:
-
Protocol Version Exchange
- Client sends:
SSH-2.0-OpenSSH_8.4 - Server responds:
SSH-2.0-OpenSSH_7.9
- Client sends:
-
Key Exchange (KEX) Initialization
- Client sends supported algorithms
- Server sends supported algorithms
-
Algorithm Negotiation
- Both sides agree on algorithms to use
SSH KEX_INIT Message
The KEX_INIT packet contains:
- KEX Algorithms: Key exchange methods
- Host Key Algorithms: Server authentication methods
- Encryption Ciphers: Symmetric encryption (client→server and server→client)
- MAC Algorithms: Message authentication codes
- Compression: Compression methods
JA4SSH Components
The JA4SSH Fingerprint Format
<protocol_version>_<kex_hash>_<cipher_hash>_<mac_hash>_<compression_hash>Example: SSH-2.0_a1b2c3d4e5f6_g7h8i9j0k1l2_m3n4o5p6q7r8_s9t0u1v2w3x4
Component Breakdown
1. Protocol Version (Variable length)
SSH protocol version string from client banner:
SSH-2.0= SSH Protocol 2.0 (standard)SSH-1.99= Compatibility mode (supports both 1 and 2)- Software version included:
SSH-2.0-OpenSSH_8.4
2. KEX Hash (12 chars)
SHA-256 hash of supported Key Exchange algorithms (sorted, first 12 chars):
curve25519-sha256diffie-hellman-group14-sha256diffie-hellman-group-exchange-sha256ecdh-sha2-nistp256
3. Host Key Algorithms Hash (12 chars)
SHA-256 hash of supported server authentication algorithms (sorted):
ssh-ed25519rsa-sha2-512rsa-sha2-256ecdsa-sha2-nistp256
4. Cipher Hash (12 chars)
SHA-256 hash of supported encryption ciphers (sorted):
aes128-ctraes192-ctraes256-ctraes128-gcm@openssh.comchacha20-poly1305@openssh.com
5. MAC Hash (12 chars)
SHA-256 hash of supported MAC algorithms (sorted):
hmac-sha2-256hmac-sha2-512umac-128@openssh.com
6. Compression Hash (12 chars)
SHA-256 hash of supported compression methods (sorted):
nonezlib@openssh.com
Step-by-Step: Constructing JA4SSH
Example SSH Client KEX_INIT
Protocol Version: SSH-2.0-OpenSSH_8.4
KEX Algorithms:
- curve25519-sha256
- diffie-hellman-group14-sha256
Host Key Algorithms:
- ssh-ed25519
- rsa-sha2-256
Encryption Ciphers (client-to-server):
- aes128-ctr
- aes256-ctr
- aes128-gcm@openssh.com
MAC Algorithms:
- hmac-sha2-256
- umac-128@openssh.com
Compression:
- none
- zlib@openssh.comStep 1: Extract Protocol Version
SSH-2.0-OpenSSH_8.4Step 2: Compute KEX Hash
Python Implementation:
import hashlib
def compute_ssh_component_hash(algorithms_list):
"""
Compute hash for SSH algorithm list.
Args:
algorithms_list: List of algorithm names
Returns:
First 12 characters of SHA-256 hash
"""
# Sort algorithms
sorted_algorithms = sorted(algorithms_list)
# Join with commas
algorithm_string = ','.join(sorted_algorithms)
# Compute SHA-256 hash
hash_value = hashlib.sha256(algorithm_string.encode()).hexdigest()
# Return first 12 characters
return hash_value[:12]
# Example KEX algorithms
kex_algorithms = [
'curve25519-sha256',
'diffie-hellman-group14-sha256'
]
kex_hash = compute_ssh_component_hash(kex_algorithms)
print(f"KEX Hash: {kex_hash}") # Example: a1b2c3d4e5f6Step 3: Compute All Component Hashes
# Host key algorithms
host_key_algorithms = ['ssh-ed25519', 'rsa-sha2-256']
hka_hash = compute_ssh_component_hash(host_key_algorithms)
# Ciphers
ciphers = ['aes128-ctr', 'aes256-ctr', 'aes128-gcm@openssh.com']
cipher_hash = compute_ssh_component_hash(ciphers)
# MACs
macs = ['hmac-sha2-256', 'umac-128@openssh.com']
mac_hash = compute_ssh_component_hash(macs)
# Compression
compression = ['none', 'zlib@openssh.com']
comp_hash = compute_ssh_component_hash(compression)Step 4: Assemble the Fingerprint
ja4ssh_fingerprint = (
f"SSH-2.0-OpenSSH_8.4_"
f"{kex_hash}_"
f"{hka_hash}_"
f"{cipher_hash}_"
f"{mac_hash}_"
f"{comp_hash}"
)
print(f"JA4SSH Fingerprint: {ja4ssh_fingerprint}")Complete Python Implementation
import hashlib
from typing import List, Dict, Optional
class JA4SSH:
"""JA4SSH Fingerprinting Implementation"""
@staticmethod
def compute_algorithm_hash(algorithms: List[str]) -> str:
"""
Compute SHA-256 hash of algorithm list.
Args:
algorithms: List of algorithm names
Returns:
First 12 characters of SHA-256 hash
"""
if not algorithms:
return '000000000000'
# Sort algorithms alphabetically
sorted_algs = sorted(algorithms)
# Join with commas
alg_string = ','.join(sorted_algs)
# Compute hash
hash_value = hashlib.sha256(alg_string.encode()).hexdigest()
return hash_value[:12]
@staticmethod
def compute_fingerprint(
protocol_version: str,
kex_algorithms: List[str],
host_key_algorithms: List[str],
encryption_algorithms: List[str],
mac_algorithms: List[str],
compression_algorithms: List[str]
) -> str:
"""
Compute complete JA4SSH fingerprint.
Args:
protocol_version: SSH protocol version string (e.g., "SSH-2.0-OpenSSH_8.4")
kex_algorithms: Key exchange algorithms
host_key_algorithms: Host key algorithms
encryption_algorithms: Encryption ciphers
mac_algorithms: MAC algorithms
compression_algorithms: Compression methods
Returns:
JA4SSH fingerprint string
"""
# Compute hashes for each component
kex_hash = JA4SSH.compute_algorithm_hash(kex_algorithms)
hka_hash = JA4SSH.compute_algorithm_hash(host_key_algorithms)
cipher_hash = JA4SSH.compute_algorithm_hash(encryption_algorithms)
mac_hash = JA4SSH.compute_algorithm_hash(mac_algorithms)
comp_hash = JA4SSH.compute_algorithm_hash(compression_algorithms)
# Assemble fingerprint
fingerprint = (
f"{protocol_version}_"
f"{kex_hash}_"
f"{hka_hash}_"
f"{cipher_hash}_"
f"{mac_hash}_"
f"{comp_hash}"
)
return fingerprint
@staticmethod
def parse_ssh_kexinit(kexinit_data: Dict) -> Dict:
"""
Parse SSH KEX_INIT packet data.
Args:
kexinit_data: Dict containing parsed KEX_INIT fields
Returns:
Dict with parsed components
"""
return {
'protocol_version': kexinit_data.get('version', 'SSH-2.0-Unknown'),
'kex_algorithms': kexinit_data.get('kex', []),
'host_key_algorithms': kexinit_data.get('host_key', []),
'encryption_algorithms': kexinit_data.get('encryption_c2s', []),
'mac_algorithms': kexinit_data.get('mac_c2s', []),
'compression_algorithms': kexinit_data.get('compression', [])
}
# Example Usage
if __name__ == "__main__":
# Sample SSH KEX_INIT data
kexinit = {
'version': 'SSH-2.0-OpenSSH_8.4',
'kex': [
'curve25519-sha256',
'diffie-hellman-group14-sha256',
'diffie-hellman-group-exchange-sha256'
],
'host_key': [
'rsa-sha2-512',
'rsa-sha2-256',
'ssh-ed25519'
],
'encryption_c2s': [
'aes128-ctr',
'aes192-ctr',
'aes256-ctr',
'aes128-gcm@openssh.com'
],
'mac_c2s': [
'hmac-sha2-256',
'hmac-sha2-512',
'umac-128@openssh.com'
],
'compression': ['none', 'zlib@openssh.com']
}
# Parse and compute fingerprint
parsed = JA4SSH.parse_ssh_kexinit(kexinit)
ja4ssh_fp = JA4SSH.compute_fingerprint(
parsed['protocol_version'],
parsed['kex_algorithms'],
parsed['host_key_algorithms'],
parsed['encryption_algorithms'],
parsed['mac_algorithms'],
parsed['compression_algorithms']
)
print(f"JA4SSH Fingerprint: {ja4ssh_fp}")Capturing SSH Traffic
Using Wireshark
- Start Capture: Select network interface
- Filter:
sshortcp.port == 22 - Establish Connection: Initiate SSH session
- Analyze: Expand SSH Protocol → Key Exchange
- Extract: Protocol version, KEX algorithms, ciphers, MACs
Using tcpdump
# Capture SSH handshake packets
sudo tcpdump -i eth0 'tcp port 22 and (tcp[13] & 2 != 0)' -w ssh_handshake.pcap -s 0
# Read captured packets
tcpdump -r ssh_handshake.pcap -XUsing Zeek
# Zeek script to extract SSH client capabilities
module SSH;
export {
redef record SSH::Info += {
client_ja4ssh: string &optional &log;
server_ja4ssh: string &optional &log;
};
}
event ssh_capabilities(c: connection, cookie: string, capabilities: SSH::Capabilities)
{
if (capabilities$is_server) {
c$ssh$server_ja4ssh = compute_ja4ssh(capabilities);
} else {
c$ssh$client_ja4ssh = compute_ja4ssh(capabilities);
}
Log::write(SSH::LOG, c$ssh);
}Using Python + Scapy
from scapy.all import sniff, TCP, Raw
import binascii
def parse_ssh_packet(packet):
"""Extract SSH KEX_INIT from packet"""
if packet.haslayer('TCP') and packet.haslayer('Raw'):
payload = packet['Raw'].load
# Check for SSH protocol version
if payload.startswith(b'SSH-'):
version = payload.split(b'\r\n')[0].decode('utf-8')
print(f"SSH Version: {version}")
# Check for KEX_INIT (message type 20)
elif len(payload) > 5 and payload[5] == 20:
print("KEX_INIT packet captured!")
# Parse KEX_INIT fields (simplified)
# Full parsing requires SSH protocol knowledge
print(f"Payload (hex): {binascii.hexlify(payload[:100])}")
# Capture SSH packets
print("Capturing SSH traffic on port 22...")
sniff(filter="tcp port 22", prn=parse_ssh_packet, store=0, count=20)Practical Applications
1. Malicious Tool Detection
Common attack tools have distinctive fingerprints:
# Known malicious SSH client fingerprints
MALICIOUS_SSH_CLIENTS = {
'SSH-2.0-Paramiko_2.7.1_malware123_xyz': {
'name': 'Custom Python Malware',
'severity': 'critical',
'description': 'Python-based backdoor using Paramiko'
},
'SSH-2.0-libssh2_1.4.3_metasploit_abc': {
'name': 'Metasploit SSH Module',
'severity': 'critical',
'description': 'Metasploit framework SSH connection'
},
'SSH-2.0-Go_generic123_def': {
'name': 'Custom Go SSH Tool',
'severity': 'high',
'description': 'Custom Go-based SSH client (potential C2)'
}
}
def check_malicious_ssh_client(ja4ssh_fingerprint):
"""Check if JA4SSH matches known malicious clients"""
# Extract protocol version (first component)
protocol_version = ja4ssh_fingerprint.split('_')[0]
# Check against known malicious fingerprints
for malicious_fp, info in MALICIOUS_SSH_CLIENTS.items():
if protocol_version == malicious_fp or ja4ssh_fingerprint.startswith(protocol_version):
return {
'is_malicious': True,
'name': info['name'],
'severity': info['severity'],
'description': info['description']
}
return {'is_malicious': False}
# Example usage
ja4ssh = "SSH-2.0-Paramiko_2.7.1_malware123_xyz_a1b2c3_d4e5f6_g7h8i9_j0k1l2_m3n4o5"
result = check_malicious_ssh_client(ja4ssh)
if result['is_malicious']:
print(f"ALERT: Malicious SSH Client Detected!")
print(f"Tool: {result['name']}")
print(f"Severity: {result['severity']}")
print(f"Description: {result['description']}")2. Unauthorized SSH Client Detection
Whitelist approved SSH clients:
class SSHClientWhitelist:
def __init__(self):
self.approved_clients = set()
def add_approved_client(self, ja4ssh_fingerprint):
"""Add approved SSH client fingerprint"""
self.approved_clients.add(ja4ssh_fingerprint)
def is_approved(self, ja4ssh_fingerprint):
"""Check if SSH client is approved"""
return ja4ssh_fingerprint in self.approved_clients
def check_connection(self, src_ip, dst_ip, ja4ssh):
"""Check SSH connection against whitelist"""
if not self.is_approved(ja4ssh):
return {
'approved': False,
'action': 'BLOCK',
'message': f'Unauthorized SSH client from {src_ip} to {dst_ip}'
}
return {'approved': True, 'action': 'ALLOW'}
# Usage
whitelist = SSHClientWhitelist()
# Add approved clients (e.g., corporate OpenSSH version)
whitelist.add_approved_client(
'SSH-2.0-OpenSSH_8.4_abc123_def456_ghi789_jkl012_mno345'
)
# Check connection
connection = whitelist.check_connection(
'10.0.0.100',
'10.0.0.50',
'SSH-2.0-PuTTY_Release_0.76_xyz999_uvw888_rst777_opq666_lmn555'
)
if not connection['approved']:
print(f"BLOCKED: {connection['message']}")3. SSH Brute-Force Detection
Track SSH fingerprints during authentication attempts:
from collections import defaultdict
from datetime import datetime, timedelta
class SSHBruteForceDetector:
def __init__(self, threshold=10, time_window_minutes=5):
self.attempts = defaultdict(list)
self.threshold = threshold
self.time_window = timedelta(minutes=time_window_minutes)
def record_attempt(self, src_ip, dst_ip, ja4ssh, success=False):
"""Record SSH authentication attempt"""
attempt = {
'timestamp': datetime.now(),
'dst_ip': dst_ip,
'ja4ssh': ja4ssh,
'success': success
}
self.attempts[src_ip].append(attempt)
# Check if brute-force detected
return self.check_brute_force(src_ip)
def check_brute_force(self, src_ip):
"""Check if source IP is performing brute-force attack"""
# Get recent attempts
cutoff = datetime.now() - self.time_window
recent_attempts = [
a for a in self.attempts[src_ip]
if a['timestamp'] > cutoff
]
# Count failed attempts
failed_attempts = [a for a in recent_attempts if not a['success']]
if len(failed_attempts) >= self.threshold:
# Check if all from same JA4SSH (automated tool)
fingerprints = set(a['ja4ssh'] for a in failed_attempts)
return {
'is_brute_force': True,
'attempt_count': len(failed_attempts),
'fingerprints': list(fingerprints),
'automated': len(fingerprints) == 1
}
return {'is_brute_force': False}
# Usage
detector = SSHBruteForceDetector(threshold=5, time_window_minutes=5)
# Simulate authentication attempts
for i in range(10):
result = detector.record_attempt(
'203.0.113.50',
'10.0.0.10',
'SSH-2.0-libssh2_1.9.0_scanner_abc123_def456_ghi789_jkl012_mno345',
success=False
)
if result['is_brute_force']:
print(f"ALERT: SSH Brute-Force Attack Detected!")
print(f"Attempts: {result['attempt_count']}")
print(f"Automated Tool: {result['automated']}")
print(f"Fingerprints: {result['fingerprints']}")Integration with Security Tools
Zeek Script
@load base/protocols/ssh
module JA4SSH;
export {
redef record SSH::Info += {
client_ja4ssh: string &optional &log;
server_ja4ssh: string &optional &log;
};
# Table of known malicious fingerprints
global malicious_ja4ssh: set[string] = set();
}
# Load malicious fingerprints from file
event zeek_init()
{
# Load from intelligence feed
Input::add_table([$source="malicious_ssh_clients.txt",
$name="malicious_ja4ssh",
$idx=Idx,
$destination=malicious_ja4ssh]);
}
event ssh_capabilities(c: connection, cookie: string, capabilities: SSH::Capabilities)
{
local ja4ssh = compute_ja4ssh(capabilities);
if (capabilities$is_server) {
c$ssh$server_ja4ssh = ja4ssh;
} else {
c$ssh$client_ja4ssh = ja4ssh;
# Check against malicious fingerprints
if (ja4ssh in malicious_ja4ssh) {
NOTICE([$note=SSH::Malicious_Client_Detected,
$msg=fmt("Malicious SSH client detected: %s", ja4ssh),
$conn=c,
$identifier=cat(c$id$orig_h, ja4ssh)]);
}
}
}Suricata Rule
alert ssh any any -> $HOME_NET 22 (msg:"Malicious SSH Client - Metasploit libssh2";
ssh.protoversion; content:"libssh2_1.4.3";
classtype:trojan-activity;
sid:2000010; rev:1;)
alert ssh any any -> $HOME_NET 22 (msg:"SSH Brute-Force Tool Detected";
ssh.protoversion; pcre:"/SSH-2.0-(hydra|medusa|ncrack)/i";
threshold:type limit, track by_src, count 5, seconds 300;
classtype:attempted-user;
sid:2000011; rev:1;)Splunk Query
index=network sourcetype=ssh_logs
| eval ja4ssh=protocol_version."_".kex_hash."_".cipher_hash."_".mac_hash."_".comp_hash
| lookup malicious_ssh_clients.csv ja4ssh OUTPUT threat_name severity
| where isnotnull(threat_name)
| stats count by _time, src_ip, dst_ip, ja4ssh, threat_name, severity
| sort -_timeCommon JA4SSH Examples
Legitimate Clients
OpenSSH 8.4 (Modern Linux):
SSH-2.0-OpenSSH_8.4_a1b2c3d4e5f6_g7h8i9j0k1l2_m3n4o5p6q7r8_s9t0u1v2w3x4_y5z6a7b8c9d0PuTTY 0.76:
SSH-2.0-PuTTY_Release_0.76_e1f2g3h4i5j6_k7l8m9n0o1p2_q3r4s5t6u7v8_w9x0y1z2a3b4_c5d6e7f8g9h0libssh2 (Legitimate):
SSH-2.0-libssh2_1.9.0_i1j2k3l4m5n6_o7p8q9r0s1t2_u3v4w5x6y7z8_a9b0c1d2e3f4_g5h6i7j8k9l0Malicious Tools
Metasploit (Default):
SSH-2.0-libssh2_1.4.3_oldversion_hash1_hash2_hash3_hash4_hash5Hydra (Brute-Force):
SSH-2.0-OpenSSH_7.4_scanner_hash6_hash7_hash8_hash9_hash10Custom Python Malware (Paramiko):
SSH-2.0-Paramiko_2.7.1_custom_hashabc_hashdef_hashghi_hashjkl_hashmnoTroubleshooting
Issue 1: Incomplete KEX_INIT capture
- Cause: Packet fragmentation or capture filter too restrictive
- Solution: Capture full packets with
-s 0in tcpdump, disable filters temporarily
Issue 2: Algorithm order varies
- Cause: Different SSH implementations may present algorithms in different orders
- Solution: Always sort algorithms before hashing (already implemented in code)
Issue 3: Version string variations
- Cause: OpenSSH vs. commercial SSH implementations
- Solution: Normalize version strings or use fuzzy matching for variants
Best Practices
-
Baseline Legitimate Clients
- Document expected SSH clients in your environment
- Create whitelist of approved fingerprints
- Monitor deviations from baseline
-
Track SSH Tools by Function
- Administrative clients (OpenSSH, PuTTY)
- Automation tools (Ansible, Fabric, Paramiko scripts)
- Development tools (git SSH, IDE integrations)
-
Correlate with Authentication Logs
- Combine JA4SSH with successful/failed auth events
- Track which fingerprints succeed vs. fail
- Identify brute-force patterns
-
Monitor for Tool Changes
- Alert when known users suddenly use different SSH clients
- Investigate fingerprint changes on critical systems
- Track SSH client updates across infrastructure
Key Takeaways
✅ JA4SSH fingerprints SSH clients by their protocol capabilities ✅ Enables detection of malicious SSH tools (Metasploit, custom malware) ✅ Identifies unauthorized SSH clients and brute-force attacks ✅ Resistant to IP rotation and domain changes ✅ Essential for detecting lateral movement and C2 over SSH
Next Steps
- Capture: Start collecting SSH traffic from your network
- Baseline: Document legitimate SSH clients
- Detect: Implement JA4SSH detection rules
- Hunt: Search for unusual SSH fingerprints in historical data
- Share: Contribute malicious fingerprints to community databases
Related Techniques: