courses

SSL Pinning: Detection and Bypass

Certificate pinning is a network anti-analysis technique that forces a client to accept only a specific certificate or public key when establishing a TLS connection. For malware analysts, a pinned sample will silently fail or abort when you try to intercept its traffic with a proxy — exactly as designed. This page covers what pinning is, how to identify it in a sample, and how to defeat it across Android, iOS, and desktop targets. It assumes familiarity with basic dynamic analysis (network capture, sandboxes) and anti-analysis techniques (evasion primitives); Frida usage builds on Advanced Dynamic Analysis.


What Is Certificate Pinning

Normal TLS validation asks: is this certificate signed by a trusted CA? Pinning asks: is this the specific certificate (or key) I expect? Even a valid, CA-signed certificate will be rejected if it doesn’t match the pinned value.

Pinning Types

Type What is pinned Survives cert renewal?
Certificate pin Full DER-encoded leaf certificate or its SHA-256 hash No — must update pin when cert rotates
Public key pin The RSA/EC public key bytes Yes — key can be reused across renewals
SPKI hash pin SHA-256 of the SubjectPublicKeyInfo ASN.1 structure Yes — same as public key pin, OkHttp format

Public key and SPKI hash pins are more durable and are preferred by malware C2 implementations: a compromise of the server certificate does not require a client update.

Why Malware Uses Pinning

From a malware author’s perspective, pinning is effective against analyst MITM: the analyst installs a proxy certificate (Burp Suite, mitmproxy), configures the infected device to route through the proxy, and expects to read the traffic. Pinning breaks the handshake because the proxy’s dynamically-generated certificate does not match the pinned value, causing the sample to report a network error and fall back to silence — making traffic analysis impossible without defeating the pin first.

MITRE ATT&CK: T1521 (Encrypted Channel) — pinning is a channel-hardening measure commonly layered on top of standard TLS to resist interception.


Identifying Pinning in a Sample

Static Indicators

Android APK (Java/Kotlin):

Android NDK / native:

Windows PE:

strings -n 8 sample.exe | grep -E "BEGIN CERTIFICATE|sha256/"

Look for imports of CertVerifyCertificateChainPolicy, CertGetCertificateChain, WinVerifyTrust, or explicit SHA-256/SPKI hash comparison logic around TLS connections.

Linux ELF:

readelf -s sample | grep -E "SSL_CTX_set_verify|X509_verify_cert|mbedtls"

Dynamic Indicators

When pinning is present, connecting through a proxy produces a characteristic pattern:


Proxy Setup

Regardless of the bypass method, you need a MITM proxy in place to capture the decrypted traffic.

mitmproxy

# Install:
pip install mitmproxy

# Run as transparent HTTP/HTTPS proxy on port 8080:
mitmproxy --listen-port 8080

# Or headless with output to file:
mitmdump -w traffic.mitm --listen-port 8080

The CA certificate is generated on first run at ~/.mitmproxy/mitmproxy-ca-cert.pem (PEM) and mitmproxy-ca-cert.cer (DER). Distribute the .cer file to the device/VM under analysis.

Burp Suite

  1. Proxy → Options → Proxy Listeners — ensure an interface and port are bound
  2. Browse to http://burpsuite/ (from the target device) to download the Burp CA certificate
  3. Install as a trusted CA on the target device or VM

Android

Network Security Configuration

Android 7.0+ (API 24+) introduced the Network Security Config framework. User-installed CA certificates are no longer trusted by default; apps must explicitly opt in. Pinning is declared in res/xml/network_security_config.xml:

<network-security-config>
    <domain-config>
        <domain includeSubdomains="true">c2.example.com</domain>
        <pin-set expiration="2026-01-01">
            <pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
        </pin-set>
    </domain-config>
</network-security-config>

Patch to allow proxy CA (two separate problems to fix):

There are two distinct NSC restrictions an app may use:

  1. CA trust restriction — the <base-config> only trusts system CAs (default since API 24). Fix: add <certificates src="user"/> to <base-config>.
  2. Explicit pin-set — a <domain-config> specifies expected public key hashes. Adding user CAs does not defeat this; you must also remove or replace the <pin-set> element in the relevant <domain-config>.

  3. Decompile the APK:
    apktool d target.apk -o target_dir/
    
  4. Edit target_dir/res/xml/network_security_config.xml. The patched version should trust user CAs and have any <pin-set> blocks removed:
    <?xml version="1.0" encoding="utf-8"?>
    <network-security-config>
        <base-config>
            <trust-anchors>
                <certificates src="system"/>
                <certificates src="user"/>
            </trust-anchors>
        </base-config>
    </network-security-config>
    
  5. If the app didn’t already have a Network Security Config, add a reference in AndroidManifest.xml:
    <application android:networkSecurityConfig="@xml/network_security_config" ...>
    
  6. Rebuild and sign:
    apktool b target_dir/ -o target_patched.apk
    # Generate a signing key once:
    keytool -genkey -v -keystore analyst.keystore -alias analyst \
        -keyalg RSA -keysize 2048 -validity 10000
    # Sign the rebuilt APK:
    apksigner sign --ks analyst.keystore --out target_signed.apk target_patched.apk
    adb install target_signed.apk
    

This approach handles NSC-based pins and CA trust restrictions. It will not defeat pins implemented directly in Java code or native libraries.

Runtime Bypass with objection

objection is a Frida-based toolkit that automates common bypasses:

# Start frida-server on the device first (requires root or emulator):
adb push frida-server /data/local/tmp/
adb shell chmod 755 /data/local/tmp/frida-server
adb shell /data/local/tmp/frida-server &

# Attach to a running app and disable all SSL pinning:
objection -g com.target.app explore
# In the objection shell:
android sslpinning disable

objection’s sslpinning disable hooks a broad set of pinning implementations: TrustManager, OkHttpClient, Retrofit, HttpsURLConnection, and several native libraries.

Custom Frida Script

For samples that objection does not cover (custom TrustManager, NDK-level pinning):

// Bypass Java-layer TrustManager certificate checking
Java.perform(function() {
    var X509TrustManager = Java.use('javax.net.ssl.X509TrustManager');
    var SSLContext = Java.use('javax.net.ssl.SSLContext');

    // Create a no-op TrustManager
    var TrustManagerImpl = Java.registerClass({
        name: 'com.analyst.NullTrustManager',
        implements: [X509TrustManager],
        methods: {
            checkClientTrusted: function(chain, authType) {},
            checkServerTrusted: function(chain, authType) {},
            getAcceptedIssuers: function() {
                // Must return a proper Java array, not a JS array
                return Java.array('java.security.cert.X509Certificate', []);
            }
        }
    });

    // Save the overload reference and call it via .call() to avoid recursion
    var SSLContext_init = SSLContext.init.overload(
        '[Ljavax.net.ssl.KeyManager;',
        '[Ljavax.net.ssl.TrustManager;',
        'java.security.SecureRandom'
    );
    SSLContext_init.implementation = function(km, tm, sr) {
        SSLContext_init.call(this, km, [TrustManagerImpl.$new()], sr);
    };
});

For OkHttp specifically, also hook CertificatePinner:

Java.perform(function() {
    var CertificatePinner = Java.use('okhttp3.CertificatePinner');
    CertificatePinner.check.overload(
        'java.lang.String', 'java.util.List'
    ).implementation = function(hostname, certs) {
        // return without checking
    };
});

Save as bypass.js and run:

frida -U -f com.target.app -l bypass.js --no-pause

Magisk: Trust User Certificates System-Wide

On a rooted Android device using Magisk, the MagiskTrustUserCerts / Always Trust User Certs module copies user-installed CA certificates into the system certificate store on each boot. This makes the proxy CA trusted at the system level without patching the APK:

# Install the module via Magisk Manager, then:
# Install your proxy CA cert via Settings → Security → Install from storage
# Reboot — the cert appears in the system store

iOS

objection

The workflow is identical to Android once Frida is running on the device:

# Requires jailbreak with Frida installed via Cydia/Sileo, or a re-signed IPA
objection -g com.target.app explore
# In the objection shell:
ios sslpinning disable

SSL Kill Switch 2

SSL Kill Switch 2 is a Cydia tweak that patches SecTrustEvaluate and SecTrustEvaluateWithError system-wide on jailbroken devices. Install from Cydia, enable in Settings → SSL Kill Switch 2, and reboot the app.

Custom Frida Script (iOS)

When SecTrustEvaluate hooks are not sufficient (e.g., the app uses a custom TLS stack):

// Hook SecTrustEvaluateWithError (iOS 12+): returns Boolean (true = trust accepted)
var SecTrustEvaluateWithError = Module.findExportByName('Security', 'SecTrustEvaluateWithError');
if (SecTrustEvaluateWithError) {
    Interceptor.replace(SecTrustEvaluateWithError, new NativeCallback(
        function(trust, errorPtr) {
            if (!errorPtr.isNull()) errorPtr.writePointer(ptr(0));  // clear error
            return 1;  // true: trust evaluation succeeded
        }, 'int', ['pointer', 'pointer']
    ));
}

// Also cover the deprecated SecTrustEvaluate API (pre-iOS 12):
// returns OSStatus (0 = errSecSuccess); writes SecTrustResultType to *result
var SecTrustEvaluate = Module.findExportByName('Security', 'SecTrustEvaluate');
if (SecTrustEvaluate) {
    Interceptor.replace(SecTrustEvaluate, new NativeCallback(
        function(trust, resultPtr) {
            if (!resultPtr.isNull()) resultPtr.writeS32(1);  // kSecTrustResultProceed
            return 0;  // errSecSuccess
        }, 'int', ['pointer', 'pointer']
    ));
}

Windows and Linux (Desktop Malware)

Desktop malware less commonly pins, but it appears in financial trojans, RATs, and nation-state implants that use custom TLS implementations.

Frida on Windows

Most Windows TLS traffic goes through WinHTTP, WinINet, or Schannel. The lowest-level interception point for pinning checks:

// Hook CertVerifyCertificateChainPolicy to always report valid
var CertVerify = Module.findExportByName('crypt32.dll', 'CertVerifyCertificateChainPolicy');
if (CertVerify) {
    Interceptor.replace(CertVerify, new NativeCallback(
        function(policy, chain, para, policyStatus) {
            // CERT_CHAIN_POLICY_STATUS layout: cbSize (DWORD, +0), dwError (DWORD, +4)
            // Zero dwError to indicate no chain policy violation
            if (!policyStatus.isNull()) policyStatus.add(4).writeU32(0);
            return 1;  // TRUE
        }, 'int', ['pointer', 'pointer', 'pointer', 'pointer']
    ));
}

If the sample uses a bundled TLS library (OpenSSL, mbedTLS, WolfSSL), find the library module name first:

# In x64dbg: Symbols panel, search for "SSL_CTX_set_verify" or "mbedtls_ssl"
# Then target that module:
var verify = Module.findExportByName('libssl-3-x64.dll', 'SSL_CTX_set_verify');

Windows .NET samples: The managed equivalent is ServicePointManager.ServerCertificateValidationCallback and the per-request HttpClientHandler.ServerCertificateCustomValidationCallback. In Frida, hook these via Mono.Cecil IL patching or by finding the native thunk. In a debugger, set a breakpoint on the callback delegate and force it to return true.

Worked Example: End-to-End LD_PRELOAD Bypass on Linux

This walks through detecting and bypassing certificate pinning in a Linux ELF that uses OpenSSL.

Step 1 — confirm the sample pins

Route traffic through mitmproxy and attempt a connection. With a non-pinning application, the handshake completes. With a pinning one, it aborts:

# Terminal 1: start mitmproxy
mitmproxy --listen-port 8080

# Terminal 2: force the sample through the proxy
http_proxy=http://127.0.0.1:8080 https_proxy=http://127.0.0.1:8080 ./sample

If mitmproxy shows TLSHandshakeException or SSL handshake failed and the sample exits silently or logs a network error, pinning is confirmed.

Step 2 — identify the pinning mechanism with strace

strace -e trace=network,openat,read,write -f ./sample 2>&1 | grep -E "SSL|verify|cert|pin"

Look for calls to SSL_CTX_set_verify, X509_verify_cert, or a custom symbol that compares hash bytes. If the binary is stripped, use nm or readelf to look for OpenSSL symbols:

readelf -s sample | grep -E "SSL_CTX_set_verify|X509_verify|EVP_Digest"

If those symbols appear, the sample uses OpenSSL’s standard verification path and LD_PRELOAD will work.

Step 3 — extract the expected pin hash (optional but useful)

# What does the legitimate server present?
openssl s_client -connect c2.example.com:443 </dev/null 2>/dev/null \
    | openssl x509 -noout -pubkey \
    | openssl pkey -pubin -outform DER \
    | openssl dgst -sha256 -binary \
    | base64
# Output: the SPKI hash the sample expects — useful for understanding what it checks

To find the hardcoded hash in the binary:

strings -n 40 sample | grep -E "^[A-Za-z0-9+/]{43}=$"
# SHA-256 base64 hashes are exactly 44 chars ending in =

Step 4 — build and apply the LD_PRELOAD shim

/* noverify.c */
#define _GNU_SOURCE
#include <dlfcn.h>
#include <openssl/ssl.h>

void SSL_CTX_set_verify(SSL_CTX *ctx, int mode, SSL_verify_cb cb) {
    typedef void (*real_fn)(SSL_CTX *, int, SSL_verify_cb);
    real_fn real = (real_fn)dlsym(RTLD_NEXT, "SSL_CTX_set_verify");
    real(ctx, SSL_VERIFY_NONE, NULL);
}

long SSL_get_verify_result(const SSL *ssl) { return 0L; }
gcc -shared -fPIC -o noverify.so noverify.c -ldl
http_proxy=http://127.0.0.1:8080 https_proxy=http://127.0.0.1:8080 \
    LD_PRELOAD=./noverify.so ./sample

Step 5 — verify the bypass worked

In the mitmproxy window, you should now see completed HTTPS exchanges rather than SSL errors. Confirm the specific connection:

# Check strace output for successful SSL_connect (return value 1 = success)
strace -e trace=network -f LD_PRELOAD=./noverify.so ./sample 2>&1 | grep SSL_connect

If the sample still fails, it may be performing an application-level pin check (computing and comparing the SPKI hash itself) in addition to the OpenSSL-level check. In that case, find the comparison function via static analysis and patch it with LD_PRELOAD intercepting the comparison function, or use binary patching.

LD_PRELOAD (Linux)

Override OpenSSL’s verification function before the application loads it:

/* noverify.c — compile:
   gcc -shared -fPIC -o noverify.so noverify.c -ldl */
#define _GNU_SOURCE
#include <dlfcn.h>
#include <openssl/ssl.h>

/* Force SSL_CTX_set_verify to always use SSL_VERIFY_NONE */
void SSL_CTX_set_verify(SSL_CTX *ctx, int mode, SSL_verify_cb cb) {
    typedef void (*real_fn)(SSL_CTX *, int, SSL_verify_cb);
    real_fn real = (real_fn)dlsym(RTLD_NEXT, "SSL_CTX_set_verify");
    real(ctx, SSL_VERIFY_NONE, NULL);
}

/* Report all certificate verification as successful */
long SSL_get_verify_result(const SSL *ssl) { return 0L; /* X509_V_OK */ }
LD_PRELOAD=./noverify.so ./sample

This covers samples that set verification mode at startup. If the sample also validates the certificate manually (e.g., computes an SPKI hash in application code), find that function via static analysis and patch it separately.

Binary Patching

When dynamic instrumentation is not feasible (e.g., the sample detects Frida), patch the binary statically:

  1. Locate the certificate comparison or verification call in IDA/Ghidra
  2. If it is a conditional branch based on a verification result: flip the branch (jzjnz, or patch to unconditional jmp)
  3. If it is a call to a validation function: replace with xor eax, eax; nop (return 0 = no error) or mov eax, 1; ret as appropriate

TLS Session Key Logging

For applications that use certificate validation without pinning (or after bypassing the pin), you can capture session keys and decrypt the traffic offline in Wireshark without a proxy:

# Works for applications that use NSS (Firefox, curl) or SSLKEYLOGFILE-aware OpenSSL builds:
SSLKEYLOGFILE=/tmp/tls_keys.log ./sample

# Also inject into a running process via Frida:
// Hook SSL_new and set a keylog callback (OpenSSL 1.1.1+)
var SSL_CTX_set_keylog_callback = Module.findExportByName(null, 'SSL_CTX_set_keylog_callback');
// ... or hook SSL_write/SSL_read to dump plaintext directly

In Wireshark: Edit → Preferences → Protocols → TLS → (Pre)-Master-Secret log filename → point to the key log file. Reload the capture.


Verification

After applying a bypass, confirm it worked before concluding the sample’s traffic is visible:

  1. Proxy log: The mitmproxy/Burp connection log should show completed HTTPS exchanges, not SSL errors
  2. Test with curl through the proxy before running the sample: curl -x http://proxy:8080 https://c2.example.com — if curl succeeds with your proxy CA trusted, the proxy is correctly positioned
  3. Check logcat / strace: After bypass, pinning-related error messages (SSL handshake failed, certificate does not match) should disappear
  4. Frida confirmation: Add a console.log to your bypass script’s hook so you can see when the intercept fires

If traffic is still not visible, the sample may use a second layer of pinning (e.g., both Java TrustManager and native BoringSSL), or it may detect Frida itself — combine with the anti-analysis bypass checklist in Advanced Dynamic Analysis.


Useful Resources