Deterministic isolated proc-macros

In the wake of the xz backdoor, can we revisit sandboxing of proc-macros? All of the points in that thread still stand.

Now we've seen that there are attackers who are willing to spend years crafting an attack, and don't even worry about deniability. The attacks can be much more sophisticated and covert than a simple smash-and-grab we've seen previously with crypto wallet stealers or ransomware.

Proc macros are incredibly convenient for covert backdoors:

  • being in the same process as rustc they can attack the compiler itself. They don't even have to inject backdoor into the proc-macro-generated tokens. They can modify compiler's own functions to inject code later in the pipeline, or infect compiled object files on disk.

  • when they run in unsandboxed they can detect the environment they run in, and attack only when they know nobody is looking.

  • proc macros can be quite complex, spanning multiple crates, generating code dynamically, and using proc macros themselves. This makes them hard to review, and because of lack of sandbox and determinism, their behavior can't be evaluated by looking at output of cargo expand or similar.

24 Likes

Would proc-macro sandboxing kill crates like SQLX?

2 Likes

To note, ~everything[1] that applies to sandboxing proc-macros theoretically applies the same to buildscript. However, buildscripts are typically simpler than proc macros (proc macros additionally need to handle using the proc macro API in addition to whatever code generation they do, and are generally nicer to use than buildscripts, all else being equal[2]), so it sticks out a bit more when a buildscript is doing a lot. Additionally, buildscripts need system access more commonly legitimately to control building/linking foreign code. (That buildscripts can be entirely overridden declaratively for packages that set package.links also contributes to the smaller surface exposed by buildscripts.)

Thus, sandboxing proc macros, even if buildscripts still aren't, is a meaningful improvement to the status quo of resilience. It's critically important to frame this as resilience rather than security; cargo/rustc are not and do not want to be a security boundary against the code you're compiling.


If this happens, I would expect the API to look like either a package.sandbox = true or lib.proc_macro = "wasm" key for proc_macro packages that says "sandbox this proc macro", and a dependencies."package.name".sandbox = true key that runs a proc macro in the sandbox independently of whether it requested it.

Whatever the opt-in looks like, it should make sure to have possible space for a future version which provides specific wasi capabilities (or whatever spec "wins" if not wasi) to the proc macro. Setting a sandbox key to a directive string is an obvious one, although running the risk of unfortunately embedding structured data in a string in structured data as directives grow more involved (e.g. access scopes).

Proc macros doing "interesting" things can't operate within the sandbox, but there will always be a way to bypass the sandbox. Some downstreams might refuse to use a macro that isn't sandbox compatible, but they'd also refuse to use such a macro even without a way to enforce the sandbox.

More "legitimate" sandbox "escapes" will be more likely to get blessed access sooner. A simple enough way for sqlx to work with a wasi sandbox would be in its mode that precompiles schema verification to use a data file (requires simple fs capability) rather than directly communicating with a local database instance.


  1. Even including certain flavors of "same-process" attacks, although those aren't currently possible — obviously a buildscript can supply arbitrary flags to rustc, but there's also extremely tentative consideration of doing a non-stdout-based protocol. ↩ī¸Ž

  2. All else is never equal. ↩ī¸Ž

4 Likes

Note that there is already an accepted change proposal for sandboxing, as of 2022. What is needed is not further argument about whether it's a good idea, but design and implementation.

11 Likes

I think it's best not to lump proc macros and build scripts into the same problem space. They have very different needs and trade-offs.

Build scripts are generally short and simple to review. Very few build scripts use dependencies beyond a handful of well-known ones. So I'm much more confident in my ability to verify the build scripts.

Proc macros can be much more complex in comparison, with many tricky language constructs, and need to understand several layers of code expansion, scopes, macro hygiene, etc. I'm really worried about overlooking a clever trick, and approving use of a backdoored crate.

8 Likes

imo the end state is both proc-macro and build.rs sandboxing. This doesn't mean we have to do both now but we need to ensure the design can handle both. A big point of complexity for build.rs is that all except the trivial code-gen cases (which should likely be handled outside of build.rs) need system access. This means they need more than a binary "sandboxed" / "unrestricted". While most proc-macros could run in a completely sandobxed system, I suspect we'll still get a lot of benefit from offering finer grained control, much like build.rs needs.

(I suspect we'd also benefit from allowing multiple build.rs's for independent tasks so you can have isolate the granted capabilities to only what needs it).

In addition to resilience and more visibility into what needs auditing (much like unsafe), sandboxing both would allow lower the risk with per-user build caches. In that, we'd start off not caching anything tainted by a potentially impure build. We'd then offer a "trust me, this is pure" but there'd be fewer compilation caching bugs if we didn't have to just trust the developer but had the capabilities define and enforced to back it up.

4 Likes

I'm not opposed to the idea of sandboxing both, but I'm worried that the expanded scope and complexity may block progress on proc macros. Rust tends to postpone features it can't do well, and interaction with C build systems and dependencies is a hairy problem that is not possible to do well.

Many proc macros can easily run isolated, but build scripts are on the opposite end of the spectrum where they purposefully poke around the OS. I'm worried that a solution trying to do both at the same time may end up being overcomplicated for proc macros, and still underwhelming for build scripts.

Proc macros are mandatory — if they're not run, the code probably won't compile. OTOH many build scripts are effectively optional, e.g. detecting rust version or finding and building a C library that could be built and linked in other ways. So a solution for build scripts may not be a sophisticated sandbox, but multiple different features replacing their use-cases, making build scripts unnecessary instead.

23 Likes

Perhaps it might also help to add a carrot here: I think performance especially for incremental check builds could be drastically improved if proc-macros are deterministic (and/or inputs are known/tracked) -- AIUI today cargo has to rerun proc-macros for every build, which can be pretty expensive.

9 Likes

Rustc will rerun proc-macros every time it is invoked. Cargo however will not rerun rustc unless any of the files or env vars mentioned in the dep-info file or any dependency is changed (without -Zbinary-dep-info dependencies are not listed in the dep-info file). Without proper use of the unstable proc_macro::tracked_path::path/proc_macro::tracked_env::var functions this can mean that proc-macros don't get rerun even when necessary, but if rustc gets rerun for any reason, all proc-macros get rerun.

5 Likes

There was a thread that I'm not finding at the moment that was about rustc caching the results of proc macro generation to avoid re-running proc macros on future invocations. The prototype was full of hacks but sandboxing could potentially improve this.

Also, to make sure this is said: wasm-built proc macros could slow down full builds because Cargo won't be able to reuse dependencies between the proc macros and native libraries/bins. I also do not see a viable path at this time for pre-built packages until some more fundamental challenges get solved. In the mid-feature, the best we have is per-user caching with a plugin system for pulling intermediate artifacts into the per-user cache from alternative sources.

1 Like

I suppose, if proc-macros do become wasm, then crates.io could serve up compiled wasm binaries (that it generates itself?). It's all sandboxed anyway, and the single binary should be portable across all users...

2 Likes

As I said, I do not see a viable path at this time for pre-built packages.

First, this is dependent on feature flags.

Second, there is nn inherent assumption in Cargo and Cargo users that their lockfile will be respected for everything. While the differences might be negligible in some cases (proc-macro2 + sync + quote) once you expand it to the full proc-macro, then you run into problems where the proc macro's output is tightly coupled with the code it is generating for. This is why a lot of proc-macro wrapper libraries use a = operator on the proc-macro. Pre-built packages can only be made for fixed sets of dependency versions.

The alternative I mentioned, per-user cache + plugin design, would shift the conversation to being about caching the exact set of circumstances you need for your build, and rebuilding from scratch if it isn't present.

8 Likes

I think that any proc macro thing that wants to go off-box should be rewritten to not do that.

Notably, any smart incremental build logic fundamentally cannot know whether your DB schema has changed, for example. So it will always be either "horribly slow" or "doesn't notice updates".

Thus if you want a schema-aware proc macro, the better answer is to have a tool to export the schema to something checked-in, and have the proc macro check against that schema export, not check the live DB.

As such, I'm completely fine with breaking crates that do weird things in their proc macros and build scripts. (At least by default -- maybe there's some heavily-discouraged system configuration flag in a non-writable-by-cargo location that can be enabled to remove the sandboxing if people really insist.)

8 Likes

And add a measurement to cargo-geiger (or a separate dedicated tool for this axis).

There was a thread that I'm not finding at the moment that was about rustc caching the results of proc macro generation to avoid re-running proc macros on future invocations. The prototype was full of hacks but sandboxing could potentially improve this.

Were you thinking of this?: Reddit - Dive into anything

The thread is about a modified version of Rust that caches the output of proc-macros, speeding up incremental recompiles.

Unfortunately the modifications are not open-source atm, so maybe you were thinking of a different thread. (since you mention it having used "hacks" to achieve the caching)

Thanks! Thats the thread! Yes, closed source but still using hacks to achieve the caching.

It's potentially interesting to note that with the wasm component model starting to be usable and the wasm-wasip2 target using the component model on track to be supported in tree, this really is an interesting time to think about running proc macros in a wasm sandbox again. Specifically, because it's theoretically straightforward how the existing RPC bridge would project onto the component model.

Not that I would expect us to, but if we were to have a stable component-model API for proc macros, we could theoretically support proc macros written in any guest language, which would certainly be interesting.

4 Likes

Interesting, but why? Isn't Rust, with its strong ML heritage, in the top tier of languages (with significant userbases) for writing programming language tooling, and isn't it safe to assume that people writing proc macros for Rust will be writing plenty of other Rust and thus are, or need to get, comfortable with Rust? Maybe some would prefer to write their proc macros in OCaml, but would it be worth the effort in rustc?

1 Like
I didn't say desirable, I specifically said interesting. Currently, I would agree Rust is the best language to write resilient tooling in.

In such a world where non-Rust guest languages are supported, however, I could absolutely see some functional and/or domain specific language beating out Rust for convenience in writing proc macros. The macro_rules! DSL is preferable to using proc macros when macros can be written in a "by example" shape, and I could absolutely see a framework allowing mixing that kind of lightweight AST manipulation with more involved procedural proc macro manipulation being useful.

In fact, there are a couple helper crates for imitating macro_rules! parsing within syn's Parse infrastructure already[1]. You can do a lot within Rust, but I've been a bit DSL pilled; enough that I'm always interested by the capabilities of wasm to host a polyglot plugin ecosystem.

Even in such a world, I expect most proc macros to continue to be written with Rust, for the reasons you cite and because of build system integration. I agree there's significant benefit to Rust being a language that you can use "full stack" comfortably.

And to be clear — this is more daydream than proposed reality. I would expect the WIT bridge to not look like the stable API, but to look like the unstable RPC bridge. There's existing performance cheats to be had by decoupling the two and not going over the RPC bridge for everything.


To cycle back around to greater relevance again, a wasm sandbox tie-in could be adjacent to a stronger build cache hint. Specifically, sharing build caches between workspaces is difficult because the entire transitive build tree versions and configuration need to match for any reuse to happen. A proc-macro crate without any build time feature flags could potentially opt in to a stronger guarantee that it's acceptable to quietly substitute in any higher patch version or transitive dependency featureset in order to reduce cross-workspace rebuilds. (So long as dependency bounds are compatible, to preserve the use of tighter bounds from runtime support the proc macro calls into; don't substitute unless -p would update.)

I don't know exactly how this would need to interact with the lockfile to avoid breaking build determinism (at a minimum, --locked/--frozen would need to suppress version substitution), and it's technically separate from wasm sandboxing, but it's certainly an interesting project similar to how wasm sandboxing is.


  1. In fact, both of my contributions to syn have been in support of doing so (for an unpublished crate) — both parse::discouraged::Speculative](https://docs.rs/syn/latest/syn/parse/discouraged/trait.Speculative.html) and [PartialOrd for Cursor`](Cursor in syn::buffer - Rust) were added with the intent of syn supporting PEG style backtracking choice and not exclusively LL(3) parsing. Improving standard usage was a happy side effect to both (details available in the PR history if you look for them). ↩ī¸Ž

3 Likes