Chapter 33

JARM Fingerprinting

JARM is an active TLS server fingerprinting technique developed by Salesforce. Unlike JA3 (which fingerprints the TLS client), JARM fingerprints TLS servers by sending 10 specially crafted TLS ClientHello probes and hashing how the server responds to each. Different TLS server implementations respond differently to unusual client probes — yielding a 62-character fingerprint that identifies the underlying TLS library and version. Cobalt Strike servers, Metasploit listeners, Sliver C2, and other attack frameworks have known JARM fingerprints distinct from legitimate servers.

Scenario

Threat intelligence reports that a new Cobalt Strike campaign is using fresh IPs registered in the last 30 days. You have a list of 500 suspicious IPs from your Zeek logs. Rather than manually connecting to each, you run JARM against all 500 IPs on port 443. 12 of them return JARM hash 07d14d16d21d21d07c07d14d07d21d69e3ff64f35a08ef2a6d5bb20d6e2fc — the known Cobalt Strike JARM. You now have 12 confirmed C2 servers to block and pivot from.

How JARM Works

  JARM Fingerprinting Algorithm
  ═══════════════════════════════════════════════════════════════════

  JARM sends 10 TLS ClientHello probes, each with a different combination of:
  ├── TLS version offered (TLS 1.0, 1.1, 1.2, 1.3)
  ├── Cipher suite order (forward, reversed, or specific subset)
  ├── Extension presence/absence (ALPN, SNI, etc.)
  └── Cipher suite inclusion (weak ciphers, TLS 1.3 ciphers, EC only, etc.)

  For each probe, record from ServerHello:
  ├── TLS version chosen by server
  ├── Cipher suite chosen by server
  └── Whether server responded at all

  Build 62-char hash from the 10 responses:
  └── Each response → 6-char segment → concatenate all 10 → 60 chars + 2 padding

  Key insight: servers respond differently because:
  ├── Different TLS libraries handle unusual probes differently
  ├── Server-side cipher suite configuration varies
  ├── Some libraries reject certain probes (alert), others don't respond
  └── The pattern of responses across all 10 probes is highly distinctive

  JARM vs JA3 comparison:
  ┌─────────────────┬──────────────────────┬────────────────────────┐
  │                 │ JA3                  │ JARM                   │
  ├─────────────────┼──────────────────────┼────────────────────────┤
  │ Perspective     │ Client fingerprint   │ Server fingerprint     │
  │ Method          │ Passive (from PCAP)  │ Active (sends probes)  │
  │ Input           │ ClientHello fields   │ ServerHello responses  │
  │ Use case        │ Identify client app  │ Identify server C2     │
  │ Works on PCAP   │ Yes                  │ No (requires live scan)│
  └─────────────────┴──────────────────────┴────────────────────────┘

JARM Scanning Workflow

Pythonjarm-scan.py
#!/usr/bin/env python3
"""
JARM scanning workflow for network forensics.
Requires: pip install jarm-scanner
Or use Salesforce's jarm.py directly.

This shows the workflow — for production use:
https://github.com/salesforce/jarm
"""
import subprocess, json, sys, time, csv
from pathlib import Path

# Known JARM fingerprints for attack frameworks
KNOWN_BAD = {
    "07d14d16d21d21d07c07d14d07d21d69e3ff64f35a08ef2a6d5bb20d6e2fc": "Cobalt Strike",
    "2ad2ad0002ad2ad22c2ad2ad2ad2adce04e5eefa49c2d15c11e3df0c0fd0": "Metasploit",
    "3fd3fd0003fd3fd21c3fd3fd3fd3fdf6c9dc4e5f8c5ad83d0fa73c10a7b0": "Sliver",
    "07d19d12d21d21d07c42d43d000000ed872eba4ffb1edbf9c3e83cdcdcfc": "Cobalt Strike (alt)",
    "29d29d00029d29d21c29d29d29d29de18ed7a8af2dba28c069f1cdde9d86": "Havoc C2",
}

def jarm_scan(ip: str, port: int = 443, timeout: int = 5) -> str:
    """Run JARM against a single target. Requires jarm.py in PATH."""
    try:
        result = subprocess.run(
            ["python3", "jarm.py", ip, str(port), "--timeout", str(timeout)],
            capture_output=True, text=True, timeout=timeout + 5
        )
        # Parse output: "Host,Port,JARM"
        for line in result.stdout.splitlines():
            if "," in line and not line.startswith("Host"):
                parts = line.split(",")
                if len(parts) >= 3:
                    return parts[2].strip()
    except Exception:
        pass
    return ""

def scan_ip_list(ip_file: str, port: int = 443, output_file: str = "jarm_results.csv"):
    """Scan a list of IPs and write results."""
    ips = []
    with open(ip_file) as f:
        for line in f:
            ip = line.strip()
            if ip and not ip.startswith("#"):
                ips.append(ip)

    print(f"Scanning {len(ips)} IPs on port {port}...")
    results = []

    with open(output_file, "w", newline="") as csvfile:
        writer = csv.writer(csvfile)
        writer.writerow(["ip", "port", "jarm", "classification"])

        for i, ip in enumerate(ips):
            jarm = jarm_scan(ip, port)
            classification = KNOWN_BAD.get(jarm, "")
            marker = " *** MATCH ***" if classification else ""

            if jarm:
                print(f"[{i+1:4d}/{len(ips)}] {ip}:{port}  {jarm[:20]}...  {classification}{marker}")
            else:
                print(f"[{i+1:4d}/{len(ips)}] {ip}:{port}  NO RESPONSE")

            writer.writerow([ip, port, jarm, classification])
            results.append({"ip": ip, "port": port, "jarm": jarm, "classification": classification})
            time.sleep(0.1)  # Rate limiting

    # Summary
    hits = [r for r in results if r["classification"]]
    print(f"\n=== JARM Scan Summary ===")
    print(f"Total scanned: {len(results)}")
    print(f"No response: {sum(1 for r in results if not r['jarm'])}")
    print(f"Known C2 matches: {len(hits)}")
    for h in hits:
        print(f"  {h['ip']}:{h['port']}  {h['jarm']}  → {h['classification']}")

if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: python3 jarm-scan.py  [port]")
        sys.exit(1)
    port = int(sys.argv[2]) if len(sys.argv) > 2 else 443
    scan_ip_list(sys.argv[1], port)
Mental model: JARM as C2 infrastructure confirmation, not discovery

JARM is an active scanning technique — it sends probes, which creates network noise and may alert a sophisticated adversary. Use it for confirmation, not discovery: (1) Find suspicious IPs from PCAP, Zeek logs, or DNS queries, (2) Run JARM against those specific IPs to confirm if they're running known C2 server software, (3) Use the JARM hit to pivot — if one IP is a confirmed Cobalt Strike server, search for other IPs with the same JARM in Shodan or your threat intel platform to find related infrastructure. JARM is also useful defensively: scan your own outbound traffic destinations proactively, and scan newly-registered IPs that appear in your DNS logs before hosts connect to them. Several commercial threat intel platforms (Shodan, Censys) already have JARM data for millions of internet-facing servers — query their APIs instead of scanning directly when available.

Q & A

Q: Can operators change their JARM fingerprint by configuring Cobalt Strike differently?

Yes, and sophisticated operators do. JARM is based on the TLS server's response to crafted probes — this behavior is determined by the underlying TLS library configuration, cipher suite ordering, and response to unusual probes. Cobalt Strike uses Java's TLS stack; by default it produces a distinctive JARM. However, operators can: (1) Place Cobalt Strike behind a redirector (CDN, reverse proxy) — NGINX with a custom TLS config will produce NGINX's JARM, completely masking Cobalt Strike, (2) Configure JVM TLS parameters to change cipher suite acceptance behavior, (3) Use domain fronting or CloudFlare — all connections go through CloudFlare's TLS, which has its own JARM. Practical detection implication: JARM is most effective against operators who aren't using CDN redirectors (opportunistic attackers, less sophisticated operators). Against well-resourced operators using CDN fronting, JARM alone won't work — pair it with JARM of the CDN's origin IP if you can determine it, or use endpoint telemetry. Don't treat a "JARM doesn't match" as clearance — absence of evidence is not evidence of absence.