Quick-Labs
JA4
JA4SSH - SSH Protocol Fingerprinting

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

  1. Detect Malicious Tools: Identify Metasploit, Cobalt Strike, custom backdoors
  2. Track APT Activity: Recognize attacker SSH tools across incidents
  3. Unauthorized Access: Detect SSH clients not approved for your environment
  4. Automated Attacks: Identify SSH brute-force tools and scanners
  5. Insider Threats: Detect unusual SSH tools used by insiders

Understanding SSH Handshake

SSH Protocol Exchange

The SSH handshake consists of several phases:

  1. Protocol Version Exchange

    • Client sends: SSH-2.0-OpenSSH_8.4
    • Server responds: SSH-2.0-OpenSSH_7.9
  2. Key Exchange (KEX) Initialization

    • Client sends supported algorithms
    • Server sends supported algorithms
  3. 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-sha256
  • diffie-hellman-group14-sha256
  • diffie-hellman-group-exchange-sha256
  • ecdh-sha2-nistp256

3. Host Key Algorithms Hash (12 chars)

SHA-256 hash of supported server authentication algorithms (sorted):

  • ssh-ed25519
  • rsa-sha2-512
  • rsa-sha2-256
  • ecdsa-sha2-nistp256

4. Cipher Hash (12 chars)

SHA-256 hash of supported encryption ciphers (sorted):

  • aes128-ctr
  • aes192-ctr
  • aes256-ctr
  • aes128-gcm@openssh.com
  • chacha20-poly1305@openssh.com

5. MAC Hash (12 chars)

SHA-256 hash of supported MAC algorithms (sorted):

  • hmac-sha2-256
  • hmac-sha2-512
  • umac-128@openssh.com

6. Compression Hash (12 chars)

SHA-256 hash of supported compression methods (sorted):

  • none
  • zlib@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.com

Step 1: Extract Protocol Version

SSH-2.0-OpenSSH_8.4

Step 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: a1b2c3d4e5f6

Step 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

  1. Start Capture: Select network interface
  2. Filter: ssh or tcp.port == 22
  3. Establish Connection: Initiate SSH session
  4. Analyze: Expand SSH Protocol → Key Exchange
  5. 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 -X

Using 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 -_time

Common JA4SSH Examples

Legitimate Clients

OpenSSH 8.4 (Modern Linux):

SSH-2.0-OpenSSH_8.4_a1b2c3d4e5f6_g7h8i9j0k1l2_m3n4o5p6q7r8_s9t0u1v2w3x4_y5z6a7b8c9d0

PuTTY 0.76:

SSH-2.0-PuTTY_Release_0.76_e1f2g3h4i5j6_k7l8m9n0o1p2_q3r4s5t6u7v8_w9x0y1z2a3b4_c5d6e7f8g9h0

libssh2 (Legitimate):

SSH-2.0-libssh2_1.9.0_i1j2k3l4m5n6_o7p8q9r0s1t2_u3v4w5x6y7z8_a9b0c1d2e3f4_g5h6i7j8k9l0

Malicious Tools

Metasploit (Default):

SSH-2.0-libssh2_1.4.3_oldversion_hash1_hash2_hash3_hash4_hash5

Hydra (Brute-Force):

SSH-2.0-OpenSSH_7.4_scanner_hash6_hash7_hash8_hash9_hash10

Custom Python Malware (Paramiko):

SSH-2.0-Paramiko_2.7.1_custom_hashabc_hashdef_hashghi_hashjkl_hashmno

Troubleshooting

Issue 1: Incomplete KEX_INIT capture

  • Cause: Packet fragmentation or capture filter too restrictive
  • Solution: Capture full packets with -s 0 in 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

  1. Baseline Legitimate Clients

    • Document expected SSH clients in your environment
    • Create whitelist of approved fingerprints
    • Monitor deviations from baseline
  2. Track SSH Tools by Function

    • Administrative clients (OpenSSH, PuTTY)
    • Automation tools (Ansible, Fabric, Paramiko scripts)
    • Development tools (git SSH, IDE integrations)
  3. Correlate with Authentication Logs

    • Combine JA4SSH with successful/failed auth events
    • Track which fingerprints succeed vs. fail
    • Identify brute-force patterns
  4. 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

  1. Capture: Start collecting SSH traffic from your network
  2. Baseline: Document legitimate SSH clients
  3. Detect: Implement JA4SSH detection rules
  4. Hunt: Search for unusual SSH fingerprints in historical data
  5. Share: Contribute malicious fingerprints to community databases

Related Techniques: