Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BTreeSet causes UB by having (partially) dangling shared reference #54957

Closed
RalfJung opened this issue Oct 10, 2018 · 25 comments · Fixed by #56648
Closed

BTreeSet causes UB by having (partially) dangling shared reference #54957

RalfJung opened this issue Oct 10, 2018 · 25 comments · Fixed by #56648
Labels
I-unsound Issue: A soundness hole (worst kind of bug), see: https://en.wikipedia.org/wiki/Soundness P-medium Medium priority T-libs-api Relevant to the library API team, which will review and decide on the PR/issue.

Comments

@RalfJung
Copy link
Member

The following code causes UB (found by a patched miri enforcing validity invariants):

    let mut b = std::collections::BTreeSet::new();
    b.insert(42);

What happens is that BTreeSet::new sets the root node to a pointer to a static EMPTY_ROOT_NODE shared for this purpose by all B-trees that has type LeafNode<(), ()>. That step is okay.

However, in the next step, insert calls ensure_root_is_owned which indirectly turns this ptr into &LeafNode<i32, ()>. The trouble is that LeafNode<i32, ()> is bigger than LeafNode<(), ()>, so this reference is partially dangling: Its beginning points to allocated memory (the static EMPTY_ROOT_NODE), but that allocated memory ends "too early". The invariant that &LeafNode<i32, ()> be dereferencable for mem::size_of::<LeafNode<i32, ()>>() bytes is violated. Since we are passing dereferencable(n) to LLVM with n set to the full size of the struct, this means we have UB not just according to our own validity rules, but according to LLVM's rules.

@Centril Centril added T-libs-api Relevant to the library API team, which will review and decide on the PR/issue. I-unsound Issue: A soundness hole (worst kind of bug), see: https://en.wikipedia.org/wiki/Soundness labels Oct 10, 2018
@Centril Centril added the P-medium Medium priority label Oct 10, 2018
@aturon
Copy link
Member

aturon commented Oct 10, 2018

cc @gankro

@Gankra
Copy link
Contributor

Gankra commented Oct 10, 2018

Good catch, and very interesting. I wasn't aware that this was a thing. When was this semantic decided/documented?

This can be easily fixed by hoisting the ptr::eq(self, &EMPTY_ROOT_NODE as *const _ as *const _) into is_shared_root.

@Gankra
Copy link
Contributor

Gankra commented Oct 10, 2018

Also: what value does that metadata have? Allowing speculative loads?

@RalfJung
Copy link
Member Author

RalfJung commented Oct 11, 2018

This can be easily fixed by hoisting the ptr::eq(self, &EMPTY_ROOT_NODE as *const _ as *const _) into is_shared_root.

I don't think that's enough. All the code that does read-only operations (get, for example) will likely also turn the shared root into a reference.

what value does that metadata have? Allowing speculative loads?

Exactly.

Moreover, even just enforcing the noalias annotations the way I proposed makes it UB to have dangling references.

@RalfJung
Copy link
Member Author

@nikomatsakis points out there might also be alignment problems. @eddyb are there any types that have alignment bigger than pointers?

@nikomatsakis
Copy link
Contributor

nikomatsakis commented Oct 11, 2018

One possible fix -- but it'd be a big lang change -- is supporting generic statics (so that the static node can have a valid type). Of course that's not a "short term" usable thing.

@RalfJung
Copy link
Member Author

Another thing @nikomatsakis suggested is to essentially use &LeafNode<(), ()> throughout the read-only operations until we determined that the node actually is non-empty. That should also work.

@Gankra
Copy link
Contributor

Gankra commented Oct 11, 2018

I need to go over the code to check but I assume the “right” fix is to define an untyped base header type that LeafNode “inherits” from, just like we did in the Swift stdlib (where swift has TBAA)

@Gankra
Copy link
Contributor

Gankra commented Oct 12, 2018

Deferring working on this for a bit while some other stuff lands, some notes:

the core of the right fix is to add something like this code:

    fn as_key_slice(&self) -> &'a [K] {
        // When taking a pointer to the keys, if our key has a stricter
        // alignment requirement than the shared root does, then the pointer
        // would be out of bounds, which LLVM assumes will not happen. If the
        // alignment is more strict, we need to make an empty slice that doesn't
        // use an out of bounds pointer.
        if mem::align_of::<K>() > mem::align_of::<LeafPrefix>() && self.is_shared_root() {
            &[]
        } else {
            // Here either it's not the root, or the alignment is less strict,
            // in which case the keys pointer will point "one-past-the-end" of
            // the node, which is allowed by LLVM.
            unsafe {
                slice::from_raw_parts(
                    &(*self.node as *const LeafNode<K, V>).keys as *const K,
                    self.len()
                )
            }
        }
    }

to btree::node::Root, and introduce an untyped header/prefix as I discussed above. The annoying part is going through all the code and making perfectly sure nothing casts to a Leaf prematurely.

@RalfJung
Copy link
Member Author

RalfJung commented Oct 13, 2018

the core of the right fix is to add something like this code:

I am not sure why comparing the alignments is enough to guarantee that things are in-bounds (I assume "the pointer" which is out of bounds is self.node, but that's not clear either). And anyway wouldn't it be much simpler to just return &[] whenever self.len() == 0? Determining the len should be possible even for the "untyped header".

@Gankra
Copy link
Contributor

Gankra commented Oct 14, 2018

The idea is we're trying to avoid any additional checks.

Alignment differences determine padding, which in turn determines if the keys would be placed within the bounds of the LeafPrefix's size.

@RalfJung
Copy link
Member Author

Oh I see, the alignment checks would only involve constants and hence not be present at run-time... self.is_shared_root() would still be a run-time check though?

@Gankra
Copy link
Contributor

Gankra commented Oct 14, 2018

Yeah it compares a dynamic pointer to a static value, but it's &&'d, so under reasonable conditions it will never run.

@Shnatsel
Copy link
Member

Is there a way to observe some kind of memory problem in practice because of this issue, such as an out-of-bounds read?

I'm asking because it would be an interesting test for https://github.com/blt/bughunt-rust - it would be very useful to see whether the testing setup detects this issue or not in order to gauge how it performs in practice.

@RalfJung
Copy link
Member Author

No, I don't think LLVM exploits this UB and it doesn't by itself lead to bad memory accesses or such things.

@pnkfelix pnkfelix changed the title BTreeSet causes UB my having (partially) dangling shared ref BTreeSet causes UB by having (partially) dangling shared ref Oct 24, 2018
@gnzlbg
Copy link
Contributor

gnzlbg commented Nov 26, 2018

are there any types that have alignment bigger than pointers?

I'm not sure I follow whether this is relevant, but this is the case for:

  • repr(align(N)) types where N is larger than align_of::<*const T>(): for example, a Context type containing the state of all registers can be created efficiently using something like _xsave, but that requires Context to be 64-byte aligned,
  • repr(simd) types: in general, these have alignment requirements much larger than pointers, for example, up to 32-byte alignment on x86.

@RalfJung RalfJung changed the title BTreeSet causes UB by having (partially) dangling shared ref BTreeSet causes UB by having (partially) dangling shared reference Dec 6, 2018
@RalfJung
Copy link
Member Author

RalfJung commented Dec 7, 2018

@gankro I have a fix for the out-of-bounds problem... but there's another one. ;) Consider into_slices_mut:

    fn into_val_slice_mut(mut self) -> &'a mut [V] {
        debug_assert!(!self.is_shared_root());
        unsafe {
            slice::from_raw_parts_mut(
                self.as_leaf_mut().vals.as_mut_ptr() as *mut V,
                self.len()
            )
        }
    }

    fn into_slices_mut(self) -> (&'a mut [K], &'a mut [V]) {
        let k = unsafe { ptr::read(&self) };
        (k.into_key_slice_mut(), self.into_val_slice_mut())
    }

I find the ptr::read quite fascinating, this is duplicating a non-Copy value. :D

But anyway, this is a blatant violation of mutable references being unique. Namely, when we call into_val_slice_mut, we call as_leaf_mut(), meaning we assert unique access to the entire leaf -- including the keys! Of course, this invalidates the mutable reference returned from into_key_slice_mut.

Cases like this and the discussions we have been having around two-phase borrows make me wonder whether it is indeed the right call to consider creating a mutable reference as being roughly equivalent to a write access. The trouble is, if you don't do that, then when do you even assert uniqueness of mutable references? Only when they are written to? That can't be it, we couldn't say noalias to LLVM with that. Passing a mutable reference to a function must also be something like a write. But then it seems really odd to special-case function calls like that, considering in particular that we want to solve #16515 some day.

@Gankra
Copy link
Contributor

Gankra commented Dec 7, 2018

The only reason we create a mutable reference here is because we lack ->. The code is just trying to compute some pointer offsets as easily as possible :/

@RalfJung
Copy link
Member Author

RalfJung commented Dec 8, 2018

Ah right, using raw pointers likely fixes the problem.

@nikomatsakis @eddyb lack of auto-deref for raw pointers considered harmful...

@eddyb
Copy link
Member

eddyb commented Dec 9, 2018

I'd rather change the deref operator from prefix to postfix than make it implicit for raw pointers.

@RalfJung
Copy link
Member Author

RalfJung commented Dec 9, 2018

Yeah, a postfix deref operator would be nice. Currently, things get a bit ugly.

@glaebhoerl
Copy link
Contributor

Couldn't we add some kind of method (inherently postfix) as an 80-20 solution? Or is the problem that we couldn't give it an appropriate type?

@RalfJung
Copy link
Member Author

RalfJung commented Dec 9, 2018

I don't think * is an operation that a function can perform. * does not mean memory actually gets accessed, as in &*!

@Gankra
Copy link
Contributor

Gankra commented Dec 9, 2018

I don't think postfix * works because ptr*.0 is ambiguous as either "mul by 0.0" or "deref to get field .0", would likely need -> or something new.

@RalfJung
Copy link
Member Author

RalfJung commented Dec 9, 2018

I submitted a proposed fix for this bug at #56648

Centril added a commit to Centril/rust that referenced this issue Dec 16, 2018
Fix BTreeMap UB

BTreeMap currently causes UB by created a shared reference to a too-small allocation.  This PR fixes that by introducing a `NodeHeader` type and using that until we really need access to the key/value arrays.  Avoiding run-time checks in `into_key_slice` was somewhat tricky, see the comments embedded in the code.

I also adjusted `as_leaf_mut` to return a raw pointer, because creating a mutable reference asserts that there are no aliases to the pointee, but that's not always correct: We use `as_leaf_mut` twice to create two mutable slices for keys and values; the second call overlaps with the first slice and hence is not a unique pointer.

Fixes rust-lang#54957

Cc @nikomatsakis @gankro
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
I-unsound Issue: A soundness hole (worst kind of bug), see: https://en.wikipedia.org/wiki/Soundness P-medium Medium priority T-libs-api Relevant to the library API team, which will review and decide on the PR/issue.
Projects
None yet
Development

Successfully merging a pull request may close this issue.

9 participants