From 128dad70c66d00c248c9821fc3e45222818a0e3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Mon, 6 Apr 2026 16:49:40 +0200 Subject: [PATCH 1/5] nix_2_28: add patch for GHSA-g3g9-5vj6-r3gj This addresses GHSA-g3g9-5vj6-r3gj, a vulnerability where std::filesystem::copy_file follows symlinks when copying FOD outputs, allowing a malicious builder to overwrite files outside the build sandbox. The patch puts FOD output copies in a temporary directory inside the store (instead of the chroot) and tightens permissions on the in-store temporary directory. --- pkgs/tools/package-management/nix/default.nix | 1 + .../patches/ghsa-g3g9-5vj6-r3gj-2.28.patch | 126 ++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 pkgs/tools/package-management/nix/patches/ghsa-g3g9-5vj6-r3gj-2.28.patch diff --git a/pkgs/tools/package-management/nix/default.nix b/pkgs/tools/package-management/nix/default.nix index bbb45c9d419d..70533da7574b 100644 --- a/pkgs/tools/package-management/nix/default.nix +++ b/pkgs/tools/package-management/nix/default.nix @@ -165,6 +165,7 @@ lib.makeExtensible ( hash = "sha256-vFv/D08x9urtoIE9wiC7Lln4Eq3sgNBwU7TBE1iyrfI="; }) lowdown30PatchOld + ./patches/ghsa-g3g9-5vj6-r3gj-2.28.patch ]; }; diff --git a/pkgs/tools/package-management/nix/patches/ghsa-g3g9-5vj6-r3gj-2.28.patch b/pkgs/tools/package-management/nix/patches/ghsa-g3g9-5vj6-r3gj-2.28.patch new file mode 100644 index 000000000000..ae50b007a30b --- /dev/null +++ b/pkgs/tools/package-management/nix/patches/ghsa-g3g9-5vj6-r3gj-2.28.patch @@ -0,0 +1,126 @@ +From 32b09e0bfeb33434866610994d89f48da8a2bf41 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= +Date: Mon, 6 Apr 2026 16:49:13 +0200 +Subject: [PATCH] Fixes for GHSA-g3g9-5vj6-r3gj +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Squashed commit of the following: + +commit 716dba6692c42e301a1e769e5eac02a4d6e63150 +Author: Sergei Zimmerman +Date: Fri Apr 3 00:21:31 2026 +0300 + + derivation-builder: Don't use copyFile for FOD output copying, put the output in a temporary directory in the store + +commit a3215e7c5c260fab5f2cb034c4df01dfa3b284e5 +Author: Sergei Zimmerman +Date: Fri Apr 3 00:21:21 2026 +0300 + + libstore: Make temporary in-store directory not world-readable + +Signed-off-by: Jörg Thalheim +--- + src/libstore/include/nix/store/local-store.hh | 2 ++ + src/libstore/local-store.cc | 5 +-- + .../unix/build/local-derivation-goal.cc | 36 ++++++++++++++----- + 3 files changed, 33 insertions(+), 10 deletions(-) + +diff --git a/src/libstore/include/nix/store/local-store.hh b/src/libstore/include/nix/store/local-store.hh +index 5893b7d8b..c9266f6b4 100644 +--- a/src/libstore/include/nix/store/local-store.hh ++++ b/src/libstore/include/nix/store/local-store.hh +@@ -408,6 +408,8 @@ private: + friend struct PathSubstitutionGoal; + friend struct SubstitutionGoal; + friend struct DerivationGoal; ++ /* Only used for createTempDirInStore. */ ++ friend class DerivationBuilderImpl; + }; + + } // namespace nix +diff --git a/src/libstore/local-store.cc b/src/libstore/local-store.cc +index 63108fab4..ab3a0d034 100644 +--- a/src/libstore/local-store.cc ++++ b/src/libstore/local-store.cc +@@ -1311,8 +1311,9 @@ std::pair LocalStore::createTempDirInStore() + do { + /* There is a slight possibility that `tmpDir' gets deleted by + the GC between createTempDir() and when we acquire a lock on it. +- We'll repeat until 'tmpDir' exists and we've locked it. */ +- tmpDirFn = createTempDir(realStoreDir, "tmp"); ++ We'll repeat until 'tmpDir' exists and we've locked it. ++ Make the directory accessible only to the current user.*/ ++ tmpDirFn = createTempDir(realStoreDir, "tmp", /*mode=*/0700); + tmpDirFd = openDirectory(tmpDirFn); + if (!tmpDirFd) { + continue; +diff --git a/src/libstore/unix/build/local-derivation-goal.cc b/src/libstore/unix/build/local-derivation-goal.cc +index 88c82e063..3e4c3017d 100644 +--- a/src/libstore/unix/build/local-derivation-goal.cc ++++ b/src/libstore/unix/build/local-derivation-goal.cc +@@ -2547,6 +2547,13 @@ SingleDrvOutputs LocalDerivationGoal::registerOutputs() + assert(output && scratchPath); + auto actualPath = toRealPathChroot(worker.store.printStorePath(*scratchPath)); + ++ /* An optional file descriptor of a directory used for intermediate ++ operations. */ ++ AutoCloseFD tempDirFd; ++ /* RAII cleanup of a temporary directory inside the store that is used ++ for intermediate operations. */ ++ std::optional delTempDir; ++ + auto finish = [&](StorePath finalStorePath) { + /* Store the final path */ + finalOutputs.insert_or_assign(outputName, finalStorePath); +@@ -2681,6 +2688,25 @@ SingleDrvOutputs LocalDerivationGoal::registerOutputs() + return newInfo0; + }; + ++ auto moveOutputToTempDir = [&]() -> void { ++ std::filesystem::path tempDir; ++ std::tie(tempDir, tempDirFd) = getLocalStore().createTempDirInStore(); ++ delTempDir.emplace(tempDir); ++ ++ auto tmpOutput = tempDir / "x"; ++ ++ /* Serialise and create a fresh copy of the output to break ++ any stale writable file descriptors. Copy through the ++ serialisation/deserialisation. TODO: Use copyRecursive here and ++ make use of reflinking. */ ++ auto source = sinkToSource([&](Sink & nextSink) { dumpPath(actualPath, nextSink); }); ++ restorePath(tmpOutput, *source, settings.fsyncStorePaths); ++ /* This makes it slightly harder to make sense of the control flow. The rule ++ of thumb is that actualPath points to the current location of the stuff ++ that we'll end up registering. */ ++ actualPath = std::move(tmpOutput); ++ }; ++ + ValidPathInfo newInfo = std::visit( + overloaded{ + +@@ -2708,14 +2734,7 @@ 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(std::filesystem::path(actualPath), std::filesystem::path(tmpOutput), true); +- +- std::filesystem::rename(tmpOutput, actualPath); +- ++ moveOutputToTempDir(); + auto newInfo0 = newInfoFromCA( + DerivationOutput::CAFloating{ + .method = dof.ca.method, +@@ -2756,6 +2775,7 @@ SingleDrvOutputs LocalDerivationGoal::registerOutputs() + }, + + [&](const DerivationOutput::Impure & doi) { ++ moveOutputToTempDir(); + return newInfoFromCA( + DerivationOutput::CAFloating{ + .method = doi.method, From 69e8fef8129d2a7377f212f8fbd0a1ceb1f38ca3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Mon, 6 Apr 2026 16:49:40 +0200 Subject: [PATCH 2/5] nixComponents_2_30: add patch for GHSA-g3g9-5vj6-r3gj This addresses GHSA-g3g9-5vj6-r3gj, a vulnerability where std::filesystem::copy_file follows symlinks when copying FOD outputs, allowing a malicious builder to overwrite files outside the build sandbox. The patch puts FOD output copies in a temporary directory inside the store (instead of the chroot) and tightens permissions on the in-store temporary directory. --- pkgs/tools/package-management/nix/default.nix | 1 + .../patches/ghsa-g3g9-5vj6-r3gj-2.30.patch | 126 ++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 pkgs/tools/package-management/nix/patches/ghsa-g3g9-5vj6-r3gj-2.30.patch diff --git a/pkgs/tools/package-management/nix/default.nix b/pkgs/tools/package-management/nix/default.nix index 70533da7574b..66cb2fc91636 100644 --- a/pkgs/tools/package-management/nix/default.nix +++ b/pkgs/tools/package-management/nix/default.nix @@ -190,6 +190,7 @@ lib.makeExtensible ( hash = "sha256-r2ZF1zBZDKMvyX6X4VsaTMrg0zdjn59Jf6Hqg56r29E="; }) lowdown30PatchOld + ./patches/ghsa-g3g9-5vj6-r3gj-2.30.patch ] ); diff --git a/pkgs/tools/package-management/nix/patches/ghsa-g3g9-5vj6-r3gj-2.30.patch b/pkgs/tools/package-management/nix/patches/ghsa-g3g9-5vj6-r3gj-2.30.patch new file mode 100644 index 000000000000..d7514f864afa --- /dev/null +++ b/pkgs/tools/package-management/nix/patches/ghsa-g3g9-5vj6-r3gj-2.30.patch @@ -0,0 +1,126 @@ +From 4d0a078f1dae9a07d04e1a72e7e62fbf2ca249e0 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= +Date: Mon, 6 Apr 2026 16:49:13 +0200 +Subject: [PATCH] Fixes for GHSA-g3g9-5vj6-r3gj +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Squashed commit of the following: + +commit a8e5b27728e3b18e63c2503a83afd04031994623 +Author: Sergei Zimmerman +Date: Fri Apr 3 00:21:31 2026 +0300 + + derivation-builder: Don't use copyFile for FOD output copying, put the output in a temporary directory in the store + +commit 49ff4d6779ec20c5339b237dec9b240c3eabf535 +Author: Sergei Zimmerman +Date: Fri Apr 3 00:21:21 2026 +0300 + + libstore: Make temporary in-store directory not world-readable + +Signed-off-by: Jörg Thalheim +--- + src/libstore/include/nix/store/local-store.hh | 2 ++ + src/libstore/local-store.cc | 5 +-- + src/libstore/unix/build/derivation-builder.cc | 36 ++++++++++++++----- + 3 files changed, 33 insertions(+), 10 deletions(-) + +diff --git a/src/libstore/include/nix/store/local-store.hh b/src/libstore/include/nix/store/local-store.hh +index 5f3a249f8..74fa1c2e8 100644 +--- a/src/libstore/include/nix/store/local-store.hh ++++ b/src/libstore/include/nix/store/local-store.hh +@@ -446,6 +446,8 @@ private: + friend struct PathSubstitutionGoal; + friend struct SubstitutionGoal; + friend struct DerivationGoal; ++ /* Only used for createTempDirInStore. */ ++ friend class DerivationBuilderImpl; + }; + + } // namespace nix +diff --git a/src/libstore/local-store.cc b/src/libstore/local-store.cc +index 49c499e3f..5f897856f 100644 +--- a/src/libstore/local-store.cc ++++ b/src/libstore/local-store.cc +@@ -1316,8 +1316,9 @@ std::pair LocalStore::createTempDirInStore() + do { + /* There is a slight possibility that `tmpDir' gets deleted by + the GC between createTempDir() and when we acquire a lock on it. +- We'll repeat until 'tmpDir' exists and we've locked it. */ +- tmpDirFn = createTempDir(config->realStoreDir, "tmp"); ++ We'll repeat until 'tmpDir' exists and we've locked it. ++ Make the directory accessible only to the current user.*/ ++ tmpDirFn = createTempDir(config->realStoreDir, "tmp", /*mode=*/0700); + tmpDirFd = openDirectory(tmpDirFn); + if (!tmpDirFd) { + continue; +diff --git a/src/libstore/unix/build/derivation-builder.cc b/src/libstore/unix/build/derivation-builder.cc +index 5f9dafb57..609d88a79 100644 +--- a/src/libstore/unix/build/derivation-builder.cc ++++ b/src/libstore/unix/build/derivation-builder.cc +@@ -1581,6 +1581,13 @@ SingleDrvOutputs DerivationBuilderImpl::registerOutputs() + assert(output && scratchPath); + auto actualPath = realPathInSandbox(store.printStorePath(*scratchPath)); + ++ /* An optional file descriptor of a directory used for intermediate ++ operations. */ ++ AutoCloseFD tempDirFd; ++ /* RAII cleanup of a temporary directory inside the store that is used ++ for intermediate operations. */ ++ std::optional delTempDir; ++ + auto finish = [&](StorePath finalStorePath) { + /* Store the final path */ + finalOutputs.insert_or_assign(outputName, finalStorePath); +@@ -1715,6 +1722,25 @@ SingleDrvOutputs DerivationBuilderImpl::registerOutputs() + return newInfo0; + }; + ++ auto moveOutputToTempDir = [&]() -> void { ++ std::filesystem::path tempDir; ++ std::tie(tempDir, tempDirFd) = getLocalStore(store).createTempDirInStore(); ++ delTempDir.emplace(tempDir); ++ ++ auto tmpOutput = tempDir / "x"; ++ ++ /* Serialise and create a fresh copy of the output to break ++ any stale writable file descriptors. Copy through the ++ serialisation/deserialisation. TODO: Use copyRecursive here and ++ make use of reflinking. */ ++ auto source = sinkToSource([&](Sink & nextSink) { dumpPath(actualPath, nextSink); }); ++ restorePath(tmpOutput, *source, settings.fsyncStorePaths); ++ /* This makes it slightly harder to make sense of the control flow. The rule ++ of thumb is that actualPath points to the current location of the stuff ++ that we'll end up registering. */ ++ actualPath = std::move(tmpOutput); ++ }; ++ + ValidPathInfo newInfo = std::visit( + overloaded{ + +@@ -1742,14 +1768,7 @@ SingleDrvOutputs DerivationBuilderImpl::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(std::filesystem::path(actualPath), std::filesystem::path(tmpOutput), true); +- +- std::filesystem::rename(tmpOutput, actualPath); +- ++ moveOutputToTempDir(); + auto newInfo0 = newInfoFromCA( + DerivationOutput::CAFloating{ + .method = dof.ca.method, +@@ -1790,6 +1809,7 @@ SingleDrvOutputs DerivationBuilderImpl::registerOutputs() + }, + + [&](const DerivationOutput::Impure & doi) { ++ moveOutputToTempDir(); + return newInfoFromCA( + DerivationOutput::CAFloating{ + .method = doi.method, From be231299383aa4a41ed83ee422f07f316f0fa63c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Mon, 6 Apr 2026 16:49:40 +0200 Subject: [PATCH 3/5] nixComponents_2_31: add patches for GHSA-g3g9-5vj6-r3gj This addresses GHSA-g3g9-5vj6-r3gj, a vulnerability where std::filesystem::copy_file follows symlinks when copying FOD outputs, allowing a malicious builder to overwrite files outside the build sandbox. The patch puts FOD output copies in a temporary directory inside the store (instead of the chroot) and tightens permissions on the in-store temporary directory. The second patch partially fixes the issue with cooperating processes being able to communicate via abstract sockets. The fix is partial, because processes outside the landlock domain of the sandboxed process can still connect to a socket created by the FOD. There's no equivalent way of restricting inbound connections. This closes the gap when there's no cooperating process on the host (i.e. 2 separate FODs). The patch applies landlock LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET on kernels >= 6.12 (default on NixOS 25.11+) to deny abstract socket connect from inside the build sandbox. --- pkgs/tools/package-management/nix/default.nix | 2 + .../patches/ghsa-g3g9-5vj6-r3gj-2.31.patch | 126 ++++++++++ ...dlock-abstract-socket-hardening-2.31.patch | 232 ++++++++++++++++++ 3 files changed, 360 insertions(+) create mode 100644 pkgs/tools/package-management/nix/patches/ghsa-g3g9-5vj6-r3gj-2.31.patch create mode 100644 pkgs/tools/package-management/nix/patches/landlock-abstract-socket-hardening-2.31.patch diff --git a/pkgs/tools/package-management/nix/default.nix b/pkgs/tools/package-management/nix/default.nix index 66cb2fc91636..a0e28a02f9f1 100644 --- a/pkgs/tools/package-management/nix/default.nix +++ b/pkgs/tools/package-management/nix/default.nix @@ -210,6 +210,8 @@ lib.makeExtensible ( }).appendPatches [ lowdown30Patch + ./patches/ghsa-g3g9-5vj6-r3gj-2.31.patch + ./patches/landlock-abstract-socket-hardening-2.31.patch ]; nix_2_31 = addTests "nix_2_31" self.nixComponents_2_31.nix-everything; diff --git a/pkgs/tools/package-management/nix/patches/ghsa-g3g9-5vj6-r3gj-2.31.patch b/pkgs/tools/package-management/nix/patches/ghsa-g3g9-5vj6-r3gj-2.31.patch new file mode 100644 index 000000000000..4d08316d67a7 --- /dev/null +++ b/pkgs/tools/package-management/nix/patches/ghsa-g3g9-5vj6-r3gj-2.31.patch @@ -0,0 +1,126 @@ +From df0153a9eca42c4ed5ac784657c8c5d0664c9e0e Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= +Date: Mon, 6 Apr 2026 16:49:13 +0200 +Subject: [PATCH] Fixes for GHSA-g3g9-5vj6-r3gj +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Squashed commit of the following: + +commit bf1d95ae399e47daeafa9e0dfac967f415c52447 +Author: Sergei Zimmerman +Date: Fri Apr 3 00:21:31 2026 +0300 + + derivation-builder: Don't use copyFile for FOD output copying, put the output in a temporary directory in the store + +commit ca5986fa353d402738acf961bcaa017c61019809 +Author: Sergei Zimmerman +Date: Fri Apr 3 00:21:21 2026 +0300 + + libstore: Make temporary in-store directory not world-readable + +Signed-off-by: Jörg Thalheim +--- + src/libstore/include/nix/store/local-store.hh | 2 ++ + src/libstore/local-store.cc | 5 +-- + src/libstore/unix/build/derivation-builder.cc | 36 ++++++++++++++----- + 3 files changed, 33 insertions(+), 10 deletions(-) + +diff --git a/src/libstore/include/nix/store/local-store.hh b/src/libstore/include/nix/store/local-store.hh +index ec88ffb4f..91df9cd77 100644 +--- a/src/libstore/include/nix/store/local-store.hh ++++ b/src/libstore/include/nix/store/local-store.hh +@@ -455,6 +455,8 @@ private: + + friend struct PathSubstitutionGoal; + friend struct DerivationGoal; ++ /* Only used for createTempDirInStore. */ ++ friend class DerivationBuilderImpl; + }; + + } // namespace nix +diff --git a/src/libstore/local-store.cc b/src/libstore/local-store.cc +index d24bc415c..524a9fdda 100644 +--- a/src/libstore/local-store.cc ++++ b/src/libstore/local-store.cc +@@ -1342,8 +1342,9 @@ std::pair LocalStore::createTempDirInStore() + do { + /* There is a slight possibility that `tmpDir' gets deleted by + the GC between createTempDir() and when we acquire a lock on it. +- We'll repeat until 'tmpDir' exists and we've locked it. */ +- tmpDirFn = createTempDir(config->realStoreDir, "tmp"); ++ We'll repeat until 'tmpDir' exists and we've locked it. ++ Make the directory accessible only to the current user.*/ ++ tmpDirFn = createTempDir(config->realStoreDir, "tmp", /*mode=*/0700); + tmpDirFd = openDirectory(tmpDirFn); + if (!tmpDirFd) { + continue; +diff --git a/src/libstore/unix/build/derivation-builder.cc b/src/libstore/unix/build/derivation-builder.cc +index 73bb026a2..d11f80159 100644 +--- a/src/libstore/unix/build/derivation-builder.cc ++++ b/src/libstore/unix/build/derivation-builder.cc +@@ -1473,6 +1473,13 @@ SingleDrvOutputs DerivationBuilderImpl::registerOutputs() + assert(output && scratchPath); + auto actualPath = realPathInSandbox(store.printStorePath(*scratchPath)); + ++ /* An optional file descriptor of a directory used for intermediate ++ operations. */ ++ AutoCloseFD tempDirFd; ++ /* RAII cleanup of a temporary directory inside the store that is used ++ for intermediate operations. */ ++ std::optional delTempDir; ++ + auto finish = [&](StorePath finalStorePath) { + /* Store the final path */ + finalOutputs.insert_or_assign(outputName, finalStorePath); +@@ -1607,6 +1614,25 @@ SingleDrvOutputs DerivationBuilderImpl::registerOutputs() + return newInfo0; + }; + ++ auto moveOutputToTempDir = [&]() -> void { ++ std::filesystem::path tempDir; ++ std::tie(tempDir, tempDirFd) = store.createTempDirInStore(); ++ delTempDir.emplace(tempDir); ++ ++ auto tmpOutput = tempDir / "x"; ++ ++ /* Serialise and create a fresh copy of the output to break ++ any stale writable file descriptors. Copy through the ++ serialisation/deserialisation. TODO: Use copyRecursive here and ++ make use of reflinking. */ ++ auto source = sinkToSource([&](Sink & nextSink) { dumpPath(actualPath, nextSink); }); ++ restorePath(tmpOutput, *source, settings.fsyncStorePaths); ++ /* This makes it slightly harder to make sense of the control flow. The rule ++ of thumb is that actualPath points to the current location of the stuff ++ that we'll end up registering. */ ++ actualPath = std::move(tmpOutput); ++ }; ++ + ValidPathInfo newInfo = std::visit( + overloaded{ + +@@ -1634,14 +1660,7 @@ SingleDrvOutputs DerivationBuilderImpl::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(std::filesystem::path(actualPath), std::filesystem::path(tmpOutput), true); +- +- std::filesystem::rename(tmpOutput, actualPath); +- ++ moveOutputToTempDir(); + auto newInfo0 = newInfoFromCA( + DerivationOutput::CAFloating{ + .method = dof.ca.method, +@@ -1682,6 +1701,7 @@ SingleDrvOutputs DerivationBuilderImpl::registerOutputs() + }, + + [&](const DerivationOutput::Impure & doi) { ++ moveOutputToTempDir(); + return newInfoFromCA( + DerivationOutput::CAFloating{ + .method = doi.method, diff --git a/pkgs/tools/package-management/nix/patches/landlock-abstract-socket-hardening-2.31.patch b/pkgs/tools/package-management/nix/patches/landlock-abstract-socket-hardening-2.31.patch new file mode 100644 index 000000000000..7584a608db7f --- /dev/null +++ b/pkgs/tools/package-management/nix/patches/landlock-abstract-socket-hardening-2.31.patch @@ -0,0 +1,232 @@ +From b743f908b10aa56b88fb490de8a0d893bd2d83d5 Mon Sep 17 00:00:00 2001 +From: Sergei Zimmerman +Date: Sun, 5 Apr 2026 16:39:58 +0300 +Subject: [PATCH] libstore: Use landlock with + LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET for new enough kernels +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +This partially fixes the issue with cooperating processes being able +to communicate via abstract sockets. The fix is partial, because processes +outside the landlock domain of the sandboxed process can still connect to +a socket created by the FOD. There's no equivalent way of restricting inbound +connections. This closes the gap when there's no cooperating process on the host +(i.e. 2 separate FODs). + +>= 6.12 kernel is widespread enough (NixOS 25.11 ships it by +default) that we have no reason not to apply this hardening, even though +it's incomplete. + +ca-fd-leak test exercises this exact code path and now the smuggling +process fails with (on new enough kernels that have landlock support enabled): + +vm-test-run-ca-fd-leak> machine # sandbox setup: applied landlock sandboxing +vm-test-run-ca-fd-leak> machine # building '/nix/store/s7brgi6pdr5f3n8yqlgmdlz8blb89njc-smuggled.drv'... +vm-test-run-ca-fd-leak> machine # building derivation '/nix/store/s7brgi6pdr5f3n8yqlgmdlz8blb89njc-smuggled.drv': woken up +vm-test-run-ca-fd-leak> machine # connect: Operation not permitted +vm-test-run-ca-fd-leak> machine # sendmsg: Socket not connected + +(cherry picked from commit 44017ca497c8b44d5dac179f5afc63e91fe45ed6) +Signed-off-by: Jörg Thalheim +--- + src/libstore/meson.build | 5 + + .../unix/build/linux-derivation-builder.cc | 103 ++++++++++++++++++ + tests/nixos/ca-fd-leak/default.nix | 8 +- + 3 files changed, 114 insertions(+), 2 deletions(-) + +diff --git a/src/libstore/meson.build b/src/libstore/meson.build +index a275f4edc..dc8a7a940 100644 +--- a/src/libstore/meson.build ++++ b/src/libstore/meson.build +@@ -77,6 +77,11 @@ foreach funcspec : check_funcs + configdata_priv.set(define_name, define_value) + endforeach + ++if host_machine.system() == 'linux' ++ has_landlock = cxx.has_header('linux/landlock.h') ++ configdata_priv.set('HAVE_LANDLOCK', has_landlock.to_int()) ++endif ++ + has_acl_support = cxx.has_header('sys/xattr.h') \ + and cxx.has_function('llistxattr') \ + and cxx.has_function('lremovexattr') +diff --git a/src/libstore/unix/build/linux-derivation-builder.cc b/src/libstore/unix/build/linux-derivation-builder.cc +index eaac2bc28..dc5abf808 100644 +--- a/src/libstore/unix/build/linux-derivation-builder.cc ++++ b/src/libstore/unix/build/linux-derivation-builder.cc +@@ -1,10 +1,16 @@ + #ifdef __linux__ + ++# include "store-config-private.hh" ++ + # include "nix/store/personality.hh" + # include "nix/util/cgroup.hh" + # include "nix/util/linux-namespaces.hh" + # include "linux/fchmodat2-compat.hh" + ++# include ++# include ++# include ++ + # include + # include + # include +@@ -13,11 +19,16 @@ + # include + # include + # include ++# include + + # if HAVE_SECCOMP + # include + # endif + ++# if HAVE_LANDLOCK ++# include ++# endif ++ + # define pivot_root(new_root, put_old) (syscall(SYS_pivot_root, new_root, put_old)) + + namespace nix { +@@ -121,6 +132,77 @@ static void setupSeccomp() + # endif + } + ++# if HAVE_LANDLOCK && defined(LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET) ++ ++# define DO_LANDLOCK 1 ++ ++/* We are using LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET on best-effort basis. There are no glibc wrappers for now. */ ++ ++static int landlockCreateRuleset(const ::landlock_ruleset_attr * attr, std::size_t size, std::uint32_t flags) ++{ ++ return ::syscall(__NR_landlock_create_ruleset, attr, size, flags); ++} ++ ++static int landlockRestrictSelf(Descriptor rulesetFd, std::uint32_t flags) ++{ ++ return ::syscall(__NR_landlock_restrict_self, rulesetFd, flags); ++} ++ ++static int getLandlockAbiVersion() ++{ ++ int abiVersion = landlockCreateRuleset(nullptr, 0, LANDLOCK_CREATE_RULESET_VERSION); ++ return abiVersion; ++} ++ ++static void setupLandlock() ++{ ++ bool landlockSupportsScopeAbstractUnixSocket = []() { ++ int abiVersion = getLandlockAbiVersion(); ++ if (abiVersion >= 6) ++ /* All good, we can use LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET. See ++ https://docs.kernel.org/userspace-api/landlock.html#abstract-unix-socket-abi-6 */ ++ return true; ++ ++ if (abiVersion == -1) { ++ debug("landlock is not available"); ++ return false; ++ } ++ ++ debug("landlock version %d does not support LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET", abiVersion); ++ return false; ++ }(); ++ ++ /* Bail out early if landlock is not enabled or LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET wouldn't work. ++ TODO: Consider adding more landlock rules for filesystem access as defense-in-depth on top. */ ++ if (!landlockSupportsScopeAbstractUnixSocket) ++ return; ++ ++ ::landlock_ruleset_attr attr = { ++ /* This prevents multiple FODs from communicating with each other ++ via abstract sockets. Note that cooperating processes outside the ++ sandbox can still connect to an abstract socket created by the FOD. To ++ mitigate that issue entirely we'd still need network namespaces. */ ++ .scoped = LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET, ++ }; ++ ++ /* This better not fail - if the kernel reports a new enough ABI version we ++ should treat any errors as fatal from now on. */ ++ AutoCloseFD rulesetFd = landlockCreateRuleset(&attr, sizeof(attr), 0); ++ if (!rulesetFd) ++ throw SysError("failed to create a landlock ruleset"); ++ ++ if (landlockRestrictSelf(rulesetFd.get(), 0) == -1) ++ throw SysError("failed to apply landlock"); ++ ++ debug("applied landlock sandboxing"); ++} ++ ++# else ++ ++# define DO_LANDLOCK 0 ++ ++# endif ++ + static void doBind(const Path & source, const Path & target, bool optional = false) + { + debug("bind mounting '%1%' to '%2%'", source, target); +@@ -159,8 +241,27 @@ struct LinuxDerivationBuilder : virtual DerivationBuilderImpl + + void enterChroot() override + { ++ /* Set the NO_NEW_PRIVS before doing seccomp/landlock setup. ++ landlock_restrict_self requires either NO_NEW_PRIVS or CAP_SYS_ADMIN. ++ With user namespaces we do get CAP_SYS_ADMIN. */ ++ if (!settings.allowNewPrivileges) ++ if (::prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == -1) ++ throw SysError("failed to set PR_SET_NO_NEW_PRIVS"); ++ + setupSeccomp(); + ++# if DO_LANDLOCK ++ try { ++ setupLandlock(); ++ } catch (SysError & e) { ++ if (e.errNo != EPERM) ++ throw; ++ /* If allowNewPrivileges is true and we don't have CAP_SYS_ADMIN ++ this code path might be hit. */ ++ warn("setting up landlock: %s", e.message()); ++ } ++# endif ++ + linux::setPersonality(drv.platform); + } + }; +@@ -709,4 +810,6 @@ struct ChrootLinuxDerivationBuilder : ChrootDerivationBuilder, LinuxDerivationBu + + } // namespace nix + ++# undef DO_LANDLOCK ++ + #endif +diff --git a/tests/nixos/ca-fd-leak/default.nix b/tests/nixos/ca-fd-leak/default.nix +index 902aacdc6..dc944290f 100644 +--- a/tests/nixos/ca-fd-leak/default.nix ++++ b/tests/nixos/ca-fd-leak/default.nix +@@ -78,7 +78,7 @@ in + + # Build the smuggled derivation. + # This will connect to the smuggler server and send it the file descriptor +- machine.succeed(r""" ++ sender_output = machine.succeed(r""" + nix-build -E ' + builtins.derivation { + name = "smuggled"; +@@ -89,9 +89,13 @@ in + outputHash = builtins.hashString "sha256" "hello, world\n"; + builder = "${pkgs.busybox-sandbox-shell}/bin/sh"; + args = [ "-c" "echo \"hello, world\" > $out; ''${${sender}} ${socketName}" ]; +- }' ++ }' 2>&1 + """.strip()) + ++ # Landlock's LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET prevents a sandboxed process ++ # from connecting to an abstract socket created in an unrelated landlock domain. ++ # There's no such flag for preventing inbound connections. ++ assert "connect: Operation not permitted" in sender_output + + # Tell the smuggler server that we're done + machine.execute("echo done | ${pkgs.socat}/bin/socat - ABSTRACT-CONNECT:${socketName}") From 7ca89c4ab60606c7ecdb10c57ef91553f31c10a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Mon, 6 Apr 2026 16:49:41 +0200 Subject: [PATCH 4/5] nixComponents_2_34: add patches for GHSA-g3g9-5vj6-r3gj This addresses GHSA-g3g9-5vj6-r3gj, a vulnerability where std::filesystem::copy_file follows symlinks when copying FOD outputs, allowing a malicious builder to overwrite files outside the build sandbox. The patch puts FOD output copies in a temporary directory inside the store (instead of the chroot) and tightens permissions on the in-store temporary directory. The second patch partially fixes the issue with cooperating processes being able to communicate via abstract sockets. The fix is partial, because processes outside the landlock domain of the sandboxed process can still connect to a socket created by the FOD. There's no equivalent way of restricting inbound connections. This closes the gap when there's no cooperating process on the host (i.e. 2 separate FODs). The patch applies landlock LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET on kernels >= 6.12 (default on NixOS 25.11+) to deny abstract socket connect from inside the build sandbox. --- pkgs/tools/package-management/nix/default.nix | 8 +- .../patches/ghsa-g3g9-5vj6-r3gj-2.34.patch | 126 ++++++++++ ...dlock-abstract-socket-hardening-2.34.patch | 234 ++++++++++++++++++ 3 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 pkgs/tools/package-management/nix/patches/ghsa-g3g9-5vj6-r3gj-2.34.patch create mode 100644 pkgs/tools/package-management/nix/patches/landlock-abstract-socket-hardening-2.34.patch diff --git a/pkgs/tools/package-management/nix/default.nix b/pkgs/tools/package-management/nix/default.nix index a0e28a02f9f1..f8e997482863 100644 --- a/pkgs/tools/package-management/nix/default.nix +++ b/pkgs/tools/package-management/nix/default.nix @@ -228,7 +228,13 @@ lib.makeExtensible ( hash = "sha256-WPuGqMQGepXoRYjtRudMAMHEoLsIObw2x4sVfho5feA="; }; }).appendPatches - patches_common; + ( + patches_common + ++ [ + ./patches/ghsa-g3g9-5vj6-r3gj-2.34.patch + ./patches/landlock-abstract-socket-hardening-2.34.patch + ] + ); nix_2_34 = addTests "nix_2_34" self.nixComponents_2_34.nix-everything; diff --git a/pkgs/tools/package-management/nix/patches/ghsa-g3g9-5vj6-r3gj-2.34.patch b/pkgs/tools/package-management/nix/patches/ghsa-g3g9-5vj6-r3gj-2.34.patch new file mode 100644 index 000000000000..ea140e3fb149 --- /dev/null +++ b/pkgs/tools/package-management/nix/patches/ghsa-g3g9-5vj6-r3gj-2.34.patch @@ -0,0 +1,126 @@ +From dfda33d358f4522450737c40407ba8d8403a86a1 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= +Date: Mon, 6 Apr 2026 16:49:13 +0200 +Subject: [PATCH] Fixes for GHSA-g3g9-5vj6-r3gj +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Squashed commit of the following: + +commit d1f2de7d683bef1e2a164f23f33172c79c7725ea +Author: Sergei Zimmerman +Date: Fri Apr 3 00:21:31 2026 +0300 + + derivation-builder: Don't use copyFile for FOD output copying, put the output in a temporary directory in the store + +commit 2f13ee6dc4ade1d919c14757891d5d8a1001eee8 +Author: Sergei Zimmerman +Date: Fri Apr 3 00:21:21 2026 +0300 + + libstore: Make temporary in-store directory not world-readable + +Signed-off-by: Jörg Thalheim +--- + src/libstore/include/nix/store/local-store.hh | 2 ++ + src/libstore/local-store.cc | 5 +-- + src/libstore/unix/build/derivation-builder.cc | 36 ++++++++++++++----- + 3 files changed, 33 insertions(+), 10 deletions(-) + +diff --git a/src/libstore/include/nix/store/local-store.hh b/src/libstore/include/nix/store/local-store.hh +index 512c198b0..0b1e13b51 100644 +--- a/src/libstore/include/nix/store/local-store.hh ++++ b/src/libstore/include/nix/store/local-store.hh +@@ -505,6 +505,8 @@ private: + + friend struct PathSubstitutionGoal; + friend struct DerivationGoal; ++ /* Only used for createTempDirInStore. */ ++ friend class DerivationBuilderImpl; + }; + + } // namespace nix +diff --git a/src/libstore/local-store.cc b/src/libstore/local-store.cc +index 649ad70dd..686e9988e 100644 +--- a/src/libstore/local-store.cc ++++ b/src/libstore/local-store.cc +@@ -1283,8 +1283,9 @@ std::pair LocalStore::createTempDirInStore() + do { + /* There is a slight possibility that `tmpDir' gets deleted by + the GC between createTempDir() and when we acquire a lock on it. +- We'll repeat until 'tmpDir' exists and we've locked it. */ +- tmpDirFn = createTempDir(std::filesystem::path{config->realStoreDir.get()}, "tmp"); ++ We'll repeat until 'tmpDir' exists and we've locked it. ++ Make the directory accessible only to the current user. */ ++ tmpDirFn = createTempDir(std::filesystem::path{config->realStoreDir.get()}, "tmp", /*mode=*/0700); + tmpDirFd = openDirectory(tmpDirFn); + if (!tmpDirFd) { + continue; +diff --git a/src/libstore/unix/build/derivation-builder.cc b/src/libstore/unix/build/derivation-builder.cc +index da225dc0d..fc877dd56 100644 +--- a/src/libstore/unix/build/derivation-builder.cc ++++ b/src/libstore/unix/build/derivation-builder.cc +@@ -1595,6 +1595,13 @@ SingleDrvOutputs DerivationBuilderImpl::registerOutputs() + assert(output && scratchPath); + auto actualPath = realPathInHost(store.printStorePath(*scratchPath)); + ++ /* An optional file descriptor of a directory used for intermediate ++ operations. */ ++ AutoCloseFD tempDirFd; ++ /* RAII cleanup of a temporary directory inside the store that is used ++ for intermediate operations. */ ++ AutoDelete delTempDir; ++ + auto finish = [&](StorePath finalStorePath) { + /* Store the final path */ + finalOutputs.insert_or_assign(outputName, finalStorePath); +@@ -1742,6 +1749,25 @@ SingleDrvOutputs DerivationBuilderImpl::registerOutputs() + return newInfo0; + }; + ++ auto moveOutputToTempDir = [&]() -> void { ++ std::filesystem::path tempDir; ++ std::tie(tempDir, tempDirFd) = store.createTempDirInStore(); ++ delTempDir = AutoDelete(tempDir); ++ ++ auto tmpOutput = tempDir / "x"; ++ ++ /* Serialise and create a fresh copy of the output to break ++ any stale writable file descriptors. Copy through the ++ serialisation/deserialisation. TODO: Use copyRecursive here and ++ make use of reflinking. */ ++ auto source = sinkToSource([&](Sink & nextSink) { dumpPath(actualPath, nextSink); }); ++ restorePath(tmpOutput, *source, store.config->getLocalSettings().fsyncStorePaths); ++ /* This makes it slightly harder to make sense of the control flow. The rule ++ of thumb is that actualPath points to the current location of the stuff ++ that we'll end up registering. */ ++ actualPath = std::move(tmpOutput); ++ }; ++ + ValidPathInfo newInfo = std::visit( + overloaded{ + +@@ -1769,14 +1795,7 @@ SingleDrvOutputs DerivationBuilderImpl::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 +- std::filesystem::path tmpOutput = actualPath.native() + ".tmp"; +- copyFile(actualPath, tmpOutput, true); +- +- std::filesystem::rename(tmpOutput, actualPath); +- ++ moveOutputToTempDir(); + return newInfoFromCA( + DerivationOutput::CAFloating{ + .method = dof.ca.method, +@@ -1793,6 +1812,7 @@ SingleDrvOutputs DerivationBuilderImpl::registerOutputs() + }, + + [&](const DerivationOutput::Impure & doi) { ++ moveOutputToTempDir(); + return newInfoFromCA( + DerivationOutput::CAFloating{ + .method = doi.method, diff --git a/pkgs/tools/package-management/nix/patches/landlock-abstract-socket-hardening-2.34.patch b/pkgs/tools/package-management/nix/patches/landlock-abstract-socket-hardening-2.34.patch new file mode 100644 index 000000000000..1ca40ee24b17 --- /dev/null +++ b/pkgs/tools/package-management/nix/patches/landlock-abstract-socket-hardening-2.34.patch @@ -0,0 +1,234 @@ +From 23c7a6490a829a978af1acd56d76cc2dee7f4ad6 Mon Sep 17 00:00:00 2001 +From: Sergei Zimmerman +Date: Sun, 5 Apr 2026 16:39:58 +0300 +Subject: [PATCH] libstore: Use landlock with + LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET for new enough kernels +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +This partially fixes the issue with cooperating processes being able +to communicate via abstract sockets. The fix is partial, because processes +outside the landlock domain of the sandboxed process can still connect to +a socket created by the FOD. There's no equivalent way of restricting inbound +connections. This closes the gap when there's no cooperating process on the host +(i.e. 2 separate FODs). + +>= 6.12 kernel is widespread enough (NixOS 25.11 ships it by +default) that we have no reason not to apply this hardening, even though +it's incomplete. + +ca-fd-leak test exercises this exact code path and now the smuggling +process fails with (on new enough kernels that have landlock support enabled): + +vm-test-run-ca-fd-leak> machine # sandbox setup: applied landlock sandboxing +vm-test-run-ca-fd-leak> machine # building '/nix/store/s7brgi6pdr5f3n8yqlgmdlz8blb89njc-smuggled.drv'... +vm-test-run-ca-fd-leak> machine # building derivation '/nix/store/s7brgi6pdr5f3n8yqlgmdlz8blb89njc-smuggled.drv': woken up +vm-test-run-ca-fd-leak> machine # connect: Operation not permitted +vm-test-run-ca-fd-leak> machine # sendmsg: Socket not connected + +(cherry picked from commit 44017ca497c8b44d5dac179f5afc63e91fe45ed6) +Signed-off-by: Jörg Thalheim +--- + src/libstore/meson.build | 5 + + .../unix/build/linux-derivation-builder.cc | 103 ++++++++++++++++++ + tests/nixos/ca-fd-leak/default.nix | 8 +- + 3 files changed, 114 insertions(+), 2 deletions(-) + +diff --git a/src/libstore/meson.build b/src/libstore/meson.build +index 9e52517d4..edc476ae7 100644 +--- a/src/libstore/meson.build ++++ b/src/libstore/meson.build +@@ -79,6 +79,11 @@ foreach funcspec : check_funcs + configdata_priv.set(define_name, define_value) + endforeach + ++if host_machine.system() == 'linux' ++ has_landlock = cxx.has_header('linux/landlock.h') ++ configdata_priv.set('HAVE_LANDLOCK', has_landlock.to_int()) ++endif ++ + has_acl_support = cxx.has_header('sys/xattr.h') \ + and cxx.has_function('llistxattr') \ + and cxx.has_function('lremovexattr') +diff --git a/src/libstore/unix/build/linux-derivation-builder.cc b/src/libstore/unix/build/linux-derivation-builder.cc +index 555a3b6da..476baabe2 100644 +--- a/src/libstore/unix/build/linux-derivation-builder.cc ++++ b/src/libstore/unix/build/linux-derivation-builder.cc +@@ -1,5 +1,7 @@ + #ifdef __linux__ + ++# include "store-config-private.hh" ++ + # include "nix/store/globals.hh" + # include "nix/store/personality.hh" + # include "nix/store/filetransfer.hh" +@@ -9,6 +11,10 @@ + # include "nix/util/serialise.hh" + # include "linux/fchmodat2-compat.hh" + ++# include ++# include ++# include ++ + # include + # include + # include +@@ -17,11 +23,16 @@ + # include + # include + # include ++# include + + # if HAVE_SECCOMP + # include + # endif + ++# if HAVE_LANDLOCK ++# include ++# endif ++ + # define pivot_root(new_root, put_old) (syscall(SYS_pivot_root, new_root, put_old)) + + namespace nix { +@@ -125,6 +136,77 @@ static void setupSeccomp(const LocalSettings & localSettings) + # endif + } + ++# if HAVE_LANDLOCK && defined(LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET) ++ ++# define DO_LANDLOCK 1 ++ ++/* We are using LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET on best-effort basis. There are no glibc wrappers for now. */ ++ ++static int landlockCreateRuleset(const ::landlock_ruleset_attr * attr, std::size_t size, std::uint32_t flags) ++{ ++ return ::syscall(__NR_landlock_create_ruleset, attr, size, flags); ++} ++ ++static int landlockRestrictSelf(Descriptor rulesetFd, std::uint32_t flags) ++{ ++ return ::syscall(__NR_landlock_restrict_self, rulesetFd, flags); ++} ++ ++static int getLandlockAbiVersion() ++{ ++ int abiVersion = landlockCreateRuleset(nullptr, 0, LANDLOCK_CREATE_RULESET_VERSION); ++ return abiVersion; ++} ++ ++static void setupLandlock() ++{ ++ bool landlockSupportsScopeAbstractUnixSocket = []() { ++ int abiVersion = getLandlockAbiVersion(); ++ if (abiVersion >= 6) ++ /* All good, we can use LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET. See ++ https://docs.kernel.org/userspace-api/landlock.html#abstract-unix-socket-abi-6 */ ++ return true; ++ ++ if (abiVersion == -1) { ++ debug("landlock is not available"); ++ return false; ++ } ++ ++ debug("landlock version %d does not support LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET", abiVersion); ++ return false; ++ }(); ++ ++ /* Bail out early if landlock is not enabled or LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET wouldn't work. ++ TODO: Consider adding more landlock rules for filesystem access as defense-in-depth on top. */ ++ if (!landlockSupportsScopeAbstractUnixSocket) ++ return; ++ ++ ::landlock_ruleset_attr attr = { ++ /* This prevents multiple FODs from communicating with each other ++ via abstract sockets. Note that cooperating processes outside the ++ sandbox can still connect to an abstract socket created by the FOD. To ++ mitigate that issue entirely we'd still need network namespaces. */ ++ .scoped = LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET, ++ }; ++ ++ /* This better not fail - if the kernel reports a new enough ABI version we ++ should treat any errors as fatal from now on. */ ++ AutoCloseFD rulesetFd = landlockCreateRuleset(&attr, sizeof(attr), 0); ++ if (!rulesetFd) ++ throw SysError("failed to create a landlock ruleset"); ++ ++ if (landlockRestrictSelf(rulesetFd.get(), 0) == -1) ++ throw SysError("failed to apply landlock"); ++ ++ debug("applied landlock sandboxing"); ++} ++ ++# else ++ ++# define DO_LANDLOCK 0 ++ ++# endif ++ + static void doBind(const std::filesystem::path & source, const std::filesystem::path & target, bool optional = false) + { + debug("bind mounting %1% to %2%", PathFmt(source), PathFmt(target)); +@@ -165,8 +247,27 @@ struct LinuxDerivationBuilder : virtual DerivationBuilderImpl + { + auto & localSettings = store.config->getLocalSettings(); + ++ /* Set the NO_NEW_PRIVS before doing seccomp/landlock setup. ++ landlock_restrict_self requires either NO_NEW_PRIVS or CAP_SYS_ADMIN. ++ With user namespaces we do get CAP_SYS_ADMIN. */ ++ if (!localSettings.allowNewPrivileges) ++ if (::prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == -1) ++ throw SysError("failed to set PR_SET_NO_NEW_PRIVS"); ++ + setupSeccomp(localSettings); + ++# if DO_LANDLOCK ++ try { ++ setupLandlock(); ++ } catch (SysError & e) { ++ if (e.errNo != EPERM) ++ throw; ++ /* If allowNewPrivileges is true and we don't have CAP_SYS_ADMIN ++ this code path might be hit. */ ++ warn("setting up landlock: %s", e.message()); ++ } ++# endif ++ + linux::setPersonality({ + .system = drv.platform, + .impersonateLinux26 = localSettings.impersonateLinux26, +@@ -760,4 +861,6 @@ struct ChrootLinuxDerivationBuilder : ChrootDerivationBuilder, LinuxDerivationBu + + } // namespace nix + ++# undef DO_LANDLOCK ++ + #endif +diff --git a/tests/nixos/ca-fd-leak/default.nix b/tests/nixos/ca-fd-leak/default.nix +index 902aacdc6..dc944290f 100644 +--- a/tests/nixos/ca-fd-leak/default.nix ++++ b/tests/nixos/ca-fd-leak/default.nix +@@ -78,7 +78,7 @@ in + + # Build the smuggled derivation. + # This will connect to the smuggler server and send it the file descriptor +- machine.succeed(r""" ++ sender_output = machine.succeed(r""" + nix-build -E ' + builtins.derivation { + name = "smuggled"; +@@ -89,9 +89,13 @@ in + outputHash = builtins.hashString "sha256" "hello, world\n"; + builder = "${pkgs.busybox-sandbox-shell}/bin/sh"; + args = [ "-c" "echo \"hello, world\" > $out; ''${${sender}} ${socketName}" ]; +- }' ++ }' 2>&1 + """.strip()) + ++ # Landlock's LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET prevents a sandboxed process ++ # from connecting to an abstract socket created in an unrelated landlock domain. ++ # There's no such flag for preventing inbound connections. ++ assert "connect: Operation not permitted" in sender_output + + # Tell the smuggler server that we're done + machine.execute("echo done | ${pkgs.socat}/bin/socat - ABSTRACT-CONNECT:${socketName}") From 50eec35e48a03435730b68b851bd27213ea1c802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Mon, 6 Apr 2026 16:49:41 +0200 Subject: [PATCH 5/5] nixComponents_git: add patches for GHSA-g3g9-5vj6-r3gj This addresses GHSA-g3g9-5vj6-r3gj, a vulnerability where std::filesystem::copy_file follows symlinks when copying FOD outputs, allowing a malicious builder to overwrite files outside the build sandbox. The patch puts FOD output copies in a temporary directory inside the store (instead of the chroot) and tightens permissions on the in-store temporary directory. The second patch partially fixes the issue with cooperating processes being able to communicate via abstract sockets. The fix is partial, because processes outside the landlock domain of the sandboxed process can still connect to a socket created by the FOD. There's no equivalent way of restricting inbound connections. This closes the gap when there's no cooperating process on the host (i.e. 2 separate FODs). The patch applies landlock LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET on kernels >= 6.12 (default on NixOS 25.11+) to deny abstract socket connect from inside the build sandbox. --- pkgs/tools/package-management/nix/default.nix | 8 +- .../nix/patches/ghsa-g3g9-5vj6-r3gj-git.patch | 126 ++++++++++ ...ndlock-abstract-socket-hardening-git.patch | 231 ++++++++++++++++++ 3 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 pkgs/tools/package-management/nix/patches/ghsa-g3g9-5vj6-r3gj-git.patch create mode 100644 pkgs/tools/package-management/nix/patches/landlock-abstract-socket-hardening-git.patch diff --git a/pkgs/tools/package-management/nix/default.nix b/pkgs/tools/package-management/nix/default.nix index f8e997482863..4460014ad689 100644 --- a/pkgs/tools/package-management/nix/default.nix +++ b/pkgs/tools/package-management/nix/default.nix @@ -250,7 +250,13 @@ lib.makeExtensible ( hash = "sha256-fybp46IQmRN7lEUTChc3MTqxmRutmDO4RNSPEQfJQsQ="; }; }).appendPatches - patches_common; + ( + patches_common + ++ [ + ./patches/ghsa-g3g9-5vj6-r3gj-git.patch + ./patches/landlock-abstract-socket-hardening-git.patch + ] + ); git = addTests "git" self.nixComponents_git.nix-everything; diff --git a/pkgs/tools/package-management/nix/patches/ghsa-g3g9-5vj6-r3gj-git.patch b/pkgs/tools/package-management/nix/patches/ghsa-g3g9-5vj6-r3gj-git.patch new file mode 100644 index 000000000000..05fb52902018 --- /dev/null +++ b/pkgs/tools/package-management/nix/patches/ghsa-g3g9-5vj6-r3gj-git.patch @@ -0,0 +1,126 @@ +From 0af4c1f88d0646bda0a90a37105360cab466a550 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= +Date: Mon, 6 Apr 2026 16:49:13 +0200 +Subject: [PATCH] Fixes for GHSA-g3g9-5vj6-r3gj +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Squashed commit of the following: + +commit a760af86b3a42aa5ac9d9002929107fe357bf128 +Author: Sergei Zimmerman +Date: Fri Apr 3 00:21:31 2026 +0300 + + derivation-builder: Don't use copyFile for FOD output copying, put the output in a temporary directory in the store + +commit 0e3412a93f43a017342c267000c152e5c45327e7 +Author: Sergei Zimmerman +Date: Fri Apr 3 00:21:21 2026 +0300 + + libstore: Make temporary in-store directory not world-readable + +Signed-off-by: Jörg Thalheim +--- + src/libstore/include/nix/store/local-store.hh | 2 ++ + src/libstore/local-store.cc | 5 +-- + src/libstore/unix/build/derivation-builder.cc | 36 ++++++++++++++----- + 3 files changed, 33 insertions(+), 10 deletions(-) + +diff --git a/src/libstore/include/nix/store/local-store.hh b/src/libstore/include/nix/store/local-store.hh +index 63a1da67d..bf3437e95 100644 +--- a/src/libstore/include/nix/store/local-store.hh ++++ b/src/libstore/include/nix/store/local-store.hh +@@ -512,6 +512,8 @@ private: + + friend struct PathSubstitutionGoal; + friend struct DerivationGoal; ++ /* Only used for createTempDirInStore. */ ++ friend class DerivationBuilderImpl; + }; + + } // namespace nix +diff --git a/src/libstore/local-store.cc b/src/libstore/local-store.cc +index e9eb48bfe..d5e457731 100644 +--- a/src/libstore/local-store.cc ++++ b/src/libstore/local-store.cc +@@ -1304,8 +1304,9 @@ std::pair LocalStore::createTempDirInStore() + do { + /* There is a slight possibility that `tmpDir' gets deleted by + the GC between createTempDir() and when we acquire a lock on it. +- We'll repeat until 'tmpDir' exists and we've locked it. */ +- tmpDirFn = createTempDir(std::filesystem::path{config->realStoreDir.get()}, "tmp"); ++ We'll repeat until 'tmpDir' exists and we've locked it. ++ Make the directory accessible only to the current user. */ ++ tmpDirFn = createTempDir(std::filesystem::path{config->realStoreDir.get()}, "tmp", /*mode=*/0700); + tmpDirFd = openDirectory(tmpDirFn, FinalSymlink::DontFollow); + if (!tmpDirFd) { + continue; +diff --git a/src/libstore/unix/build/derivation-builder.cc b/src/libstore/unix/build/derivation-builder.cc +index 8f6343e0f..8288a4a31 100644 +--- a/src/libstore/unix/build/derivation-builder.cc ++++ b/src/libstore/unix/build/derivation-builder.cc +@@ -1597,6 +1597,13 @@ SingleDrvOutputs DerivationBuilderImpl::registerOutputs() + assert(output && scratchPath); + auto actualPath = realPathInHost(store.printStorePath(*scratchPath)); + ++ /* An optional file descriptor of a directory used for intermediate ++ operations. */ ++ AutoCloseFD tempDirFd; ++ /* RAII cleanup of a temporary directory inside the store that is used ++ for intermediate operations. */ ++ AutoDelete delTempDir; ++ + auto finish = [&](StorePath finalStorePath) { + /* Store the final path */ + finalOutputs.insert_or_assign(outputName, finalStorePath); +@@ -1744,6 +1751,25 @@ SingleDrvOutputs DerivationBuilderImpl::registerOutputs() + return newInfo0; + }; + ++ auto moveOutputToTempDir = [&]() -> void { ++ std::filesystem::path tempDir; ++ std::tie(tempDir, tempDirFd) = store.createTempDirInStore(); ++ delTempDir = AutoDelete(tempDir); ++ ++ auto tmpOutput = tempDir / "x"; ++ ++ /* Serialise and create a fresh copy of the output to break ++ any stale writable file descriptors. Copy through the ++ serialisation/deserialisation. TODO: Use copyRecursive here and ++ make use of reflinking. */ ++ auto source = sinkToSource([&](Sink & nextSink) { dumpPath(actualPath, nextSink); }); ++ restorePath(tmpOutput, *source, store.config->getLocalSettings().fsyncStorePaths); ++ /* This makes it slightly harder to make sense of the control flow. The rule ++ of thumb is that actualPath points to the current location of the stuff ++ that we'll end up registering. */ ++ actualPath = std::move(tmpOutput); ++ }; ++ + ValidPathInfo newInfo = std::visit( + overloaded{ + +@@ -1771,14 +1797,7 @@ SingleDrvOutputs DerivationBuilderImpl::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 +- std::filesystem::path tmpOutput = actualPath.native() + ".tmp"; +- copyFile(actualPath, tmpOutput, true); +- +- std::filesystem::rename(tmpOutput, actualPath); +- ++ moveOutputToTempDir(); + return newInfoFromCA( + DerivationOutput::CAFloating{ + .method = dof.ca.method, +@@ -1795,6 +1814,7 @@ SingleDrvOutputs DerivationBuilderImpl::registerOutputs() + }, + + [&](const DerivationOutput::Impure & doi) { ++ moveOutputToTempDir(); + return newInfoFromCA( + DerivationOutput::CAFloating{ + .method = doi.method, diff --git a/pkgs/tools/package-management/nix/patches/landlock-abstract-socket-hardening-git.patch b/pkgs/tools/package-management/nix/patches/landlock-abstract-socket-hardening-git.patch new file mode 100644 index 000000000000..5b0fbdb46536 --- /dev/null +++ b/pkgs/tools/package-management/nix/patches/landlock-abstract-socket-hardening-git.patch @@ -0,0 +1,231 @@ +From 44017ca497c8b44d5dac179f5afc63e91fe45ed6 Mon Sep 17 00:00:00 2001 +From: Sergei Zimmerman +Date: Sun, 5 Apr 2026 16:39:58 +0300 +Subject: [PATCH] libstore: Use landlock with + LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET for new enough kernels +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +This partially fixes the issue with cooperating processes being able +to communicate via abstract sockets. The fix is partial, because processes +outside the landlock domain of the sandboxed process can still connect to +a socket created by the FOD. There's no equivalent way of restricting inbound +connections. This closes the gap when there's no cooperating process on the host +(i.e. 2 separate FODs). + +>= 6.12 kernel is widespread enough (NixOS 25.11 ships it by +default) that we have no reason not to apply this hardening, even though +it's incomplete. + +ca-fd-leak test exercises this exact code path and now the smuggling +process fails with (on new enough kernels that have landlock support enabled): + +vm-test-run-ca-fd-leak> machine # sandbox setup: applied landlock sandboxing +vm-test-run-ca-fd-leak> machine # building '/nix/store/s7brgi6pdr5f3n8yqlgmdlz8blb89njc-smuggled.drv'... +vm-test-run-ca-fd-leak> machine # building derivation '/nix/store/s7brgi6pdr5f3n8yqlgmdlz8blb89njc-smuggled.drv': woken up +vm-test-run-ca-fd-leak> machine # connect: Operation not permitted +vm-test-run-ca-fd-leak> machine # sendmsg: Socket not connected + +Signed-off-by: Jörg Thalheim +--- + src/libstore/meson.build | 5 + + .../unix/build/linux-derivation-builder.cc | 101 ++++++++++++++++++ + tests/nixos/ca-fd-leak/default.nix | 8 +- + 3 files changed, 112 insertions(+), 2 deletions(-) + +diff --git a/src/libstore/meson.build b/src/libstore/meson.build +index 445798544..753c78687 100644 +--- a/src/libstore/meson.build ++++ b/src/libstore/meson.build +@@ -79,6 +79,11 @@ foreach funcspec : check_funcs + configdata_priv.set(define_name, define_value) + endforeach + ++if host_machine.system() == 'linux' ++ has_landlock = cxx.has_header('linux/landlock.h') ++ configdata_priv.set('HAVE_LANDLOCK', has_landlock.to_int()) ++endif ++ + has_acl_support = cxx.has_header('sys/xattr.h') \ + and cxx.has_function('llistxattr') \ + and cxx.has_function('lremovexattr') +diff --git a/src/libstore/unix/build/linux-derivation-builder.cc b/src/libstore/unix/build/linux-derivation-builder.cc +index 9cfd3cbcd..c71d23e15 100644 +--- a/src/libstore/unix/build/linux-derivation-builder.cc ++++ b/src/libstore/unix/build/linux-derivation-builder.cc +@@ -1,5 +1,7 @@ + #ifdef __linux__ + ++# include "store-config-private.hh" ++ + # include "nix/store/globals.hh" + # include "nix/store/personality.hh" + # include "nix/store/filetransfer.hh" +@@ -11,6 +13,8 @@ + + # include + # include ++# include ++ + # include + # include + # include +@@ -19,11 +23,16 @@ + # include + # include + # include ++# include + + # if HAVE_SECCOMP + # include + # endif + ++# if HAVE_LANDLOCK ++# include ++# endif ++ + # define pivot_root(new_root, put_old) (syscall(SYS_pivot_root, new_root, put_old)) + + namespace nix { +@@ -129,6 +138,77 @@ static void setupSeccomp(const LocalSettings & localSettings) + # endif + } + ++# if HAVE_LANDLOCK && defined(LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET) ++ ++# define DO_LANDLOCK 1 ++ ++/* We are using LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET on best-effort basis. There are no glibc wrappers for now. */ ++ ++static int landlockCreateRuleset(const ::landlock_ruleset_attr * attr, std::size_t size, std::uint32_t flags) ++{ ++ return ::syscall(__NR_landlock_create_ruleset, attr, size, flags); ++} ++ ++static int landlockRestrictSelf(Descriptor rulesetFd, std::uint32_t flags) ++{ ++ return ::syscall(__NR_landlock_restrict_self, rulesetFd, flags); ++} ++ ++static int getLandlockAbiVersion() ++{ ++ int abiVersion = landlockCreateRuleset(nullptr, 0, LANDLOCK_CREATE_RULESET_VERSION); ++ return abiVersion; ++} ++ ++static void setupLandlock() ++{ ++ bool landlockSupportsScopeAbstractUnixSocket = []() { ++ int abiVersion = getLandlockAbiVersion(); ++ if (abiVersion >= 6) ++ /* All good, we can use LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET. See ++ https://docs.kernel.org/userspace-api/landlock.html#abstract-unix-socket-abi-6 */ ++ return true; ++ ++ if (abiVersion == -1) { ++ debug("landlock is not available"); ++ return false; ++ } ++ ++ debug("landlock version %d does not support LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET", abiVersion); ++ return false; ++ }(); ++ ++ /* Bail out early if landlock is not enabled or LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET wouldn't work. ++ TODO: Consider adding more landlock rules for filesystem access as defense-in-depth on top. */ ++ if (!landlockSupportsScopeAbstractUnixSocket) ++ return; ++ ++ ::landlock_ruleset_attr attr = { ++ /* This prevents multiple FODs from communicating with each other ++ via abstract sockets. Note that cooperating processes outside the ++ sandbox can still connect to an abstract socket created by the FOD. To ++ mitigate that issue entirely we'd still need network namespaces. */ ++ .scoped = LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET, ++ }; ++ ++ /* This better not fail - if the kernel reports a new enough ABI version we ++ should treat any errors as fatal from now on. */ ++ AutoCloseFD rulesetFd = landlockCreateRuleset(&attr, sizeof(attr), 0); ++ if (!rulesetFd) ++ throw SysError("failed to create a landlock ruleset"); ++ ++ if (landlockRestrictSelf(rulesetFd.get(), 0) == -1) ++ throw SysError("failed to apply landlock"); ++ ++ debug("applied landlock sandboxing"); ++} ++ ++# else ++ ++# define DO_LANDLOCK 0 ++ ++# endif ++ + static void doBind(const std::filesystem::path & source, const std::filesystem::path & target, bool optional = false) + { + debug("bind mounting %1% to %2%", PathFmt(source), PathFmt(target)); +@@ -169,8 +249,27 @@ struct LinuxDerivationBuilder : virtual DerivationBuilderImpl + { + auto & localSettings = store.config->getLocalSettings(); + ++ /* Set the NO_NEW_PRIVS before doing seccomp/landlock setup. ++ landlock_restrict_self requires either NO_NEW_PRIVS or CAP_SYS_ADMIN. ++ With user namespaces we do get CAP_SYS_ADMIN. */ ++ if (!localSettings.allowNewPrivileges) ++ if (::prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == -1) ++ throw SysError("failed to set PR_SET_NO_NEW_PRIVS"); ++ + setupSeccomp(localSettings); + ++# if DO_LANDLOCK ++ try { ++ setupLandlock(); ++ } catch (SysError & e) { ++ if (e.errNo != EPERM) ++ throw; ++ /* If allowNewPrivileges is true and we don't have CAP_SYS_ADMIN ++ this code path might be hit. */ ++ warn("setting up landlock: %s", e.message()); ++ } ++# endif ++ + linux::setPersonality({ + .system = drv.platform, + .impersonateLinux26 = localSettings.impersonateLinux26, +@@ -765,4 +864,6 @@ struct ChrootLinuxDerivationBuilder : ChrootDerivationBuilder, LinuxDerivationBu + + } // namespace nix + ++# undef DO_LANDLOCK ++ + #endif +diff --git a/tests/nixos/ca-fd-leak/default.nix b/tests/nixos/ca-fd-leak/default.nix +index 902aacdc6..dc944290f 100644 +--- a/tests/nixos/ca-fd-leak/default.nix ++++ b/tests/nixos/ca-fd-leak/default.nix +@@ -78,7 +78,7 @@ in + + # Build the smuggled derivation. + # This will connect to the smuggler server and send it the file descriptor +- machine.succeed(r""" ++ sender_output = machine.succeed(r""" + nix-build -E ' + builtins.derivation { + name = "smuggled"; +@@ -89,9 +89,13 @@ in + outputHash = builtins.hashString "sha256" "hello, world\n"; + builder = "${pkgs.busybox-sandbox-shell}/bin/sh"; + args = [ "-c" "echo \"hello, world\" > $out; ''${${sender}} ${socketName}" ]; +- }' ++ }' 2>&1 + """.strip()) + ++ # Landlock's LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET prevents a sandboxed process ++ # from connecting to an abstract socket created in an unrelated landlock domain. ++ # There's no such flag for preventing inbound connections. ++ assert "connect: Operation not permitted" in sender_output + + # Tell the smuggler server that we're done + machine.execute("echo done | ${pkgs.socat}/bin/socat - ABSTRACT-CONNECT:${socketName}")