HaxOS: A NixOS hacking environment

In the last few months I’ve been getting back to studying pentesting and ethical hacking in general. While playing around in platforms like HackTheBox or trying to get flags in vulnerable VMs, I soon realized that I should be doing it from a VM instead of from my own machine, mainly for security reasons but for organization and convenience as well, the tools and files that you need to download quickly add up and it starts to become a mess.

This post will try to describe my path putting together a NixOS config that can generate a virtual machine so I can fool around without breaking or compromising my main machine.

Some requirements

I recently migrated to a workflow with a tilling window manager and nvim so I wanted to have the same workflow in this VM. I wanted a basic version of my actual machine that could be reproducible, where I could reuse my dotfiles and config and easily add the tools that I would need, I also wanted it to be easy to generate a new and clean instance of the VM.

So, why not Kali?

This is would be a perfect use case for Kali, I would probably just need to do a few customizations and it would be good to go, saving me an incredible amount of hours and saving you of reading this post.

But as a beginner in cybersecurity, having everything preinstalled bothers me, I like to think that searching for, installing and maintaning your own set of tools can give you great insight in how they work. Not that I want to write everything from scratch but I want to have “my own” Kali Linux, or in other words, I want to gather my own collection of tools and learn how they work in the meantime.

The naive approach

My main machine is running on Arch, so my first approach was to simply export the list of what I had installed via pacman, make a script to download this list and clone my dotfiles to my home, run this script in an Arch VM and then everything would work, right? eh, kinda.

It worked but had some problems, this naive approach was important to realize that I did not want a exact copy of my machine, I wanted my workflow tied together with a specific list of packages that not necessarily should be in the main machine, for instance, I did not want things like metasploit or gobuster to be in the list of things installed in my main machine.

The other important thing that I saw with this approach is that this list of packages is going to grow a lot, so I needed a easy way of adding something to this list and could not be restricted to some specific package repository, so I also needed a structured way of building it from source.

So, why NixOS?

Mainly because I was curious about it and wanted to give it a go, so why not? But NixOS covers pretty much all the requirements that I had:

  • Nothing is preinstalled, I can start from scratch
  • I can add new tools very easily
  • If the tool that I want is not on nixpkgs, NixOS provides a way of packaging it yourself
  • I can reuse my dotfiles
  • A tool that generates a QEMU VM image from a nix config already exists

From now on we will be getting into the details of how the config that I put together works, so it is a good time for a disclaimer, I am by no means a NixOS expert and just started messing around with it, so I’m pretty confident that may be better and simpler ways to achieve the same results.

It’s good to note that the objective of this is not to be some kind of universal thing like Kali Linux, but to be my pentesting lab/environment that you can take inspiration or ideas from it, I know I had to look a lot at other people’s configs so I could build this one.

An overview

You can check the github repo here, I will be adding code snippets to illustrate what I’m talking about but you can follow through the repository as well.

There are three parts of the config that I would like to give a quick intro to what they are and how they can be used:

Nix Flakes

Nix Flakes are basically a way to manage dependencies in the Nix ecosystem, you can define inputs and outputs to a Flake, these inputs and outputs can be another Flake, so it is possible to reuse or chain them to achieve some result. You can read more about Flakes here.

In my config I’m using flake mainly to initialize Home-Manager and transform everything into a VM image using NixOS Generators.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
  inputs = {
    nixpkgs.url = "nixpkgs/nixos-unstable";
    nixos-generators = {
      url = "github:nix-community/nixos-generators";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    home-manager.url = "github:nix-community/home-manager";
    home-manager.inputs.nixpkgs.follows = "nixpkgs";
  };
  outputs = { self, nixpkgs, nixos-generators, home-manager }: {
    packages.x86_64-linux = {
      qcow = nixos-generators.nixosGenerate {
        system = "x86_64-linux";
        modules = [
          ./configuration.nix
          home-manager.nixosModules.home-manager {
              home-manager.useGlobalPkgs = true;
              home-manager.useUserPackages = true;
              home-manager.users.haxos = import ./home.nix;
          }
        ];
        format = "qcow";
      };
    };
  };
}

Home-Manager

Home-Manager allows us to declaratively manage a user’s packages and dotfiles, these will be applied only to the user profile, not globally. In my config I try to keep the system configuration at configuration.nix and the packages and dotfiles at home.nix.

We need to initialize Home-Manager and define where our user configuration will be, in my config I do this at flake.nix:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
  inputs = {
    nixpkgs.url = "nixpkgs/nixos-unstable";
    nixos-generators = {
      url = "github:nix-community/nixos-generators";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    home-manager.url = "github:nix-community/home-manager";
    home-manager.inputs.nixpkgs.follows = "nixpkgs";
  };
  outputs = { self, nixpkgs, nixos-generators, home-manager }: {
    packages.x86_64-linux = {
      qcow = nixos-generators.nixosGenerate {
        system = "x86_64-linux";
        modules = [
          ./configuration.nix
          home-manager.nixosModules.home-manager {
              home-manager.useGlobalPkgs = true;
              home-manager.useUserPackages = true;
              home-manager.users.haxos = import ./home.nix;
          }
        ];
        format = "qcow";
      };
    };
  };
}

Here at the “modules” property we are importing our general configuration (configuration.nix) and our user configuration (controlled by Home-Manager) into the generator. If you want to know more about Home-Manager take a look at its manual and options.

NixOS Generators

Because the objective of this config is to output a VM image, I’m using NixOS generators a project to generate a VM in various formats using the nix configuration.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{
  inputs = {
    nixpkgs.url = "nixpkgs/nixos-unstable";
    # Importing nix-generators as a dependency to this flake
    nixos-generators = {
      url = "github:nix-community/nixos-generators";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    home-manager.url = "github:nix-community/home-manager";
    home-manager.inputs.nixpkgs.follows = "nixpkgs";
  };
  outputs = { self, nixpkgs, nixos-generators, home-manager }: {
    packages.x86_64-linux = {
      # Using the generator function to give us a qcow2 VM image
      qcow = nixos-generators.nixosGenerate {
        system = "x86_64-linux";
        modules = [
          ./configuration.nix
          home-manager.nixosModules.home-manager {
              home-manager.useGlobalPkgs = true;
              home-manager.useUserPackages = true;
              home-manager.users.haxos = import ./home.nix;
          }
        ];
        format = "qcow";
      };
    };
  };
}

Oh my dotfiles!

I spent a good amount of time in my dotfiles, mainly messing around in awesomeWM and neovim, so I already have a stablished config and workflow. While browsing through other people’s configs I saw that was pretty common to configure everything in nix files and Home-Manager, but I did not want to migrate all my dotfiles to a nix format and ended up simply “putting” my config in the generated home folder.

There is two properties that I used to put my dotfiles in the VM home folder:

  • xdg.configFile will set the files “/home/user/.config/”
  • home.file will set the files directly at “/home/user/”
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
{ config, pkgs, lib, ... }:
let
 # Fetching my dotfiles from github 
 dotfiles = pkgs.fetchgit {
  url = "https://github.com/vncsb/dotfiles.git";
  rev = "39ba3fefc87a5dd674d848a34c9da662cb288372";
  hash = "sha256-zHjdXlDkoN669yZewWi/e3jsyYf99BbC3q2ezjFwY6c=";
  fetchSubmodules = true;
 };
...
 # Setting my dotfiles to the VM home.
xdg.configFile = {
 "awesome" = {
  source = "${dotfiles}/.config/awesome";
  recursive = true;
  };
 "nvim" = {
  source = "${dotfiles}/.config/nvim";
  recursive = true;
 };
 "alacritty" = {
  source = "${dotfiles}/.config/alacritty";
  recursive = true;
 };
};

home.file = {
 ".zshrc".source = "${dotfiles}/.zshrc";
 ".p10k.zsh".source = "${dotfiles}/.p10k.zsh";
 "wordlists/seclists".source = seclists;
};

This basically just gets files from a repo and put them in the required folders so the applications can read their configuration. It is important to use the recursive option when copying a folder with its subfolders.

While test-driving the VM I needed a collection of wordlists to try and find some directories, so I decided to add one to the config as an example, I created a separated file that it all it does is fetch a repo from github:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{ fetchFromGitHub }:
let
  pname = "seclists";
  version = "2023.3";
in
fetchFromGitHub {
  owner = "danielmiessler";
  repo = "SecLists";
  rev = "refs/tags/${version}";
  hash = "sha256-mJgCzp8iKzSWf4Tud5xDpnuY4aNJmnEo/hTcuGTaOWM=";
}

In the beginning of the home.nix file where I declare the variables I bring the result from this file into the current context and use it to save the repo at ~/wordlists/seclists:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{ config, pkgs, lib, ... }:
let
 seclists = pkgs.callPackage ./pkgs/seclists.nix {}; 
in
{
...

home.file = {
 ".zshrc".source = "${dotfiles}/.zshrc";
 ".p10k.zsh".source = "${dotfiles}/.p10k.zsh";
 "wordlists/seclists".source = seclists;
};

Adding packages

This is very very simple if the package already exists into nixpkgs, you can search for packages in here.

With Home-Manager the property that declares the packages to be installed for the user is home.packages, check this example from my home.nix:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
home.packages = with pkgs; [
    git
    gcc
    zsh
    alacritty
    ...
    go
    openvpn
    unzip
    raccoon
    python3
    metasploit
];

I had a requirement that it should be possible to build something from source, or simply install the binary, in case I needed to add some package that is not already in nixpkgs, so I tried to find some cybersec tools that were not packaged already and it was much harder than I expected, there are a lot of well known tools in nixpkgs.

I ended up finding that Raccoon, a tool for recon and information gathering, was not in nixpkgs and decided to give it a try to package it myself, after a few hours I had this file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{ pkgs }:
with pkgs.python3Packages;
buildPythonPackage rec {
  pname = "raccoon";
  version = "0.8.5";
  format = "wheel";
  src = fetchPypi {
    inherit version format;
    dist = "py3";
    python = "py3";
    pname = "raccoon_scanner";
    sha256 = "sha256-hUiSxC+y5hszqHAM7oKXIE9/k1dh49csQk1CUiO+Ri8=";
  };
  doCheck = false;
  propagatedBuildInputs = [
    xmltodict
    dnspython
    requests
    lxml
    beautifulsoup4
    click
    fake-useragent
    pysocks
  ];
}

This snippet just uses the fetchPypi function to get the binary from PyPi and buildPythonPackage to install it. This was the same experience when I tried to build gobuster from source as an example, I just used the buildGoModule function, these helper functions are provided by NixOS and help a lot when packaging things yourself.

A tip that helped me a lot is to look at how other tools were already packaged, you can see this at the nixpkgs repository, take the gobuster example, this is how it was packaged.

It took hours because of my non-existent experience with nix and the python ecosystem. I recommend that you read a little about the nix language itself, it was not that straight forward for me to understand what was happening in some examples or documentation that I found.

After this we just need to add our self-packaged programs to our package list in home.nix:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
{ config, pkgs, lib, ... }:
let
  ...
  gobuster = pkgs.callPackage ./pkgs/gobuster.nix {};
  seclists = pkgs.callPackage ./pkgs/seclists.nix {}; 
  raccoon = pkgs.callPackage ./pkgs/raccoon.nix {};
in
{
  ...
  home.packages = with pkgs; [
    git
    gcc
    zsh
    alacritty
    neovim
    chromium
    firefox
    eza
    meslo-lgs-nf
    terminus-nerdfont
    gobuster
    nodejs
    xsel
    ripgrep
    fd
    wget
    rustup
    go
    openvpn
    unzip
    raccoon
    python3
    metasploit
  ];
}

The callPackage function is just a convenience function that makes it so we do not need to pass all of the inputs if they are already present in the context, in this case all of the functions that we need are in the pkgs input. If you want to know more about this checkout this nix pill.

Lets try it out

Due to the Flakes being an experimental feature, to interact with it we need to explicitly enable Flakes on our nix installation, this process changes depending on your system, so just follow this guide.

After enabling Flakes, we can run the following command where the flake.nix file is located: $ nix build ./#qcow

The path after the “#” is related to the name you gave in the flake.nix file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
{
  inputs = {
      ...
  };
  outputs = { self, nixpkgs, nixos-generators, home-manager }: {
    packages.x86_64-linux = {
      qcow = nixos-generators.nixosGenerate {
        system = "x86_64-linux";
        modules = [
          ./configuration.nix
          home-manager.nixosModules.home-manager {
              home-manager.useGlobalPkgs = true;
              home-manager.useUserPackages = true;
              home-manager.users.haxos = import ./home.nix;
          }
        ];
        format = "qcow";
      };
    };
  };
}

This command will download all the dependencies and build your VM image from the config, after the build is finished there will be result folder with a .qcow2 file inside it, remember that you can change settings so that the flake will output the image in another format, check this list of supported formats.

The steps to launch this VM will change based on how you want to run it, in my case I wanted to use QEMU/KVM so I made a script that uses virsh and virt-install:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#!/bin/bash

cp -f ./result/nixos.qcow2 ./nixos.qcow2
sudo virsh undefine haxos --nvram 
sudo virsh destroy haxos
sudo virt-install -n haxos \
  --memory 4096 \
  --vcpus=4 \
  --boot uefi \
  --disk ./nixos.qcow2,size=40,bus=virtio \
  --graphics spice \
  --video virtio \
  --import \
  --os-variant nixos-unstable \
  --network default 

This script basically deletes any old image and installs a new one, I recommend always copying the image out from the result folder and installing the copy so that you always have a clean image ready to go.

Keep in mind that the progress you make in a VM instance will be stored in the .qcow2 file.

So after running the install script we get a new window:

And I think this ends it, now we a have a reproducible, easily configurable and very flexible VM that we can break with no worries!

@vncsb

Trying to blog about various tech things.


2023-10-13