Better Sandboxing In Rust
This is a follow up to the Sandboxing Code In Rust that I published just earlier this morning.
As I continued to improve the code I tried to think about what problems the library would face. It’s immediately obvious that sandboxes do not compose well.
Imagine a Linux Capabilities sandbox. It would allow you to drop specific capabilities. So you drop all capapilities not required, but then you try to move into a Chroot sandbox. This would naturally require the Chroot capability. Every sandbox has to take every other sandbox into account, which can mean that a single descriptor may need to split its execution - drop some caps, do chroot, drop other caps. It gets convoluted quickly.
I don’t consider this to be a huge problem, at the very least the current approach is useful for quick and dirty sandboxing. But I think it’s worth exploring something beyond that.
The ideal sandboxing library would provide:
- Compile time validation that code adheres to the sandbox
- Low performance impact
- High abstraction, but fine grained access control
- Near drop-in level usability
The original implementation only provides the last of those - it’s very easy to use. But it doesn’t necessarily provide a lot of abstraction as it requires knowledge of sandbox implementations and there’s no compile time validation.
So I spent some time trying to think through what a more ideal sandbox would look like.
I think the answer is that it is not currently possible - at least, not in rust. The ability to encode capabilities into the type system just isn’t there in an enforceable way. What I want is something that looks like this:
let sandbox : ReadFileSandbox = Sandbox::new()
.withReadFile();
sandbox.execute(|readFileBroker| {
// The broker can read a file
let mut buf = Vec::new();
let _ = readFileBroker.read_to_end(buf);
File::open("path"); // This would be a compile time error
});
In this code the File structure would have a type that is incompatible with the closure, the fact that it performs IO would be encoded into the type system and prevent the code from compiling.
The readFileBroker would not truly be a broker - it would exist in the same context as the sandbox. The sandbox ‘type’ would know to provide it, and if you needed a writeFile broker you would have to encode that into the type system by modifying the sandbox’s type.
let sandbox : ReadWriteFileSandbox = Sandbox::new()
.withReadFile()
.withWriteFile();
Of course type inference would make this invisible to the user.
The sandbox can then construct a seccomp filter under the hood based on the type and you would effectively encode a seccomp filter into your program. Based on the capabilities you use, which brokers are accessed, it could be possible to remove unnecessary capabilities at compile time - since each capability would be a type.
Without language level support something like this is impossible. Rust doesn’t have a concept of IO or purity, the File structure is not special or is a socket structure or anything else. You would need marker traits on anything that can have effects, and a way to ensure that you can not capture or create anything with that trait in your sandbox.
Whether this would actually work in practice without becoming prohibitively bothersome, I’m not sure. Something like using a hyper client to download a webpage would mean being able to replace the inner socket with a broker socket.
Not to mention that capabilities combinations will grow quite quickly. It would certainly not be feasible to write the code by hand, it would have to be generated or handled through some other magic.
Could become quite annoying.
Either way, this isn’t possible, so it’s only fair to drop the first requirement.
Compile time validation that code adheres to the sandbox- Low performance impact
- High abstraction, but fine grained access control
- Near drop-in level usability
But, we can still tackle the others. I mentioned that the above would be done through seccomp and I think that’s still the best way to go. Seccomp gives you fine grained control over system calls and their arguments, but it works very well as a capability-based sandbox since in order to have an effect on the system a program has to use a system call. Seccomp should be very fast. So what’s left is high abstraction and usability.
Unfortunately, without the ability to reason about the seccomp filters at compile time, there’s going to be a balance between coarse and find grained policies. One can group seccomp rules into groups, such as write + fstat + mmap into a Read capability but if fstat is never used in the program there’s no way to know and eliminate it.
And there’s no way at compile time to know that you’re not violating the sandbox so you may need a system call you weren’t aware of and get a syscall violation.
Still, you can strike a decent balance if your goal isn’t strict kernel attack surface reduction and instead a capabilities sandbox by grouping seccomp rules into higher level capabilities.
And then it’s just a matter of turning those capabilities into descriptors and using the original model. Though, at this point, I think that may be essentially what gaol does (I have actually only looked at the Gaol example code because I wanted to try to think this out without too much bias).
The short story is that, with a bit of work, sandboxing in rust may end up very ergonomic, efficient, and decently secure, but I don’t think it’s as good as it could be in theory.
Next step for me is to look at Gaol more closely so I don’t end up duplicating a ton of work for no reason.
blog comments powered by Disqus