But I feel like it has way too much off-topic emotion mixed in.
So I want to start a purely technical topic to discuss this new compilation mode:
Is it actually memory safe?
How big is the performance hit? In some cases, will it degrade to the level of languages with GC?
Most importantly, from a coding perspective, will it have a major impact — to the point where it splits into “safe Zig” and “unsafe Zig”?
Personally, I also have one more question: In a complex real-world project made up of Zig code plus C/C++ libraries, could the library code cause my Fil-C mode build to fail? And would I then have to go in and modify those libraries myself?
First, thanks for beginning a purely technical discussion.
My understanding of what I read earlier today is that Andrew’s goal is clearly that 1 = yes, it is actually memory safe, 2 = 1-6x perf hit based on Fil-C measurement of Fil-C implementation, though this would absolutely be a new implementation, and 3 = no, it does not change your code.
But with more certainty than any of the other answers, I would say that the new issue describes a goal and not an implementation and that makes measurement very difficult.
Edit: I realized I had something to add re your last question as well.
Fil-C has a list of programs that work that should give a clearer idea of its progress so far. It’s not zero changes required for all programs ever, but it’s small enough that one person (I think) has updated sqlite/rsync/tmux/python/openssl/openssh and a few dozen other projects.
Read through the list, the nuances are interesting: rsync and tmux and sqlite worked out of the box, but the SQLite test suite needed changes. A note on Python mentions a lot of pointer-as-int usages that had to change to an actual pointer type.
The Linux Sandboxes And Fil-C page describes some specific details of their threading/seccomp changes.
Again, that is all from Fil-C’s testing of Fil-C’s implementation and not a hypothetical Zig implementation, but it can give us some idea what issues exist. A hypothetical new Zig-specific implementation (maybe one shared with Aro or zig libc?) could end up adding other limitations or avoiding some of these limitations.
I have a question: is this inherently a whole-program abi change or could it be scoped to only some allocators or an @optimizeFor(.filc) scoped? I’d love to have only my most interesting allocations be checked and have my task-specific tight loops of allocations just use trivial arenas (as long as I use another mechanism to make sure those pointers are gone before the arena).
There are some gaps depending on your definition of “memory-safe”. Strictly speaking, all pointers are checked… but to what granularity?
AFAIK – and forgive me for not actually trying this but I don’t really want to install the Fil-C compiler – intra-object overflows are not checked. For example:
struct User {
char name[16];
int is_admin;
};
struct User user = {0};
strcpy(user.name, attacker_input);
This would not trap.
This is outlined in the “Gep” section of his “Garbage In, Memory Safety Out” (GIMSO) doc. It’s some technical jargon with LLVM semantics but if understood correctly, the pointer to name carries the capabilities of its parent – the User.
Once the read/write crosses outside of the parent container (in this case the User struct into adjacent memory) it would be flagged. IF you corrupt a pointer field, you’re likely to corrupt its capabilities which would cause a trap later down the road.
Hardware-based solutions like MTE and I think CHERI also share this gap.
I think this would need to be all-or-nothing, otherwise your “safe” code could not reasonably touch data from “unsafe” code since there’d be no capabilities to check. Likewise, your “unsafe” code would need some way of knowing it’s interacting with pointers carrying provenance. This can probably be expressed via some fancy types but at that point you’re making code changes to facilitate something that desires to require no code changes according to Andrew.
From an exploitation perspective if your safe world CAN suddenly start stripping capabilities from pointers that could probably be used as a bridge to attack the unsafe world. You could probably use something like an LFI sandbox to further isolate these, but that’s adding yet another layer of complexity
I think you’d be better off using ControlFlowIntegrity (CFI) on platforms that don’t support pointer authentication/BTI. Speaking from experience though of trying to migrate a very large codebase to use CFI is that it UB traps, which is what you want but an annoying way to discover UB. We hit it frequently because of some code that did a roundtrip cast of a fn pointer to void* and back.
Thanks a lot for this clear example @landaire! I’ve been hearing about CHERI for years, but this is the first time I see the subobject issue
if my understanding is correct, you still can’t mess up, eg, vtable pointer this way. This has an interesting connection to IO. It should be guaranteed that code can’t manufacture an IO instance out of thin air. That level of baseline integrity, combined with Zig code which is already structured around passing an IO capability, allows for untrusted library: you can pass a sanitized IO to a library, and know that even if an attacker messes up library internals, they wouldn’t be able to do malicious side effects. Which is a cool primitive to have in theory, and a nice synergy with IO, but, yeah, not entirely clear who is this for.
@nonzeroq continuing from the other thread, no references, but inline explanation is easy enough. Almost all languages allow specially crafted code to escape language’s sandbox.
In Rust, there’s unsafe, and the type system is complex enough that there are soundness compiler bugs to dodge it entirely (plutonium - Rust). In Go, there’s also unsafe, and you can corrupt memory via races on interface or slice pointers (research!rsc: Off to the Races). Python is a fun one, there are boring types in standard library which allow to poke memory directly: Memory Safety Is … | Lobsters. In Java, there’s also sun.misc.unsafe.
This is a non-issue for most practical cases, as you don’t try to deliberately exploit software you are yourself writing. As an author, you don’t need to escape language runtime to do bad stuff, you can do bad stuff directly.
The big exception is JavaScript in the browser. On the web, we routinely download arbitrary untrusted code from the internet and execute it on our computers. This is fine because the code runs with restricted capabilities — JavaScript can’t issue syscalls directly, it needs to go via a browser APIs to open a confirmation dialog box to do anything nefarious. But to make this work, browsers have to ensure that any malicious code can’t escape language own abstractions. JavaScript parser/JIT themselves need to be hardened against malicious input.
I presume that widely mentioned “1-6x perf hit” refers to the CPU overhead. How about extra memory usage? GC tends to increase memory pressure and memory footprint of an app.
I see! I gave it a bit of thought after posting my comment and reached a hand-wavy conclusion similar to your explanation, though what I was missing was that Java had an escape-hatch. I had always thought java was thoroughly memory safe, maybe I shouldn’t have assumed so since my experience with it is limited to the two semesters in university I was forced to use it in an OOP class :^).
I have a hot take on this from other runtime safety work.
Runtime safety checks are most useful during development, and can be a problem in the field.
If you have a memory safety flaw in your code, that translates into a potential bug or attack surface for a hacker. If you add a runtime safety check and ship it, the bug is still a bug. In fact now it’s a hard crash unless you’ve written a crash handler. The attack surface is now protected in that it can’t cause illegal behaviour, but can still be exploited for denial of service attacks.
Hard crashes might be worse behaviour in the field than the software silently taking a piece of data it’s not meant to and continuing on. It depends on the situation. If you’re flying a plane, do you want the plane’s navigation to go off-line during landing, or for the navigation to read stale data and continue. Neither is good, but going off-line is probably worse. If you’re serving a web-site, a hard crash and restart is probably better than allowing an attack a foothold.
So for me, the value of safety checks is to alert the developer that there’s a issue to fix. They are not a solution. They are a step on the road to a solution.
When you use safety checks it makes memory safety issues far more visible. When coupled with testing strategies like fuzzing, you’ll probably shake a very high proportion of issues out of your code base during development. Fix those issues and now you have code that is highly resistant to attacks because the vulnerabilities have been removed. The safety checks no longer do anything. They always pass, so I would ship ReleaseFast builds at that point. The fast build has just the same number of issues as the “safe” build.
I would leave safety checks in when shipping releases only if there was a defined recovery scheme in the system. (Recovering crash handler, watch dog, etc). If you’re doing it because “A crash is safer than an exploit” I think you’re missing the point. Both are bad results.
Is sun.* part of the spec? I remember that I’ve (once) read the specification just because I was young and had a lot of time but one thing I remember for sure is that Java is memory-safe. I’d swear I’ve even seen a video by one of the lang designers and they said they really wanted to fix mem-safety for their language, so they did.
For the purposes of this issue, is there an agreed upon definition of “memory safe”?
My understanding is that memory safety is a fairly loosely defined term and my quick searching of the issue hasn’t come up with any specific definition. For an issue such as this it feels beneficial to have a couple of concrete goal posts beyond “Do what Fil-C does”
There’s no agreed upon definition. I am a fan of the one in Memory Safety Is ... obviously:
Memory safety is a property of an implementation of a programming language, which guarantees that for all (potentially invalid) programs, the behavior of compiled program either can be explained in terms of the semantics of source program, or is a crash.
From this perspective, given that Fil-C doesn’t guaranteed subobject integrity, arguably it should use a different term than memory safety.
Contrary to C, they would be checked in ReleaseSafe and Debug in Zig, if my understanding is correct. So the Fil-C model and Zig runtime safety modes would complement each other quite well here I would think.
Or is there some escape hatch around this in Zig that would make such an overflow possible in the safety checked modes?
Say this is implemented and it works and is useful. It is going to draw a crowd of users that is not currently interested in Zig, which could be a good thing, but they are going to make requests for the language to change to be helpful for memory safety, and those requests will keep coming forever.
If Zig is about making C explicit and putting proper bounds on things, having a fallback that doesn’t require explicitly doing things correctly means that is a new direction of the language, and feature requests will come from active users that want to continue to have more support in this area.
It’s a new direction for the language, because it’s a selling point for a new type of audience that previously was not interested in manual memory managed languages, or learning how to use arenas.
This will be a big turning point for the language, because there are a lot more memory-safety oriented users than manual memory management oriented users.
I don’t think I agree. Improved memory safety has always been a feature of Zig. Slices with automatic bounds checking is a big part of that, but so are “Single item pointers” and the checked allocator. There’s a philosophical difference between Rust and Zig. Where “safe-Rust” will limit you to writing code that matches it’s safety model, Zig will let you write anything and then instrument the code to catch problems.
Fil-C is “Slices + bounds checks” on steroids.Nothing in it manages your memory for you or stops arenas working exactly as they did. It just tells you when you got it wrong in a loud, crashy way. It’s an extension of Zig’s existing philosophy.
Primary I expect a compile time solution very much like searching idea in this field, before going to runtime check + panic
There reasons is it needs exhausting testing to trigger all code path correctly and ensuering no malicious formed input can break it again. It is a big disadvantage compared to the most goto language if user need memory safety and fast enough speed aka GC language like Golang. In Golang we just bother to test business logic, now in this version we need to bother to test both business logic and memory safety which is can be a turn off for user. But if it has speed gap big above Golang, it can fullfill this downside
Also since if this is used in development phase, then it looks like ASAN, then why is there still memory bug in C/C++ despite they have been doing ASAN in development mode? One of the reason is because the test is not exhaustive, that point the important of compile time first as much as possible, before we go to runtime check + panic
Now for the use case if it is used for production deployment not development process, it must offers benefit compared to high performance GC language out there because it clearly has downside of the test I mentioned in the first paragraph. Comptime is already benefit compared to GC language that does not has reflection or compile time reflection. The strong candidate it massively outspeed the performance not just small gap, because small gap is not enough compared to the test hell. If the tests written is not exhaustive, the panic slip through production which can also be bad for software than banned any kind of crash. So there should a callback API to catch the panic and run specific code user provided. But for software that really banned any kind of crash, this panic handler is also unacceptable, consider a medical software where it stopped working randomly because a panic crash. That is why is really there is no compile time solution?
This is one technical concern I have too, specifically for embedded systems / freestanding targets. So I’ve been writing an OS for the last couple of years (in Zig) and things like allocators are fairly easy to integrate. But how will a memory-safe compilation mode turn out for embedded systems?
Fil-C doesn’t really support freestanding targets; but one of the goals of Zig is to make big parts of the standard library not really depend on any OS, provided that you can supply your own parts. How would that (Fil-C-level functionality with own “checker” or whatever components that a kernel could supply) turn out?
Would this be an idea for a freestanding-fil ABI besides the planned fil ABI (because the fil ABI implies the existence of a certain standard library and is based off of musl, which needs system calls; but a second freestanding-fil ABI could provide only the compiler side of things, with the runtime side of things not being provided by the standard library, but by custom components)?
Another idea is that (in the example of the OS) a kernel could provide only the hardware abstraction layer, and all (even privileged) drivers could be loaded from an userland object that communicates via message-passing (or similar) to the kernel (and so the userland drivers are practically privileged kernel processes, but outsource the unsafety to code from the kernel)…
But for that, I’d probably have to implement support for that OS in the Zig standard library.