_htbbox.py (27122B)
1 """htbbox -- one directory and one box.json per HTB machine. 2 3 htbbox new Sauna 10.10.10.175 windows easy positional, any order 4 htbbox new Sauna 10.10.10.175 win easy sauna.htb dc01.sauna.htb 5 htbbox new prompt for everything 6 htbbox use sauna switch box (+ $TARGET, /etc/hosts) 7 htbbox ls | info | path | json look around 8 htbbox set ip 10.10.10.176 change a field 9 htbbox host dc01.sauna.htb add hostnames (-> /etc/hosts) 10 htbbox cred fsmith 'Thestrokes23' kerberoast record a credential 11 htbbox flag user 3f4e... record a flag (status -> user/root) 12 htbbox note "WinRM open, fsmith in Remote Mgmt" timestamped note 13 htbbox ports import open ports from recon/*.xml 14 15 Every subcommand that acts on a box takes `-b <box>`; without it, the current 16 box ($BOX, set by `new`/`use`) is used. 17 18 box.json is the single source of truth for a box. Other tools read it with jq 19 (`htbbox json ip`, `jq .creds "$BOXDIR/box.json"`). writeup.md is rendered once 20 from the vault's Templater template (or a built-in skeleton) and never 21 rewritten afterwards -- it is prose you own. 22 23 Stdlib only. Nix wraps this with gum on PATH (boxes.nix); without gum the 24 prompts fall back to input(). 25 """ 26 27 import datetime as dt 28 import ipaddress 29 import json 30 import os 31 import re 32 import shutil 33 import subprocess 34 import sys 35 import xml.etree.ElementTree as ET 36 37 SCHEMA = 2 38 STATE = os.path.join( 39 os.environ.get("XDG_STATE_HOME") or os.path.expanduser("~/.local/state"), "htb" 40 ) 41 ROOT = os.environ.get("HTB_ROOT") or os.path.expanduser("~/htb") 42 VAULT = os.environ.get("NETRUNNER_VAULT") or os.path.expanduser("~/git/NetrunnerVault") 43 TEMPLATE = os.path.join(VAULT, "00Meta/Templates/daemon-sec-htb-post.md") 44 SUBDIRS = ["recon", "enum", "creds", "loot", "exploit", "serve", "casts"] 45 46 DIFFICULTIES = ["easy", "medium", "hard", "insane"] 47 # alias -> canonical display form (the website's frontmatter reads the latter) 48 OSES = { 49 "windows": "Windows", "win": "Windows", 50 "linux": "Linux", "lin": "Linux", 51 "freebsd": "FreeBSD", "bsd": "FreeBSD", 52 "openbsd": "OpenBSD", 53 "android": "Android", 54 "other": "Other", 55 } 56 OS_CHOICES = ["windows", "linux", "freebsd", "openbsd", "android", "other"] 57 STATUSES = ["active", "user", "root", "retired", "paused"] 58 SETTABLE = ["name", "ip", "os", "difficulty", "status", "domain", "notes_url"] 59 60 USAGE = """\ 61 usage: htbbox <command> [args] [-b box] 62 63 new [name] [ip] [os] [difficulty] [host...] scaffold a box (any order; prompts for the rest) 64 -n name -i ip -o os -d difficulty -H host (flags, if you prefer) 65 use <box> make <box> current: $BOX, $TARGET, /etc/hosts 66 ls all boxes, newest first (* = current) 67 info [box] the box card: target, flags, creds, ports, notes 68 path [box] the directory (or just: cd $BOXDIR) 69 json [box] [key] raw box.json, or one key (dotted: flags.user) 70 set <key> <value> key: name ip os difficulty status domain 71 host <name...> add hostnames; re-points /etc/hosts if current 72 cred [user secret [note...]] add a credential, or list them 73 flag <user|root> <value> record a flag; status follows 74 note [text...] add a timestamped note, or list them 75 ports [file.xml] import open ports from the newest recon/*.xml 76 77 os: windows linux freebsd openbsd android other (win, lin ok) 78 difficulty: easy medium hard insane 79 """ 80 81 82 # ── output ────────────────────────────────────────────────────────────────── 83 84 def _color_on(stream): 85 return stream.isatty() and "NO_COLOR" not in os.environ 86 87 88 def paint(text, code, stream=sys.stdout): 89 return f"\033[{code}m{text}\033[0m" if _color_on(stream) else text 90 91 92 def die(msg, code=2): 93 print(f"htbbox: {msg}", file=sys.stderr) 94 sys.exit(code) 95 96 97 def info(msg): 98 print(msg, file=sys.stderr) 99 100 101 # ── validation ────────────────────────────────────────────────────────────── 102 103 def is_ipv4(s): 104 try: 105 return isinstance(ipaddress.ip_address(s), ipaddress.IPv4Address) 106 except ValueError: 107 return False 108 109 110 HOST_RE = re.compile(r"^(?=.{1,253}$)[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$") 111 112 113 def is_hostname(s): 114 return bool(HOST_RE.match(s)) and not is_ipv4(s) 115 116 117 def norm_os(s): 118 v = OSES.get(s.lower()) 119 if not v: 120 die(f"os must be one of {', '.join(OS_CHOICES)} (got: {s})") 121 return v 122 123 124 def norm_difficulty(s): 125 if s.lower() not in DIFFICULTIES: 126 die(f"difficulty must be easy, medium, hard or insane (got: {s})") 127 return s.lower() 128 129 130 def norm_ip(s): 131 if s and not is_ipv4(s): 132 die(f"not an IPv4 address: {s}") 133 return s 134 135 136 def slugify(s): 137 return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-") 138 139 140 def now(): 141 return dt.datetime.now().replace(microsecond=0).isoformat() 142 143 144 # ── state ─────────────────────────────────────────────────────────────────── 145 146 def current(): 147 try: 148 with open(os.path.join(STATE, "box")) as fh: 149 return fh.read().strip() or None 150 except OSError: 151 return None 152 153 154 def set_current(box): 155 os.makedirs(STATE, exist_ok=True) 156 with open(os.path.join(STATE, "box"), "w") as fh: 157 fh.write(box + "\n") 158 159 160 def boxdir(box): 161 return os.path.join(ROOT, box) 162 163 164 def resolve(box): 165 """Box name -> directory name, or die with the command that fixes it.""" 166 box = box or current() 167 if not box: 168 die("no box given and none current -- htbbox new, or htbbox use <box>") 169 if box.startswith("htb-"): 170 box = box[4:] 171 if not re.match(r"^[a-z0-9][a-z0-9-]*$", box): 172 box = slugify(box) 173 if not os.path.isdir(boxdir(box)): 174 die(f"no such box: {box} (htbbox ls)") 175 return box 176 177 178 def load(box): 179 path = os.path.join(boxdir(box), "box.json") 180 try: 181 with open(path) as fh: 182 data = json.load(fh) 183 except FileNotFoundError: 184 data = {"name": box, "slug": f"htb-{box}"} 185 except json.JSONDecodeError as e: 186 die(f"{path} is not valid JSON ({e}) -- fix it by hand", 1) 187 # Upgrade older manifests in place: missing keys get defaults. 188 defaults = { 189 "ip": "", "os": "Other", "difficulty": "", "hostnames": [], "domain": "", 190 "platform": "HTB-Labs", "status": "active", "created": dt.date.today().isoformat(), 191 "updated": now(), "flags": {"user": None, "root": None}, 192 "creds": [], "ports": [], "notes": [], "tags": [], 193 } 194 for k, v in defaults.items(): 195 data.setdefault(k, v) 196 data["schema"] = SCHEMA 197 return data 198 199 200 def save(box, data): 201 data["updated"] = now() 202 path = os.path.join(boxdir(box), "box.json") 203 tmp = path + ".tmp" 204 with open(tmp, "w") as fh: 205 json.dump(data, fh, indent=2) 206 fh.write("\n") 207 os.replace(tmp, path) 208 209 210 def point_target(data): 211 """Point $TARGET (and /etc/hosts, when there are names) at this box.""" 212 if not data.get("ip"): 213 return 214 exe = "/run/current-system/sw/bin/htbtarget" 215 if not os.access(exe, os.X_OK): 216 exe = shutil.which("htbtarget") 217 if not exe: 218 info("htbbox: htbtarget not found -- $TARGET not set") 219 return 220 r = subprocess.run([exe, data["ip"], *data.get("hostnames", [])], 221 stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) 222 if r.returncode != 0: 223 info(r.stderr.rstrip() or "htbbox: htbtarget failed") 224 225 226 # ── prompts ───────────────────────────────────────────────────────────────── 227 228 def interactive(): 229 return sys.stdin.isatty() and sys.stderr.isatty() 230 231 232 def ask(prompt, placeholder=""): 233 if shutil.which("gum"): 234 r = subprocess.run(["gum", "input", "--prompt", f"{prompt} > ", "--placeholder", placeholder], 235 stdout=subprocess.PIPE, text=True) 236 if r.returncode != 0: 237 sys.exit(1) 238 return r.stdout.strip() 239 return input(f"{prompt} [{placeholder}]: ").strip() 240 241 242 def choose(header, options): 243 if shutil.which("gum"): 244 r = subprocess.run(["gum", "choose", "--header", header, *options], 245 stdout=subprocess.PIPE, text=True) 246 if r.returncode != 0: 247 sys.exit(1) 248 return r.stdout.strip() 249 while True: 250 v = input(f"{header} ({'/'.join(options)}): ").strip().lower() 251 if v in options or v in OSES: 252 return v 253 254 255 # ── argument helpers ──────────────────────────────────────────────────────── 256 257 def pop_box(args): 258 """Remove `-b X` / `--box X` from anywhere in args; return (box, rest).""" 259 box, rest, it = None, [], iter(args) 260 for a in it: 261 if a in ("-b", "--box"): 262 box = next(it, None) or die("-b needs a box name") 263 elif a.startswith("--box="): 264 box = a.split("=", 1)[1] 265 else: 266 rest.append(a) 267 return box, rest 268 269 270 # ── commands ──────────────────────────────────────────────────────────────── 271 272 def cmd_new(args): 273 name = ip = os_ = diff = None 274 hosts, tags = [], [] 275 it = iter(args) 276 for a in it: 277 flagval = lambda: next(it, None) or die(f"{a} needs a value") # noqa: E731 278 if a in ("-n", "--name"): 279 name = flagval() 280 elif a in ("-i", "--ip"): 281 ip = flagval() 282 elif a in ("-o", "--os"): 283 os_ = flagval() 284 elif a in ("-d", "--difficulty"): 285 diff = flagval() 286 elif a in ("-H", "--host"): 287 hosts.append(flagval()) 288 elif a in ("-t", "--tag"): 289 tags.append(flagval()) 290 elif a in ("-h", "--help"): 291 print(USAGE, end="") 292 return 293 elif a.startswith("-"): 294 die(f"unknown option: {a}") 295 # Positional: classify by shape, so the order does not matter. 296 elif is_ipv4(a) and not ip: 297 ip = a 298 elif a.lower() in DIFFICULTIES and not diff: 299 diff = a 300 elif a.lower() in OSES and not os_: 301 os_ = a 302 elif is_hostname(a): 303 hosts.append(a) 304 elif not name: 305 name = a 306 else: 307 die(f"don't know what '{a}' is (name already '{name}'). Use -n/-i/-o/-d to be explicit.") 308 309 # Ask for what is missing -- only on a terminal, so scripts fail fast. 310 tty = interactive() 311 if not name: 312 name = ask("machine name", "Sauna") if tty else None 313 if not name: 314 die("a machine name is required\n" + USAGE) 315 if ip is None and tty: 316 ip = ask("target ip", "10.10.10.175 (blank: later)") 317 if not diff: 318 diff = choose("difficulty", DIFFICULTIES) if tty else die("difficulty is required (easy|medium|hard|insane)") 319 if not os_: 320 os_ = choose("operating system", OS_CHOICES) if tty else die("os is required (windows|linux|...)") 321 322 ip = norm_ip(ip or "") 323 diff = norm_difficulty(diff) 324 os_ = norm_os(os_) 325 for h in hosts: 326 if not is_hostname(h): 327 die(f"not a hostname: {h}") 328 box = slugify(name) 329 if not box: 330 die(f"name has no usable characters: {name}") 331 332 d = boxdir(box) 333 existed = os.path.exists(os.path.join(d, "box.json")) 334 for sub in SUBDIRS: 335 os.makedirs(os.path.join(d, sub), exist_ok=True) 336 337 # Re-running `new` on a box updates the fields given and keeps everything 338 # else (creds, flags, notes) -- it never starts the manifest over. 339 data = load(box) 340 data.update({"name": name, "slug": f"htb-{box}", "os": os_, "difficulty": diff}) 341 if ip: 342 data["ip"] = ip 343 data["hostnames"] = sorted(set(data["hostnames"]) | set(hosts), key=len, reverse=True) 344 if hosts and not data["domain"]: 345 data["domain"] = guess_domain(data["hostnames"]) 346 data["tags"] = sorted(set(data["tags"]) | set(tags)) 347 save(box, data) 348 render_writeup(d, data) 349 350 set_current(box) 351 point_target(data) 352 info(paint(f"{'updated' if existed else 'new box'} {name}", "1;35", sys.stderr) 353 + f" {data['ip'] or '(no ip)'} {os_} {diff}" 354 + (f" {' '.join(data['hostnames'])}" if data["hostnames"] else "")) 355 info(paint(" cd $BOXDIR · htbbox info · htbpaths", "2", sys.stderr)) 356 print(d) 357 358 359 def guess_domain(hosts): 360 # Shortest name with exactly one dot is usually the AD domain (sequel.htb). 361 two = [h for h in hosts if h.count(".") == 1] 362 return min(two, key=len) if two else "" 363 364 365 def cmd_use(args): 366 box, rest = pop_box(args) 367 box = resolve(box or (rest[0] if rest else None)) 368 data = load(box) 369 set_current(box) 370 point_target(data) 371 info(f"current box: {data['name']} {data['ip'] or '(no ip)'}") 372 print(boxdir(box)) 373 374 375 def cmd_ls(_args): 376 if not os.path.isdir(ROOT): 377 print("no boxes yet -- htbbox new") 378 return 379 cur = current() 380 entries = [] 381 for b in os.listdir(ROOT): 382 d = boxdir(b) 383 if not os.path.isdir(d): 384 continue 385 j = os.path.join(d, "box.json") 386 entries.append((os.path.getmtime(j if os.path.exists(j) else d), b)) 387 if not entries: 388 print("no boxes yet -- htbbox new") 389 return 390 rows = [] 391 for _, b in sorted(entries, reverse=True): 392 if os.path.exists(os.path.join(boxdir(b), "box.json")): 393 x = load(b) 394 rows.append(("*" if b == cur else " ", b, x["ip"] or "-", x["os"], x["difficulty"] or "-", 395 x["status"], x["created"])) 396 else: 397 rows.append(("*" if b == cur else " ", b, "(no manifest)", "", "", "", "")) 398 head = ("", "BOX", "IP", "OS", "DIFF", "STATUS", "CREATED") 399 widths = [max(len(r[i]) for r in rows + [head]) for i in range(len(head))] 400 fmt = lambda r: " ".join(c.ljust(w) for c, w in zip(r, widths)).rstrip() # noqa: E731 401 out = [paint(fmt(head), "2")] 402 for r in rows: 403 line = fmt(r) 404 out.append(paint(line, "1;35") if r[0] == "*" else line) 405 print("\n".join(out)) 406 407 408 def cmd_info(args): 409 box, rest = pop_box(args) 410 box = resolve(box or (rest[0] if rest else None)) 411 x = load(box) 412 d = boxdir(box) 413 k = lambda s: paint(f"{s:<11}", "2") # noqa: E731 414 flag = lambda f: paint("✓ " + (f.get("at", "")[:16]), "32") if f else paint("·", "2") # noqa: E731 415 lines = [ 416 paint(f"{x['name']}", "1;35") + paint(f" {x['slug']}" + (" (current)" if box == current() else ""), "2"), 417 f"{k('ip')}{x['ip'] or '-'}", 418 f"{k('os')}{x['os']}", 419 f"{k('difficulty')}{x['difficulty'] or '-'}", 420 f"{k('status')}{x['status']}", 421 ] 422 if x["domain"]: 423 lines.append(f"{k('domain')}{x['domain']}") 424 if x["hostnames"]: 425 lines.append(f"{k('hostnames')}{' '.join(x['hostnames'])}") 426 lines.append(f"{k('flags')}user {flag(x['flags'].get('user'))} root {flag(x['flags'].get('root'))}") 427 lines.append(f"{k('created')}{x['created']} updated {x['updated'][:16]}") 428 lines.append(f"{k('path')}{d}") 429 if x["ports"]: 430 lines.append("") 431 lines.append(paint("ports", "1")) 432 for p in x["ports"]: 433 svc = " ".join(s for s in (p.get("service"), p.get("product"), p.get("version")) if s) 434 lines.append(f" {str(p['port']) + '/' + p.get('proto', 'tcp'):<10} {svc}") 435 if x["creds"]: 436 lines.append("") 437 lines.append(paint("creds", "1")) 438 for c in x["creds"]: 439 lines.append(f" {c['user']:<20} {c['secret']:<28} {paint(c.get('note', ''), '2')}") 440 if x["notes"]: 441 lines.append("") 442 lines.append(paint("notes", "1")) 443 for n in x["notes"][-8:]: 444 lines.append(f" {paint(n['at'][5:16], '2')} {n['text']}") 445 if len(x["notes"]) > 8: 446 lines.append(paint(f" … {len(x['notes']) - 8} older (htbbox note)", "2")) 447 lines.append("") 448 counts = " ".join(f"{s} {len(os.listdir(os.path.join(d, s))) if os.path.isdir(os.path.join(d, s)) else 0}" 449 for s in SUBDIRS) 450 lines.append(paint(counts, "2")) 451 print("\n".join(lines)) 452 453 454 def cmd_path(args): 455 box, rest = pop_box(args) 456 print(boxdir(resolve(box or (rest[0] if rest else None)))) 457 458 459 def cmd_json(args): 460 box, rest = pop_box(args) 461 # `htbbox json ip` (key on the current box) vs `htbbox json sauna`. 462 key = None 463 if rest and not box and os.path.isdir(boxdir(slugify(rest[0]))) and rest[0] not in load_keys(): 464 box, rest = rest[0], rest[1:] 465 if rest: 466 key = rest[0] 467 x = load(resolve(box)) 468 if key: 469 for part in key.split("."): 470 x = x.get(part) if isinstance(x, dict) else None 471 if isinstance(x, (dict, list)): 472 print(json.dumps(x, indent=2)) 473 elif x is not None: 474 print(x) 475 return 476 print(json.dumps(x, indent=2)) 477 478 479 def load_keys(): 480 return {"name", "slug", "ip", "os", "difficulty", "hostnames", "domain", "platform", "status", 481 "created", "updated", "flags", "creds", "ports", "notes", "tags", "schema"} 482 483 484 def cmd_set(args): 485 box, rest = pop_box(args) 486 if len(rest) < 2: 487 die(f"usage: htbbox set <key> <value> keys: {' '.join(SETTABLE)}") 488 key, value = rest[0], " ".join(rest[1:]) 489 box = resolve(box) 490 x = load(box) 491 if key == "ip": 492 value = norm_ip(value) 493 elif key == "os": 494 value = norm_os(value) 495 elif key == "difficulty": 496 value = norm_difficulty(value) 497 elif key == "status" and value not in STATUSES: 498 die(f"status must be one of {', '.join(STATUSES)}") 499 elif key not in SETTABLE: 500 die(f"can't set '{key}' -- settable: {' '.join(SETTABLE)} (or edit box.json)") 501 x[key] = value 502 save(box, x) 503 if key == "ip" and box == current(): 504 point_target(x) 505 info(f"{x['name']}: {key} = {value}") 506 507 508 def cmd_host(args): 509 box, rest = pop_box(args) 510 if not rest: 511 die("usage: htbbox host <name...>") 512 for h in rest: 513 if not is_hostname(h): 514 die(f"not a hostname: {h}") 515 box = resolve(box) 516 x = load(box) 517 x["hostnames"] = sorted(set(x["hostnames"]) | set(rest), key=len, reverse=True) 518 if not x["domain"]: 519 x["domain"] = guess_domain(x["hostnames"]) 520 save(box, x) 521 if box == current(): 522 point_target(x) 523 info(f"{x['name']}: hostnames {' '.join(x['hostnames'])}") 524 525 526 def cmd_cred(args): 527 box, rest = pop_box(args) 528 box = resolve(box) 529 x = load(box) 530 if not rest: 531 if not x["creds"]: 532 print("no creds yet -- htbbox cred <user> <secret> [note]") 533 for c in x["creds"]: 534 print(f"{c['user']}\t{c['secret']}\t{c.get('note', '')}") 535 return 536 if len(rest) < 2: 537 die("usage: htbbox cred <user> <secret> [note...]") 538 user, secret, note = rest[0], rest[1], " ".join(rest[2:]) 539 kind = "hash" if re.fullmatch(r"[0-9a-fA-F]{32}(:[0-9a-fA-F]{32})?", secret) else "password" 540 x["creds"].append({"user": user, "secret": secret, "type": kind, "note": note, "at": now()}) 541 save(box, x) 542 # Keep a plain user/pass list beside it, for nxc/hydra -U/-P style use. 543 with open(os.path.join(boxdir(box), "creds", "creds.txt"), "a") as fh: 544 fh.write(f"{user}:{secret}\n") 545 info(f"{x['name']}: cred {user} ({kind}) -- {len(x['creds'])} total") 546 547 548 def cmd_flag(args): 549 box, rest = pop_box(args) 550 if len(rest) != 2 or rest[0] not in ("user", "root"): 551 die("usage: htbbox flag <user|root> <value>") 552 which, value = rest 553 if not re.fullmatch(r"[0-9a-fA-F]{32}", value): 554 info("htbbox: note -- that is not a 32-char hex flag; saved anyway") 555 box = resolve(box) 556 x = load(box) 557 x["flags"][which] = {"value": value, "at": now()} 558 if which == "root" or x["status"] == "active": 559 x["status"] = which 560 save(box, x) 561 info(paint(f"{x['name']}: {which} flag recorded", "1;32", sys.stderr)) 562 563 564 def cmd_note(args): 565 box, rest = pop_box(args) 566 box = resolve(box) 567 x = load(box) 568 if not rest: 569 for n in x["notes"]: 570 print(f"{n['at'][:16]} {n['text']}") 571 if not x["notes"]: 572 print("no notes yet -- htbbox note <text>") 573 return 574 x["notes"].append({"at": now(), "text": " ".join(rest)}) 575 save(box, x) 576 info(f"{x['name']}: note added ({len(x['notes'])})") 577 578 579 def cmd_ports(args): 580 box, rest = pop_box(args) 581 box = resolve(box) 582 d = boxdir(box) 583 if rest: 584 xml = rest[0] 585 else: 586 found = [] 587 for root, _, files in os.walk(os.path.join(d, "recon")): 588 found += [os.path.join(root, f) for f in files if f.endswith(".xml")] 589 if not found: 590 die("no nmap XML in recon/ -- scan with -oA recon/<name> (or -oX), then rerun") 591 xml = max(found, key=os.path.getmtime) 592 try: 593 tree = ET.parse(xml) 594 except (ET.ParseError, OSError) as e: 595 die(f"can't read {xml}: {e}", 1) 596 ports = {} 597 x = load(box) 598 for p in x["ports"]: 599 ports[(p["port"], p.get("proto", "tcp"))] = p 600 for port in tree.iter("port"): 601 st = port.find("state") 602 if st is None or st.get("state") != "open": 603 continue 604 svc = port.find("service") 605 entry = {"port": int(port.get("portid")), "proto": port.get("protocol", "tcp")} 606 if svc is not None: 607 for a in ("name", "product", "version"): 608 if svc.get(a): 609 entry["service" if a == "name" else a] = svc.get(a) 610 ports[(entry["port"], entry["proto"])] = entry 611 x["ports"] = sorted(ports.values(), key=lambda p: (p["proto"], p["port"])) 612 save(box, x) 613 info(f"{x['name']}: {len(x['ports'])} open ports (from {os.path.relpath(xml, d)})") 614 for p in x["ports"]: 615 print(f"{p['port']}/{p['proto']}\t{p.get('service', '')}\t{p.get('product', '')} {p.get('version', '')}".rstrip()) 616 617 618 # ── writeup ───────────────────────────────────────────────────────────────── 619 620 def render_writeup(outdir, x): 621 """Render writeup.md once. Never overwrites: it is your prose after this.""" 622 writeup = os.path.join(outdir, "writeup.md") 623 if os.path.exists(writeup): 624 return 625 today = dt.date.today().isoformat() 626 body = None 627 if os.path.exists(TEMPLATE): 628 with open(TEMPLATE) as fh: 629 body = fh.read() 630 # Drop the Templater header: <%* ... -%> (the JS that prompts in Obsidian). 631 body = re.sub(r"^<%\*.*?-%>\n", "", body, count=1, flags=re.S) 632 subs = { 633 "<% yaml(machine) %>": json.dumps(x["name"]), 634 "<% yaml(slug) %>": json.dumps(x["slug"]), 635 "<% yaml(targetOS) %>": json.dumps(x["os"]), 636 "<% yaml(difficulty) %>": json.dumps(x["difficulty"]), 637 '<% tp.date.now("YYYY-MM-DD") %>': today, 638 "<% targetOS %>": x["os"], 639 "<% difficulty %>": x["difficulty"], 640 "<% tp.file.cursor() %>": "", 641 } 642 for token, value in subs.items(): 643 body = body.replace(token, value) 644 body = body.replace('ip: ""', f"ip: {json.dumps(x['ip'])}") 645 # A leftover <% ... %> means the vault template grew a token this 646 # renderer does not know. Fail loudly rather than publish Templater source. 647 leftover = re.findall(r"<%.*?%>", body, flags=re.S) 648 if leftover: 649 die(f"template has tokens this renderer does not handle: {leftover[:3]} -- update _htbbox.py", 1) 650 if body is None: 651 body = SKELETON.format( 652 name=json.dumps(x["name"]), slug=json.dumps(x["slug"]), os=json.dumps(x["os"]), 653 difficulty=json.dumps(x["difficulty"]), today=today, ip=json.dumps(x["ip"]), 654 ip_raw=x["ip"], os_raw=x["os"], difficulty_raw=x["difficulty"], 655 hosts=" ".join(x["hostnames"]) or "-", 656 ) 657 info("htbbox: vault template not found, used the built-in skeleton") 658 with open(writeup, "w") as fh: 659 fh.write(body) 660 661 662 SKELETON = """\ 663 --- 664 title: {name} 665 slug: {slug} 666 type: writeup 667 site: daemon-sec 668 category: ctf 669 platform: HTB-Labs 670 machine: {name} 671 target_os: {os} 672 difficulty: {difficulty} 673 author: DAEMON 674 excerpt: "" 675 status: active 676 publish_status: draft 677 creation_date: {today} 678 published_at: "" 679 updated_at: "" 680 ip: {ip} 681 tools_used: [] 682 techniques: [] 683 bannerImage: "" 684 tags: 685 - HTB 686 - HTB/Labs 687 cssclasses: 688 - editorial 689 - note-banner 690 --- 691 692 ```dataviewjs 693 await dv.view("00Meta/Views/NoteBanner"); 694 ``` 695 696 ## Explain like I'm new 697 698 ## Attack path 699 700 ## Target details 701 702 | Field | Value | 703 | --- | --- | 704 | IP Address | {ip_raw} | 705 | Hostnames | {hosts} | 706 | Operating system | {os_raw} | 707 | Difficulty | {difficulty_raw} | 708 709 ## Reconnaissance 710 711 ## Enumeration 712 713 ## Initial access 714 715 ## Privilege escalation 716 717 ## Credentials and flags 718 719 ## Operator notes 720 721 ## Lessons learned 722 723 ## References 724 """ 725 726 727 # ── main ──────────────────────────────────────────────────────────────────── 728 729 COMMANDS = { 730 "new": cmd_new, "use": cmd_use, "switch": cmd_use, 731 "ls": cmd_ls, "list": cmd_ls, 732 "info": cmd_info, "show": cmd_info, 733 "path": cmd_path, "json": cmd_json, "set": cmd_set, 734 "host": cmd_host, "hosts": cmd_host, 735 "cred": cmd_cred, "creds": cmd_cred, 736 "flag": cmd_flag, "note": cmd_note, "notes": cmd_note, 737 "ports": cmd_ports, 738 } 739 740 741 def main(argv): 742 if not argv: 743 argv = ["info"] if current() and os.path.isdir(boxdir(current())) else ["ls"] 744 cmd, args = argv[0], argv[1:] 745 if cmd in ("-h", "--help", "help"): 746 print(USAGE, end="") 747 return 748 fn = COMMANDS.get(cmd) 749 if not fn: 750 print(USAGE, end="", file=sys.stderr) 751 die(f"unknown command: {cmd}") 752 fn(args) 753 754 755 if __name__ == "__main__": 756 try: 757 main(sys.argv[1:]) 758 sys.stdout.flush() 759 except BrokenPipeError: 760 # `htbbox ls | grep -q x` closes the pipe early; that is success, not 761 # an error. Point stdout at devnull so the interpreter's own flush at 762 # exit does not raise a second time. 763 os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno()) 764 sys.exit(0) 765 except KeyboardInterrupt: 766 sys.exit(130)