Skip to content
Venish Joe Clarence

Certificate Pin Bypass via Register Rewriting

Table of contents

Open Table of contents

Overview

In my post about Intercepting Running Linux Processes with ptrace, I attached to a running Linux process, injected an assembly payload, and resumed execution without a restart.

I wondered if that same mechanism works against something explicitly designed to resist tampering. Certificate pinning is a perfect test case for this. I wanted to see if debugging survives contact with a target that has no source, no symbols beyond what the binary exports, and nothing but process-level access to the host.

This post isn’t really about defeating pinning. It is about building the minimal tool required to override a function’s return value at runtime and testing a security claim empirically. I also wanted to see what happened when the tool I built ran into a wall I hadn’t accounted for.

The Logic of Hardcoded Check

In a pinned environment, a client compares a server’s public key against a value hardcoded into the binary rather than trusting a CA. The underlying assumption is that if you cannot access the source code or recompile the binary, the result of that comparison is immutable. I suspect this assumption is underspecified.

The phrase “without the source” carries a lot of weight, and I wanted to see exactly how much room it leaves for runtime manipulation.

The Case for Minimal Instrumentation

GDB can perform every action described here, including setting breakpoints, inspecting registers, forcing return values, and resuming execution. I could have scripted the entire workflow via GDB’s Python API with much less effort.

Instead, I chose to build this using raw ptrace calls. This follows the same reasoning that drives me to write a custom eBPF program rather than blindly trusting a metrics dashboard. A tool designed for general use tells you that a bypass is possible, but a custom tool reveals exactly what that bypass requires. I want to map out the specific syscalls, the necessary permissions, and the inevitable failure modes. GDB performs these tasks incidentally, whereas I want to perform them explicitly. A minimal tool is honest about its own mechanics in a way a complex debugger is not.

Architecture

The lab setup consists of three components.

  1. pinned_client, a C client that performs a real TLS handshake. It extracts the SubjectPublicKeyInfo from the peer certificate, hashes it, and gates further I/O on a memcmp against a hardcoded SHA256 pin. I designed this target to be synthetic so the entire experiment is fully reproducible.
  2. pin_patch, the debugger. It attaches to the target, sets a breakpoint, bypasses the check by rewriting the return value in a register, and then detaches.
  3. openssl s_server instance standing in for whatever the client would normally talk to.

tls-pin-architecture

The resulting diff is more important than it appears. I cannot rely on client stdout for verification since a patched process can output whatever it wants. Independent verification requires comparing the actual traffic that left the server against a file the patcher never touched.

Building the Target

I did not have OpenSSL development headers available in my test environment. I also did not want the article to depend on the reader installing the libssl-dev package. Instead, pinned_client.c manually declares the specific libssl and libcrypto entry points it requires and links directly against the versioned runtime .so files.

Here is the complete header and ABI section of the file.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>

/* ---- Hand-declared OpenSSL ABI ---- */

typedef struct ssl_st SSL;
typedef struct ssl_ctx_st SSL_CTX;
typedef struct ssl_method_st SSL_METHOD;
typedef struct x509_st X509;
typedef struct evp_pkey_st EVP_PKEY;
typedef struct evp_md_st EVP_MD;

extern const SSL_METHOD *TLS_client_method(void);
extern SSL_CTX *SSL_CTX_new(const SSL_METHOD *method);
extern void SSL_CTX_free(SSL_CTX *ctx);
extern void SSL_CTX_set_verify(SSL_CTX *ctx, int mode, void *callback);
extern SSL *SSL_new(SSL_CTX *ctx);
extern void SSL_free(SSL *ssl);
extern int SSL_set_fd(SSL *ssl, int fd);
extern int SSL_connect(SSL *ssl);
extern int SSL_read(SSL *ssl, void *buf, int num);
extern int SSL_shutdown(SSL *ssl);
extern X509 *SSL_get1_peer_certificate(const SSL *ssl);
extern EVP_PKEY *X509_get_pubkey(X509 *x);
extern void X509_free(X509 *x);
extern void EVP_PKEY_free(EVP_PKEY *pkey);
extern int i2d_PUBKEY(EVP_PKEY *pkey, unsigned char **out);
extern void CRYPTO_free(void *ptr, const char *file, int line);
extern const EVP_MD *EVP_sha256(void);
extern int EVP_Digest(const void *data, size_t count, unsigned char *md,
                       unsigned int *size, const EVP_MD *type, void *impl);

#define SSL_VERIFY_NONE 0x00pinned_client.c

This approach works because the public API for OpenSSL since version 1.1.0 relies entirely on opaque pointers. None of these functions require knowledge of a struct internal layout, only the signature and existence of the function. Verify the symbols exist before relying on this.

nm -D /usr/lib/x86_64-linux-gnu/libssl.so.3 | grep SSL_connect
0000000000042aa0 T SSL_connect@@OPENSSL_3.0.0

nm -D /usr/lib/x86_64-linux-gnu/libcrypto.so.3 | grep i2d_PUBKEY
00000000003f1840 T i2d_PUBKEY@@OPENSSL_3.0.0
00000000003ed740 T i2d_PUBKEY_bio@@OPENSSL_3.0.0
00000000003ed4d0 T i2d_PUBKEY_fp@@OPENSSL_3.0.0

The pin resides immediately adjacent to the function that performs the check.

static const unsigned char EXPECTED_PIN[32] = {
    0x9b,0xfd,0x0e,0x8a,0x3a,0x45,0x89,0x5c,0x7f,0x61,
    0xb2,0x05,0x23,0x8f,0x4a,0x65,0x9f,0x5a,0xdb,0x2b,
    0x77,0x19,0x9a,0x26,0x7e,0x76,0x0d,0x09,0x57,0x7b,
    0xf7,0x70
};

__attribute__((noinline))
int verify_pin(const unsigned char *actual_hash) {
    return memcmp(actual_hash, EXPECTED_PIN, 32) == 0;
}

static void hexdump(const unsigned char *buf, size_t len) {
    for (size_t i = 0; i < len; i++) printf("%02x", buf[i]);
    printf("\n");
}pinned_client.c

I used the noinline attribute for a specific reason. Without it, a compiler might fold this small function into main even at an optimization level of zero. The debugger requires a distinct call instruction in the disassembly to set a breakpoint against the function. Using noinline ensures the compiler does not optimize away the exact seam I need to target.

The remainder of main handles the socket, the handshake, and the extraction of the peer key. This is the segment that matters most to any process with a debugger attached.

int main(int argc, char **argv) {
    const char *host = "127.0.0.1";
    int port = 4433;
    int attach_delay = 5;

    if (getenv("ATTACH_DELAY")) attach_delay = atoi(getenv("ATTACH_DELAY"));
    if (argc > 1) port = atoi(argv[1]);

    int sock = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in addr = {0};
    addr.sin_family = AF_INET;
    addr.sin_port = htons(port);
    inet_pton(AF_INET, host, &addr.sin_addr);

    if (connect(sock, (struct sockaddr *)&addr, sizeof(addr)) != 0) {
        perror("connect");
        return 1;
    }

    SSL_CTX *ctx = SSL_CTX_new(TLS_client_method());
    SSL_CTX_set_verify(ctx, SSL_VERIFY_NONE, NULL);

    SSL *ssl = SSL_new(ctx);
    SSL_set_fd(ssl, sock);

    if (SSL_connect(ssl) != 1) {
        fprintf(stderr, "TLS handshake failed\n");
        return 1;
    }

    X509 *cert = SSL_get1_peer_certificate(ssl);
    if (!cert) {
        fprintf(stderr, "no peer certificate presented\n");
        return 1;
    }

    EVP_PKEY *pkey = X509_get_pubkey(cert);
    unsigned char *der = NULL;
    int der_len = i2d_PUBKEY(pkey, &der);

    unsigned char actual_hash[32];
    unsigned int hash_len = 0;
    EVP_Digest(der, der_len, actual_hash, &hash_len, EVP_sha256(), NULL);

    printf("[client] pid=%d presented SPKI sha256=", getpid());
    hexdump(actual_hash, hash_len);pinned_client.c

And this is the part that actually matters to whatever is sitting on the other side with a debugger attached.

    /* --- Attach window --- */
    printf("[client] sleeping %ds before pin check (attach window)\n", attach_delay);
    fflush(stdout);
    sleep(attach_delay);

    int pin_ok = verify_pin(actual_hash);

    if (!pin_ok) {
        printf("[client] PIN MISMATCH - aborting connection\n");
        SSL_shutdown(ssl);
        goto cleanup;
    }

    printf("[client] PIN OK - reading payload\n");
    char buf[4096] = {0};
    int n = SSL_read(ssl, buf, sizeof(buf) - 1);
    if (n > 0) {
        buf[n] = 0;
        printf("[client] received %d bytes\n", n);
        FILE *out = fopen("client_received.txt", "w");
        if (out) { fwrite(buf, 1, n, out); fclose(out); }
    }

cleanup:
    X509_free(cert);
    EVP_PKEY_free(pkey);
    if (der) CRYPTO_free(der, __FILE__, __LINE__);
    SSL_free(ssl);
    SSL_CTX_free(ctx);
    close(sock);
    return pin_ok ? 0 : 1;
}pinned_client.c

The sleep function is the only unrealistic part of this harness. In a real scenario, the process would not provide a printed PID and a countdown. A researcher would instead attach during an existing window such as an initial connection, a slow read, or a long lived process that repeats this check on every request. I included the sleep to make this demonstration deterministic and reproducible rather than making it dependent on winning a race. The technique itself does not depend on the sleep, only the demonstration does.

Here is the flow the unmodified client actually follows.

tls-pin-unmodified-flow

Establishing a Control

Before using ptrace, I needed to verify both outcomes of the pin check independently. A debugger that forces a false positive is only meaningful if the unpatched code enforces the boundary correctly.

I began by generating two certificates. The first is the one the client expects, and the second acts as a substituted key.

openssl req -x509 -newkey rsa:2048 -nodes -keyout legit.key -out legit.crt -days 3650 -subj "/CN=legit.lab.internal"

openssl req -x509 -newkey rsa:2048 -nodes -keyout mitm.key -out mitm.crt -days 3650 -subj "/CN=legit.lab.internal"

Compute the pin and format it for the source file in one pass.

openssl x509 -in legit.crt -pubkey -noout | openssl pkey -pubin -outform DER | openssl dgst -sha256 -r
9bfd0e8a3a45895c7f61b205238f4a659f5adb2b77199a267e760d09577bf770 *stdin

echo "9bfd0e8a3a45895c7f61b205238f4a659f5adb2b77199a267e760d09577bf770" | fold -w2 | sed 's/^/0x/' | paste -sd, -
0x9b,0xfd,0x0e,0x8a,0x3a,0x45,0x89,0x5c,0x7f,0x61,0xb2,0x05,0x23,0x8f,0x4a,0x65,0x9f,0x5a,0xdb,0x2b,0x77,0x19,0x9a,0x26,0x7e,0x76,0x0d,0x09,0x57,0x7b,0xf7,0x70

Paste that into EXPECTED_PIN[32], compile, and run the baseline test.

gcc -O0 -g -Wall -o pinned_client pinned_client.c \
  /usr/lib/x86_64-linux-gnu/libssl.so.3 /usr/lib/x86_64-linux-gnu/libcrypto.so.3 \
  -Wl,-rpath,/usr/lib/x86_64-linux-gnu

echo "TOP-SECRET-PAYLOAD-7331" > secret.txt

openssl s_server -accept 4433 -cert legit.crt -key legit.key -naccept 1 -quiet < secret.txt &
[1] 19845

ATTACH_DELAY=1 ./pinned_client 4433
[client] pid=19856 presented SPKI sha256=9bfd0e8a3a45895c7f61b205238f4a659f5adb2b77199a267e760d09577bf770
[client] sleeping 1s before pin check (attach window)
[client] PIN OK - reading payload
[client] received 24 bytes
[1]+  Done

To establish a negative control, I used the same unmodified client but pointed it at the substituted certificate instead.

openssl s_server -accept 4434 -cert mitm.crt -key mitm.key -naccept 1 -quiet < secret.txt &
[1] 19866

ATTACH_DELAY=1 ./pinned_client 4434
[client] pid=19876 presented SPKI sha256=76b5e65776a9619a0e2dab18c292ad34b735b2c1595d7189254136b2ef770ac8
[client] sleeping 1s before pin check (attach window)
[client] PIN MISMATCH - aborting connection
[1]+  Done

The check works as expected when there is no interference. The next step is to see if that behavior holds up when a debugger attempts to manipulate the result.

Building the Debugger

pin_patch does four things in sequence. It locates the target in memory, injects a breakpoint, catches the trap to rewrite a register, and then restores the original state.

Finding the target. The first step is finding the target. I chose to make the client a PIE binary. A fixed offset patch that only works on a non PIE binary would be a lucky hardcoded guess rather than a general technique. Locating the target starts with reading /proc/pid/maps to identify where the loader placed the binary.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/user.h>

static unsigned long find_load_base(pid_t pid, const char *binary_realpath) {
    char maps_path[64];
    snprintf(maps_path, sizeof(maps_path), "/proc/%d/maps", pid);
    FILE *f = fopen(maps_path, "r");
    if (!f) { perror("fopen maps"); exit(1); }

    char line[512];
    unsigned long base = 0;
    int found = 0;
    while (fgets(line, sizeof(line), f)) {
        if (strstr(line, binary_realpath)) {
            unsigned long start = strtoul(line, NULL, 16);
            if (!found || start < base) { base = start; found = 1; }
        }
    }
    fclose(f);
    if (!found) {
        fprintf(stderr, "could not find %s in pid %d maps\n", binary_realpath, pid);
        exit(1);
    }
    return base;
}pin_patch.c

The static offset of the breakpoint site comes from disassembling the file itself, not the running process.

objdump -d --disassemble=main pinned_client | grep -A1 "call.*verify_pin"

1992:       e8 32 fc ff ff          call   15c9 <verify_pin>
1997:       89 85 80 ef ff ff       mov    %eax,-0x1080(%rbp)

The instruction at 0x1997 occurs right after the call returns and serves as the breakpoint target. This static offset is meaningless at runtime until I add it to the load base returned by find_load_base. Since this ELF’s first LOAD segment starts at vaddr 0, the mapped base and the load bias are the same value.

tls-pin-elf

Argument parsing and the address computation happen first in main.

int main(int argc, char **argv) {
    if (argc != 4) {
        fprintf(stderr, "usage: %s <pid> <static_offset_hex> <binary_path>\n", argv[0]);
        return 1;
    }

    pid_t pid = atoi(argv[1]);
    unsigned long static_offset = strtoul(argv[2], NULL, 16);
    char real_bin[4096];
    if (!realpath(argv[3], real_bin)) { perror("realpath"); return 1; }

    unsigned long base = find_load_base(pid, real_bin);
    unsigned long bp_addr = base + static_offset;
    printf("[patch] load base=0x%lx  breakpoint addr=0x%lx\n", base, bp_addr);pin_patch.c

During the attach process, I included full diagnostic messages in the code. I avoided using a bare perror because the failure mode here is common enough to warrant more than just an errno name.

    if (ptrace(PTRACE_ATTACH, pid, NULL, NULL) != 0) {
        if (errno == EPERM) {
            fprintf(stderr,
                "PTRACE_ATTACH: Operation not permitted\n",
                pid);
        } else {
            perror("PTRACE_ATTACH");
        }
        return 1;
    }
    int status;
    waitpid(pid, &status, 0);pin_patch.c

Setting the breakpoint. The second step is setting the breakpoint. Once attached, pin_patch reads the original instruction word, injects 0xcc, and releases the target.

    errno = 0;
    long orig_word = ptrace(PTRACE_PEEKTEXT, pid, (void *)bp_addr, NULL);
    if (orig_word == -1 && errno) { perror("PEEKTEXT"); return 1; }

    long trap_word = (orig_word & ~0xffL) | 0xccL;
    if (ptrace(PTRACE_POKETEXT, pid, (void *)bp_addr, (void *)trap_word) != 0) {
        perror("POKETEXT (set bp)"); return 1;
    }

    printf("[patch] breakpoint set, releasing target\n");
    ptrace(PTRACE_CONT, pid, NULL, NULL);
    waitpid(pid, &status, 0);

    if (!WIFSTOPPED(status)) {
        fprintf(stderr, "[patch] target did not stop as expected (status=0x%x)\n", status);
        return 1;
    }pin_patch.c

Confirming the trap, then the actual patch. The final step involves confirming the trap and applying the patch. Before any manipulation, the code confirms the trap landed exactly where expected. Only after this confirmation does it rewrite RAX and rewind RIP past the injected byte. I chose to rewrite the return value instead of flipping the conditional jump that follows it. Locating the je or jne and inverting it might be simpler, but it assumes a single comparison and jump pair. It also leaves CPU flags in a state that the branch might depend on. Rewriting the return value works for any caller of verify_pin and operates at the same abstraction level a general purpose debugger uses to force a function return.

    struct user_regs_struct regs;
    ptrace(PTRACE_GETREGS, pid, NULL, &regs);

    if (regs.rip != bp_addr + 1) {
        fprintf(stderr, "[patch] unexpected trap at rip=0x%llx (expected 0x%lx)\n",
                regs.rip, bp_addr + 1);
        return 1;
    }

    printf("[patch] breakpoint hit - verify_pin returned RAX=%llu (0=mismatch)\n", regs.rax);
    printf("[patch] rewriting RAX to 1 (forcing pin match)\n");

    regs.rax = 1;
    regs.rip = bp_addr;  /* rewind past the int3 byte we injected */
    ptrace(PTRACE_SETREGS, pid, NULL, &regs);

    /* restore the original instruction byte before resuming */
    ptrace(PTRACE_POKETEXT, pid, (void *)bp_addr, (void *)orig_word);

    printf("[patch] detaching, target resumes normally\n");
    ptrace(PTRACE_DETACH, pid, NULL, NULL);

    return 0;
}pin_patch.c

tls-pin-patch

gcc -O0 -g -Wall -o pin_patch pin_patch.c

The implementation is compiled and ready.

The ptrace_scope Restriction

I used two terminals. In the first terminal, I started the server and client against the substituted certificate. The client prints its PID and sleeps to provide an attachment window.

openssl s_server -accept 4434 -cert mitm.crt -key mitm.key -naccept 1 -quiet < secret.txt &
[1] 20362

ATTACH_DELAY=20 ./pinned_client 4434
[client] pid=20372 presented SPKI sha256=76b5e65776a9619a0e2dab18c292ad34b735b2c1595d7189254136b2ef770ac8
[client] sleeping 20s before pin check (attach window)

In the second terminal, I used that PID.

./pin_patch 20372 1997 ./pinned_client

[patch] load base=0x5b7deee4e000  breakpoint addr=0x5b7deee4f997
PTRACE_ATTACH: Operation not permitted

Back in Terminal 1.

[client] PIN MISMATCH - aborting connection
[1]+  Done

The load base math was correct and the breakpoint address resolved properly, but the kernel denied the request.

This failure is due to the Yama LSM ptrace_scope setting, which defaults to 1 on a fresh Ubuntu installation.

cat /proc/sys/kernel/yama/ptrace_scope
1

Mode 1 restricts PTRACE_ATTACH to a process’s direct parent or to a process holding CAP_SYS_PTRACE. Because I launched both the debugger and the target from the same shell as separate background jobs, they were siblings rather than a parent and child relationship. Same user ownership does not satisfy the check in this mode like it does under scope 0. I had implicitly assumed same user access was the only gate, which was the same assumption used in the original claim I set out to test. There is a second, unstated gate that most people do not think to mention until they encounter it.

I fixed this by running the debugger with CAP_SYS_PTRACE. This bypasses the restriction regardless of the process relationship. Since the client exited after the failed attachment, I had to restart the process in the first terminal.

openssl s_server -accept 4434 -cert mitm.crt -key mitm.key -naccept 1 -quiet < secret.txt &
[1] 20425

ATTACH_DELAY=20 ./pinned_client 4434
[client] pid=20445 presented SPKI sha256=76b5e65776a9619a0e2dab18c292ad34b735b2c1595d7189254136b2ef770ac8
[client] sleeping 20s before pin check (attach window)

The Actual Bypass

Terminal 2, new PID, sudo this time.

sudo ./pin_patch 20445 1997 ./pinned_client
[patch] load base=0x58ef8fd9e000  breakpoint addr=0x58ef8fd9f997
[patch] breakpoint set, releasing target
[patch] breakpoint hit - verify_pin returned RAX=0 (0=mismatch)
[patch] rewriting RAX to 1 (forcing pin match)
[patch] detaching, target resumes normally

Back in Terminal 1.

[client] PIN OK - reading payload
[client] received 24 bytes
[1]+  Done

The debugger log showed RAX=0 at the trap. This confirmed independently of the client that the pin genuinely mismatched before the patch was applied. The real proof comes from a diff against a file the patcher never touched, rather than relying on the client stdout.

diff secret.txt client_received.txt && echo MATCH
MATCH

The client successfully received the real payload over a connection it should have refused. The server presented a key that shares nothing with the one hardcoded into the binary.

I ran the full sequence multiple times in a row to ensure the result was repeatable and not a timing fluke. I had three clean runs with the same outcome. There were no crashes on resume and no corrupted state during the subsequent SSL_read. If the register rewrite had left anything inconsistent, such as corrupted flags or an incorrect return address, it would have resulted in an intermittent crash during repetition.

Limits of the Technique

Stripped binaries. The whole approach depends on objdump and nm resolving verify_pin by name. If I strip the binary, that symbol disappears.

strip -o pinned_client_stripped pinned_client

objdump -d --disassemble=verify_pin pinned_client_stripped
pinned_client_stripped:     file format elf64-x86-64
Disassembly of section .init:
Disassembly of section .plt:
Disassembly of section .plt.got:
Disassembly of section .plt.sec:
Disassembly of section .text:
Disassembly of section .fini:

nm pinned_client_stripped
nm: pinned_client_stripped: no symbols

Silent, empty output from objdump, and nm refuses outright. Locating the call site without a symbol would require pattern matching the disassembly for a memcmp shaped comparison against a 32 byte constant. That is a different and more difficult task. Frida and IDA use FLIRT signatures to solve versions of this problem, which is a separate topic for a future post.

Yama at scope 2 or 3. Root gets you past scope 1. It does not get you past scope 2, which needs CAP_SYS_PTRACE specifically rather than just root, though on most default configurations root has that capability anyway, or scope 3, which disables ptrace system wide until reboot. A host locked down that tightly completely prevents this technique.

Anti-debug checks I deliberately did not build. A real target might check TracerPid in /proc/self/status, call PTRACE_TRACEME on itself to occupy the single tracer slot Linux allows, or install a SIGTRAP handler to detect breakpoint injection. None of that is in pinned_client. This is a harness designed to test the pin check specifically rather than an adversarial target. Conflating the two would have made this post much less focused.

Conclusion

The technique works and the ptrace mechanics from my previous post generalize cleanly to a target that is actively enforcing a boundary. The more useful finding is the one I did not set out to find. Stating that pinning cannot be bypassed without the source code is the wrong boundary. What actually held the line was CAP_SYS_PTRACE. I never had access to the source code during this test.

The claim I would defend is more narrow. Certificate pinning resists tampering from an unprivileged process on the same host. It does not resist a debugger running as root or one that has the parent child relationship Yama expects. That is a more honest guarantee than the one usually stated. It is the kind of gap that only appears when you build the tool to test an assumption instead of just reading an assumption on a slide.

The broader habit worth keeping is this. pin_patch is not a pinning bypass tool. It is a general register and return value override that happened to target a boolean check in this post. The same handful of ptrace calls will force any function return value on a running process without a recompile. This is a useful capability for a home lab toolbox. I can force error paths that are hard to trigger, test retry logic without waiting for a real failure, or check whether a health check remains successful even if the underlying function is lying. Building the minimal version yourself ensures you walk away with a tool shaped exactly like the next problem you need to solve.



Next Post
Simulating Asymmetric Partitions with Raft Pre-Vote