X-Git-Url: https://git.ralfj.de/web.git/blobdiff_plain/3ba2a1fa1e76a80148b7a7e6fe03a7fc46a071da..57350c3dab4d2921ab6cdd208abd193271f74a9e:/personal/_posts/2022-04-11-provenance-exposed.md?ds=sidebyside diff --git a/personal/_posts/2022-04-11-provenance-exposed.md b/personal/_posts/2022-04-11-provenance-exposed.md index 47160fa..53d0b84 100644 --- a/personal/_posts/2022-04-11-provenance-exposed.md +++ b/personal/_posts/2022-04-11-provenance-exposed.md @@ -17,18 +17,20 @@ There's a lot of information packed into this post, so better find a comfortable In case you don't know what I mean by "pointer provenance", you can either read that previous blog post or the [Strict Provenance documentation](https://doc.rust-lang.org/nightly/core/ptr/index.html#provenance). The gist of it is that a pointer consists not only of the address that it points to in memory, but also of its *provenance*: an extra piece of "shadow state" that is carried along with each pointer and that tracks which memory the pointer has permission to access and when. -This is required to make sense of restrictions like "pointer arithmetic can never be used to construct a pointer that is valid for a different allocation than the one it started out in" (even with operations like Rust's [`wrapping_offset`](https://doc.rust-lang.org/std/primitive.pointer.html#method.wrapping_offset) that *do* allow out-of-bounds pointer arithmetic), or "use-after-free is Undefined Behavior, even if you checked that there is a new allocation at the same address as the old one". +This is required to make sense of restrictions like "use-after-free is Undefined Behavior, even if you checked that there is a new allocation at the same address as the old one". Architectures like CHERI make this "shadow state" explicit (pointers are bigger than usual so that they can explicitly track which part of memory they are allowed to access), but even when compiling for AMD64 CPUs, compilers act "as if" pointers had such extra state -- it is part of the specification, part of the Abstract Machine, even if it is not part of the target CPU. ## Dead cast elimination considered harmful -The key ingredient that will help us understand the nuances of provenance is `restrict`, a C keyword to promise that a given pointer `x` does not alias any other pointer not derived from `x`. +The key ingredient that will help us understand the nuances of provenance is `restrict`, a C keyword to promise that a given pointer `x` does not alias any other pointer not derived from `x`.[^restrict] This is comparable to the promise that a `&mut T` in Rust is unique. However, just like last time, we want to consider the limits that `restrict` combined with integer-pointer casts put on an optimizing compiler -- so the actual programming language that we have to be concerned with is the IR of that compiler. Nevertheless I will use the more familiar C syntax to write down this example; you should think of this just being notation for the "obvious" equivalent function in LLVM IR, where `restrict` is expressed via `noalias`. Of course, if we learn that the IR has to put some limitations on what code may do, this also applies to the surface language -- so we will be talking about all three (Rust, C, LLVM) quite a bit. +[^restrict]: The exact semantics of `restrict` are subtle and I am not aware of a formal definition. (Sadly, the one in the C standard does not really work, as you can see when you try to apply it to my example.) My understanding is as follows: `restrict` promises that this pointer, and all pointers derived from it, will not be used to perform memory accesses that *conflict* with any access done by pointers outside of that set. A "conflict" arises when two memory accesses overlap and at least one of them is a write. This promise is scoped to the duration of the function call when `restrict` appears in an argument type; I have no good idea for what the scope of the promise is in other situations. + With all that out of the way, consider the following program: {% highlight c %} #include @@ -76,7 +78,7 @@ static int uwu(int *restrict x, int *restrict y) { int *y2 = y-1; uintptr_t y2addr = (uintptr_t)y2; - int *ptr = (int*)y2addr; + int *ptr = (int*)y2addr; // <-- using y2addr *ptr = 1; return *x; @@ -102,12 +104,12 @@ static int uwu(int *restrict x, int *restrict y) { int *ptr = (int*)y2addr; *ptr = 1; - return 0; + return 0; // <-- hard-coded return value } int main() { - int i = 0; - int res = uwu(&i, &i); + int i[2] = {0, 0}; + int res = uwu(&i[0], &i[1]); // Now this prints 0! printf("%d\n", res); } @@ -115,9 +117,9 @@ int main() { We started out with a program that always prints `1`, and ended up with a program that always prints `0`. This is bad news. Our optimizations changed program behavior. That must not happen! What went wrong? -Fundamentally, this is the same situation as in the previous blog post: this -example demonstrates that either the original program already had Undefined -Behavior, or (at least) one of the optimizations is wrong. However, the only possibly suspicious part of the original program is a pointer-integer-pointer round-trip -- and if casting integers to pointers is allowed, *surely* that must work. +Fundamentally, this is the same situation as in the previous blog post: this example demonstrates that either the original program already had Undefined Behavior, or (at least) one of the optimizations is wrong. +However, the only possibly suspicious part of the original program is a pointer-integer-pointer round-trip -- and if casting integers to pointers is allowed, *surely* that must work. +I will, for the rest of this post, assume that replacing `x` by `(int*)(uintptr_t)x` is always allowed. So, which of the optimizations is the wrong one? ## The blame game @@ -161,7 +163,7 @@ To explain what that side-effect is, we have to get deep into the pointer proven Specifically, `x` has permission to access `i[0]` (declared in `main`), and `y` has permission to access `i[1]`.[^dyn] `y2` just inherits the permission from `y`. -[^dyn]: Actually, this is not quite how `restrict` works. The exact set of locations these pointers can access is determined *dynamically*, and the only constraint is that they cannot be used to access *the same location* (except if both are just doing a load). However, I carefully picked this example so that these subtleties do not change anything. +[^dyn]: As mentioned in a previous footnote, this is not actually how `restrict` works. The exact set of locations these pointers can access is determined *dynamically*, and the only constraint is that they cannot be used to access *the same location* (except if both are just doing a load). However, I carefully picked this example so that these subtleties should not change anything. But which permission does `ptr` get? Since integers do not carry provenance, the details of this permission information are lost during a pointer-integer cast, and have to somehow be 'restored' at the integer-pointer cast. @@ -193,11 +195,13 @@ I moved the discussion of this point into the appendix below.) This may sound like bad news for low-level coding tricks like pointer tagging (storing a flag in the lowest bit of a pointer). Do we have to optimize this code less just because of corner cases like the above? As it turns out, no we don't -- there are some situations where it is perfectly fine to do a pointer-integer cast *without* having the "exposure" side-effect. -Specifically, this is the case if we never intent to cast the integer back to a pointer! -That might seem like a niche case, but it turns out that most of the time, we can avoid 'bare' integer-pointer casts, and instead use an operation like [`with_addr`](https://doc.rust-lang.org/nightly/std/primitive.pointer.html#method.with_addr) that explicitly specifies which provenance to use for the newly created pointer. +Specifically, this is the case if we never intend to cast the integer back to a pointer! +That might seem like a niche case, but it turns out that most of the time, we can avoid 'bare' integer-pointer casts, and instead use an operation like [`with_addr`](https://doc.rust-lang.org/nightly/std/primitive.pointer.html#method.with_addr) that explicitly specifies which provenance to use for the newly created pointer.[^with_addr] This is more than enough for low-level pointer shenanigans like pointer tagging, as [Gankra demonstrated](https://gankra.github.io/blah/tower-of-weakenings/#strict-provenance-no-more-getting-lucky). Rust's [Strict Provenance experiment](https://doc.rust-lang.org/nightly/std/ptr/index.html#strict-provenance) aims to determine whether we can use operations like `with_addr` to replace basically all integer-pointer casts. +[^with_addr]: `with_addr` has been unstably added to the Rust standard library very recently. Such an operation has been floating around in various discussions in the Rust community for quite a while, and it has even made it into [an academic paper](https://iris-project.org/pdfs/2022-popl-vip.pdf) under the name of `copy_alloc_id`. Who knows, maybe one day it will find its way into the C standard as well. :) + As part of Strict Provenance, Rust now has a second way of casting pointers to integers, `ptr.addr()`, which does *not* "expose" the permission of the underlying pointer, and hence can be treated like a pure operation![^experiment] We can do shenanigans on the integer representation of a pointer *and* have all these juicy optimizations, as long as we don't expect bare integer-pointer casts to work. As a bonus, this also makes Rust work nicely on CHERI *without* a 128bit wide `usize`, and it helps Miri, too. @@ -242,12 +246,12 @@ But now we have the same contradiction as before! Either the original program already has Undefined Behavior, or one of the optimizations is incorrect. Previously, we resolved this conundrum by saying that removing the "dead cast" `(uintptr_t)x` whose result is unused was incorrect, because that cast had the side-effect of "exposing" the permission of `x` to be picked up by future integer-pointer casts. -We could apply the same solution again, but this time, we would have to say that a `union` access or a `memcpy` has an "expose" side-effect and hence cannot be entirely removed even if its result is unused. +We could apply the same solution again, but this time, we would have to say that a `union` access (at integer type) or a `memcpy` (to an integer) can have an "expose" side-effect and hence cannot be entirely removed even if its result is unused. And that sounds quite bad! `(uintptr_t)x` only happens in code that does tricky things with pointers, so urging the compiler to be careful and optimize a bit less seems like a good idea (and at least in Rust, `x.addr()` even provides a way to opt-out of this side-effect). However, `union` and `memcpy` are all over the place. Do we now have to treat *all* of them as having side-effects? -In Rust, due to the lack of a strict aliasing restriction, things get even worse, since literally *any* load of an integer from a raw pointer might be doing a pointer-integer transmutation and thus have the "expose" side-effect! +In Rust, due to the lack of a strict aliasing restriction (or in C with `-fno-strict-aliasing`), things get even worse, since literally *any* load of an integer from a raw pointer might be doing a pointer-integer transmutation and thus have the "expose" side-effect! To me, and speaking from a Rust perspective, that sounds like bad idea. Sure, we want to make it as easy as possible to write low-level code in Rust, and that code sometimes has to do unspeakable things with pointers. @@ -257,7 +261,7 @@ So what are the alternatives? Well, I would argue that the alternative is to treat the original program (after translation to Rust) as having Undefined Behavior. There are, to my knowledge, generally two reasons why people might want to transmute a pointer to an integer: - Chaining many `as` casts is annoying, so calling `mem::transmute` might be shorter. -- The code doesn't actually care about the *integer* per se, it just needs *some way* to hold arbitrary data in a container of a given time. +- The code doesn't actually care about the *integer* per se, it just needs *some way* to hold arbitrary data in a container of a given type. The first kind of code should just use `as` casts, and we should do what we can (via lints, for example) to identify such code and get it to use casts instead.[^compat] Maybe we can adjust the cast rules to remove the need for chaining, or add some [helper methods](https://doc.rust-lang.org/nightly/std/primitive.pointer.html#method.expose_addr) that can be used instead. @@ -271,7 +275,7 @@ The right type to use for holding arbitrary data is `MaybeUninit`, so e.g. `[May Because of that, I think we should move towards discouraging, deprecating, or even entirely disallowing pointer-integer transmutation in Rust. That means a cast is the only legal way to turn a pointer into an integer, and after the discussion above we got our casts covered. -A [first careful step](https://github.com/rust-lang/rust/pull/95547) has recently been taken on this journey; the `mem::transmute` documentation no cautions against using this function to turn pointers into integers. +A [first careful step](https://github.com/rust-lang/rust/pull/95547) has recently been taken on this journey; the `mem::transmute` documentation now cautions against using this function to turn pointers into integers. ## A new hope for Rust @@ -293,7 +297,8 @@ This is the entire reason why all of this "untagged pointer" mess exists. Under this brave new world, I can entirely ignore pointer-integer round-trips when designing memory models for Rust. Once that design is done, support for pointer-integer round-trips can be added as follows: - When a pointer is cast to an integer, its provenance (whatever information it is that the model attaches to pointers -- in Stacked Borrows, this is called the pointer's *tag*) is marked as "exposed". -- When an integer is cast to a pointer, we *guess* the provenance that the new pointer should have from among all the provenances that have been previously marked as "exposed". (And I mean *all* of them, not just the ones that have been exposed "at the same address" or anything like that. People will inevitably do imperfect round-trips where the integer is being offset before being cast back to a pointer, and we should support that. As far as I know, this doesn't really cost us anything in terms of optimizations.) +- When an integer is cast to a pointer, we *guess* the provenance that the new pointer should have from among all the provenances that have been previously marked as "exposed". + (And I mean *all* of them, not just the ones that have been exposed "at the same address" or anything like that. People will inevitably do imperfect round-trips where the integer is being offset before being cast back to a pointer, and we should support that. As far as I know, this doesn't really cost us anything in terms of optimizations.) This "guess" does not need to be described by an algorithm. Through the magic that is formally known as [angelic non-determinism](https://en.wikipedia.org/wiki/Angelic_non-determinism), we can just wave our hands and say "the guess will be maximally in the programmer's favor": if *any* possible choice of (previously exposed) provenance makes the program work, then that is the provenance the new pointer will get. @@ -313,7 +318,8 @@ And who knows, maybe there *is* a clever way that Miri can actually get reasonab It doesn't have to be perfect to be useful. What I particularly like about this approach is that it makes pointer-integer round-trips a purely local concern. -With an approach like Stacked Borrows "untagged pointers", *every* memory operation has to define how it handles such pointers -- complexity increases globally, and even when reasoning about Strict Provenance code we have to keep in mind that some pointers in other parts of the program might be "untagged". +With an approach like Stacked Borrows "untagged pointers", *every* memory operation has to define how it handles such pointers. +Complexity increases globally, and even when reasoning about Strict Provenance code we have to keep in mind that some pointers in other parts of the program might be "untagged". In contrast, this "guessing maximally in your favor"-based approach is entirely local; code that does not syntactically contain exposing pointer-integer or integer-pointer casts can literally forget that such casts exist at all. This is true both for programmers thinking about their `unsafe` code, and for compiler authors thinking about optimizations. Compositionality at its finest! @@ -322,7 +328,7 @@ Compositionality at its finest! I have talked a lot about my vision for "solving" pointer provenance in Rust. What about other languages? -As you might have heard, C is moving towards making [PNVI-ae-udi](http://www.open-std.org/jtc1/sc22/wg14/www/docs/n2577.pdf) an official recommendation for how to interpret the C memory model. +As you might have heard, C is moving towards making [PNVI-ae-udi](http://www.open-std.org/jtc1/sc22/wg14/www/docs/n2676.pdf) an official recommendation for how to interpret the C memory model. With C having so much more legacy code to care about and many more stakeholders than Rust does, this is an impressive achievement! How does it compare to all I said above? @@ -336,20 +342,25 @@ However, if/when a more precise model of C with `restrict` emerges, I don't thin The "udi" part of the name means "user disambiguation", and is basically the mechanism by which an integer-pointer cast in C "guesses" the provenance it has to pick up. The details of this are complicated, but the end-to-end effect is basically exactly the same as in the "best possible guess" model I have described above! Here, too, my vision for Rust aligns very well with the direction C is taking. -(The set of valid guesses in C is just a lot more restricted since they do not have `wrapping_offset`. That means they can actually feasibly give an algorithm for how to do the guessing.) +(The set of valid guesses in C is just a lot more restricted since they do not have `wrapping_offset`, and the model does not cover `restrict`. +That means they can actually feasibly give an algorithm for how to do the guessing. +They don't have to invoke scary terms like "angelic non-determinism", but the end result is the same -- and to me, the fact that it is equivalent to angelic non-determinism is what justifies this as a reasonable semantics. +Presenting this as a concrete algorithm to pick a suitable provenance is then just a stylistic choice.) +Kudos go to Michael Sammler for opening my eyes to this interpretation of "user disambiguation", and arguing that angelic non-determinism might not be such a crazy idea after all. What is left is the question of how to handle pointer-integer transmutation, and this is where the roads are forking. PNVI-ae-udi explicitly says loading from a union field at integer type exposes the provenance of the pointer being loaded, if any. -So, the example with `transmute_union` would be allowed, meaning the optimization of removing the "dead" load from the `union` would *not* be allowed. -Same for `transmute_memcpy`, where the proposal says that `memcpy` should *preserve* provenance, but later when we access the contents of `ret` at type `uintptr_t`, that will again implicitly expose the provenance of the pointer. +So, the example with `transmute_union` would be allowed, meaning the optimization of removing the "dead" load from the `union` would *not* (in general) be allowed. +Same for `transmute_memcpy`, where the proposal says that when we access the contents of `ret` at type `uintptr_t`, that will again implicitly expose the provenance of the pointer. I think there are several reasons why this choice makes sense for C, that do not apply to Rust: - There is a *lot* of legacy code. A *LOT*. - There is no alternative like `MaybeUninit` that could be used to hold data without losing provenance. - Strict aliasing means that not *all* loads at integer type have to worry about provenance; only loads at character type are affected. -On the other hand, I estimate the cost of this choice to be huge. +On the other hand, I am afraid that this choice might come with a significant cost in terms of lost optimizations. As the example above shows, the compiler has to be very careful when removing any operation that can expose a provenance, since there might be integer-pointer casts later that rely on this. +(Of course, until this is actually implemented in GCC or LLVM, it will be hard to know the actual cost.) Because of all that, I think it is reasonable for Rust to make a different choice here. ## Conclusion @@ -410,19 +421,26 @@ My personal stance is that we should not let the cast synthesize a new provenanc This would entirely lose the benefit I discussed above of making pointer-integer round-trips a *local* concern -- if these round-trips produce new, never-before-seen kinds of provenance, then the entire rest of the memory model has to define how it deals with those provenances. We already have no choice but treat pointer-integer casts as an operation with side-effects; let's just do the same with integer-pointer casts and remain sure that no matter what the aliasing rules are, they will work fine even in the presence of pointer-integer round-trips. +That said, under this model integer-pointer casts still have no side-effect, in the sense that just removing them (if their result is unused) is fine. +Hence, it *could* make sense to implicitly perform integer-pointer casts in some situations, like when an integer value (without provenance) is used in a pointer operation (due to an integer-to-pointer transmutation). +This breaks some optimizations like load fusion (turning two loads into one assumes the same provenance was picked both times), but most optimizations (in particular dead code elimination) are unaffected. + #### What about LLVM? I discussed above how my vision for Rust relates to the direction C is moving towards. What does that mean for the design space of LLVM? -Which changes need to be made to fix (potential) miscompilations in LLVM and to make it compatible with these ideas for C and/or Rust? +Which changes would have to be made to fix (potential) miscompilations in LLVM and to make it compatible with these ideas for C and/or Rust? Here's the list of open problems I am aware of: -- LLVM needs to stop [removing `inttoptr(ptrtoint(_))`](https://bugs.llvm.org/show_bug.cgi?id=34548) and stop doing [replacement of `==`-equal pointers](https://bugs.llvm.org/show_bug.cgi?id=35229). +- LLVM would have to to stop [removing `inttoptr(ptrtoint(_))`](https://github.com/llvm/llvm-project/issues/33896) and stop doing [replacement of `==`-equal pointers](https://github.com/llvm/llvm-project/issues/34577). - As the first example shows, LLVM also needs to treat `ptrtoint` as a side-effecting operation that has to be kept around even when its result is unused. (Of course, as with everything I say here, there can be special cases where the old optimizations are still correct, but they need extra justification.) - I think LLVM should also treat `inttoptr` as a side-effecting (and, in particular, non-deterministic) operation, as per the last example. However, this could possibly be avoided with a `noalias` model that specifically accounts for new kinds of provenance being synthesized by casts. (I am being vague here since I don't know what that provenance needs to look like.) So far, this all applies to LLVM as a Rust and C backend equally, so I don't think there are any good alternatives. On the plus side, adapting this strategy for `inttoptr` and `ptrtoint` means that the recent LLVM ["Full Restrict Support"](https://lists.llvm.org/pipermail/llvm-dev/2019-March/131127.html) can also handle pointer-integer round-trips "for free"! +Adding `with_addr`/`copy_alloc_id` to LLVM is not strictly necessary, since it can be implemented with `getelementptr` (without `inbounds`). +However, optimizations don't seem to always deal well with that pattern, so it might still be a good idea to add this as a primitive operation to LLVM. + Where things become more subtle is around pointer-integer transmutation. If LLVM wants to keep doing replacement of `==`-equal integers (which I strongly assume to be the case), *something* needs to give: my first example, with casts replaced by transmutation, shows a miscompilation. If we focus on doing an `i64` load of a pointer value (e.g. as in the LLVM IR produced by `transmute_union`, or pointer-based transmutation in Rust), what are the options?