Effect System + Crate Permissions?

Hello, how are you? I know I sometimes have rather unusual suggestions, and this one is no exception. I wanted to propose integrating a basic effects system, would something like this be feasible?

I noticed that in Koka, for example, functions often declare not only their return type but also the effects they produce. For instance, if a function prints something to the console, it must declare the effect “prints to the console.” Similarly, if it reads or writes from the console, or performs file operations, these effects are explicitly declared. I realize implementing something similar in Rust would be challenging, perhaps achievable through macros, comments, or another mechanism. I’m not sure, but I wanted to suggest it because I believe it could evolve into a permission system for crates.

By analyzing the code, we could determine whether a crate requires access to the console, syscalls, files, or similar resources. This would allow us to track whether a crate, from one version to another, begins to access new capabilities it did not previously require, thus providing an additional layer of security, minimal, perhaps, but still valuable.

This could work similarly to Deno, which asks for permissions before execution (in our case, compilation), indicating for example: "this crate requires internet access", "this crate requires filesystem access" and so on.

I realize this is somewhat unusual, but I wanted to share the idea. It may seem trivial, but I truly believe it could help prevent supply chain issues within our ecosystem.

JavaScript is a sandbox language from the start. Due to being critical to browser security, a lot of effort has been invested into making its implementations quite robust. Plus due to being a VM, it naturally has ability to control everything the code does.

Rust never had that. From the start it trusted the code, and allowed programs to do whatever they wanted. Borrow checking, crate and module namespaces, and even types are only optional tools for the all-powerful programmer, not a shield against malicious code. Rust helps to catch honest mistakes of cooperative programmers, but wasn't designed to fight intentional bypasses.

Rust is a fortress, JavaScript is a prison. Fortress isn't a strong prison and can be easily defeated from the inside.

Before edition 2024, features like #[no_mangle] weren't even marked as unsafe, despite having power to completely break the language and replace code of functions in arbitrary unrelated crates and operating system libraries.

Rust sits on top of the C linker, where the concept of a crate doesn't exist, there's no security boundary, everything is global, and separation between trusted and untrusted code doesn't exist. To the linker, all code has all the power to do everything it wants.

The Rust compiler has known cases of unsoundness. These are usually edge cases in weird code that wouldn't be used in any normal program, so they're not a big deal in a model where the programmer is trusted. However, the same issues would be serious vulnerabilities in a sandbox security model, because they allow escaping from the sandbox.

This is not a good foundation to build an internal sandbox that isn't backed by anything other than Rust's own soft rules.

14 Likes

I agree with that, but I also think it is worth trying. The Rust compiler knows a lot more about a crate during compilation compared to JavaScript which can only ever achieve something like that by running in a VM. Yes, unsafe code, inline assembly and soundness holes can relatively easily break this (giving a dependency something akin to arbitrary code execution). If you want true sandboxing you'll need some kind of VM. So this would still only be a best-effort basis. [1]

But as said above: Rust knows a lot more about the crate, so we could attempt a basic/likely incomplete permission system based on MIR or HIR that attempts to blacklist functionality [2]. As long as it's documented as a best-effort mechanism and not a guarantee [3] we'd likely still get the benefit of making life harder for malicious dependencies and potentially making it easier to detect when it is doing something it isn't supposed to do [4].

Proper sandboxing would of course be preferable [5], but that likely requires compiling to and running wasm (which is effectively the (JIT) compiler/interpreter doing what was proposed here), or effectively having the Rust binary be a hypervisor that communicates with the untrusted dependency running in a real VM (without an OS), which likely has significant performance downsides.


  1. I think the soundness guarantees are also on a best-effort basis, as evident by the existence of soundness holes in the first place. ↩︎

  2. Though I wouldn't be surprised if detecting this "weird code" proves really difficult if not impossible, it would effectively require a linter for these unsound uses. ↩︎

  3. Could even be based on confidence: dependency_name = {version = "...", permissions = [], min_confidence_no_other_permissions_are_possible = 0.99}, or at a project-wide level, though that can of course mean that compiler changes can "break" your dependency chain by being less confident in the permission system. ↩︎

  4. Still insanely difficult of course, as it would effectively requiring the code to run in a VM and check for malicious behavior at runtime or require extensive static analysis. But at least you'd know whether a crate is supposed to do something. ↩︎

  5. Sometimes I wish all/most applications would be compiled with a unikernel and run in an isolated VM. In some cases it could even improve performance, potentially at the cost of memory use. Though at some point you'd just end up with processes with individual memory regions + an additional permissions system, so maybe we already have this (kind of) but few people use it. ↩︎

Rust does not form a security boundary. That is the official policy as far as I have heard. LLVM has the same policy. This does not seem viable unless you can get LLVM to change that policy first.

Just to clarify, what I actually had in mind wasn’t a full sandbox. The idea would be something much simpler, just preventing Cargo from compiling until the user authorizes the permissions. I’m not thinking of a complex system here, because I understand we can’t guarantee absolute security.

What we might be able to guarantee, though, is that nothing gets compiled until it’s verified that the permissions declared by the packages match what the user expects. That way, we’d add a minimal but useful control step before the code is executed or integrated into a project.

Something like how Deno works, it only asks the user for confirmation the first time the program is executed, in our case, it would be at the first compilation.

1 Like

To understand this idea better, what happens if a package uses some functionality but declares to the build system that it doesn't need any permission?

It seems like the "not a security boundary" argument pops up for the enforcement of only using what's declared.

It's possible to write a rustc driver by recognizing attributes and performing static analysis to achieve the idea.

But Another existing tool is GitHub - cackle-rs/cackle: A code ACL checker for Rust , written by wild linker author, by hacking the rustc and linker.

1 Like

If it's a best-effort/not-guaranteed mechanism then it's not for security. If malicious actors know it's there they will try to find a way to bypass it, so it becomes a matter of whether bypassing it is possible or not. If we don't want to guarantee bypassing it should not be possible then what is it for? I feel like it would just give you a false sense of security plus an additional inconvenience.

6 Likes

I'm of two minds here. On one hand, yes, papering over problems is not really all that useful (cf. this comment where I think being able to disable tracing to hide leaks from build logs is not all that useful). However, on the other hand, there are cases where being able to add another slice of Swiss cheese is still beneficial. Here, I think this is closer to the "slice of cheese" as an idea, but the inaccuracies inherent to it make it not suitable for cargo proper. A tool like cackle seems quite apt as a way to canary issues one might have and care about. Similar to cargo-vet and cargo-audit if you ask me: if you care enough, you do the prep work, stand up a CI job for it, and keep it green.

But as a basic piece of infrastructure? There's just too much wiggle room. Does using AF_UNIX count as "network"? Maybe for you, but not for me. Either way, std::process, socat, and AF_UNIX can cobble together Internet access without ever using AF_INET directly. IMO, something like that is far better enforced at deployment time:

  • don't set up the network at all
  • strict routing tables
  • forced proxies

But I can see the value in being able to do build-time checks that certain API sets aren't used.

A weak permissions system can be useful if it forced malware authors to do obviously weird stuff to work around it. This could make code reviews easier.

However, I'm worried that a lot of people would want permissions to avoid having to do code reviews, and get a false sense of security from crates that say they don't need any permissions (and then do whatever they want, because the weak permissions system can't stop them).

5 Likes

I'd like a really weak version of it, actually.

Basically, we could make a system that doesn't help for "interesting" crates, but that's ok. What I want most is something that helps with the boring crates that aren't interesting enough to bother code reviewing instead of just rewriting.

Even if it was just something trivial like "core+alloc only and must have forbid(unsafe)" that accepts a whole bunch of little helper crates, even if it does nothing at all for the big important things.

The big ones always require review (directly or via hoping the community did it for you) no matter what we do. But we could reduce the "oh no why are there all these little crates" paranoia a bunch with a simpler "look, they're not doing anything funky" check.

(Now obviously that's not enough to fully trust them, since bugs exist, but I think it'd still be a big help.)

You also need to forbid build scripts for them.

1 Like

Since build scripts are not sandboxed they probably shouldn't be run by default either and should require an opt-in (though that change likely won't happen).

How will this work?

Soundness bugs allow having unsafe code in forbid(unsafe) crates.

Soundness bugs will allow unlimited disk access and network I/O in core-only crates that have no permissions and no dependencies.

In code review you would likely notice that the code does bizarre stuff to trigger arbitrary code execution vulnerabilities, but if you assume you don't have to review the code because it doesn't ask for permissions or dependencies, that's exactly the false sense of security problem.

4 Likes

I was wondering whether it might work in the sense of "most would-be attackers won't be able to figure out how to sneak working exploit code past the type-checker". After all, soundness bugs mostly just give you an arbitrary transmute, which is approximately equal in power to a use-after-free exploit – and because C code has use-after-free exploits so often, compilers and operating systems already use a range of mitigations against them which are nontrivial to bypass.

However, after thinking about it for a while, I concluded that exploits against Rust code from a crate it links (via using soundness bugs) are significantly easier to pull off than exploits against C code from a use-after-free vulnerability. I don't want to talk about the details in case there are would-be attackers who are reading this, but it does indeed seem to be the case that checking a crate for calls to unsafe or side-effecting functions doesn't do all that much to stop an attacker who has managed to place malicious code into the crate.

I think it might be plausible to envision changes to the compiler that could make it safer, though: the basic idea would be to do a check for unsound code post-monomorphisation (which would make almost all the known soundness bugs unexploitable – I think there might still be a couple it would miss, but those tend to be platform-specific). This check would never fail except in cases where a soundness bug were exploited (by definition), and is much easier to get right than a pre-monomorphisation check is. Unfortunately, it is hard to implement with the current implementation of the compiler because it doesn't track lifetimes through monomorphisation.

Security by obscurity is not security.

Most attackers on supply chains are unlikely yo be "script kiddie" level. So would assume this does present much of a barrier. Besides there are multiple publically known soundness issues (see the joke crate cve-rs for example, or just look at the Rust bug tracker). While finding an arbitrary transmute might be a bit of effort, the rest of the process to code execution is fairly straight forward.

It took like maybe a minute to have a rough idea of the steps involved in my head (things like NX and various anti-ROP measures make it a bit harder, but the techniques to work around those are well known). There would likely be additional hurdles I didn't think of, but they wouldn't be insurmountable to overcome. And this is not a field I work in, nor do I assume I'm a genius. This means it should be fairly easy for someone working in computer security.

I think I have an entirely opposite perspective here:

  1. I think that the majority of would-be attackers probably aren't that sophisticated (because there are so many more people interested in attacking than people who actually have the technical skills to do it).
  2. I consider finding arbitrary transmute in safe code to be considerably easier than the leveraging of an arbitrary transmute into arbitrary code execution using only safe code. Yes, it's "standard" in the sense that exploits against C programs have been doing that sort of thing for years. But it's more sophisticated than I expect most would-be attackers to be able to manage, especially given the extent of exploit mitigations that modern operating systems use. The skills that you need to steal a maintainer's permissions to upload to crates.io (e.g. via social engineering/phishing) don't have much relation to the skills you need to convert arbitrary transmutes into arbitrary code execution.
  3. Right now, repositories like crates.io are protected pretty much only by "this isn't as big a target as things like npm" and "if there is an exploit, maybe someone will notice it before it becomes a problem" – this is pretty much security through obscurity, or maybe even weaker. I agree that security through obscurity is unreliable and easily broken past: but it's all we have at the moment. As such, I don't want to make the situation even worse by lifting some of the obscurity and weakening one of the few barriers to exploits that currently exists: it might well be too weak, but that isn't a reason to make it even weaker.