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 }