Top
Best
New

Posted by formerly_proven 11 hours ago

11 year old unsoundness bugs found in Rust borrow checker(github.com)
625 points | 356 commentspage 2
wat10000 8 hours ago|
The emotional reactions to this stuff are so weird. Yeah, software has bugs, what a surprise. That doesn't mean that static safety is useless or that we should all just continue YOLOing stuff in C.
bcjdjsndon 7 hours ago|
[flagged]
samus 5 hours ago|||
That's not how "unsafe" works at all. If "unsafe" is so commonplace in a code base that your statement becomes relevant then the project is badly run, and memory safety is just another problem among many others. Similar to accepting code with empty or nonsensical `catch` blocks.
loa_in_ 6 hours ago||||
You can iterate through all unsafe sections of the codebase by simply grepping for `unsafe`, the rules are followed everywhere else. It's a great improvement.
yencabulator 18 minutes ago||
Even better, configuring the built-in static analysis to forbid it is a one-liner.
benjaminkiefl 4 hours ago|||
As always the same tropes get repeated ad nauseam.

Rust code using `unsafe` != C code:

-) you can encapsulate `unsafe` internals behind safe interfaces

-) you can concentrate operations requiring manual safety reasoning in small parts of the codebase

-) safety obligations become explicit

-) `unsafe` does not turn off the borrow checker, type checking, ordinary bounds checks, or automatic cleanup and you still benefit from Rust's provided abstractions and its composability

I'm not a Rust shill, and not a single programming language I use is part of my online identity, but if "Rust is a cult", the anti-Rust gang isn't?

Getting more and more annoyed by the latter to be honest.

wat10000 4 hours ago||
Yeah, I'm always told that the Rust community is obnoxious and condescending. But I never actually see it. I do see a bunch of obnoxious anti-Rusters show up every time Rust gets mentioned to complain about it.

Also not a Rust shill, I used it for a few months about a decade ago. I am a memory-safe-programming-languages shill, though. I know there are good reasons people still use C-family languages (and I'm often one of them) but we should definitely be doing our best to move off them as quickly as we reasonably can.

FuzzywarmWalnut 11 hours ago||
Worked on a variance-related lint once and the scary part is how rarely anyone writes down variance by hand, so bugs there just sit untested for a decade.
afdbcreid 8 hours ago||
Nobody writes variance at hand in Rust because it's impossible (since before 1.0). You sometimes need to enforce stricter variance than the default (can cause unsoundness and is very subtle but actual unsoundness here is rare) or give more permissive variance (harder and also cannot cause unsoundness, just annoyance).

When implementing the type system, however, variance does have to be treated with care. Historically it wasn't much, now it is.

Surac 8 hours ago||
The question is. If rust used rust would that have prevented this?
zamalek 7 hours ago||
Rust does not prevent all bugs, only certain ones (that are common and dangerous).
romac 4 hours ago|||
The Rust compiler (rustc) is written in Rust, but there can always be bugs in the compiler, regardless of whether it's written in a memory-safe language or not.
loa_in_ 6 hours ago|||
Rust allows for type-proofing your logic, but it can't force you to.
efficax 8 hours ago||
no
MrBuddyCasino 11 hours ago||
The heart of the matter:

  struct Q<T: 'static>(Option<T>);

  impl<T: 'static> Drop for Q<T> {
    fn drop(&mut self) {
        // Code in here has access to a non-'static &String
        // but assumes that it's a &'static String
    }
  }

  fn main() {
    let l = String::new();
    let mut q = Q(None);
    q.0 = Some(&l);
  }
I wonder if the certified compiler for safety critical uses (edit: Ferrocene) has the same issue.
afdbcreid 10 hours ago||
> I wonder if the certified compiler for safety critical uses has the same issue.

It uses the same code, so yes.

(Although I don't know what are their guidelines, it might be that they require avoiding code relying on implied bounds, for example).

1718627440 10 hours ago|||
Can someone ELI5 for people who don't know Rust (but e.g. C)?
thinkharderdev 9 hours ago|||
Rust is supposed to guarantee statically that a reference cannot outlive its referent. The `T: 'static` bound means that the value that Q holds must have a 'static lifetime (e.g. either be an owned value or a reference to a static value). So this program should not compile since a reference to l is not 'static. This code is unsound because rust will drop the string l before q so when the Q destructor runs the underlying string has already been freed yet we have a valid reference to it.
1718627440 9 hours ago|||
So the equivalent in C would be:

        $ cat main.c
        #include <stdlib.h>
        #include <string.h>

        struct Q {
                /*@null@*/ /*@only@*/ char * t;
        };


        /*@only@*/
        struct Q *
        Q_new (/*@only@*/ char * t)
        {
                struct Q * q = malloc (sizeof *q);
                q->t = t;
                return q;
        }

        void
        Q_set (struct Q * q, /*@only@*/ char * t)
        {
                free (q->t);
                q->t = t;
                return;
        }

        void
        Q_free (/*@only@*/ struct Q * q)
        {
                free (q->t);
                free (q);
                return;
        }



        int
        main (void)
        {
                /*@only@*/ char * l;
                /*@only@*/ struct Q * q;

                l = strdup ("");
                q = Q_new (NULL);

                Q_set (q, l);
                free (l);

                /* This is UB */
                Q_free (q);

                return EXIT_SUCCESS;
        }

        $ splint main.c
        Splint 3.1.2 --- 21 Feb 2021

        main.c: (in function Q_new)
        main.c:15:3: Arrow access from possibly null pointer q: q->t
          A possibly null pointer is dereferenced.  Value is either the result of a
          function which may return null (in which case, code should check it is not
          null), or a global, parameter or structure field declared with the null
          qualifier. (Use -nullderef to inhibit warning)
           main.c:14:17: Storage q may become null
        main.c: (in function main)
        main.c:43:6: Unrecognized identifier: strdup
          Identifier used in code has not been declared. (Use -unrecog to inhibit
          warning)
        main.c:44:13: Null storage passed as non-null param: Q_new (NULL)
          A possibly null pointer is passed as a parameter corresponding to a formal
          parameter with no /*@null@*/ annotation.  If NULL may be used for this
          parameter, add a /*@null@*/ annotation to the function parameter declaration.
          (Use -nullpass to inhibit warning)
        main.c:47:8: Dead storage l passed as out parameter to free: l
          Memory is used after it has been released (either by passing as an only param
          or assigning to an only global). (Use -usereleased to inhibit warning)
           main.c:46:12: Storage l released
        main.c:12:1: Function exported but not used outside main: Q_new
          A declaration is exported, but not used outside this module. Declaration can
          use static qualifier. (Use -exportlocal to inhibit warning)
           main.c:17:1: Definition of Q_new
        main.c:20:1: Function exported but not used outside main: Q_set
           main.c:25:1: Definition of Q_set
        main.c:28:1: Function exported but not used outside main: Q_free
           main.c:33:1: Definition of Q_free

        Finished checking --- 7 code warnings
(The equivalent warning is main.c:47)
NobodyNada 8 hours ago||
If you wrote a Q_free function and called it after `free(l);` to actually demonstrate the UAF, that would be pretty close to the semantics of the Rust example.
1718627440 7 hours ago||
I have added that now.

I originally didn't do this, because I didn't feel the need to invoke UB without necessity. Also I wanted to demonstrate, that the error can (and is) caught in C, without actually committing the double-free (so that the program behaviour is still well-defined, but the abstraction is already violated).

NobodyNada 6 hours ago||
I'm not familiar with Splint, but my understanding is that the error is because of the @only@ marker indicating that the string is not shared. That's an incorrect translation of the Rust program, where the reference to the string is explicitly a shared reference rather than an owned reference. The issue isn't the sharing; it's that rustc mistakenly allows the code to assign a reference that is valid until the end of the current scope to a field that expects a reference valid forever (like a string constant in the program binary). The Rust program with equivalent semantics to your C program (passing an owned String instead of a shared reference) is correctly rejected by the compiler.

Also, the Q_free function should not free q->t. It may access it (e.g. printing it), but it does not own that reference and is not responsible for deallocating its memory.

Sorry, I should have looked closer the first time! My excuse is that it was too early in the morning to be thinking about such things.

1718627440 6 hours ago||
Yes, @only@ means that the parameter/variable is owning. I believed that would have been the meaning in Rust. Now I can no longer edit my comment, oh well.

> Also, the Q_free function should not free q->t.

Well, that's because I thought it would own it. But if in Rust it is not implied that the Q owns the string, then I fail to see where the error is? Both l and q will be deallocated at the end of the main function. Depending on the order of the deallocation maybe q exists for a theoretical time while pointing to already deallocated memory. But both get deallocated at the same point, so q does not outlive the existence of l, so where is the error?

NobodyNada 6 hours ago||
The error is the destructor of `q` could look at the reference and stash it away somewhere, like a global variable (since the compiler thinks the reference is valid forever). Then, some other part of the program could access that variable and cause a UAF.
biorach 8 hours ago||||
> rust will drop the string l before q

Why?

NobodyNada 6 hours ago|||
That's incorrect; the compiler drops q before l. Drops happen in LIFO order; and the compiler knows that it can't drop l before q has been dropped, because q contains a reference to l.

The issue is that the destructor of q can observe a reference which is about to be deallocated as having a 'static lifetime (i.e. it will live forever), and can thus stash it away somewhere for other code to access after it's been freed (as in the examples in the issue which demonstrate UB.)

1718627440 7 hours ago|||
My thoughts as well. The problem can be alleviated either by making the ownership change an error or "just" invoking the destructors in the correct order.

But I think https://news.ycombinator.com/item?id=50072252 answers it:

    A 'static lifetime constraint on T says: types that satisfy T must not contain any borrowed value/content whose lifetime will be shorter than the life of the program. 
So the compiler assumes it is guaranteed, that 'l' still exists, so it doesn't consider it in regard of finalizer ordering.
1718627440 9 hours ago|||
What does "q.0" and "Some(...)" mean?
thinkharderdev 9 hours ago|||
So Q is a "tuple-struct". In rust you can define a struct like

struct Q<T> { value: Option<T> }

with a named field or you can define it like

struct<T> Q(Some<T>)

which is the same struct layout but without an explicit name for the value. In that case, the field is implicitly names `0`

So q.0 = Some(foo) is equivalent to q.value = Some(foo).

The Some(..) is just one variant of the Option enum:

enum Option<T> { None, Some(T) }

Option represents a value that may or may not be initialized. For uninitialized values you use the None variant and then you can initialize it to Some(value).

The C rough equivalent would be

struct Q { void* value; }

struct Q q = q { NULL }; q->value = ...

cowlevel 9 hours ago||||
Some(...) is just constructor for an option type as seen in other languages e.g. java's Optional, haskell's Maybe, c++'s std::optional. I assume that it's needed here because the field can't start as a null pointer otherwise. Maybe the vulnerability still works if the field starts with one string value and swaps to a different string value, but having it start holding None made for a simpler demo.

q.0 is the first field of q, which is anonymous. They could have just as well called it x and written q.x.

bonzini 9 hours ago||||
"Some(...)" means that this Option<T> does have a value of type T in it; "None" means it doesn't.

"q.0" is not really needed, it's a struct with unnamed fields. Here is the same with named fields:

  struct Q<T: 'static> { pub x: Option<T> }
  ...
  let l = String::new();
  let mut q = Q { x: None };
  q.x = Some(&l);
efficax 8 hours ago|||
'static lifetime bounds are kind of confusing in Rust, but here goes: A 'static lifetime constraint on T says: types that satisfy T must not contain any borrowed value/content whose lifetime will be shorter than the life of the program. So suppose you have a string s, and Option<&String> q. If q has the constraint 'static, and you do q = Some(&s) this should be rejected by the compiler since a reference to s gets a shorter lifetime than 'static by default (you could do q = Some(&'static s) if s is say a string literal or a leaked box but not for a normal string). In the examples it looks like you have to use a tuple to trick the borrow checker but the concept is the same: Rust allowed violation of the 'static lifetime bounds. The drop implemenation demonstrates it: drop happens in reverse declaration order, so the string s gets dropped first. Now q has a dangling reference, a pointer to dropped memory essentially, and we get a pretty classic use after free bug.
high_na_euv 11 hours ago|||
which compiler?
sshine 11 hours ago||
Ferrocene
wg0 11 hours ago||
DeepSeek v4.1 Flash thinks it should NOT compile. Whereas in reality it DOES compile which is why it is filed as a bug.
jug 10 hours ago||
They're seriously using AI as a judge for what to file?
afdbcreid 10 hours ago|||
The issue and none of the comments mention AI. It is pretty clear your parent said it by themselves.
wg0 10 hours ago|||
I was just checking what outcome an LLM would have based on the patterns in its probabilistic distribution it has seen the wild.

Most LLMs would probably say this piece of code won't compile.

ChickeNES 10 hours ago||
chatgpt and grok linked to the the soundness issue, claude I am in usage timeout, and gemini shit the bed as usual: "The compiler rejects the program with error[E0597] on the assignment q.0 = Some(&l)"
badatnames 11 hours ago||
There is a collection of these someplace. Rust is better but it'll never be perfect, news at 10
zer00eyz 10 hours ago|
Rust is good, I generally do not dislike it - but it does have flaws.

I will happily continue to write things in Golang first. I don't enjoy refactors in Rust, and it's rare that I see things that genuinely need its raw performance.

Cloudflare's Oxy is a stellar example of build in Golang till your design is settled and then port over for performance when you need it.

vincent-uden 11 hours ago||
What the hell. This is not the esoteric, crazy, impractical constellation I expected to find with a title like this. The minimal example is so simple and is definitely something you'd easily come across in normal code.
insanitybit 11 hours ago||
For a long time `loop {}` was undefined behavior. Lots of l-unsounds are really nuts but a few are not.

The reality is that if this were actually code that people wrote, well, it wouldn't have sat around for a decade.

masklinn 9 hours ago||
Afaik loop {} has never been UB, however because llvm started as a c++ compiled where it is, this has been causing miscompilations in rust and C for… nearly two decades (llvm issue 965).
insanitybit 4 hours ago|||
Sure, I think it was still under the same unsound tag regardless.
masklinn 3 hours ago||
Unsoundness and UB are very different things, UB tells users of the language "don't do that it's wrong", unsoundness tells users of the language "be careful we need to fix that", it's a bug.
cowlevel 9 hours ago|||
Since the Rust language is defined by the Rust compiler and has no other specification, if it's UB in the compiler then it's UB in the language.
masklinn 8 hours ago|||
And this, kids, is how you get called Billy Madison.

May god have mercy on your soul, cowlevel1.

pmarreck 7 hours ago|||
Rust has no formal spec? Really?
Georgelemental 5 hours ago||
It's a work in progress. The Reference (https://doc.rust-lang.org/reference) is slowly getting more complete over time. Other ongoing efforts include a-mir-formality (https://github.com/rust-lang/a-mir-formality), minirust (https://github.com/minirust/minirust), the FLS (https://github.com/rust-lang/fls), and Rust via Desugarings (https://nadrieril.github.io/rust-via-desugarings/). But writing a 100% complete and correct formal spec is hard! Not even C gets this right, the C standard has holes which lead to miscompilations and UB in practice: https://www.ralfj.de/blog/2020/12/14/provenance.html
fg137 10 hours ago|||
> is definitely something you'd easily come across in normal code.

The fact that nobody reported this before means it's not, by definition.

vincent-uden 9 hours ago||
Not at all. There are way more valid lifetimes than the lifetime system of Rust can express. There are countless scenarios where you could accidentally use the unsound behaviour while not producing a segmentation fault because the lifetimes happens to be arranged such that no invalid memory access actually happened.

A program isn't invalid just because it violates the ownership and borrowing rules of Rust. There are tons of programs in the world with shared, mutable access, devoid of Mutexes, Arcs and Locks that nonetheless are completely valid.

fg137 8 hours ago||
I think you are talking about a different problem and that has nothing to do with my comment.
foldr 8 hours ago||
Simplified, the point is that a lot of badly typed code that this bug lets through might be mostly harmless (or even fully semantically correct, in some instances). So you can't conclude that 'normal' code wouldn't trigger this bug just because no-one's noticed it until now. The bad code could could be happily executing and performing as expected.
LoganDark 11 hours ago|||
I wonder if the reason it works is because you'd easily come across it in normal code, and someone didn't realize that saving the effort of implementing a check for this would result in unsoundness.
afdbcreid 8 hours ago||
That's not how it works. rustc doesn't "check" for specific cases explicitly, the borrow checker (apparently at fault here by reports) has a set of rules that should reject invalid programs. So either one of the fundamental rules was broken (unlikely here from what I've heard), or some optimization caused the rules to not be implemented correctly.
LoganDark 49 minutes ago||
I know the borrow checker uses rules, but I don't know enough about the internal implementation details to use fully precise terminology. I don't mean borrow checking is just a chain of if-statements or something. I meant check in the abstract.

From the GitHub thread: "I think unification just generally doesn't check lifetime bounds of the type." [0]

There are steps, or rules or whatever, that perform soundness (or well-formedness or whatever) checks. And clearly one was missing.

My guess was just that maybe someone didn't think it was necessary for soundness.

[0]: https://github.com/rust-lang/rust/issues/164250#issuecomment...

jvanderbot 11 hours ago||
Not really. I've worked with rust for 4 years, systems side, and never had to write a custom drop implementation. That's the gotcha here, not the rigamarole with the static+Send option type (which _would_ be something you'd encounter in prod).
bryanlarsen 11 hours ago|||
I've written dozens of custom Drop implementations. Well, many of them are named cleanup because of the async Drop mess in Rust, but...
dwattttt 9 hours ago|||
How many of them only conditionally implement Drop, based on a generic param lifetime?
Xirdus 9 hours ago||
Why would you ever conditionally custom impl Drop? I don't think I've ever seen that pattern, and I can't think of a use case.
jason_oster 5 hours ago||
Therefore explaining why the bug hadn't been hit in practice for 11 years.
Xirdus 1 hour ago||
The bug in the OP is about UNconditional impl. And honestly, looks like very standard code, definitely something that could've been written for real. I can't even explain why it passes checks, it clearly shouldn't.
dwattttt 32 minutes ago||
This code, from the bug submission?

  impl<T: 'static + Send> Drop for Q<T> {
      fn drop(&mut self) { if let Some(t) = self.0.take() { KEPT.lock().unwrap().push(Box::new(t)); } }
  }
The bound on T in the Drop impl means Drop is not implemented for all T, only where T: 'static + Send. Q<T> also having the same bound doesn't make a difference to the Drop impl.
jvanderbot 9 hours ago|||
OK. That's good to know. I'm not sure why sharing our professional experience with this type of code is controversial, but here we are.
neysofu 10 hours ago||||
That's easily the exception rather than the rule. I've seen custom `Drop` implementations in almost every non-trivial Rust codebase I worked on.
gspr 10 hours ago||||
A custom `Drop` appears very quickly with FFI code. Suppose for example you interface with some C library that expects you to call a function to release some resources.
rvz 9 hours ago|||
Now we are at the phase where, Rust is “safe” but only if you use it “correctly” and without using X language feature or a custom Y feature.

This is the problem when a language advertises with “guarantees” and it turns out it never had any due to many false assumptions by their promoters.

So what other soundness bugs are there in the language that we need to know about?

jvanderbot 9 hours ago|||
I'm not defending rust here - a correctness bug invalidates the premise. But I am saying that I've never seen a custom drop in production.

For some reason this is controversial, but it remains true.

bryanlarsen 9 hours ago|||
You said/implied 2 things:

- you've never written a custom Drop

- "Not really". Implying that your experience generalizes

I've written a bunch of rust programs without custom Drop. I believe your first statement. There are large classes of programs that don't need it. There are also many that do. I think your statement says a lot more about what kind of programs you work on than about custom Drop not being common. People are downvoting the implication that custom Drop is not a common pattern.

cmrdporcupine 9 hours ago|||
Custom drops are all over the place. As others have said.

But not custom drops that are doing variadic type stuff like this, usually.

cmrdporcupine 9 hours ago|||
No we're in a situation where a bug in the type checker was found and will be fixed and a new release will be made.

That it took 11 years for someone to come across this is a fairly intense counterargument to your attempt to paint this as a language with dangerous footguns.

bcjdjsndon 6 hours ago||
[flagged]
thunderfork 6 hours ago||
A gun in a glass case with a warning label reading "do not aim at foot"?
Hugsbox 11 hours ago||
I don't understand how something like this could take so long to be discovered. Pretty crazy stuff.
lelanthran 11 hours ago||
> I don't understand how something like this could take so long to be discovered.

Maybe they wrote it in C :-)

On a serious note, old software is generally more trustworthy than new software, because bugs are discovered over time and with usage.

dabinat 5 hours ago|||
> On a serious note, old software is generally more trustworthy than new software, because bugs are discovered over time and with usage.

The age of software means bugs are more likely to be discovered but not necessarily fixed. In the case of Rust, the developers have documented a whole lot of unsoundness bugs that require a new borrow checker architecture to fix.

zaphar 10 hours ago|||
Old C software is generally more trustworthy than new C software. I don't think this rule of thumb transfers across languages very well. And the definition of new vs old has a fairly large effect. If a language has been under constant change it doesn't matter when it was started a large part of the codebase is "new".
fg137 10 hours ago|||
Because it's rarely used like this, just like weird bugs caused by edge cases found in all kinds of software in the world.
chelseahermes 8 hours ago||
[flagged]
kirle_pg 6 hours ago|
[flagged]
More comments...