Created
April 9, 2026 23:20
-
-
Save seifallahhomrani1/1b198ca1763e6f7b27d5e0457c08c340 to your computer and use it in GitHub Desktop.
Terminal interface for the HackTheBox API with 0xdf writeup integration
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| htb.py β HackTheBox CLI | |
| API base: https://labs.hackthebox.com/api/v4 | |
| Commands: | |
| whoami verify token + username | |
| profile full profile | |
| status profile + active machine | |
| active running machine + IP | |
| tags list all tag categories and IDs | |
| machines browse machines with filters | |
| --status active|retired|both (default: active; retired/both needs VIP) | |
| --os linux|windows OS filter | |
| --diff easy|medium|hard|insane difficulty filter | |
| --lang "Python" language tag name (from `tags` command) | |
| --area "Active Directory" area of interest tag name | |
| --vuln "SQL Injection" vulnerability tag name | |
| --tech "Pass the Hash" technique tag name | |
| --tag 3723 raw tag ID (repeatable) | |
| --owned only machines you owned | |
| --todo only machines in your todo list | |
| --free free tier only | |
| --sort name|stars|owns|difficulty|release | |
| --search <query> name substring filter | |
| search <query> search any machine by name (active + retired) | |
| info <name|id> full details + 0xdf writeup summary | |
| writeup <name|id> fetch full 0xdf writeup (description + techniques + tags) | |
| start <name|id> spawn a machine | |
| stop terminate active machine | |
| reset reset active machine | |
| submit <flag> [--diff 1-10] submit user/root flag | |
| Config: | |
| export HTB_TOKEN=your_token | |
| or: echo your_token > ~/.htb_token | |
| Get: https://app.hackthebox.com/profile/settings β App Tokens | |
| """ | |
| import os, sys, base64, json, argparse, requests, re | |
| from pathlib import Path | |
| BASE = "https://labs.hackthebox.com/api/v4" | |
| OXDF_SITE = "https://0xdf.gitlab.io" | |
| OXDF_UA = {"User-Agent": "htb-cli/1.0 (personal research tool)"} | |
| # ββ Token βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_token() -> str: | |
| t = os.getenv("HTB_TOKEN", "").strip() | |
| if t: return t | |
| cfg = Path.home() / ".htb_token" | |
| if cfg.exists(): | |
| t = cfg.read_text().strip().splitlines()[0] | |
| if t: return t | |
| print("[!] No token.\n export HTB_TOKEN=... or echo token > ~/.htb_token") | |
| print(" Get one: https://app.hackthebox.com/profile/settings") | |
| sys.exit(1) | |
| TOKEN = get_token() | |
| HEADERS = { | |
| "Authorization": f"Bearer {TOKEN}", | |
| "Accept": "application/json", | |
| "Content-Type": "application/json", | |
| "User-Agent": "HTB-CLI/1.0", | |
| } | |
| # ββ HTTP ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _htb_request(method: str, path: str, **kwargs) -> requests.Response: | |
| """ | |
| Single HTTP request to the HTB API. | |
| Automatically retries once on 429 after the Retry-After delay. | |
| Prints a live rate-limit indicator when remaining < 5. | |
| """ | |
| url = f"{BASE}{path}" | |
| r = getattr(requests, method)(url, headers=HEADERS, timeout=15, **kwargs) | |
| # Rate-limit feedback | |
| remaining = r.headers.get("x-ratelimit-remaining") | |
| limit = r.headers.get("x-ratelimit-limit") | |
| if remaining is not None and limit is not None: | |
| rem = int(remaining); lim = int(limit) | |
| if rem <= 5: | |
| bar_w = 20 | |
| filled = int(bar_w * rem / lim) | |
| bar = "\033[33m" + "β"*filled + "\033[2m" + "β"*(bar_w-filled) + "\033[0m" | |
| print(f"\r \033[33mβ‘ Rate limit: {bar} {rem}/{lim} remaining\033[0m ", end="", flush=True) | |
| # Auto-retry on 429 | |
| if r.status_code == 429: | |
| retry = int(r.headers.get("retry-after", 60)) | |
| reset = r.headers.get("x-ratelimit-reset") | |
| import time as _time | |
| # Recalculate wait from reset timestamp if available | |
| if reset: | |
| wait = max(0, int(reset) - int(_time.time())) + 1 | |
| else: | |
| wait = retry | |
| print(f"\n \033[33m[~] Rate limited β waiting {wait}s (30 req/min cap) ...\033[0m") | |
| _time.sleep(wait) | |
| print() | |
| r = getattr(requests, method)(url, headers=HEADERS, timeout=15, **kwargs) | |
| return r | |
| def get(path: str, params: dict = None) -> dict: | |
| r = _htb_request("get", path, params=params) | |
| _check(r); return r.json() | |
| def post(path: str, data: dict = None) -> dict: | |
| r = _htb_request("post", path, json=data or {}) | |
| _check(r); return r.json() | |
| def _check(r): | |
| if r.status_code == 401: | |
| print("\n[!] 401 β token invalid/expired.") | |
| print(" Refresh: https://app.hackthebox.com/profile/settings\n"); sys.exit(1) | |
| if r.status_code == 404: | |
| try: msg = r.json().get("message","Not found") | |
| except: msg = "Not found" | |
| print(f"\n[!] 404: {msg}\n"); sys.exit(1) | |
| if not r.ok: | |
| try: msg = r.json().get("message", r.text[:200]) | |
| except: msg = r.text[:200] | |
| print(f"\n[!] HTTP {r.status_code}: {msg}\n"); sys.exit(1) | |
| # ββ JWT / profile βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def jwt_uid() -> int | None: | |
| try: | |
| p = TOKEN.split(".")[1]; p += "=" * (-len(p) % 4) | |
| pl = json.loads(base64.urlsafe_b64decode(p)) | |
| uid = pl.get("sub") or pl.get("uid"); return int(uid) if uid else None | |
| except: return None | |
| def my_profile() -> dict: | |
| uid = jwt_uid() | |
| if uid: return get(f"/user/profile/basic/{uid}").get("profile", {}) | |
| return get("/user/info").get("info", {}) | |
| # ββ Tag lookup ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _TAG_CACHE: dict | None = None | |
| def load_tags() -> list: | |
| """Returns list of {id, name, category} for all tags.""" | |
| global _TAG_CACHE | |
| if _TAG_CACHE is not None: | |
| return _TAG_CACHE | |
| data = get("/tags/list") | |
| flat = [] | |
| for cat in data.get("info", []): | |
| cat_name = cat.get("name","") | |
| for t in cat.get("tags", []): | |
| flat.append({"id": t["id"], "name": t["name"], "category": cat_name}) | |
| _TAG_CACHE = flat | |
| return flat | |
| def resolve_tag_name(name: str, category: str) -> tuple[int | None, str | None]: | |
| """ | |
| Find tag ID by name (case-insensitive) within a category. | |
| Returns (tag_id, actual_category_if_found_elsewhere). | |
| Strips trailing whitespace from name before matching. | |
| """ | |
| name = name.strip() | |
| tags = load_tags() | |
| # 1. Exact match in correct category | |
| for t in tags: | |
| if t["category"].lower() == category.lower() and t["name"].lower() == name.lower(): | |
| return t["id"], None | |
| # 2. Fuzzy match in correct category | |
| for t in tags: | |
| if t["category"].lower() == category.lower() and name.lower() in t["name"].lower(): | |
| return t["id"], None | |
| # 3. Exact match in ANY category (wrong flag used) | |
| for t in tags: | |
| if t["name"].lower() == name.lower(): | |
| return t["id"], t["category"] | |
| # 4. Fuzzy match in ANY category | |
| for t in tags: | |
| if name.lower() in t["name"].lower(): | |
| return t["id"], t["category"] | |
| return None, None | |
| # ββ Machine fetching βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def fetch_machines(status: str, tag_ids: list[int]) -> list: | |
| """ | |
| Fetch machines from /machine/paginated. | |
| status: "active" (retired=0), "retired" (retired=1), "both" (both calls combined) | |
| tag_ids: list of tag IDs to pass as tag[] params (server-side, VIP only has effect) | |
| Non-VIP accounts always receive only the 20 active machines regardless. | |
| """ | |
| def _fetch(retired_val: int) -> list: | |
| # Build query string manually for tag[] array params | |
| base_params = f"per_page=100&retired={retired_val}" | |
| for tid in tag_ids: | |
| base_params += f"&tag[]={tid}" | |
| r = _htb_request("get", f"/machine/paginated?{base_params}&page=1") | |
| _check(r) | |
| first = r.json() | |
| data = first.get("data", []) | |
| last = (first.get("meta") or {}).get("last_page", 1) or 1 | |
| for pg in range(2, last + 1): | |
| r2 = _htb_request("get", f"/machine/paginated?{base_params}&page={pg}") | |
| _check(r2) | |
| data += r2.json().get("data", []) | |
| return data | |
| if status == "both": | |
| # Deduplicate by ID | |
| seen = {} | |
| for m in _fetch(0) + _fetch(1): | |
| seen[m["id"]] = m | |
| return list(seen.values()) | |
| elif status == "retired": | |
| return _fetch(1) | |
| else: | |
| return _fetch(0) | |
| def client_filter(machines: list, args) -> list: | |
| """Apply all client-side filters.""" | |
| if args.os: | |
| machines = [m for m in machines if args.os.lower() in (m.get("os") or "").lower()] | |
| if args.diff: | |
| machines = [m for m in machines if args.diff.lower() == (m.get("difficultyText") or "").lower()] | |
| if args.owned: | |
| machines = [m for m in machines if m.get("authUserInUserOwns") or m.get("authUserInRootOwns")] | |
| if args.todo: | |
| machines = [m for m in machines if m.get("isTodo")] | |
| if args.free: | |
| machines = [m for m in machines if m.get("free")] | |
| if args.search: | |
| machines = [m for m in machines if args.search.lower() in (m.get("name") or "").lower()] | |
| return machines | |
| def sort_machines(machines: list, sort_key: str) -> list: | |
| key_map = { | |
| "name": lambda m: (m.get("name") or "").lower(), | |
| "stars": lambda m: -(float(m.get("star") or 0)), | |
| "owns": lambda m: -(int(m.get("user_owns_count") or 0)), | |
| "difficulty": lambda m: int(m.get("difficulty") or 0), | |
| "release": lambda m: -(0 if not m.get("release") else | |
| int(m["release"].replace("-","").replace("T","").replace(":","")[:14])), | |
| } | |
| return sorted(machines, key=key_map.get(sort_key, key_map["release"])) | |
| # ββ Colours βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| R="\033[31m"; G="\033[32m"; Y="\033[33m"; M="\033[35m"; C="\033[36m" | |
| DIM="\033[2m"; BOLD="\033[1m"; RST="\033[0m" | |
| def bar(pct, w=28): | |
| f = int(w * min(float(pct or 0), 100) / 100) | |
| return G + "β"*f + DIM + "β"*(w-f) + RST | |
| def dcol(d): | |
| return {"easy":G+"Easy"+RST,"medium":Y+"Medium"+RST, | |
| "hard":R+"Hard"+RST,"insane":M+"Insane"+RST}.get((d or "").lower(), (d or "?")) | |
| def osicon(s): | |
| s = (s or "").lower() | |
| if "linux" in s: return "π§" | |
| if "windows" in s: return "πͺ" | |
| if "freebsd" in s: return "π" | |
| return "β" | |
| # ββ Commands βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def cmd_whoami(): | |
| p = my_profile() | |
| print(f"\n Token : β ") | |
| print(f" Username : {BOLD}{p.get('name','?')}{RST}") | |
| print(f" User ID : {p.get('id', jwt_uid() or '?')}") | |
| print(f" Rank : {p.get('rank','?')}") | |
| print(f" VIP : {'β ' if p.get('isVip') else 'β (retired machines require VIP)'}\n") | |
| def cmd_profile(): | |
| p = my_profile() | |
| print(f"\n{BOLD}{C}{'β'*52}{RST}") | |
| print(f" {BOLD}{p.get('name','?')}{RST} {DIM}#{p.get('id','')}{RST}") | |
| print(f" Rank : {BOLD}{p.get('rank','?')}{RST} β {p.get('next_rank','?')}") | |
| prog = float(p.get("rank_ownership") or 0) | |
| print(f" Progress : {bar(prog)} {prog:.1f}%") | |
| print(f" Points : {BOLD}{p.get('points','?')}{RST}") | |
| u = p.get('user_owns',0); r = p.get('system_owns',0) | |
| ub = p.get('user_bloods',0); rb = p.get('system_bloods',0) | |
| print(f" Owns : {G}User {u}{RST} {R}Root {r}{RST} π©Έ {ub+rb} bloods") | |
| print(f" Ranking : #{p.get('ranking','?')}") | |
| print(f" Server : {p.get('server','?')}") | |
| print(f" VIP : {'β ' if p.get('isVip') else 'β'}") | |
| print(f"{BOLD}{C}{'β'*52}{RST}\n") | |
| def cmd_active(): | |
| data = get("/machine/active") | |
| info = data.get("info") or {} | |
| if not info or not info.get("id"): | |
| print(f"\n{Y}[~] No active machine.{RST}\n"); return | |
| name = info.get("name","?"); ip = info.get("ip","N/A") | |
| os_n = info.get("os","?"); diff = info.get("difficultyText","?") | |
| print(f"\n{BOLD}{G}Active Machine{RST}") | |
| print(f" {osicon(os_n)} {BOLD}{name}{RST} {dcol(diff)} {DIM}({os_n}){RST}") | |
| print(f" IP : {BOLD}{C}{ip}{RST}") | |
| print(f" Points : {info.get('points','?')}\n") | |
| def cmd_status(): | |
| cmd_profile(); cmd_active() | |
| def cmd_tags(): | |
| tags = get("/tags/list").get("info", []) | |
| for cat in tags: | |
| print(f"\n{BOLD}{C}{cat['name']}{RST} {DIM}(category id={cat['id']}){RST}") | |
| for t in sorted(cat.get("tags",[]), key=lambda x: x["name"]): | |
| print(f" {t['id']:>6} {t['name']}") | |
| print() | |
| def cmd_machines(args): | |
| # Resolve named tag args to IDs | |
| tag_ids: list[int] = list(args.tag or []) | |
| for flag, cat, val in [("--lang", "Language", args.lang), | |
| ("--area", "Area of Interest", args.area), | |
| ("--vuln", "Vulnerability", args.vuln), | |
| ("--tech", "Technique", args.tech)]: | |
| if val: | |
| tid, actual_cat = resolve_tag_name(val, cat) | |
| if tid and actual_cat is None: | |
| tag_ids.append(tid) # found in correct category | |
| elif tid and actual_cat: | |
| # Found in a DIFFERENT category β auto-apply but warn user | |
| tag_ids.append(tid) | |
| correct_flag = {"Language":"--lang","Area of Interest":"--area", | |
| "Vulnerability":"--vuln","Technique":"--tech"}.get(actual_cat, f"--tag {tid}") | |
| print(f"{Y}[!] '{val}' is not in {cat} β found in '{actual_cat}' (id={tid}).{RST}") | |
| print(f" Use {correct_flag} \"{val}\" next time. Applying anyway.\n") | |
| else: | |
| print(f"{R}[!] Tag '{val}' not found in any category. Run `htb tags` to browse.{RST}\n") | |
| # Non-VIP early warning for retired/both before wasting the fetch | |
| is_vip = my_profile().get("isVip", False) | |
| if args.status in ("retired", "both") and not is_vip: | |
| print(f"{Y}β Retired machines require VIP+. Your account is free tier.{RST}") | |
| print(f" Only the 20 active machines are available via this listing.") | |
| print(f" To look up a specific retired machine: htb search <name> or htb info <name>\n") | |
| machines = fetch_machines(args.status, tag_ids) | |
| machines = client_filter(machines, args) | |
| machines = sort_machines(machines, args.sort) | |
| if not machines: | |
| print(f"\n{Y}[~] No machines match filters.{RST}\n") | |
| return | |
| # Build filter summary line | |
| tags_used = [] | |
| if args.lang: tags_used.append(f"lang={args.lang}") | |
| if args.area: tags_used.append(f"area={args.area}") | |
| if args.vuln: tags_used.append(f"vuln={args.vuln}") | |
| if args.tech: tags_used.append(f"tech={args.tech}") | |
| if args.tag: tags_used.extend(f"tag={t}" for t in args.tag) | |
| filter_parts = [] | |
| if args.status != "active": filter_parts.append(args.status) | |
| if args.os: filter_parts.append(f"os={args.os}") | |
| if args.diff: filter_parts.append(f"diff={args.diff}") | |
| if args.owned: filter_parts.append("owned") | |
| if args.todo: filter_parts.append("todo") | |
| if args.free: filter_parts.append("free") | |
| if args.search: filter_parts.append(f"search={args.search}") | |
| filter_parts += tags_used | |
| filter_str = " " + " Β· ".join(filter_parts) if filter_parts else "" | |
| print(f"\n{BOLD}{C}MACHINES{RST}{DIM}{filter_str}{RST} ({len(machines)} results)\n") | |
| print(f"{BOLD}{'ID':>6} {'Name':<22} {'OS':<10} {'Diff':<10} {'Pts':>4} {'β ':>4} {'Solves':>7} U R{RST}") | |
| print("β" * 80) | |
| for m in machines: | |
| mid = str(m.get("id","")) | |
| name = m.get("name","")[:21] | |
| os_n = m.get("os","")[:9] | |
| diff = m.get("difficultyText","") | |
| pts = str(m.get("points","")) | |
| stars = f"{float(m.get('star') or 0):.1f}" | |
| owns = str(m.get("user_owns_count","")) | |
| u = "β " if m.get("authUserInUserOwns") else " " | |
| r = "β " if m.get("authUserInRootOwns") else " " | |
| todo = " π" if m.get("isTodo") else "" | |
| free = " π" if m.get("free") else "" | |
| print(f" {mid:>6} {name:<22} {osicon(os_n)+os_n:<11} {dcol(diff):<10} {pts:>4} {stars:>4} {owns:>7} {u} {r}{todo}{free}") | |
| print() | |
| def _get_machine(name_or_id: str) -> dict: | |
| return get(f"/machine/profile/{name_or_id}").get("info", {}) | |
| def _print_machine_row(m: dict, retired: bool = False): | |
| mid = str(m.get("id","")) | |
| name = m.get("name","")[:21] | |
| os_n = m.get("os","")[:9] | |
| diff = m.get("difficultyText","") | |
| pts = str(m.get("points") or m.get("static_points","")) | |
| stars = f"{float(m.get('star') or m.get('stars') or 0):.1f}" | |
| owns = str(m.get("user_owns_count","")) | |
| u = "β " if m.get("authUserInUserOwns") else " " | |
| r = "β " if m.get("authUserInRootOwns") else " " | |
| badge = f" {DIM}retired{RST}" if retired else f" {G}active{RST}" | |
| print(f" {mid:>6} {name:<22} {osicon(os_n)+os_n:<11} {dcol(diff):<10} {pts:>4} {stars:>4} {owns:>7} {u} {r}{badge}") | |
| def cmd_search(query: str): | |
| """ | |
| Search machines by name via /search/fetch (returns up to 5 matches, | |
| works for BOTH active and retired machines). | |
| Each result is enriched with a /machine/profile/{id} call for full data. | |
| """ | |
| data = get("/search/fetch", params={"query": query}) | |
| matches = data.get("machines", []) | |
| if not matches: | |
| print(f"\n{Y}[~] No machines found for '{query}'.{RST}\n") | |
| return | |
| print(f"\n{BOLD}Search: '{query}'{RST} ({len(matches)} result{'s' if len(matches)!=1 else ''})\n") | |
| print(f"{BOLD}{'ID':>6} {'Name':<22} {'OS':<10} {'Diff':<10} {'Pts':>4} {'β ':>4} {'Solves':>7} U R Status{RST}") | |
| print("β" * 86) | |
| for hit in matches: | |
| mid = hit.get("id") | |
| name = hit.get("value","?") | |
| # Enrich with full profile | |
| try: | |
| info = get(f"/machine/profile/{mid}").get("info", {}) | |
| except SystemExit: | |
| info = {"id": mid, "name": name} | |
| _print_machine_row(info, retired=info.get("retired", False)) | |
| print() | |
| # ββ 0xdf blog integration βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _OXDF_INDEX: dict | None = None # {machine_slug: url} | |
| def _oxdf_index() -> dict: | |
| """ | |
| Build a slugβURL index from 0xdf's sitemap. | |
| Cached for the lifetime of the process. | |
| """ | |
| global _OXDF_INDEX | |
| if _OXDF_INDEX is not None: | |
| return _OXDF_INDEX | |
| try: | |
| r = requests.get(f"{OXDF_SITE}/sitemap.xml", headers=OXDF_UA, timeout=15) | |
| r.raise_for_status() | |
| urls = re.findall(r"<loc>(https://0xdf\.gitlab\.io/\d{4}/\d{2}/\d{2}/htb-[^<]+)</loc>", r.text) | |
| index = {} | |
| for url in urls: | |
| slug = re.search(r"/htb-([^/]+)\.html$", url) | |
| if slug: | |
| index[slug.group(1).lower()] = url | |
| _OXDF_INDEX = index | |
| return index | |
| except Exception as e: | |
| print(f"{Y}[!] Could not fetch 0xdf sitemap: {e}{RST}") | |
| return {} | |
| def _oxdf_find_url(machine_name: str) -> str | None: | |
| """Find the 0xdf writeup URL for a machine name.""" | |
| index = _oxdf_index() | |
| slug = machine_name.lower().replace(" ", "-").replace("_", "-") | |
| # Exact match first | |
| if slug in index: | |
| return index[slug] | |
| # Fuzzy: machine name is a substring of slug (e.g. "forest" in "forest") | |
| for k, url in index.items(): | |
| if slug == k: | |
| return url | |
| # Partial | |
| for k, url in index.items(): | |
| if slug in k or k in slug: | |
| return url | |
| return None | |
| def _oxdf_fetch(url: str) -> dict: | |
| """ | |
| Fetch and parse a 0xdf writeup page. | |
| Returns: {description, tags, techniques, url, title} | |
| """ | |
| try: | |
| r = requests.get(url, headers=OXDF_UA, timeout=15) | |
| r.raise_for_status() | |
| html = r.text | |
| except Exception as e: | |
| return {"error": str(e), "url": url} | |
| # Title | |
| title_m = re.search(r"<title>(.*?)</title>", html) | |
| title = title_m.group(1).replace(" | 0xdf hacks stuff", "").strip() if title_m else "?" | |
| # Description β first <p> that starts with the machine name or is substantial | |
| desc = "" | |
| for m in re.finditer(r"<p>(.*?)</p>", html, re.DOTALL): | |
| raw = m.group(1) | |
| text = re.sub(r"<[^>]+>", "", raw).strip() | |
| if len(text) > 80 and "I'll" in text or len(text) > 150: | |
| desc = text | |
| break | |
| # Tags (post-tag links) | |
| tags = re.findall(r'class="post-tag[^"]*"[^>]*>(.*?)</a>', html) | |
| htb_tags = [t for t in tags if t not in ("htb", "hackthebox", "ctf", "nmap")] | |
| # Section headers = techniques used | |
| headers = re.findall(r"<h[23][^>]*>(.*?)</h[23]>", html) | |
| headers = [re.sub(r"<[^>]+>", "", h).strip() for h in headers] | |
| # Skip boilerplate headers | |
| skip = {"box info", "recon", "initial scanning", "enumeration", "shell", | |
| "escalation", "beyond root", "other tools considered"} | |
| techniques = [h for h in headers if h.lower() not in skip and len(h) > 3][:20] | |
| return { | |
| "title": title, | |
| "description": desc, | |
| "tags": htb_tags, | |
| "techniques": techniques, | |
| "url": url, | |
| } | |
| def cmd_info(target: str): | |
| """Show machine details + 0xdf writeup description if available.""" | |
| m = _get_machine(target) | |
| if not m.get("id"): | |
| print(f"{R}[!] Machine not found: {target}{RST}"); return | |
| retired = m.get("retired", False) | |
| active = m.get("active", False) | |
| status = (f"{G}Active{RST}" if active else | |
| f"{DIM}Retired{RST}" if retired else f"{Y}Unknown{RST}") | |
| name = m.get("name","?") | |
| print(f"\n{BOLD}{C}{'β'*56}{RST}") | |
| print(f" {osicon(m.get('os',''))} {BOLD}{name}{RST} {DIM}id={m.get('id')}{RST} {status}") | |
| print(f" OS : {m.get('os','?')}") | |
| print(f" Difficulty : {dcol(m.get('difficultyText','?'))}") | |
| print(f" Points : {m.get('points') or m.get('static_points','?')}") | |
| print(f" Stars : {float(m.get('stars') or m.get('star') or 0):.1f}") | |
| print(f" Solves : User {m.get('user_owns_count','?')} Root {m.get('root_owns_count','?')}") | |
| print(f" Release : {(m.get('release') or '')[:10]}") | |
| maker = m.get("maker") or {} | |
| if maker: print(f" Maker : {maker.get('name','?')}") | |
| u = "β " if m.get("authUserInUserOwns") else "β" | |
| r = "β " if m.get("authUserInRootOwns") else "β" | |
| print(f" Owned : User {u} Root {r}") | |
| # 0xdf writeup | |
| url = _oxdf_find_url(name) | |
| if url: | |
| w = _oxdf_fetch(url) | |
| if w.get("description"): | |
| print(f"\n {BOLD}0xdf Writeup{RST} {DIM}{url}{RST}") | |
| # Word-wrap description at 72 chars | |
| words = w["description"].split() | |
| line = " " | |
| for word in words: | |
| if len(line) + len(word) + 1 > 74: | |
| print(f" {DIM}{line.strip()}{RST}") | |
| line = " " + word + " " | |
| else: | |
| line += word + " " | |
| if line.strip(): | |
| print(f" {DIM}{line.strip()}{RST}") | |
| if w.get("tags"): | |
| print(f"\n {BOLD}Tags{RST} {DIM}{' Β· '.join(w['tags'][:12])}{RST}") | |
| else: | |
| print(f"\n {DIM}No 0xdf writeup found for '{name}'{RST}") | |
| print(f"{BOLD}{C}{'β'*56}{RST}\n") | |
| def cmd_writeup(target: str): | |
| """ | |
| Fetch full 0xdf writeup for a machine: | |
| description, tags, and all technique headers. | |
| """ | |
| # Resolve name from HTB if numeric ID given | |
| try: | |
| m = _get_machine(target) | |
| name = m.get("name", target) | |
| except SystemExit: | |
| name = target | |
| url = _oxdf_find_url(name) | |
| if not url: | |
| print(f"\n{Y}[~] No 0xdf writeup found for '{name}'.{RST}") | |
| print(f" Browse manually: https://0xdf.gitlab.io\n") | |
| return | |
| print(f"\n{DIM}Fetching {url} ...{RST}") | |
| w = _oxdf_fetch(url) | |
| if w.get("error"): | |
| print(f"{R}[!] Fetch failed: {w['error']}{RST}\n"); return | |
| print(f"\n{BOLD}{C}{'β'*56}{RST}") | |
| print(f" {BOLD}{w.get('title','?')}{RST}") | |
| print(f" {DIM}{url}{RST}") | |
| print(f"{BOLD}{C}{'β'*56}{RST}\n") | |
| if w.get("description"): | |
| print(f"{BOLD}Description{RST}") | |
| # Word-wrap at 76 chars | |
| words = w["description"].split() | |
| line = "" | |
| for word in words: | |
| if len(line) + len(word) + 1 > 76: | |
| print(f" {line.strip()}") | |
| line = word + " " | |
| else: | |
| line += word + " " | |
| if line.strip(): | |
| print(f" {line.strip()}") | |
| print() | |
| if w.get("techniques"): | |
| print(f"{BOLD}Techniques / Steps{RST}") | |
| for i, t in enumerate(w["techniques"], 1): | |
| print(f" {DIM}{i:>2}.{RST} {t}") | |
| print() | |
| if w.get("tags"): | |
| print(f"{BOLD}Tags{RST}") | |
| print(f" {' Β· '.join(w['tags'])}\n") | |
| print(f" {DIM}Full writeup β {url}{RST}\n") | |
| def cmd_start(target: str): | |
| print(f"[*] Resolving '{target}' ...") | |
| m = _get_machine(target) | |
| mid = m.get("id"); name = m.get("name","?") | |
| os_n = m.get("os","?"); diff = m.get("difficultyText","?") | |
| print(f"[*] {osicon(os_n)} {BOLD}{name}{RST} {dcol(diff)} id={mid}") | |
| resp = post("/vm/spawn", {"machine_id": mid}) | |
| msg = resp.get("message", str(resp)) | |
| ip = resp.get("ip") or (resp.get("info") or {}).get("ip","") | |
| print(f"[+] {G}{msg}{RST}") | |
| if ip: | |
| print(f"[+] IP : {BOLD}{C}{ip}{RST}") | |
| print(f"\n {DIM}/etc/hosts:{RST} {ip} {name.lower()}.htb\n") | |
| def cmd_stop(): | |
| info = get("/machine/active").get("info") or {} | |
| mid = info.get("id") | |
| if not mid: print(f"{Y}[~] No active machine.{RST}"); return | |
| resp = post("/vm/terminate", {"machine_id": mid}) | |
| print(f"[+] {G}Terminated {info.get('name','?')} (id={mid}){RST} β {resp.get('message','')}") | |
| def cmd_reset(): | |
| info = get("/machine/active").get("info") or {} | |
| mid = info.get("id") | |
| if not mid: print(f"{Y}[~] No active machine.{RST}"); return | |
| resp = post("/vm/reset", {"machine_id": mid}) | |
| print(f"[+] {Y}Reset {info.get('name','?')} (id={mid}){RST} β {resp.get('message','')}") | |
| def cmd_submit(flag: str, difficulty: int): | |
| info = get("/machine/active").get("info") or {} | |
| mid = info.get("id") | |
| if not mid: print(f"{R}[!] No active machine.{RST}"); return | |
| resp = post("/machine/own", {"flag": flag, "id": mid, "difficulty": difficulty * 10}) | |
| msg = resp.get("message", str(resp)) | |
| ok = resp.get("success") or "Owned" in msg or "correct" in msg.lower() | |
| if ok: print(f"\n {G}π FLAG ACCEPTED β {info.get('name','?')}{RST}\n {msg}\n") | |
| else: print(f"\n {R}[!] {msg}{RST}\n") | |
| # ββ Main βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def main(): | |
| p = argparse.ArgumentParser( | |
| prog="htb", | |
| description="HackTheBox CLI β’ labs.hackthebox.com/api/v4", | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| ) | |
| sub = p.add_subparsers(dest="cmd") | |
| sub.add_parser("whoami", help="Verify token + username") | |
| sub.add_parser("status", help="Profile + active machine") | |
| sub.add_parser("profile", help="Full profile") | |
| sub.add_parser("active", help="Active machine + IP") | |
| sub.add_parser("tags", help="List all tag categories and IDs") | |
| pm = sub.add_parser("machines", help="Browse/filter machines") | |
| pm.add_argument("--status", default="active", | |
| choices=["active","retired","both"], | |
| help="active (default) | retired (VIP) | both (VIP)") | |
| pm.add_argument("--os", metavar="OS", help="linux | windows") | |
| pm.add_argument("--diff", metavar="DIFF", help="easy | medium | hard | insane") | |
| pm.add_argument("--lang", metavar="LANG", help='Language tag e.g. "Python"') | |
| pm.add_argument("--area", metavar="AREA", help='Area of Interest e.g. "Active Directory"') | |
| pm.add_argument("--vuln", metavar="VULN", help='Vulnerability tag e.g. "SQL Injection"') | |
| pm.add_argument("--tech", metavar="TECH", help='Technique tag e.g. "Pass the Hash"') | |
| pm.add_argument("--tag", metavar="ID", type=int, action="append", | |
| help="Raw tag ID (repeatable, see `htb tags`)") | |
| pm.add_argument("--owned", action="store_true", help="Only machines you owned") | |
| pm.add_argument("--todo", action="store_true", help="Only your todo list") | |
| pm.add_argument("--free", action="store_true", help="Free tier only") | |
| pm.add_argument("--search", metavar="QUERY", help="Filter by name substring") | |
| pm.add_argument("--sort", default="release", | |
| choices=["name","stars","owns","difficulty","release"], | |
| help="Sort order (default: release)") | |
| pst = sub.add_parser("start", help="Spawn a machine") | |
| pst.add_argument("target", help="Machine name or numeric ID") | |
| pse = sub.add_parser("search", help="Search machines by name (active + retired, up to 5 results)") | |
| pse.add_argument("query", help="Name to search for") | |
| pin = sub.add_parser("info", help="Full details + 0xdf writeup summary for any machine") | |
| pin.add_argument("target", help="Machine name or numeric ID") | |
| pw = sub.add_parser("writeup", help="Fetch full 0xdf writeup (description + techniques + tags)") | |
| pw.add_argument("target", help="Machine name or numeric ID") | |
| sub.add_parser("stop", help="Terminate active machine") | |
| sub.add_parser("reset", help="Reset active machine") | |
| psu = sub.add_parser("submit", help="Submit a flag") | |
| psu.add_argument("flag") | |
| psu.add_argument("--diff", type=int, default=5, metavar="1-10", | |
| help="Difficulty rating 1-10 (default 5)") | |
| args = p.parse_args() | |
| try: | |
| match args.cmd: | |
| case "whoami": cmd_whoami() | |
| case "status": cmd_status() | |
| case "profile": cmd_profile() | |
| case "active": cmd_active() | |
| case "tags": cmd_tags() | |
| case "machines": cmd_machines(args) | |
| case "search": cmd_search(args.query) | |
| case "info": cmd_info(args.target) | |
| case "writeup": cmd_writeup(args.target) | |
| case "start": cmd_start(args.target) | |
| case "stop": cmd_stop() | |
| case "reset": cmd_reset() | |
| case "submit": cmd_submit(args.flag, args.diff) | |
| case _: p.print_help() | |
| except KeyboardInterrupt: | |
| print("\n[~] Aborted.") | |
| if __name__ == "__main__": | |
| main() |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
htb-cli.py
Browse machines, filter by difficulty/OS/technique/vulnerability tags, look up retired boxes, fetch [0xdf](https://0xdf.gitlab.io) writeup summaries, and manage your active machine session β all from the command line.
Requirements
pip install requests python3 --version # 3.10+ required (uses match/case)Setup
Get an App Token from [app.hackthebox.com/profile/settings](https://app.hackthebox.com/profile/settings) β App Tokens β Create, then either:
Verify it works:
Commands
Account
Browse Machines
Advanced Tag Filters
The four filter categories match the HTB web interface exactly:
Search & Info
infoshows: OS, difficulty, points, star rating, solve counts, release date, maker, owned status, and the 0xdf writeup opening description + tags if one exists.0xdf Writeups
python3 htb-cli.py writeup Forest python3 htb-cli.py writeup DarkZero python3 htb-cli.py writeup 212 # by IDPulls from [0xdf.gitlab.io](https://0xdf.gitlab.io) β indexes 540+ writeups from the sitemap on first call (cached in memory), then fetches the full page to extract:
Works for any machine 0xdf has written up, active or retired.
Session Management
Rate Limits
The HTB API enforces 30 requests/minute per token. The script handles this transparently:
429 Too Many Requests, automatically waits for the reset timestamp and retries β no action neededNotes
searchandinfo/writeupcommands work for retired machines on free accounts.--lang,--area,--vuln,--tech) is applied client-side on free accounts since the server-side tag filter is also VIP-gated. Results are still accurate.match/case).requests.