Build Security

Rust builds have two Trojan features. Here are thoughts about reigning them in:

1. Build.rs

I recall that there was a discussion long ago that the most common tasks could be implemented centrally and activated by configuration. In many crates this would be enough: only a build.toml instead of an omnipotent (hence potentially dangerous) build.rs. This sounded good, but I haven’t heard news for a long time.

Independently of where this stands, I would add a Cargo.toml property allowing build.rs. Cargo new/init would start adding it with an empty list to push adoption:

build-rs-allowed = ["crate1", "crate2"]

If this property is present (and as of edition 2027 even if not present) a dependency (even indirect) with a build.rs would cause a build error, unless it is in the list.

2. Proc Macros

Most macros have no business snooping around the file system, or even modifying it, or calling home. I guess this is equivalent to saying: proc macros should be restricted to core and alloc. Only a few, like sqlx (I suppose sqlx-macros concretely), need std.

I would add a Cargo.toml property allowing std. Cargo new/init would start adding it with an empty list to push adoption:

macro-may-use-std = ["sqlx-macros"]

If this property is present (and as of edition 2027 even if not present) a proc macro dependency (even indirect) must be no-std, unless it is in the list.

3 Likes

For the second idea, what about adding a cfg to std that causes its fs implementation to always panic, or something like that? I see this as similar to the wasm32-unknown-unknown target. Then, a macro-may-use-fs list could configure that cfg.

Though, that approach would need to cover other problematic parts of std to be functional: arch, fs of course, net, os, process, ….

And…. I just realized a critical flaw. I know const-eval happens in an interpreter that can catch UB, but what happens if a proc-macro has UB? Malware doesn’t need to be future-proof, and if a proc-macro is compiled to the host’s target (presumably capable of running some form of not-sandboxed assembly), it could conceivably find a way to execute whatever assembly it wants via UB, at least for the current compiler version where the malware author can tweak the code until they see the optimizer doesn’t notice/take advantage of the UB.

Macro sandboxing would need to happen at a lower level than std.

2 Likes

Sandboxing macros at the lowest level with, say, Wasm, is of course not a new idea and has been discussed here elsewhere.

1 Like

It wouldn't even really have to be UB, just some form of system-specific transmute (which is possible even in safe Rust by exploiting type-system soundness bugs – it isn't supposed to be but there are numerous ways to do it in practice). Operating systems are gradually starting to move away from the model of "any read-only part of the executable image can be executed", but there are still lots of computers around which do have that rule, so on such computers a program would just need to create a constant static array of bytes that represented the code it wanted to run, then transmute a reference to it (either using unsafe code or using a type-system soundness hole) into a fn and call it. This sort of transmute isn't obviously even undefined behavior (although it is highly system-specific).

In general, Rust's security model is not designed to defend against malicious code given as input to the compiler (as opposed to malicious input given to a program at runtime) and thus any sort of build-time sandboxing has to be outside the Rust language itself.

3 Likes

It doesn’t even have to be a transmute or anything, an unsandboxed procmacro can just extern "C" { fn open(…); } and call OS APIs directly. Hiding stds wrappers doesn’t change that at all. A proper sandbox is the only way to go.

14 Likes

Fwiw I thought about this personally a lot and while I appreciate the need for a more secure build system I just don't think it's worth the added complexity right now;

In almost any case if a library is included even if they aren't proc macro and do not use a build.rs their code gets run only a few seconds after the compile anyways when the developer is testing the code; this even works without calling it directly using crates like ctor.

In my mind the only way to get around this is to move to a fully sandboxed model throughout the entire chain from building but to executing as well. For that to work, in my opinion the only proper (or easy for that matter) way to do that right now is by compiling the code from proc macros and build.rs but the users code as well to wasm (with e.g. wasi) and run that in a sandbox, but I don't think that is something I would like to see being baked into cargo at this point in time. But what I would find interesting is the ability to somehow hook compilation (for compilation it might even be enough to just be able to specify a different target triple) and execution of build.rs and proc macros to allow experimentation with this through external tools, and then at a later point allow some kind of generic per crate permission system in crates.io, for example something like this:


[permissions.macro.sqlx]
network-access = { action = "permit", routes = ["127.0.0.1:5432"] }
filesystem-access = { action = "permit", paths = ["mydb.sqlite"] }

[permissions.build.rust_version]

[permissions.run]

Where the permission values and keys are sandbox implementation defined for now, until a concrete implementation has been fleshed out. But for now this wouldn't even need support from cargo's side and could just be done through the metadata system.

At a later point in time this experiment could then go three routes in my mind:

  1. Decide it wasn't worth the additional complexity
  2. Decide a singular implementation has been fleshed out and accept that into rust directly
  3. add some kind of [sandbox] config section that can be used to specify a concrete sandbox implementation (crate) and then be used to enable / disable build.rs, proc_macro and run sandboxing, and/or be able to override it for individual crates.

Let me know what you think about my suggestions

Thanks for reading Justus

4 Likes

The primary obstacle to getting sandboxing into Cargo (for build scripts) and rustc (for proc-macros) is implementing it (or at least prototyping it), not convincing people that it should be done.

Sandboxing is already an accepted Major Change Proposal. You can use host.runner to experimentally plug in your choice of sandbox to Cargo build script execution. What this problem needs is people working on implementing solutions and seeing how well they work in practice.

10 Likes

Note that needling to list crate names in build-rs-allowed means that adding a build.rs is a SemVer hazard (i.e., major version bump). One should also use the exposed name of the crate so that you can grant, say, serde1 build.rs access, but not serde2.

3 Likes

The exposed name doesn’t work for transitive deps. First thought is that I would expect it to be either with a version specifier (so you can choose whether you want to have to re-audit after updates) or a checksum (of the build.rs and its imports). But both of those are complicated by the fact it may have build-dependencies though :thinking:.

1 Like

Actually, I need convincing.

It is a huge effort with a lot of design work and it is incomplete. It only covers build time behavior and not runtime behavior. There are ways (e.g. cackle) where we can get the benefit for both build time and runtime. That seems like a much higher pay off direction to go.

EDIT: I also very much want us to explore

8 Likes

A flag like good, depends if Cargo build scripts can execute arbiratry code, that is supply chain risks, we need a flag named --disable-build-scripts, some deps they not need any build scripts and also in Cargo.toml disable-build-scripts will be allowed and also you can specify what crates can execute arbiratry code and what crates are not allowed via a whitelist

And also sandboxing permissions for build scripts is a good idea

1 Like

It is much easier for compile time, because for compile time the rules are simple: no network at all, no access to hidden files at all, everything except the target directory is read-only. The reason for these rules isn't even security, though it obviously addresses it, but rather reproducible builds. I've been burnt by dependencies becoming unavailable enough times that I want total control over what is downloaded during the process.

Obviously for runtime the rules need to be customized depending on what the software does, so that's much more difficult.

Note, however, that the second thing cackle tells you in its installation instructions is to install bubblewrap so it can properly sandbox the build scripts.

It is a bit of an issue that bubblewrap only exists for and works on Linux, and I don't know of any portable tool. Which is where using WASM with a host simply not providing that access would be a good option. Plus it would open a path to providing pre-built macros.

Again, not applicable to runtime though.

We've had a lot of discussion about build scripts and proc macros, but how far do we want to/should we go?

  • sandbox build scripts and proc macros. They could still inject code that runs in the binary, thus not really helping with cargo run.
  • additionally sandbox the runner (preferably with an easier way to enable it than setting a custom runner).
  • additionally sandbox the LSP (more up to the users configuration than rust/cargo, but I have not heard that being mentioned at all). This might be out of scope for this discussion, but I am not sure what outside of build scripts and proc macros rust-analyzer executes or interprets, or how vulnerable it is against malicious code.
  • At some point you might want to consider whether the compiler itself should run in a sandbox/container to limit the impact of vulnerabilities in the compiler, which can have additional benefits around reproducibility/cross-distro compilation.

All of this likely needs configuration around things like network access and should be easy to discover and use, if not even enabled by default. Each of them alone is already useful, but Security must be easy to use/configure otherwise it won't be used.

1 Like

I am worried about an incomplete sandbox giving users false sense of security, but I also see upsides.

Users understand that running untrusted code can be dangerous, but it's surprising that merely viewing Rust code can be dangerous too! That's because an IDE may run cargo check or an LSP may run proc macros (which runs build.rs of its deps too, not only the macro). Some editors ask when opening a new project, or don't run anything until you save a file, but that's still a click away from accidentally running arbitrary code.

I don't think it's even feasible to properly isolate Rust libraries at run time to prevent malware from causing damage when run in the same process as trusted code, but for proc macros a WASM sandbox seems like an obvious choice that could improve safety and make them more deterministic too.

Proc macros are very difficult to review for hidden malware. Metaprogramming adds more ways to obscure what code is generated and run. It's helpful to also look at the code generated by proc macros, but smart malware authors know to only attack when nobody is looking. It would be useful if the sandbox made it hard for proc macros to guess when they're building for cargo expand vs other situations.


Sandboxing of build.rs seems hopeless, because scripts for system libraries need to be able to search the system and build or link arbitrary code. However, build.rs scripts are also typically small and simple, so it's feasible to review their code.

So together some kind of review + approval for build.rs and a sandbox for proc macros could at least make browsing of Rust code safe. That isn't enough to run arbitrary code without reviewing it, but at least makes it safe to review arbitrary code.

6 Likes