GithubHelp home page GithubHelp logo

Comments (3)

cbiffle avatar cbiffle commented on August 15, 2024

This is a good observation, thank you Ilja. Here are my notes.

tl;dr: I lean toward tightening validation here, but for a reason that is somewhat different from yours. Full recommendations at the end.

Analysis of ProbeForRead and kin

In C/assembly kernels, one often winds up with a (pointer, length) pair provided by an untrusted source. Validating that pair using e.g. ProbeForRead in Windows doesn't actually change the situation: you still have a (pointer, length) pair. This means that, somewhere, your code is going to wind up taking that user-provided pointer and dereferencing it. The dereference operations work on pointers, not (pointer, length) pairs, meaning that it is entirely possible to ProbeForRead using length 4 and then load an 8-byte quantity from the pointer. Such code would be wrong, of course, but is not significantly different (structurally or visually) from the correct code.

Critically, note the type signature of ProbeForRead. The address given to it is a const volatile void *, and the return value is ... void. (The Windows kernel has an exception-like mechanism for handling such failures, which is why it doesn't return their equivalent of bool.) This means that the untrusted address is already in dereferenceable pointer form before ProbeForRead is called, and further implies that one could simply delete the call to ProbeForRead and the code would still compile -- the code after the check is going to either pass the pointer to a routine like memcpy that accepts void *, or is going to cast it to a different pointer type.

This is a deeply-ingrained structural violation of the Parse, Don't Validate principle, which maintains that the pre-check and post-check types should be meaningfully different at the type level (that is, different enough that implicit conversions don't work). One way to improve this in C is to model untrusted addresses as intptr_t (i.e. just some random number that happens to be pointer sized) and have the validation routine return the pointer-casted equivalent. This is not foolproof (in particular, such an API will almost certainly return NULL on validation failure, so let's hope your kernel catches internal null accesses) but is roughly as good as one can do without strong opaque value types and typesafe unions to implement e.g. Option.

Note: While I'm describing this issue in terms of Windows, Windows is not unique here -- the Linux kernel's copy_from_user routine, for instance, requires its arguments to already be in pointer form, indistinguishable at the type level from kernel-internal pointers. However, I do think that Windows might be riskier in this case, since its check routines don't have any source-level-visible impact on the program control flow graph due to the use of exceptions to indicate failure -- one could lose a ProbeForRead check due to a merge conflict pretty easily, for instance.

Situation in Hubris

In Hubris, can_access is a validation routine, and plays a similar role to ProbeForRead (as you observed). There are two important differences.

  1. can_access returns a flag, and so has visible impact on the control flow graph, and is unlikely to be deleted by accident or machine.
  2. More importantly, the operands to can_access are not dereferenceable pointers (they are USlice). In order to dereference them after can_access completes, you have to invoke unsafe routines to make pointers/references.

Converting a USlice into something that can be dereferenced is done through the unsafe routines assume_readable and assume_writable. These are used in a small number of places in the kernel and are usually accessed indirectly through routines like safe_copy. (TBH there are more places where these get called than I would like, suggesting some refactoring.)

The result of the assume_xxx calls is a slice. This means that, by calling these unsafe routines, one can gain authority to access memory at some arbitrary address/length, but not the authority to index off its end, which would take more work. This means the case you mentioned in the description,

someone else does a non 0-sized read or write on a user provided USlice of 0-size

is difficult to achieve in practice. One would need to do something like this:

fn evil_naughty_routine(mem: USlice<u8>) -> u8 {
    // We're going to treat this as valid without checks, mwa ha
    let mem = unsafe { mem.assume_readable() };
    // And now we're going to access out of bounds! THE POWER
    unsafe { *mem.as_ptr().offset(12345) }
}

While kernel code can certainly do this -- we don't ban unsafe or anything because, well, there's a baby in that bathwater -- it looks nothing like a normal use of a uslice. This means it's unlikely to occur by accident, and even then, unlikely to escape review. Plus, in general, we can't design APIs that are perfectly safe in the presence of unsafe code -- by definition.

Okay Cliff but what are you getting at here

I don't think the comparison of can_access to similar routines in systems that

  1. Globally violate the parse-don't-validate principle when it comes to userland addresses and
  2. Don't have a native concept of slices with bounds-checked accesses in privileged code

is perfect, and I think the structure of the kernel means we're unlikely to have the same class of bug. However, that doesn't mean you haven't pointed out something worth fixing!

Postel's law considered harmful

In general I think that secure systems attempting to be liberal in the input they accept is a misfeature, particularly when all components of the system are versioned together -- that's how bugs happen. Either the input is right, or it isn't, and leaving undefined intermediate cases is dangerous. In general, when a task makes a syscall with arguments that are malformed in any way, Hubris takes that task out with a fault.

Initially, Hubris required all slice addresses passed from tasks to point to locations within that task's memory map, which came to a screeching halt as soon as someone tried to send an empty message with &[]. Empty slice literals, in order not to alias real objects, have as their base address a distinguished non-null properly aligned address (one size_of past zero for most types, address 1 for ZSTs). Because a zero-length slice gives safe code no authority to access memory, it is technically memory-safe for the kernel to accept zero-length slices with any base address. However, this could potentially mask bugs in tasks.

In practice there are two ways to get an empty slice (from safe code):

  1. Writing an empty slice literal like &[] or &mut [].
  2. Taking a valid slice and sub-setting it to become empty.

In the first case the base address will be that distinguished made-up address, but in the second case, the address will be valid for the task. This means that, to validate the base address of slices coming from Rust programs, we need to do a can_read check or accept the distinguished made-up address.

We could implement that, and it would be more consistent with the rest of our syscall behavior. However, there is an elephant in the room that looks vaguely like Ken Thompson:

Supporting tasks written in C

So far, Hubris's syscall interface is designed to be language-agnostic, with the explicit goal of supporting tasks written in non-Rust languages such as C or assembly. In the case of C, where there aren't slices, syscalls expecting a slice would take two arguments, (pointer, length). The idiomatic way of passing an empty slice into such a call in C would be to pass NULL, 0. Today, this would work. If we implement base address validation, that C program would fault.

To make this convenient, we would need to provide utility support code in C to generate the "distinguished fake address" that Rust uses for slice bases. I can definitely do this correctly in C++, but I don't know how to do it robustly in C because it needs to be type-parametric. I can probably do it with a somewhat fragile macro similar to how C tends to do offsetof. (Note: there are no cases in the kernel syscall API that involve slices of zero-sized types. This is deliberate, because C and C++ disagree on whether ZSTs exist.)

Anyway, I wanted to point this out, but I'd argue that since we have no current product need to write any tasks in C, a kernel design decision that makes it slightly more inconvenient to write tasks in C but doesn't prevent it outright should be fine.

Recommendations

The rambling above comes down to this:

  1. Let's validate slice base addresses, as a method for exposing bugs in task code by catching suspicious behavior. We can always loosen this later if we have to.
  2. Alexis King is still a very sharp person.
  3. can_access is a violation of the parse-don't-validate principle, albeit a less flagrant one than ProbeForRead, and should get refactored. If it continues to exist at all, it should probably be module-private with better wrappers exposed.
  4. We should expect a slightly higher implementation burden for a future C version of userlib as a result of these changes, but I believe it will be tractable. (I also don't see C support on the visible horizon.)

from hubris.

steveklabnik avatar steveklabnik commented on August 15, 2024

teeeeny comment,

(one size_of past zero for most types, address 1 for ZSTs)

We realized in our discussion that this is align_of, not size_of, but yes.

from hubris.

cbiffle avatar cbiffle commented on August 15, 2024

So the plot thickens.

While Rust will use the dangly-address for some empty slices, e.g. those that appear in empty vecs, it does not always do this. It appears that it will also generate empty slice literals with a base address in the program's rodata section. For instance, in one case, a task receives a zero-length message by passing length 0 and base address 0x0800af30, which is a correctly-aligned valid address in that task's Flash area.

Problem is, it's receiving, meaning that's a &mut [], and Flash is not mutable. So, my initial attempt at this turns out to be wrong.

If we want to handle this specific case, we've got another special case: we need to check that empty slices point into one of the task's regions, but suppress the access permissions check if the slice is empty.

To summarize the proposed change at that point, we've got:

  1. For non-empty slices, do the obvious thing.
  2. For empty slices, pass any slice with the dangly-address. (Note: we could remove this for now, as none of our tasks are using alloc/std -- but if one does and has an empty vec, this will cause issues.)
  3. Otherwise, for empty slices, check region intersection only and not permissions.

I am beginning to fear that the complexity this introduces is actually worse than what we had before:

  • With the refactoring to the memory access checks that I proposed in 29dcf39159568ab3ad6efe91f9cea8679f070ec1, it seems even less likely that a zero-length slice with a stray address is going to accidentally get misinterpreted as conferring access within the kernel.
  • It's not clear that Rust makes any promises as to the base address of an empty slice, save that it will not be 0 and will be correctly aligned for the type, and so any validation we introduce here is likely to be fragile.

from hubris.

Related Issues (20)

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.