NixDaemon

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

htb.nix (20249B)


      1 # modules/features/pentest/htb.nix — the box you are currently on, shared by
      2 # every terminal.
      3 #
      4 #   htbtarget 10.10.11.5 dc01.vintage.htb   set the target (+ optional name)
      5 #   htbtarget                               print it
      6 #   htbtarget clear                         forget it, and clear /etc/hosts
      7 #   htbtime [host]                          clock-skew helper (defaults to $TARGET)
      8 #
      9 # htbbox lives in boxes.nix (_htbbox.py): the per-box tree, box.json and
     10 # writeup.md. It writes $STATE/box and calls htbtarget, so the two halves stay
     11 # in step; the loader below turns $STATE/box into $BOX and $BOXDIR.
     12 #
     13 # It is `htbbox`, not `htb`, on purpose: the dotfiles' pentesting.zsh already
     14 # defines an `htb()` shell function, and a zsh function always beats a command
     15 # on PATH — so an `htb` here would simply never run. Their `htb-newbox` does
     16 # the same scaffolding backed by a sqlite database; this one sets \$TARGET as
     17 # well, which is what makes it visible in every terminal.
     18 #
     19 # Why a file and a shell hook rather than an exported variable: a variable set
     20 # in one terminal cannot reach a shell that is already running. The target
     21 # lives in $XDG_STATE_HOME/htb/, and modules/home/htb-shell.nix re-reads it in
     22 # zsh's precmd, so every terminal picks up a change at its next prompt. A shell
     23 # sitting mid-command keeps the old value until it returns — that is inherent,
     24 # and the cheat card says so.
     25 #
     26 # /etc/hosts needs care on NixOS: it is normally a symlink into the store, so
     27 # it cannot be edited at all. environment.etc.hosts.mode below makes NixOS copy
     28 # it instead, which is what makes `htbtarget <ip> <fqdn>` possible. A rebuild
     29 # regenerates the file and drops the block, which is fine: it is as ephemeral
     30 # as the target itself. Kerberos needs the name to resolve, so this matters on
     31 # every AD box.
     32 { lib, self, ... }:
     33 {
     34   flake.nixosModules.pentest-htb =
     35     { config, pkgs, lib, user, ... }:
     36     let
     37       on = config.daemon.pentest.enable;
     38       sudo = "/run/wrappers/bin/sudo";
     39       beginMark = "# BEGIN htb (managed by htbtarget — edits here are overwritten)";
     40       endMark = "# END htb";
     41 
     42       # Root half: rewrites only the marked region of /etc/hosts. Validates its
     43       # own arguments rather than trusting the caller, because it runs as root.
     44       htb-hosts = pkgs.writeShellScriptBin "htb-hosts" ''
     45         set -uo pipefail
     46         [ "$(id -u)" = 0 ] || { echo "htb-hosts: run as root (htbtarget does that)" >&2; exit 1; }
     47         PATH=${lib.makeBinPath [ pkgs.coreutils pkgs.gnused pkgs.gnugrep ]}:$PATH
     48 
     49         F=/etc/hosts
     50         BEGIN=${lib.escapeShellArg beginMark}
     51         END=${lib.escapeShellArg endMark}
     52 
     53         if [ -L "$F" ]; then
     54           echo "htb-hosts: $F is a symlink into the Nix store and cannot be edited." >&2
     55           echo "htb-hosts: environment.etc.hosts.mode should have made it a real file;" >&2
     56           echo "htb-hosts: rebuild the system (nh os switch) and try again." >&2
     57           exit 1
     58         fi
     59 
     60         strip_block() { sed "/^$BEGIN\$/,/^$END\$/d" "$F"; }
     61 
     62         case "''${1:-}" in
     63           clear)
     64             tmp=$(mktemp); strip_block > "$tmp"
     65             cat "$tmp" > "$F"; rm -f "$tmp"
     66             echo "htb-hosts: cleared" ;;
     67           set)
     68             ip="''${2:-}"; shift 2 || true
     69             [ -n "$ip" ] && [ "$#" -gt 0 ] || { echo "usage: htb-hosts set <ip> <name>…" >&2; exit 2; }
     70 
     71             # Re-validate as root: this helper is reachable DIRECTLY through a
     72             # NOPASSWD sudo rule, so its own checks are the real boundary, not
     73             # htbtarget's.
     74             #
     75             # Each argument is validated as ONE token — `for n in "$@"`, quoted.
     76             # An earlier version did `names="$*"` then `for n in $names`, which
     77             # word-split on whitespace: every token passed the DNS-label test
     78             # while the embedded newline survived into the printf, writing an
     79             # arbitrary extra line into /etc/hosts as root. The deny pattern
     80             # below rejects a newline only because the argument is not split.
     81             case "$ip" in
     82               *[!0-9a-fA-F.:]*|"") echo "htb-hosts: bad address: $ip" >&2; exit 2 ;;
     83             esac
     84             names=""
     85             for n in "$@"; do
     86               case "$n" in
     87                 *[!A-Za-z0-9.-]*|-*|.*|"")
     88                   echo "htb-hosts: bad hostname: $n" >&2; exit 2 ;;
     89               esac
     90               names="''${names:+$names }$n"
     91             done
     92 
     93             tmp=$(mktemp)
     94             strip_block > "$tmp"
     95             printf '%s\n%s %s\n%s\n' "$BEGIN" "$ip" "$names" "$END" >> "$tmp"
     96             cat "$tmp" > "$F"; rm -f "$tmp"
     97             echo "htb-hosts: $ip $names" ;;
     98           *) echo "usage: htb-hosts set <ip> <name>… | clear" >&2; exit 2 ;;
     99         esac
    100       '';
    101 
    102       stateSh = ''
    103         STATE="''${XDG_STATE_HOME:-$HOME/.local/state}/htb"
    104         mkdir -p "$STATE"
    105       '';
    106 
    107       htbtarget = pkgs.writeShellScriptBin "htbtarget" ''
    108         set -uo pipefail
    109         PATH=${lib.makeBinPath [ pkgs.coreutils pkgs.gnugrep ]}:$PATH
    110         ${stateSh}
    111 
    112         show() {
    113           if [ -s "$STATE/target" ]; then
    114             printf 'target %s' "$(cat "$STATE/target")"
    115             [ -s "$STATE/host" ] && printf '  (%s)' "$(cat "$STATE/host")"
    116             [ -s "$STATE/box" ] && printf '  box %s' "$(cat "$STATE/box")"
    117             printf '\n'
    118             echo "  \$TARGET updates in other terminals at their next prompt"
    119           else
    120             echo "no target set — htbtarget <ip> [name]"
    121           fi
    122         }
    123 
    124         # Review Focus #4: validate BEFORE anything privileged runs. The deny
    125         # patterns reject embedded newlines too, so no extra /etc/hosts line can
    126         # be smuggled through a hostname.
    127         valid_ip() {
    128           case "$1" in
    129             *[!0-9a-fA-F.:]*|"") return 1 ;;
    130           esac
    131           # dotted quad, or something with a colon (v6)
    132           case "$1" in
    133             *:*) return 0 ;;
    134             *.*.*.*)
    135               local o IFS=.
    136               for o in $1; do
    137                 case "$o" in ""|*[!0-9]*) return 1 ;; esac
    138                 [ "$o" -le 255 ] || return 1
    139               done
    140               return 0 ;;
    141             *) return 1 ;;
    142           esac
    143         }
    144         valid_host() {
    145           case "$1" in
    146             *[!A-Za-z0-9.-]*|-*|.*|*..*|"") return 1 ;;
    147           esac
    148           return 0
    149         }
    150 
    151         # Every branch ends with an explicit `exit 0`: a consumer like
    152         # `htbtarget | grep -q 10.10.11.5` exits as soon as it matches, the
    153         # trailing echo takes EPIPE, and with `set -o pipefail` that would
    154         # otherwise become this script's exit status. Same bug as time.nix had.
    155         case "''${1:-}" in
    156           "") show; exit 0 ;;
    157           clear)
    158             rm -f "$STATE/target" "$STATE/host" "$STATE/box"
    159             # Report a failure rather than claiming success: if the helper
    160             # cannot rewrite /etc/hosts, a stale FQDN mapping survives and the
    161             # next box's Kerberos resolves to the previous DC.
    162             if ! ${sudo} -n ${lib.getExe htb-hosts} clear >/dev/null 2>&1; then
    163               echo "htbtarget: state cleared, but /etc/hosts was NOT updated" >&2
    164               echo "htbtarget: a stale name mapping may survive — check /etc/hosts" >&2
    165               exit 1
    166             fi
    167             echo "htbtarget: cleared"
    168             exit 0 ;;
    169           -h|--help) echo "usage: htbtarget [<ip> [hostname…]] | clear"; exit 0 ;;
    170           *)
    171             ip="$1"; shift
    172             if ! valid_ip "$ip"; then
    173               echo "htbtarget: not an IP address: $ip" >&2
    174               exit 2
    175             fi
    176             for n in "$@"; do
    177               if ! valid_host "$n"; then
    178                 echo "htbtarget: not a hostname: $n" >&2
    179                 exit 2
    180               fi
    181             done
    182             printf '%s\n' "$ip" > "$STATE/target"
    183             if [ "$#" -gt 0 ]; then
    184               printf '%s\n' "$*" > "$STATE/host"
    185               ${sudo} -n ${lib.getExe htb-hosts} set "$ip" "$@" || {
    186                 echo "htbtarget: target set, but /etc/hosts was not updated" >&2
    187                 exit 1
    188               }
    189             else
    190               rm -f "$STATE/host"
    191             fi
    192             show
    193             exit 0 ;;
    194         esac
    195       '';
    196 
    197       htbtime = pkgs.writeShellScriptBin "htbtime" ''
    198         set -uo pipefail
    199         ${stateSh}
    200         case "''${1:-}" in
    201           off|status) exec ${sudo} -n /run/current-system/sw/bin/htb-time "$1" ;;
    202         esac
    203         host="''${1:-}"
    204         if [ -z "$host" ]; then
    205           if [ -s "$STATE/target" ]; then host=$(cat "$STATE/target"); else
    206             echo "htbtime: no target set — run 'htbtarget <ip>' first, or 'htbtime <host>'" >&2
    207             exit 2
    208           fi
    209         fi
    210         exec ${sudo} -n /run/current-system/sw/bin/htb-time "$host"
    211       '';
    212 
    213     in
    214     {
    215       config = lib.mkIf on {
    216         environment.systemPackages = [ htbtarget htbtime htb-hosts ];
    217 
    218         # $TARGET in every terminal.
    219         #
    220         # This has to be the NixOS option, not home-manager's
    221         # programs.zsh.initContent: zsh's real configuration here is the
    222         # dotfiles' ZDOTDIR tree (modules/home/shell.nix points ZDOTDIR at
    223         # ~/.dotfiles), home-manager's zsh module is not even enabled, and so
    224         # it generates no ~/.zshrc at all. An earlier version of this lived
    225         # there and was dead code — the state file was written and no shell
    226         # ever read it.
    227         #
    228         # /etc/zshrc is sourced for every interactive zsh BEFORE
    229         # $ZDOTDIR/.zshrc, so this coexists with the dotfiles rather than
    230         # competing with them, and `add-zsh-hook` appends, so their own precmd
    231         # hooks still run.
    232         programs.zsh.interactiveShellInit = lib.mkAfter ''
    233           # --- htb target, shared across terminals ---------------------------
    234           # Re-read before each prompt, so a target set in another terminal
    235           # shows up here. Three small reads of files under $XDG_STATE_HOME.
    236           _htb_state="''${XDG_STATE_HOME:-$HOME/.local/state}/htb"
    237           _htb_load() {
    238             if [[ -s "$_htb_state/target" ]]; then
    239               local t
    240               t="$(<"$_htb_state/target")"
    241               export TARGET="''${t%%$'\n'*}"
    242               export RHOST="$TARGET" IP="$TARGET"
    243             else
    244               unset TARGET RHOST IP
    245             fi
    246             if [[ -s "$_htb_state/box" ]]; then
    247               local b
    248               b="$(<"$_htb_state/box")"
    249               export BOX="''${b%%$'\n'*}"
    250               export BOXDIR="''${HTB_ROOT:-$HOME/htb}/$BOX"
    251             else
    252               unset BOX BOXDIR
    253             fi
    254           }
    255           autoload -Uz add-zsh-hook
    256           add-zsh-hook precmd _htb_load
    257           _htb_load
    258         '';
    259 
    260         # Makes /etc/hosts a real, writable file instead of a store symlink.
    261         # Without this htb-hosts cannot work at all (and says so).
    262         environment.etc.hosts.mode = "0644";
    263 
    264         security.sudo.extraRules = [
    265           {
    266             groups = [ "wheel" ];
    267             commands = [
    268               { command = "${lib.getExe htb-hosts}"; options = [ "NOPASSWD" ]; }
    269               { command = "/run/current-system/sw/bin/htb-hosts"; options = [ "NOPASSWD" ]; }
    270             ];
    271           }
    272         ];
    273       };
    274     };
    275 
    276   perSystem =
    277     { pkgs, ... }:
    278     {
    279       checks.pentest-htb-vm = pkgs.testers.runNixOSTest {
    280         name = "pentest-htb";
    281 
    282         nodes.machine = { pkgs, ... }: {
    283           imports = [
    284             self.nixosModules.pentest-options
    285             self.nixosModules.pentest-htb
    286             self.nixosModules.pentest-boxes # htbbox, asserted below
    287           ];
    288           daemon.pentest.enable = true;
    289           # The real host has zsh as the login shell; /etc/zshrc (where the
    290           # $TARGET loader goes) only exists when this is on.
    291           programs.zsh.enable = true;
    292           _module.args.user = "daemonsec";
    293           users.users.daemonsec = {
    294             isNormalUser = true;
    295             extraGroups = [ "wheel" ];
    296             shell = pkgs.zsh;
    297           };
    298         };
    299 
    300         testScript = ''
    301           machine.wait_for_unit("multi-user.target")
    302 
    303           def as_user(cmd):
    304               return f"su -l daemonsec -c {cmd!r}"
    305 
    306           # The NixOS-specific precondition: /etc/hosts must be a real file.
    307           machine.succeed("test -f /etc/hosts && test ! -L /etc/hosts")
    308 
    309           machine.succeed(as_user("htbtarget") + " | grep -q 'no target set'")
    310 
    311           # A plain address is accepted and persisted.
    312           machine.succeed(as_user("htbtarget 10.10.11.5"))
    313           machine.succeed(as_user("htbtarget") + " | grep -q 10.10.11.5")
    314 
    315           # Review Focus #4: malformed input is refused BEFORE /etc/hosts is touched.
    316           hosts_before = machine.succeed("cat /etc/hosts")
    317           machine.fail(as_user("htbtarget 'not an ip'"))
    318           machine.fail(as_user("htbtarget 10.10.11.999"))
    319           machine.fail(as_user("htbtarget 10.10.11.5 'bad name'"))
    320           # An embedded newline must not smuggle a second /etc/hosts entry.
    321           machine.fail(as_user("htbtarget 10.10.11.5 $'x\\n1.2.3.4 evil'"))
    322           assert machine.succeed("cat /etc/hosts") == hosts_before, "/etc/hosts changed on a rejected input"
    323           machine.fail("grep -q evil /etc/hosts")
    324 
    325           # A hostname writes exactly one marked block, and is idempotent.
    326           machine.succeed(as_user("htbtarget 10.10.11.5 dc01.vintage.htb vintage.htb"))
    327           machine.succeed("grep -q '10.10.11.5 dc01.vintage.htb vintage.htb' /etc/hosts")
    328           assert machine.succeed("grep -c 'BEGIN htb' /etc/hosts").strip() == "1"
    329           machine.succeed(as_user("htbtarget 10.10.11.6 dc01.vintage.htb"))
    330           assert machine.succeed("grep -c 'BEGIN htb' /etc/hosts").strip() == "1", "block duplicated"
    331           machine.succeed("grep -q '10.10.11.6 dc01.vintage.htb' /etc/hosts")
    332           machine.fail("grep -q 10.10.11.5 /etc/hosts")
    333 
    334           # localhost must survive all of this.
    335           machine.succeed("grep -q '127.0.0.1 localhost' /etc/hosts")
    336 
    337           # The ROOT helper must refuse the same input, not just htbtarget:
    338           # it is reachable directly through a NOPASSWD sudo rule, so its own
    339           # validation is the real boundary. `names="$*"` + unquoted `for n in
    340           # $names` word-split each token while leaving the newline intact,
    341           # which wrote an arbitrary second line into /etc/hosts as root.
    342           machine.fail("htb-hosts set 1.2.3.4 $'dc01.htb\n6.6.6.6 attacker.evil'")
    343           machine.fail("grep -q attacker.evil /etc/hosts")
    344           machine.fail("su -l daemonsec -c \"sudo -n htb-hosts set 1.2.3.4 $'a\n9.9.9.9 evil2'\"")
    345           machine.fail("grep -q evil2 /etc/hosts")
    346           machine.succeed("grep -q '127.0.0.1 localhost' /etc/hosts")
    347 
    348           # clear removes both the state and the block.
    349           machine.succeed(as_user("htbtarget clear"))
    350           machine.fail("grep -q 'BEGIN htb' /etc/hosts")
    351           machine.succeed(as_user("htbtarget") + " | grep -q 'no target set'")
    352 
    353           # C1, the whole point of the feature: an interactive shell must
    354           # actually export $TARGET. The state file existing is not enough —
    355           # the first implementation wrote the file and no shell ever read it.
    356           machine.succeed(as_user("htbtarget 10.10.11.5 dc01.vintage.htb"))
    357           # NB the escaped dollars: `su -l … -c "…"` runs a NON-interactive
    358           # login shell, which would expand $TARGET to empty itself before the
    359           # inner interactive zsh (the one that sources /etc/zshrc) ever sees
    360           # it. That made this assertion fail against working code once.
    361           out = machine.succeed(
    362               "su -l daemonsec -c 'zsh -ic \"echo T=\\$TARGET R=\\$RHOST I=\\$IP\"'"
    363           )
    364           for want in ["T=10.10.11.5", "R=10.10.11.5", "I=10.10.11.5"]:
    365               assert want in out, f"expected {want} in a fresh shell, got: {out!r}"
    366 
    367           # ...and clearing it must unset them, not leave a stale value.
    368           machine.succeed(as_user("htbtarget clear"))
    369           # No brackets: zsh globs "[]" and fails on no-match. No braced
    370           # parameter expansion either, because Nix interpolates that out of
    371           # this testScript string. A trailing dot shows an empty expansion.
    372           out = machine.succeed(
    373               "su -l daemonsec -c 'zsh -ic \"print -r -- t=\\$TARGET.\"'"
    374           )
    375           assert "t=." in out, f"expected $TARGET unset after clear, got: {out!r}"
    376 
    377           # htbtime with no target must name the command that sets one.
    378           machine.fail(as_user("htbtime") + " 2>&1 | grep -q htbtarget")
    379 
    380           # Engagement scaffolding (boxes.nix). Every field is passed, so gum
    381           # never prompts -- a prompt in a VM test would hang until timeout.
    382           out = machine.succeed(as_user(
    383               "htbbox new -n EscapeTwo -i 10.10.11.202 -d medium -o windows"
    384           )).strip()
    385           assert out.endswith("/htb/escapetwo"), out
    386           for sub in ["recon", "enum", "creds", "loot", "exploit", "serve", "casts"]:
    387               machine.succeed(f"test -d /home/daemonsec/htb/escapetwo/{sub}")
    388 
    389           # The manifest every other tool reads, and the slug the website uses.
    390           machine.succeed(
    391               "grep -q '\"slug\": \"htb-escapetwo\"' /home/daemonsec/htb/escapetwo/box.json"
    392           )
    393           machine.succeed(
    394               "grep -q '\"difficulty\": \"medium\"' /home/daemonsec/htb/escapetwo/box.json"
    395           )
    396           # Frontmatter the importer needs, from the built-in skeleton (no
    397           # vault in the VM), and no un-substituted Templater tokens anywhere.
    398           machine.succeed("grep -q '^target_os: \"Windows\"' /home/daemonsec/htb/escapetwo/writeup.md")
    399           machine.succeed("grep -q '^ip: \"10.10.11.202\"' /home/daemonsec/htb/escapetwo/writeup.md")
    400           machine.fail("grep -q '<%' /home/daemonsec/htb/escapetwo/writeup.md")
    401 
    402           # Scaffolding a box points the whole toolkit at it.
    403           machine.succeed(as_user("htbtarget") + " | grep -q 10.10.11.202")
    404           machine.succeed(as_user("htbbox ls") + " | grep -q escapetwo")
    405           machine.succeed(as_user("htbbox path") + " | grep -q /htb/escapetwo")
    406           machine.succeed(as_user("htbbox info") + " | grep -q EscapeTwo")
    407 
    408           # Positional form, any order, with hostnames: the shape of each
    409           # argument says what it is.
    410           machine.succeed(as_user("htbbox new 10.10.11.9 hard Vintage linux dc01.vintage.htb vintage.htb"))
    411           machine.succeed("grep -q '\"os\": \"Linux\"' /home/daemonsec/htb/vintage/box.json")
    412           machine.succeed("grep -q '\"domain\": \"vintage.htb\"' /home/daemonsec/htb/vintage/box.json")
    413           machine.succeed("grep -q '10.10.11.9 dc01.vintage.htb vintage.htb' /etc/hosts")
    414           machine.succeed(as_user("htbbox cred svc_sql 'Passw0rd!' mssql"))
    415           machine.succeed(as_user("htbbox json creds") + " | grep -q svc_sql")
    416           machine.succeed(as_user("htbbox flag user 0123456789abcdef0123456789abcdef"))
    417           assert machine.succeed(as_user("htbbox json status")).strip() == "user"
    418           machine.succeed(as_user("htbbox use escapetwo"))
    419           machine.succeed(as_user("htbtarget") + " | grep -q 10.10.11.202")
    420           out = machine.succeed("su -l daemonsec -c 'zsh -ic \"echo D=\\$BOXDIR\"'")
    421           assert "D=/home/daemonsec/htb/escapetwo" in out, out
    422 
    423           # Bad input is refused rather than written into the manifest.
    424           machine.fail(as_user("htbbox new -n Nope -d impossible -o linux -i 10.0.0.1"))
    425           machine.fail(as_user("htbbox new -n Nope -d easy -o plan9 -i 10.0.0.1"))
    426           machine.fail(as_user("htbbox new -n Nope -d easy -o linux -i notanip"))
    427           machine.fail("test -d /home/daemonsec/htb/nope")
    428 
    429           # Rerunning must not clobber a writeup already being written.
    430           machine.succeed("echo 'MY PROSE' >> /home/daemonsec/htb/escapetwo/writeup.md")
    431           machine.succeed(as_user(
    432               "htbbox new -n EscapeTwo -i 10.10.11.202 -d medium -o windows"
    433           ))
    434           machine.succeed("grep -q 'MY PROSE' /home/daemonsec/htb/escapetwo/writeup.md")
    435         '';
    436       };
    437     };
    438 }