lobsters score: 24 · tags: c, security
Even data races result in memory-safe outcomes.
I need to reread the sidecap description again more carefully because I’m still not clear on how the case where you lose a race works. I’m also not clear on what happens if you have a union of a pointer and an integer and you store through to integer and load the pointer. Unless there’s some extra cryptographic checking (which is feasible with a 256-bit encoding, just slow) type aliasing causes all sorts of headaches.
score 4 · canonical
You’re sort of on the right track with your “cryptographic checking” comment, except it’s not cryptographic, and it is deterministic.
The key is that the sidecar and capability are substantially redundant. In the in_array case (the most interesting one), they share 80 bits - the kind, the ptr itself, and the type. The capability has the upper and the sidecar has the lower.
In an isoheaped environment (or in a GC’d environment - Fil-C could be GC’d if I wanted), you simply cannot have two in-bounds pointers to the same memory location unless you agree on what the memory is in the capability associated with that pointer.
So, after loading both the sidecar and capability in the in_array case, we just check that:
- sidecar.kind == in_array && capability.kind == in_array
- sidecar.ptr == capability.ptr
- sidecar.type == capability.type
If that all matches, you can use the lower bound from the sidecar. Otherwise you infer it from ptr/upper/type in the capability.
The story is a bit different for the other three kinds (at_lower just ignores the sidecar while flex_base and oob have different rules, but based on the same principle).
One way to convince yourself that it’s right is try to find a way to create two pointers that share ptr and type and both are known in bounds such that if you take the lower from one of them and the upper from the other, you get a bug. I cannot think of a way to do it.
score 2 · canonical
not clear on what happens if you have a union of a pointer and an integer and you store through to integer and load the pointer
Allocations are typed, so I assume stores have to look up the address they store to in a sideband structure to figure out what to do.
score 3 · canonical
I don’t think that helps. Stephen Kell’s libcrunch dynamically updated the metadata when you stored but I think this stores the type tag in the capability. The hard cases for CHERI C were (distressingly common in C) things like this:
union { void *ptr; char data[sizeof(void*)]; };If one thread stores via ptr and the other via data, what do you do? For us, there at least is a solution: it’s unordered, but if the data store wins then ptr stops being a valid pointer, irrespective of the bit pattern written. This means that it fails closed: if you corrupt a pointer, then it stops being a pointer.
Without hardware support, it’s almost impossible to fix cases like that without huge performance impact.
score 6 · canonical
In Fil-C, you have to change
uniontostructin this case and then it all works. I’ve made a handful of such changes in OpenSSL and curl. Maybe in musl and zlib too, don’t remember.
score 2 · canonical
Not supporting type aliasing will break huge amounts of C and C++ code and will break a disproportionately high amount of TCB code. Language runtimes, kernels, and low-level data structures are the most likely code to use those.
We put a lot of effort into making this work well with CHERI because feedback from multiple vendors was that it was a deal breaker if it doesn’t work.
score 5 · canonical
Good thing I don’t answer to any vendors. ;-)
As a long-time systems programmer, I think you’re overstating the problem with type aliasing. Maybe your vendors are just trying to come up with excuses not to use CHERI!
CHERI is in a different boat because it has to have a story for language runtimes. It’s not clear that Fil-C has to. If you wanted to call between Fil-C and C# then you wouldn’t compile the C# runtime with Fil-C. Ditto for Java/Rust/JavaScript/etc.
I’ve already been able to convert enough code to Fil-C that I don’t think type aliasing is that big of a problem. The typical story is one of these:
-
Unions between types Fil-C considers “int”, like char/short/int/long/float/double/etc. Those just work, no problem. Fil-C even gives those strong semantics. Fil-C will never do the UBsan thing of trapping on “active union member” misuse.
-
Unions between types Fil-C considers “ptr”, like T* for any T. Those just work, no problem.
-
Unions that have a ptr member for no good reason. Lots of those! OpenSSL uses one to create an internal “aligned word” type. You can just remove the ptr from those unions.
-
Unions that are very small, like just one ptr and one int. Making those into structs is not a big deal. Also, a T* in Fil-C can “box” an int via int-to-ptr and ptr-to-int casts. So, if you really care about memory, you can replace the union with just a ptr member.
-
Unions that are large and have both ptrs and ints, but roughly the same number of ptrs in each union member. Making sure that all of the struct members of the union place their ptrs at the same offsets solves the problem.
I’ve yet to encounter a union I can’t fix.
Two other thoughts:
-
I don’t think that the CHERI approach is all that safe. Because it doesn’t prevent type aliasing, it also doesn’t prevent you from writing a custom allocator that vends übercapabilities. For example, did y’all do anything to OpenSSL’s super funny “secure” malloc?
-
it totally makes sense for you to use this as a selling point of CHERI relative to Fil-C, since it does mean that (a) you can run more stuff out of the box and (b) it’s neat that you can probably even run a JIT on top of CHERI.
But if we’re being honest, this is just a hard trade-off between security and compatibility. Fil-C is safer, CHERI is more compatible.
score 4 · canonical
As a long-time systems programmer, I think you’re overstating the problem with type aliasing. Maybe your vendors are just trying to come up with excuses not to use CHERI!
We have compiled a lot of C and C++ codebases. Without supporting type aliasing, very few would work.
CHERI is in a different boat because it has to have a story for language runtimes.
It aims to support all TCB code, because if you don’t have memory safety in your TCB then you don’t have it anywhere.
I’ve yet to encounter a union I can’t fix.
Does that include implicit unions? For example, many consumers of uthash store integers in the void* value slots. A lot of generic data structures in C expose void* or uintptr_t entries and have consumers that store pointers, data, or both.
We have surveyed a lot of exciting C idioms and we support almost all of them. Dismissing things that we’ve seen thousands of times across diverse codebases as not important does not give me any confidence in your approach or your evaluation methodology.
Final thought: I don’t think that the CHERI approach is all that safe. Because it doesn’t prevent type aliasing, it also doesn’t prevent you from writing a custom allocator that vends übercapabilities. For example, did y’all do anything to OpenSSL’s super funny “secure” malloc?
Yes, we set bounds on allocation. The port of OpenSSL was subject to a DARPA-funded red teaming exercise where they reintroduced bugs such as Heartbleed and had professional exploit writers try to break it. They were not able to.
Your custom allocator gets memory from somewhere, whether that’s mmap, malloc, or something a layer down. The thing above will apply bounds, so bugs in nested allocators can affect their consumers but not anything else.
score 4 · canonical
Does that include implicit unions? For example, many consumers of uthash store integers in the void* value slots. A lot of generic data structures in C expose void* or uintptr_t entries and have consumers that store pointers, data, or both.
Because int-to-ptr casts work, that code would work in Fil-C so long as the store operation knows it’s storing to a ptr.
We have surveyed a lot of exciting C idioms and we support almost all of them. Dismissing things that we’ve seen thousands of times across diverse codebases as not important does not give me any confidence in your approach or your evaluation methodology.
Folks will have these options:
-
buy special hardware (i.e. CHERI)
-
change a couple unions and about half of your malloc calls to use Fil-C
-
rewrite everything in Rust or another memory-safe language.
Nobody wants (1).
That leaves (2) and (3), and it’s definitely less work to replace a couple unions and half your mallocs than it is to rewrite in Rust.
Therefore: it’s true that you CHERI folks totally have to survey lots of idioms and work hard to make all of them Just Work. You probably have to do that and then some, because you’re trying to convince people to buy hardware that they dont want.
On the other hand, I just have to give folks an option that is easier than rewriting everything in Rust. So, I don’t have to support all the idioms.
score 2 · canonical
-
-
The memory addresses themselves are typed—that’s why you have to allocate memory containing pointers with zalloc. I think this is what the ‘type’ field in the pointer is for (I think probably it uses that, not a sideband structure like I said in the other comment, to determine what points to a pointer).
If one thread stores via ptr and the other via data, what do you do?
The store via data is instrumented, so it knows that it’s overwriting something that overlaps with a pointer, and it knows to mark it as containing an integer rather than a memory address at the same time.
huge performance impact
it claims to be 200x slower than native. (That said—most performant implementations of gc languages do just fine with a write barrier instrumenting every write…)
score 2 · canonical
write barrier instrumenting each write is much cheaper than checking each access.
also GC languages don’t instrument each write, they just instrument writes to pointers
so, a GC write barrier can easily be 0% overhead in practice because it’s only instrumenting stores to pointers and even then it’s usually just one branch
score 3 · canonical
The store via data is instrumented, so it knows that it’s overwriting something that overlaps with a pointer, and it knows to mark it as containing an integer rather than a memory address at the same time.
Fil-C does not do this. Fil-C never allows the type of memory to change. If you store an int to a ptr or a ptr to an int, you trap and die.
This also enables good redundant check elimination. The compiler can know (but currently doesn’t lmao) that the moment you do any access to a pointer, then after that access, the pointer can only point to memory for which that access would have succeeded.
score 3 · canonical
If you store an int to a ptr or a ptr to an int, you trap and die.
We were talking about the case where you have a union of a pointer and an int—that obviously can’t trap and die, right? (That said, I don’t understand why you would trap and die anyway; what are ‘boxed integers’ for if not this sort of thing?)
GC write barrier
Lots of different designs, though you’re right they usually only target pointer stores; but they generally cost a little bit more than that. The main designs I know are: 1) card-mark: unconditional ‘card_table[pointee >> k] = 0’, 2) g1-style region check: ‘if ((pointer ^ pointee) >> k) goto slow’.
score 1 · canonical
We were talking about the case where you have a union of a pointer and an int—that obviously can’t trap and die, right? (That said, I don’t understand why you would trap and die anyway; what are ‘boxed integers’ for if not this sort of thing?)
That traps and dies in Fil-C.
Lots of different designs, though you’re right they usually only target pointer stores; but they generally cost a little bit more than that. The main designs I know are: 1) card-mark: unconditional ‘card_table[pointee >> k] = 0’, 2) g1-style region check: ‘if ((pointer ^ pointee) >> k) goto slow’.
I’m a GC designer. Usually the good barriers are much cheaper than what you say. And they cost nothing (the overhead gets lots in the spare bandwidth of the CPU’s pipe).
score 3 · canonical
Btw, did you consider trying to compress the pointer format by getting rid of the sidecar and using a floating-point format for the bounds? This would require rounding up allocation sizes to match the fp rounding—so e.g. char *x = malloc(big); x[big] might fail to trap—but that doesn’t seem like an awful tradeoff.
score 1 · canonical
I’ve thought of that and it’s a good idea.
My experience from building languages tells me that it’s super valuable to have the loosest possible coupling between compiler, runtime, and memory management during early bringup. Rounding up allocation sizes to whatever the pointer encoding handlings is a tight coupling.
So, it’s not that the compression you propose is bad, it’s just that it would slow down the pace of Fil-C development if I introduced it today.
If Fil-C becomes successful and folks start screaming for perf then this approach is one of the approaches I would consider using, provided that SideCap size was actually the longest pole in the tent.
score 3 · canonical
That’s what we do in CHERI. For allocation-bounded things it’s totally fine. The rounding that an allocator does of size classes is far coarser than we require. It can be annoying enforcing sub-object bounds, particularly if you want something like a slice type: we can’t represent an arbitrary range of a very large array. You won’t hit this unless you’re doing sub-object bounds for multi-MiB objects on a 64-bit system, but it can be annoying for things like separate capabilities for header and data in an encoded video frame.
score 2 · canonical
The store via data is instrumented, so it knows that it’s overwriting something that overlaps with a pointer, and it knows to invalidate the capability at the same time.
I can see that working in the trivial case, but there are a bunch of places in C code where that doesn’t help. For example, if you pass a pointer to data to another function, the callee doesn’t know that it’s aliased and so needs to defensively check. This gets even worse if you store a pointer to data in some other type-erased data structure. It rapidly falls back to every store needing to do do a check of the real dynamic type. If you store 16 individual bytes through that pointer then any may race a store of a pointer and so you need to do the dynamic type check on each store.
it claims to be 200x slower than native
That’s worse than I would expect, but I’d expect at least a 50-80% slowdown.
Well, I’m happy marketing CHERI as a performance feature with a 200x speedup.
score 5 · canonical
I think it makes a lot of sense to market CHERI that way, but here’s the reason why that might be wrong:
-
Fil-C enforces a tighter contract than CHERI, and so catches more bugs, and so likely prevents more security vulnerabilities. For example, because CHERI allows ptr-to-int-and-back “transmutations”, you could write code on top of CHERI where you have a custom alloc and all of your pointers end up having enormously powerful capabilities. (Plz correct me if I’m wrong.) Fil-C prevents this entirely, no such shenanigans are allowed. You can’t even write a custom alloc in Fil-C.
-
Fil-C, being a language change, allows for the introduction of thin pointer annotations later, and those annotations could be made to be memory-safe. And, those annotations would eliminate substantially all of the overhead of Fil-C in those places where you used them. I think CHERI would have no answer to that, in the sense that Fil-C code that used those annotations wouldn’t have a good story for how to compile to CHERI and still get all of the safety guarantees.
score 3 · canonical
In terms of language semantics, CHERI doesn’t really enforce anything on its own. When you target CHERI, you choose how to use its primitives to enforce whatever properties you want to enforce. CHERI allows ptr->int->ptr because C mandates it.
For example, because CHERI allows ptr-to-int-and-back “transmutations”, you could write code on top of CHERI where you have a custom alloc and all of your pointers end up having enormously powerful capabilities. (Plz correct me if I’m wrong.)
I don’t quite understand this. Even without ptr->int->ptr casts, an alloc implementation could certainly choose to return very powerful capabilities, as powerful as whatever the allocator itself has access to. But you’d probably choose to use an alloc implementation that doesn’t do that, just as one can choose whether to use Fil-C or not.
score 1 · canonical
For example, because CHERI allows ptr-to-int-and-back “transmutations”, you could write code on top of CHERI where you have a custom alloc and all of your pointers end up having enormously powerful capabilities. (Plz correct me if I’m wrong.)
You are wrong :-).
Nothing in CHERI (including assembly) can bypass the capability model and we have proofs of this run against the formal model of the ISA. We allow pointers to be round tripped at intptr_t because not supporting that breaks basically all non-trivial C code, but doing so does not amplify rights. It remains a capability with the same bounds and if your arithmetic takes it out of bounds then that’s fine, but you can’t dereference it while it’s out of bounds (taking it too far out of bounds will make it unrepresentable and unable to be used as a capability) . This is important because we’re building a model that is a foundation for higher-level security abstractions.
If the same memory location is used for a pointer and an integer, the hardware guarantees atomicity with respect to the capability and its tag bit, so any data store (even a single byte) destroys the capability.
If you can’t do these things, you break C idioms that most non-trivial programs depend on. We did a lot of work with formal models of C and surveys of C codebases and programmers to identify a de facto C specification that encapsulated expected C behaviour.
Fil-C, being a language change, allows for the introduction of thin pointer annotations later, and those annotations could be made to be memory-safe. And, those annotations would eliminate substantially all of the overhead of Fil-C in those places where you used them. I think CHERI would have no answer to that, in the sense that Fil-C code that used those annotations wouldn’t have a good story for how to compile to CHERI and still get all of the safety guarantees.
Early versions of CHERI C used a hybrid ABI where some pointers were annotated and others were not. Annotated pointers are capabilities, the rest are integers relative to the default data capability. We found that the friction in adopting the hybrid model was more than an order of magnitude higher than the overhead of recompiling in pure capability mode. We can compile large (multi million line) C and C++ codebases with pure capability mode with small changes (often none), whereas annotating the pointers that want dynamic checks was a lot more work. Our tcpdump changes, for example, to bound packet buffers required around 700 lines of diffs, to build as pure cap required four. Most of the changes for pure-cap mode are easy to upstream because they’re things like using uintptr_t instead of size_t for things that round trip pointers through integer types (which is what C requires anyway). Changes like this have been upstreamed to git, a bunch of KDE projects, and so on.
So, we can do that, but it’s rarely a good idea. You end up with the annotations needing to flow through the code and it becomes a data-flow problem. C programs like to do things like put void* pointers in heterogenous collections and this is very painful if you have two kinds of pointer.
We now recommend that no one use the hybrid ABI because it starts off looking simpler (‘look, I can make these changes incrementally’) but ends up being more work in the end.
Note that this does not always apply to other languages. For example, Java and JavaScript VMs can use 32-bit pointers, left shifted by two or three, as object pointers. They are interpreted as relative to a safe-heap capability and can be bounded based on the dynamic type when passed FFI code. We do the same on a smaller scale with Microvium on CHERIoT: the JS runtime uses a semispace compacting heap and uses 16-bit offsets as JavaScript pointers. When it passes them to C code, it removes store permission and sets the bounds, so C code cannot violate the JavaScript invariants.
score 6 · canonical
For example, because CHERI allows ptr-to-int-and-back “transmutations”, you could write code on top of CHERI where you have a custom alloc and all of your pointers end up having enormously powerful capabilities. (Plz correct me if I’m wrong.)
You are wrong :-).
Nothing in CHERI (including assembly) can bypass the capability model and we have proofs of this run against the formal model of the ISA. We allow pointers to be round tripped at intptr_t because not supporting that breaks basically all non-trivial C code, but doing so does not amplify rights. It remains a capability with the same bounds and if your arithmetic takes it out of bounds then that’s fine, but you can’t dereference it while it’s out of bounds (taking it too far out of bounds will make it unrepresentable and unable to be used as a capability) . This is important because we’re building a model that is a foundation for higher-level security abstractions.
I think you misunderstood me. The point is: if I write a custom malloc in CHERI like so:
-
mmap something big, now I have a capability with a large lower/upper range
-
loop over the memory to create a linked free list, now I have a linked list where every next ptr has the same large lower/upper range as the original mmap
-
implement my_awesome_alloc() as popping off the list. now I’m returning übercapabilities
This isn’t about bypassing the capability model. Nothing is being bypassed here, the capability model is working as intended. But, I now have übercapabilities flowing through the system.
score 3 · canonical
-
-
An overflow-checking
zallocand hardened allocator is a good idea, regardless of the rest of the solution. It’s also cheap to implement with plain old unsafe C pointers.However, I don’t think the speed overhead, and code changes required can be completely handwaved. In the end it has to win over other solutions, like compiling to WASM+WASI, or running with LLVM sanitizers.
score 3 · canonical
The 200x perf is the result of me very aggressively going for ease-of-implementation. It’s not even close to being a real number, if even a modicum of perf effort goes into the project.
score 3 · canonical
I understand that. But it matters whether the ideal implementation will end up being 5x or 2x or 1.2x slower than the unsafe baseline. The farther it is from 1x the more alternative solutions are available.
score 1 · canonical
We don’t know what the ideal implementation would be, and I don’t think it’s worth asking that question so long as it can only run a handful of programs.
score 3 · canonical
Well, a 200x slowdown is clearly not practical. Many years ago, I wrote a toy Objective-C to JavaScript translator, which represented each C type as a typed array with any pointers also stored as separate fields. This gave you a fully GC’d C implementation (with no pointer forging) and wasn’t that slow (definitely slower than I’d want to run in production).
I think the key thing here is that it’s possible. How fast you can make it is then a question of optimisation. The first CHERI performance numbers were over 20%, which many people told us were totally unacceptable for desktop / server workloads, but we were able to get it down to the point where Arm expects 2% on an optimised microarchitecture. The 200x is a worst case for an unoptimised system. That doesn’t tell you very much about the final speed. With no hardware support, it’s doing more work and doing it less efficiently (multiple instructions rather than folded into other instructions, so having to pay the cost of instruction scheduling, larger code, and so on) than CHERI, so I’d expect a 50% overhead to be plausible. The really interesting question, at least to me, is what the minimum extra hardware would be to make this fast. Aligned 32-byte atomics may be enough.
score 6 · canonical
I think the key thing here is that it’s possible.
Exactly!
I’d expect a 50% overhead to be plausible
My estimate is:
- if no thin pointer annotations are used, ever: 2x overhead
- if thin pointer annotations are used in anger: less than 50% overhead, maybe even parity
score 2 · canonical

