Using std.Io as a Sandbox

One cool thing which you can do with std.Io is to create versions of it with have some functions disabled. For example, let’s say you have parts of your code which you don’t want to have file system access. Here is NoFileIo.zig:

const std = @import("std");

const NoFileIo = @This();

vtable: std.Io.VTable,
userdata: ?*anyopaque,

pub fn init(base_io: std.Io) NoFileIo {
    var self = NoFileIo{
        .vtable = base_io.vtable.*,
        .userdata = base_io.userdata,
    };
    const failing = std.Io.failing.vtable;

    // Set all file and directory operations to fail.
    self.vtable.dirCreateDir = failing.dirCreateDir;
    self.vtable.dirCreateDirPath = failing.dirCreateDirPath;
    self.vtable.dirCreateDirPathOpen = failing.dirCreateDirPathOpen;
    self.vtable.dirOpenDir = failing.dirOpenDir;
    self.vtable.dirStat = failing.dirStat;
    self.vtable.dirStatFile = failing.dirStatFile;
    self.vtable.dirAccess = failing.dirAccess;
    self.vtable.dirCreateFile = failing.dirCreateFile;
    self.vtable.dirCreateFileAtomic = failing.dirCreateFileAtomic;
    self.vtable.dirOpenFile = failing.dirOpenFile;
    self.vtable.dirClose = failing.dirClose;
    self.vtable.dirRead = failing.dirRead;
    self.vtable.dirRealPath = failing.dirRealPath;
    self.vtable.dirRealPathFile = failing.dirRealPathFile;
    self.vtable.dirDeleteFile = failing.dirDeleteFile;
    self.vtable.dirDeleteDir = failing.dirDeleteDir;
    self.vtable.dirRename = failing.dirRename;
    self.vtable.dirRenamePreserve = failing.dirRenamePreserve;
    self.vtable.dirSymLink = failing.dirSymLink;
    self.vtable.dirReadLink = failing.dirReadLink;
    self.vtable.dirSetOwner = failing.dirSetOwner;
    self.vtable.dirSetFileOwner = failing.dirSetFileOwner;
    self.vtable.dirSetPermissions = failing.dirSetPermissions;
    self.vtable.dirSetFilePermissions = failing.dirSetFilePermissions;
    self.vtable.dirSetTimestamps = failing.dirSetTimestamps;
    self.vtable.dirHardLink = failing.dirHardLink;
    self.vtable.fileStat = failing.fileStat;
    self.vtable.fileLength = failing.fileLength;
    self.vtable.fileClose = failing.fileClose;
    self.vtable.fileWritePositional = failing.fileWritePositional;
    self.vtable.fileWriteFileStreaming = failing.fileWriteFileStreaming;
    self.vtable.fileWriteFilePositional = failing.fileWriteFilePositional;
    self.vtable.fileReadPositional = failing.fileReadPositional;
    self.vtable.fileSeekBy = failing.fileSeekBy;
    self.vtable.fileSeekTo = failing.fileSeekTo;
    self.vtable.fileSync = failing.fileSync;
    self.vtable.fileIsTty = failing.fileIsTty;
    self.vtable.fileEnableAnsiEscapeCodes = failing.fileEnableAnsiEscapeCodes;
    self.vtable.fileSupportsAnsiEscapeCodes = failing.fileSupportsAnsiEscapeCodes;
    self.vtable.fileSetLength = failing.fileSetLength;
    self.vtable.fileSetOwner = failing.fileSetOwner;
    self.vtable.fileSetPermissions = failing.fileSetPermissions;
    self.vtable.fileSetTimestamps = failing.fileSetTimestamps;
    self.vtable.fileLock = failing.fileLock;
    self.vtable.fileTryLock = failing.fileTryLock;
    self.vtable.fileUnlock = failing.fileUnlock;
    self.vtable.fileDowngradeLock = failing.fileDowngradeLock;
    self.vtable.fileRealPath = failing.fileRealPath;
    self.vtable.fileHardLink = failing.fileHardLink;
    self.vtable.fileMemoryMapCreate = failing.fileMemoryMapCreate;
    self.vtable.fileMemoryMapDestroy = failing.fileMemoryMapDestroy;
    self.vtable.fileMemoryMapSetLength = failing.fileMemoryMapSetLength;
    self.vtable.fileMemoryMapRead = failing.fileMemoryMapRead;
    self.vtable.fileMemoryMapWrite = failing.fileMemoryMapWrite;
    self.vtable.processExecutableOpen = failing.processExecutableOpen;
    self.vtable.processExecutablePath = failing.processExecutablePath;
    self.vtable.progressParentFile = failing.progressParentFile;
    self.vtable.netWriteFile = failing.netWriteFile;

    return self;
}

pub fn io(self: *NoFileIo) std.Io {
    return .{
        .userdata = self.userdata,
        .vtable = &self.vtable,
    };
}

test "NoFileIo errors when accessing files" {
    var no_file_io = NoFileIo.init(std.testing.io);
    const nf_io = no_file_io.io();
    try std.testing.expectError(error.FileNotFound, std.Io.Dir.openDir(.cwd(), nf_io, "nonexistent", .{}));

}

test "NoFileIo can still do randomness" {
    var no_file_io = NoFileIo.init(std.testing.io);
    const nf_io = no_file_io.io();
    var buf: [4]u8 = undefined;
    nf_io.random(&buf);
}

If course one can get around this by just using a new std.Io.Theaded instance instead of the one that was passed down. I think it would be nice if one could disallow using certain parts of the standard library in build.zig on a per-module basis. (also C imports, inline assembly…)

What do you folks think of this?

7 Likes

I wish there was a way to only let the entrypoint create some things. Would be really nice in some cases

1 Like

What’s to stop someone from doing an import to circumvent the Io you’ve provided?

I don’t think you can do this to accomplish a secure sandbox - you can just call std.os.linux.openat etc. (Or just run syscalls

But this idea I think has some nice use cases for testing, or other tasks where you want a sandbox, but you trust the code you’re running.

Edit: Just to clarify, even disallowing parts of the language (which would be restrictive) would not be enough. A malicious user could introduce a memory vulnerability with an out-of-bound memory access, and use that to manually call syscalls. I think it would take changes to the language itself to achieve sandboxing of native code.

3 Likes

This sandboxing is useful if the code you’re passing the sandboxed instance down to is not malicious by itself, but it might process data that could cause it to behave maliciously at runtime, like a WASM interpreter that implements WASI filesystem operations directly on top of std.Io calls for example

3 Likes

Yea nothing. That’s why it would be nice to have some way to ban the use of some parts of the standard library in a certain module.

Testing: I’m thinking of implementing an in-memory file system for testing. You could pre-fill it in code using test data, and then the code under test can read from that. It would make tests faster, and you don’t have to clean up after each test.

Security: Yes, also you could run some assembler code that does these syscalls manually. I think it’s useful for your own code. Basically this is a way to encode “this module does not do file io” explicitly in the code and it would be found by tests.

1 Like

Some stuff, like environment variables, and cli arguments are—at least on linux—purely available to the entrypoint as far as I know. You could of course still have a mock, but you can never have a library give it to you, except if it acts as start.zig.

This is a common idea which doesn’t work. Rust is somewhat more susceptible here, “if we ban unsafe, the result is sandboxed, right?”, but this doesn’t work without quite a bit more of a language machinery. It’s also not clear whether this is the right layer to do this kind of thing at all. Arguably, OS interface should be capabilities based, so that you say “this process can access only this file”, and then allow the process to make absolute hash of its memory, if it is so inclined.

Let’s say we forbid importing anything nasty in Zig, and also ban assembly. How do we poke a hole in it? Here’s a sketch

There’s going to be a function that makes some syscall, read. We can const code_ptr = @as([*]u8, &read) to get pointer to its code. We can get syscall-calling instruction by offsetting it const syscall = code_ptr + offset. We can even find the offset by std.mem.indexOfing the relevant machine code.

How do we jump there? Well we are on the stack, and there’s a return address on the stack as well, so we can start with var dummy: u8 = 0; const stack_pointer: *u8 = &dummy. Again, offsetting to return address const return_address = stack_pointer + offset. And I think again we can just find the offset by looking at where on the stack the known return address is (f calls g, g inspects the stack finding pointer back at f). Then we do retun_address.* = syscall, and here’s our syscall without assembly. We still need to maneuver the registers to contain the right syscall number, but that’s also possible using similar techniques.

5 Likes