Fil-C on Nix

Filnix wiki

staging only

Fil-C · runtime and compiler · proof of concept on the filnix staging toolchain (mbrock/fil-c staging-deluge 18b6663a) · sealed-closures.patch + noinline-callee-users.patch

Sealed closures

Fil-C already makes pointers unforgeable. So can several mutually distrusting code chunks share one address space, call each other through closures and share objects, while none of them can see another's private state? Not with upstream closures: whoever holds one can read its data. zclosure_new_sealed makes a closure that callers can run but not open.

Closures hide nothing today

A Fil-C closure is a function pointer with a data pointer attached. The body reads it with zcallee_closure_data(). But the same data is available to anyone holding the closure:

void* zclosure_new(void* function, void* data);
void* zclosure_get_data(void* closure);             // any caller can read it
void  zclosure_set_data(void* closure, void* data); // or swap it
void* zcallee_closure_data(void);                   // the running body

Worse, zclosure_new(closure, forged) copies the code of an existing closure and pairs it with new data, so a caller can run the body on state it built itself. The runtime only checks that the argument is a closure.

The API

void* zclosure_new_sealed(void* function, void* data);

A sealed closure is called like any other, and its body reads its data with zcallee_closure_data() as before. Every other way to reach the data through the closure is a Fil-C safety error. The seal is set at creation and is permanent. Ordinary closures don't change.

Try it. Pick a closure and an operation. The output is what the staging runtime does; the test suite checks each of these at -O0 and -O2.


Reading through a function pointer fails for every function, sealed or not: function objects have no bytes in bounds.

Where the seal lives

function object
What a Fil-C function pointer's capability points to. Its payload holds the entry points and signature; its size is zero, so nothing can load or store through it.
readonly flag
Plain functions are readonly function objects. A function object that is not readonly is a closure. Both runtime and compiler use that to recognize closures.
flight pointer
A pointer with its capability: 16 bytes, 16-byte aligned.

The closure payload already had a spare word. The compiler's filc_closure type pads after the 24-byte function payload so that data_ptr lands 16-byte aligned at offset 32. The patch names that padding closure_flags and stores the seal there:

fast_entry generic_entry signature closure_flagswas padding data_ptr (flight pointer) 0816243248 filc_function, copied from the function 0, or SEALED still at offset 32 (asserted) Written once in closure_new before the pointer is returned; never changed. The GC marks only data_ptr.
The closure payload. Sealing changes no size, offset or ABI; existing closures get a zero where they had padding.

From there the runtime change is small. closure_new is the only place that makes non-readonly function objects, and it now always writes the flag word. zclosure_get_data and zclosure_set_data refuse sealed closures, and closure_new refuses to copy a sealed closure's code. The body's own read is unchanged: the compiler lowers zcallee_closure_data() inline to a check that the callee is a closure and a load at offset 32, so the seal costs a sealed body nothing.

The bypass review found: inlining moves the read

zcallee_closure_data() means “the data of the closure the current function was called through”. FilPizlonator reads it from the function's hidden callee argument. But LLVM's inliner runs before FilPizlonator. If a module calls a closure body directly from some other function, the body can be inlined, and its read then uses the other function's callee:

static int body(void) { return *(int*)zcallee_closure_data(); }

int helper(void) { return body(); }   // exported, or its pointer leaked

// elsewhere, attacker code:
int (*evil)(void) = zclosure_new(helper, &forged);
evil();   // -O2: body's code runs on forged data
before: body inlined into helper closure(helper, &forged) callee = attacker's closure helper { load data_ptr } reads &forged → LEAK 7 after: body stays noinline helper → call body direct call passes body's readonly object body { callee is a closure? } no → safety error
The sealed closure is never touched: the attacker wraps a different function that happens to contain the body's code.

On the staging toolchain without the compiler patch, the inline probe prints LEAK 7 at -O2 and panics at -O0. The fix is a module pass at the start of the Fil-C pipeline. It marks every function that calls zcallee() or zcallee_closure_data() noinline, removing always_inline if present. Upstream's own zcalleedirect tests already mark their functions noinline, for the same reason. This also fixes what an inlined zcallee() returns in programs that don't use sealing.

What the review found holds

What sealing does not do

PathWhy the seal doesn't cover it
another pointer to the dataThe seal guards the closure, not the object. Don't let the body return, store or throw its data.
the body function itselfAnyone who can name bump can run zclosure_new(bump, forged). Keep bodies static. In C++, inline functions, templates and lambdas have linkonce linkage; use an anonymous namespace.
re-entry and interruptionCallbacks, signals, threads, longjmp and exceptions can interleave with the body. It must keep its own invariants.
the operating systemAll chunks share syscalls, files, threads, signals and exit. Readable /proc/self/mem is not refused. Isolating that needs seccomp or similar.
non-Fil-C codeOne chunk built with -yolo-assembler or native assembly breaks it for all. Whole-program LTO across chunks is untested.

The pattern

struct counter { long count; };

static long bump(long by)            // static: only this file can name it
{
    struct counter* c = zcallee_closure_data();
    return c->count += by;
}

long (*make_counter(void))(long)     // handed to other chunks
{
    return zclosure_new_sealed(bump, calloc(1, sizeof(struct counter)));
}

Each chunk keeps its state in static storage or objects only it points to, and exports sealed closures over that state built from static functions. Shared objects pass as ordinary pointers. For per-instance handles, use sealed closures like this counter, or integers from a private zexact_ptrtable.

Tests

checks.x86_64-linux.staging-sealed-closures builds two programs at -O0 and -O2:

Probe inline-O0-O2
staging, runtime patch onlysafety errorLEAK 7
staging, runtime + compiler patchsafety errorsafety error

Proof of concept, applied only by filnix's staging toolchain; the release toolchain is unchanged. Details and the review notes are in docs/sealed-closures.md. Not proposed upstream yet.