Chapter 50

JA3 Fingerprint Randomization

Your implant's C2 traffic is TLS encrypted — but the TLS handshake itself is visible in plaintext before the encryption starts. JA3 is a fingerprinting technique that hashes the fields of the TLS ClientHello message (cipher suites, TLS version, extensions, elliptic curves) into a 32-character MD5. Cobalt Strike's default JA3 hash was blocked across most enterprise networks within weeks of publication. Your custom C2 will be fingerprinted the same way. This chapter covers what JA3 captures, how to randomize your TLS fingerprint using Windows Schannel, and the escalating counter-measures (JA3S, JARM) that have raised the bar further.

Anatomy of a JA3 Fingerprint

TLS ClientHello fields captured by JA3
  TLS ClientHello message (visible before encryption — sent in cleartext):
  ─────────────────────────────────────────────────────────────────────────
  Content Type: 22 (Handshake)
  Version: TLS 1.0 (even for TLS 1.3 connections — compatibility field)
  ┌─────────────────────────────────────────────────────────────────────┐
  │ Handshake Type: Client Hello                                        │
  │ Version: TLS 1.2 (or 1.3 in extension)                             │
  │                                                                     │
  │ Random: [32 bytes] ← changes every connection (NOT fingerprinted)   │
  │ Session ID: [0-32 bytes]                                            │
  │                                                                     │
  │ Cipher Suites: [list of 2-byte codes]  ← FINGERPRINTED             │
  │   e.g.: TLS_AES_256_GCM_SHA384 (0x1302)                            │
  │         TLS_CHACHA20_POLY1305_SHA256 (0x1303)                       │
  │         TLS_AES_128_GCM_SHA256 (0x1301)                             │
  │                                                                     │
  │ Compression Methods: [usually just 0x00 = none]                     │
  │                                                                     │
  │ Extensions: [list of type+length+data]  ← FINGERPRINTED             │
  │   0x0000 server_name (SNI)                                          │
  │   0x000a supported_groups (elliptic curves)  ← FINGERPRINTED       │
  │   0x000b ec_point_formats                                           │
  │   0x000d signature_algorithms               ← FINGERPRINTED        │
  └─────────────────────────────────────────────────────────────────────┘
  
  JA3 algorithm:
  ─────────────────────────────────────────────────────────────────────────
  1. Extract: TLSVersion, CipherSuites, Extensions, EllipticCurves, EllipticCurveFormats
  2. Remove GREASE values (0x?A?A pattern — browser compatibility probes)
  3. Format as CSV: "771,49195-49199-49196-...,0-10-11-...,23-24-...,0"
  4. MD5 hash → 32 hex chars = JA3 fingerprint
  
  Example fingerprints (well-known):
  ─────────────────────────────────────────────────────────────────────────
  Chrome 120:            "771,4866-4867-4865-..." → aaa...  (changes per version)
  Firefox 121:           "771,4866-4867-..." → bbb...
  Cobalt Strike default: "771,2-49195-..." → e7d705a3cf1f2...
                                              ↑ instantly blocked on most NSMs
  curl/openssl default:  "771,4866-..." → d0ec4...
  Metasploit Meterpreter: "769,..." → 6d1b...  (TLS 1.0 — already suspicious)

Controlling Your TLS Fingerprint via Schannel

/* ja3_randomize.c — Control cipher suites and TLS parameters via Schannel
   
   Windows' native TLS library is Schannel (SChannel.dll).
   WinHTTP, WinINet, and raw BCrypt/SChannel APIs all go through Schannel.
   Schannel exposes control over:
     - Which cipher suites are offered (via ALG_ID or BCrypt provider selection)
     - TLS version (SCHANNEL_CRED.grbitEnabledProtocols)
     - Signature algorithms (partially, via cipher suite selection)
     - Extensions (limited control — Schannel adds some extensions automatically)
   
   The most effective approach: use raw Schannel via SSPI to build a custom
   ClientHello with controlled cipher suite order and extension set.
   
   Simpler approach: supply a custom cipher suite list to WinHTTP/WinINet
   via BCrypt/Schannel session options.
*/

#include <windows.h>
#include <winhttp.h>
#include <wincrypt.h>
#include <schannel.h>
#include <stdio.h>

#pragma comment(lib, "winhttp.lib")
#pragma comment(lib, "secur32.lib")

/* Cipher suite IDs (from RFC 8446 / IANA TLS registry) */
/* These are the codes that appear in the ClientHello cipher list */
#define TLS_AES_256_GCM_SHA384           0x1302
#define TLS_AES_128_GCM_SHA256           0x1301
#define TLS_CHACHA20_POLY1305_SHA256     0x1303
#define TLS_ECDHE_RSA_WITH_AES_256_GCM  0xC02C
#define TLS_ECDHE_RSA_WITH_AES_128_GCM  0xC02B
#define TLS_ECDHE_ECDSA_WITH_AES_256_GCM 0xC02C
#define TLS_RSA_WITH_AES_256_CBC_SHA256  0x003D  /* older suite — changes fingerprint */

/* ── Approach 1: WinHTTP with custom Schannel credentials ─────────── */
HINTERNET winhttp_with_custom_schannel(void) {
    /*
     * Build a SCHANNEL_CRED structure with specific cipher suite selection.
     * The cSupportedAlgs and palgSupportedAlgs fields control which cipher
     * suites Schannel will offer in the ClientHello.
     *
     * Note: On Windows 10+ with modern Schannel, SCHANNEL_CRED is deprecated
     * in favor of SCH_CREDENTIALS. Both still work for our purpose.
     */
    
    /* Use Chrome's cipher suite order to mimic Chrome's JA3 */
    /* Chrome 120 cipher order (approximate): */
    ALG_ID chrome_ciphers[] = {
        CALG_ECDH_EPHEM,  /* placeholder — actual control via BCrypt handles */
    };
    
    SCHANNEL_CRED scred = {0};
    scred.dwVersion       = SCHANNEL_CRED_VERSION;
    scred.grbitEnabledProtocols = SP_PROT_TLS1_2_CLIENT | SP_PROT_TLS1_3_CLIENT;
    scred.dwFlags         = SCH_USE_STRONG_CRYPTO
                          | SCH_CRED_NO_DEFAULT_CREDS
                          | SCH_CRED_MANUAL_CRED_VALIDATION;
    /*
     * dwMinimumCipherStrength / dwMaximumCipherStrength:
     * These control key strength, not cipher suite identity.
     * For fine-grained cipher suite control, prefer the BCrypt/NCrypt approach.
     */
    scred.dwMinimumCipherStrength = 128;

    CredHandle cred_handle;
    TimeStamp  expiry;
    SECURITY_STATUS ss = AcquireCredentialsHandleA(
        NULL, UNISP_NAME_A,  /* "Microsoft Unified Security Protocol Provider" */
        SECPKG_CRED_OUTBOUND,
        NULL, &scred,
        NULL, NULL,
        &cred_handle, &expiry
    );

    if (ss != SEC_E_OK) {
        printf("[-] AcquireCredentialsHandle: 0x%08lX\n", ss);
        return NULL;
    }
    printf("[+] Custom Schannel credentials acquired\n");
    printf("[+] TLS protocols: TLS 1.2 + TLS 1.3 only (no 1.0/1.1)\n");

    /* Open WinHTTP session and apply custom credentials */
    HINTERNET hSession = WinHttpOpen(
        L"Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0 Safari/537.36",
        WINHTTP_ACCESS_TYPE_DEFAULT_PROXY,
        WINHTTP_NO_PROXY_NAME,
        WINHTTP_NO_PROXY_BYPASS,
        0
    );
    
    /* Set the custom Schannel credential handle on the session */
    /* Note: WinHTTP's built-in TLS uses Schannel. You can override the
       credential handle via WINHTTP_OPTION_CLIENT_CERT_CONTEXT combined with
       a custom SCHANNEL_CRED. Full control requires dropping WinHTTP and
       using SSPI directly (shown in the raw SSPI section below). */
    
    FreeCredentialsHandle(&cred_handle);
    return hSession;
}

/* ── Approach 2: Random cipher suite selection for each connection ─── */
/*
 * The most impactful change for JA3 randomization:
 * Vary the CIPHER SUITE ORDER on each connection.
 * JA3 is an MD5 of the concatenated cipher suite list — different order = different hash.
 *
 * Strategy: maintain a pool of "acceptable" cipher suites.
 * On each new connection, shuffle the pool and offer a random subset.
 * No network security monitor can block by JA3 — your JA3 changes per connection.
 */
static WORD g_cipher_pool[] = {
    0x1302,  /* TLS_AES_256_GCM_SHA384 (TLS 1.3) */
    0x1301,  /* TLS_AES_128_GCM_SHA256 (TLS 1.3) */
    0x1303,  /* TLS_CHACHA20_POLY1305_SHA256 (TLS 1.3) */
    0xC02C,  /* ECDHE-RSA-AES256-GCM-SHA384 (TLS 1.2) */
    0xC02B,  /* ECDHE-RSA-AES128-GCM-SHA256 (TLS 1.2) */
    0xC030,  /* ECDHE-RSA-AES256-SHA384 (TLS 1.2) */
    0x009D,  /* RSA-AES256-GCM-SHA384 (TLS 1.2) */
    0x009C,  /* RSA-AES128-GCM-SHA256 (TLS 1.2) */
};
#define POOL_SIZE (sizeof(g_cipher_pool) / sizeof(g_cipher_pool[0]))

void shuffle_cipher_pool(WORD *pool, int n) {
    /* Fisher-Yates shuffle using CryptGenRandom for entropy */
    HCRYPTPROV hProv;
    CryptAcquireContextA(&hProv, NULL, NULL, PROV_RSA_FULL, CRYPT_VERIFYCONTEXT);
    for (int i = n - 1; i > 0; i--) {
        DWORD r;
        CryptGenRandom(hProv, sizeof(r), (BYTE*)&r);
        int j = r % (i + 1);
        WORD tmp = pool[i]; pool[i] = pool[j]; pool[j] = tmp;
    }
    CryptReleaseContext(hProv, 0);
}

void demonstrate_randomization(void) {
    WORD session_ciphers[POOL_SIZE];
    memcpy(session_ciphers, g_cipher_pool, sizeof(g_cipher_pool));
    shuffle_cipher_pool(session_ciphers, POOL_SIZE);
    
    printf("Connection cipher suite order (varies per connection):\n");
    for (int i = 0; i < (int)POOL_SIZE; i++) {
        printf("  [%d] 0x%04X\n", i, session_ciphers[i]);
    }
    printf("JA3 hash will be different each session.\n");
}

Beyond JA3: JA3S, JARM, and TLS Fingerprinting Escalation

TLS fingerprinting arms race — JA3, JA3S, JARM
  JA3 (2017, Salesforce):
  ─────────────────────────────────────────────────────────────────────────
  Fingerprints: ClientHello (outbound from client)
  Who sees it: Any network tap between client and server
  Defeated by: Randomizing cipher suite order/selection
  
  JA3S (2018, Salesforce):
  ─────────────────────────────────────────────────────────────────────────
  Fingerprints: ServerHello (response from SERVER)
  Fields: TLS version, cipher suite chosen, extensions
  Use: "This client got this JA3, and the server responded with this JA3S"
       The (JA3, JA3S) pair is more unique than JA3 alone.
  Defeated by: Control your C2 server's TLS response fields
               Use nginx/caddy as TLS terminator with standard server config
  
  JARM (2020, Salesforce):
  ─────────────────────────────────────────────────────────────────────────
  Fingerprints: THE SERVER'S TLS STACK (not the client)
  Method: Send 10 specially crafted ClientHellos to the server.
          Record which cipher suite the server selects for each.
          Hash the 10 responses → JARM fingerprint.
  Use: "This IP:port responds with JARM=abc123" → identify C2 servers
       Cobalt Strike team server has a known JARM. Mythic has another.
       Brute-force scan suspected C2 IPs — JARM identifies the framework.
  Defeated by: Proper TLS configuration on your C2 server
               Use a CDN or cloud redirect to obscure server identity
               Configure the C2 framework's TLS response order (most
               modern C2s allow cipher suite response customization)
  
  Current state (2026):
  ─────────────────────────────────────────────────────────────────────────
  Major threat intel teams use ALL THREE simultaneously:
    JA3 of implant → "this is CobaltStrike beacon with default TLS"
    JARM of C2 server → "this server is running Cobalt Strike team server"
    (JA3, JA3S) pair → "this client connected to this server with matching pair"
  
  Mitigation:
    Randomize JA3 (randomize cipher suite order per connection)
    Put your C2 behind a cloud CDN (Cloudflare, AWS CloudFront)
    → defenders see Cloudflare's JARM, not your C2's JARM
    Use domain fronting or redirectors (Part 12)

Questions & Answers

If you randomize the JA3, can defenders still correlate your C2 traffic?

Yes — by other means. Randomized JA3 defeats static JA3 blocklists, but defenders fall back to behavioral signals: connection timing (beacons checking in at regular intervals), byte-frequency analysis of the TLS payload sizes (Cobalt Strike's default Malleable C2 profiles have predictable request sizes), DNS resolution patterns (your implant resolves the C2 domain at predictable intervals), certificate information (a self-signed cert on your C2 server with specific X.509 fields), and JARM of the server. The full evasion stack for C2 traffic (Part 12) combines: randomized JA3 (this chapter), Malleable C2 profiles with jitter, CDN redirectors for JARM masking, and legitimate-looking certificates from Let's Encrypt.

What's the practical limitation of Schannel-based JA3 control compared to a custom TLS stack?

Schannel gives you control over cipher suites, TLS version, and some extension behavior, but it adds its own extensions automatically (including the Renegotiation Info extension, Extended Master Secret, and others). These fixed extensions contribute to the JA3 fingerprint and can't be removed through the Schannel API. A custom TLS stack (BoringSSL, rustls, your own TLS 1.3 implementation) gives complete control over every byte of the ClientHello. The trade-off: a custom TLS stack is hundreds of lines of code and must be maintained against CVEs, while Schannel is battle-tested and gets security updates from Microsoft. For a real implant: Schannel randomization gets you 80% of the way there at minimal cost; a custom stack gets you to 100% at significant development overhead.

Does using a CDN (Cloudflare) for your C2 traffic fully solve the JARM problem?

For JARM, yes — effectively. When your implant connects to your C2 through Cloudflare, defenders scanning the IP:port see Cloudflare's JARM fingerprint (well-known, obviously belonging to Cloudflare), not your C2 server's JARM. They cannot distinguish your C2 from the millions of other sites behind Cloudflare. However, CDN use introduces other detection vectors: your C2 domain must be registered (WHOIS, passive DNS), Cloudflare has its own abuse reporting and detection that can take down your domain, and defenders can use "domain categorization" — a newly registered domain behind Cloudflare resolving from an enterprise environment at regular intervals is still suspicious, especially if it doesn't have a web presence older than the infection date. The full technique is "domain fronting," covered in Part 12.

How do defenders collect JA3 in practice, and from what vantage points?

JA3 collection requires visibility into the TLS ClientHello at the network layer — the full handshake before the TLS session is established. Collection points: network security monitors (NSMs) like Zeek (Bro) running at network egress points compute JA3 for every outbound TLS connection. Zeek has built-in JA3 support via the ssl.log. Enterprise firewalls with deep packet inspection (Palo Alto, Fortinet) compute JA3 natively. Cloud security platforms (Zscaler, Netskope) compute JA3 for all proxied traffic. In a mature enterprise, every TLS ClientHello that leaves the network gets a JA3 computed and potentially checked against threat intelligence feeds. If your implant connects to a known bad JA3 hash, it's blocked at the egress firewall before your TCP SYN even completes.

Can randomizing cipher suites break the TLS connection or cause server-side errors?

Only if you offer cipher suites that no server supports, or if you remove all cipher suites that are compatible with your C2 server's configuration. The randomization strategy is to shuffle a curated pool of well-supported suites — not to include exotic or deprecated suites that would cause negotiation failure. As long as at least one offered cipher suite overlaps with the server's accepted suites, TLS negotiation succeeds. In practice: keeping TLS 1.3 suites (0x1301, 0x1302, 0x1303) in the pool guarantees success against any modern server (all support TLS 1.3), and the randomization in cipher order changes the JA3 without affecting negotiation success. Only shuffle among suites you've verified the server accepts.