2026-08-16

Mimicing KARL using LLVM BOLT

OpenBSD's KARL (Kernel Address Randomized Link) is one of the rare powermoves I see in computer security - along with OpenBSD's unveil and pledge feature. It singlehandly make an entire category of exploites (blind ROPs) much, much harder without much code and execution model change. However, unlike pledge and unveil, KARL does not have a Linux counterpart. OpenBSD just ships object files and uses a linker to relink. That model is not viable for most Linux distributions where most people just ship finished binaries. With the latest AI wave of vulns. I was thinking to myself... can I make my system, or at least the service I deploy hardned in the same way?

ASLR helps but it changes where shared objects are loaded into memory, never the ordering within each shared object. KARL relayouts within the executable.. Turns out it is possible.

Random image I found on the internet showing what ASLR does
Image: Random image I found on the internet showing what ASLR does

LLVM BOLT is a post-linking optimizer that does binary level PGO. We won't be using it like that. Instead the flag set, -reorder-functions=random -reorder-blocks=none --trap-old-code turns it into KARL letting BOLT randomly reorder functions within a binary.

Take a blatently exploitable function that has a stack sapce of 88 bytes and we win by calling win with the magic value:

#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

static const uint64_t required_magic = UINT64_C(0x524f505f4f4b2121);

__attribute__((noinline, used)) void win(uint64_t magic) {
    if (magic == required_magic) {
        puts("ROP_OK");
        fflush(stdout);
        exit(0);
    }
    exit(2);
}

__attribute__((naked, noinline, used)) void gadget_ret(void) {
    __asm__("ret");accounce the new keyid in DB
}

__attribute__((naked, noinline, used)) void gadget_pop_rdi_ret(void) {
    __asm__("pop %rdi; ret");
}

/* Fixed 0x50-byte frame: saved return address is 0x50 + 8 = 88 bytes in. */
__attribute__((naked, noinline, used)) void vulnerable(void) {
    __asm__(
        "push %rbp\n"
        "mov %rsp, %rbp\n"
        "sub $0x50, %rsp\n"
        "xor %edi, %edi\n"
        "lea -0x50(%rbp), %rsi\n"
        "mov $0x200, %edx\n"
        "call read@PLT\n"
        "leave\n"
        "ret\n");
}

int main(void) {
    vulnerable();
    return 0;
}

We can trivially construct a ROP chain by sending in the following, which function address can be obtained using nm:

"A" x 88
  address(gadget_ret)
  address(gadget_pop_rdi_ret)
  0x524f505f4f4b2121        # magic value
  address(win)

For online applications, the threat model with ROP being an attacker obtained the exact binary you use for deployment, develop and ROP chain in their local enviroment and execute against your machine. The obvious solution is to never allow the attackers to obtain your specific binary. But rebuilding your application or Nginx on deployment is quite expensive. When the executable is linked with -Wl,--emit-relocs Using bolt, run llvm-bolt explotable_binary -o explotable_binary.bolt -reorder-functions=random -reorder-blocks=none --trap-old-code (--trap-old-code is important, BOLT keeps a copy of the old code at the original location by default, the flag replaces them traps) to generate a new binary where functions are at a different, reordered address.

Now, it is still possible to generate a new payload. But the old one fails. In my console

./explotable_binary.bolt < exploit.payload
[1]    90081 trace trap (core dumped)  ./explotable_binary.bolt < exploit.payload

It works! But know that is just makes your service a moving target and doesn't solve return to libc. Ideally you also build your own libc, libcrypto, et al.. with --emit-relocs and relink them on boot/make a private version per service start. But hey, it can be done and we have a similar solution on Linux!

Also not saying this is tested and stable. It needs more real world testing. I am doing that now. This site now is relinked by bolt on every start.