Skip to content

Instantly share code, notes, and snippets.

@unixpickle
Created November 3, 2025 15:31
Show Gist options
  • Save unixpickle/4eaae977d79c3b9eeda45d5baf52859f to your computer and use it in GitHub Desktop.
Save unixpickle/4eaae977d79c3b9eeda45d5baf52859f to your computer and use it in GitHub Desktop.
Fil-C race allows access to incorrect buffer.

What happens

Compile with Fil-C and run it a few times. Most of the time, it will trap. However, every few times, it will print "secret password".

This covers the scenario where a malicious user causes a program to leak information using an overflowing offset. Typically Fil-C will mitigate this kinds of thing with a bounds check. However, a data race can cause the bounds on a pointer to be incorrect.

Why this is a realistic issue

This isn’t just contrived:

Racy communication through shared globals is everywhere.
Think:

  • Lock-free queues, ring buffers, flags.
  • “Benign” data races that people rely on in C/C++.
  • Faster-than-necessary shortcuts where people skip atomics/locks.

Separating “check” and “use” is standard.
The pattern:

if (ptr == expected) {
    use(ptr + user_offset);
}

shows up in filtering APIs, RPC handlers, security policies, etc. This could be inside a library where the safe pointer (expected) is considered trusted and the offset comes from unvalidated external data.

Unchecked offsets and index bugs are the norm.
Out-of-bounds indices, integer overflow, off-by-one errors—these are routine in real systems. If your “safe C” system doesn’t protect against “valid pointer + bad offset,” then it isn’t actually enforcing memory safety.

Compiler & hardware reordering make races worse, not better.
The asm volatile("" : : : "memory") is just to keep the compiler from optimizing away the race. In real hardware, CPU reordering, cache coherence effects, and weak memory models make it even harder to reason about which pointer value is observed.

So the punchline:

  • The example leaks secret password even though there’s a seemingly valid check (“only read if p == allowed_pointer”).
  • This shows that a memory-safety scheme which only reasons about pointer provenance—without addressing offsets, races, and TOCTOU—is insufficient.
  • And the problematic patterns (integer offsets + racy communication + check-then-use) are common, so any claim of “completely memory safe C” while preserving compatibility must confront this directly.
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
static char* public_pointer;
static char* secret_pointer;
static char* message_pointer;
static uintptr_t unchecked_offset;
static void* thread_main(void* arg) {
char* allowed_pointer = public_pointer;
uintptr_t offset = unchecked_offset; // possibly overflowed offset passed by user
while (1) {
asm volatile("" : : : "memory");
char* p = (char*)message_pointer;
// Make sure we are ready to read from the pointer that the user's allowed to see!
if (p == allowed_pointer) {
printf("%s\n", p + offset);
exit(0);
}
}
return NULL;
}
int main() {
public_pointer = (char*)malloc(128);
secret_pointer = (char*)malloc(128);
strcpy(public_pointer, "public password");
strcpy(secret_pointer, "secret password");
message_pointer = secret_pointer;
// In a real situation, this would be some accidentally unchecked access.
unchecked_offset = ((uintptr_t)secret_pointer) - ((uintptr_t)public_pointer);
pthread_t t;
pthread_create(&t, NULL, thread_main, NULL);
while (1) {
message_pointer = public_pointer;
asm volatile("" : : : "memory");
message_pointer = secret_pointer;
asm volatile("" : : : "memory");
}
return 0;
}
@dfawcus
Copy link

dfawcus commented Nov 7, 2025

Having adjusted the test program to use stdatomics for the pointer swap operations, so far it appears to be trapping every time.

#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdatomic.h>

static char* public_pointer;
static char* secret_pointer;
static _Atomic(char*) message_pointer;
static uintptr_t unchecked_offset;

static void* thread_main(void* arg) {
    char* allowed_pointer = public_pointer;
    uintptr_t offset = unchecked_offset; // possibly overflowed offset passed by user
    while (1) {
        asm volatile("" : : : "memory");
        char* p = atomic_load_explicit(&message_pointer, memory_order_relaxed);
        // Make sure we are ready to read from the pointer that the user's allowed to see!
        if (p == allowed_pointer) {
            printf("%s\n", p + offset);
            exit(0);
        }
    }
    return NULL;
}

int main() {
    public_pointer = (char*)malloc(128);
    secret_pointer = (char*)malloc(128);
    strcpy(public_pointer, "public password");
    strcpy(secret_pointer, "secret password");
    message_pointer = secret_pointer;

    // In a real situation, this would be some accidentally unchecked access.
    unchecked_offset = ((uintptr_t)secret_pointer) - ((uintptr_t)public_pointer);

    pthread_t t;
    pthread_create(&t, NULL, thread_main, NULL);
    while (1) {
        atomic_store_explicit(&message_pointer, public_pointer, memory_order_relaxed);
        asm volatile("" : : : "memory");
        atomic_store_explicit(&message_pointer, secret_pointer, memory_order_relaxed);
        asm volatile("" : : : "memory");
    }
    return 0;
}

It has the same (always trap) behaviour with the asm statements commented out.

Also simply taking the original code example, and changing the definition of message_pointer as follows seems to be sufficient, although I am not sure if that is guaranteed.

static char* volatile message_pointer;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment