Skip to content

Instantly share code, notes, and snippets.

@seifallahhomrani1
Created April 9, 2026 23:20
Show Gist options
  • Select an option

  • Save seifallahhomrani1/1b198ca1763e6f7b27d5e0457c08c340 to your computer and use it in GitHub Desktop.

Select an option

Save seifallahhomrani1/1b198ca1763e6f7b27d5e0457c08c340 to your computer and use it in GitHub Desktop.
Terminal interface for the HackTheBox API with 0xdf writeup integration
#!/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()
@seifallahhomrani1

Copy link
Copy Markdown
Author

htb-cli.py

Terminal interface for the HackTheBox API with 0xdf writeup integration.

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:

export HTB_TOKEN=your_token_here
# or persist it:
echo "your_token_here" > ~/.htb_token

Verify it works:

python3 htb-cli.py whoami

Commands

Account

python3 htb-cli.py whoami        # verify token, show username + rank
python3 htb-cli.py profile       # full profile: rank progress, owns, bloods, ranking
python3 htb-cli.py status        # profile + currently active machine at a glance
python3 htb-cli.py active        # show running machine name, IP, difficulty

Browse Machines

python3 htb-cli.py machines                          # all active machines (newest first)
python3 htb-cli.py machines --diff medium --os linux # filter by difficulty + OS
python3 htb-cli.py machines --owned                  # only machines you've pwned
python3 htb-cli.py machines --todo                   # your todo list
python3 htb-cli.py machines --free                   # free tier only
python3 htb-cli.py machines --sort stars             # sort by rating
python3 htb-cli.py machines --sort owns              # sort by solve count
python3 htb-cli.py machines --search cobble          # filter by name substring
python3 htb-cli.py machines --status retired         # retired machines (VIP+ required)
python3 htb-cli.py machines --status both            # active + retired (VIP+)

Advanced Tag Filters

The four filter categories match the HTB web interface exactly:

python3 htb-cli.py machines --lang "Python"
python3 htb-cli.py machines --area "Active Directory"
python3 htb-cli.py machines --vuln "SQL Injection"
python3 htb-cli.py machines --tech "Pass the Hash"
python3 htb-cli.py machines --tag 4156              # raw tag ID (repeatable)

# Combine freely
python3 htb-cli.py machines --diff hard --os windows --tech "Kerberos Abuse"

Run python3 htb-cli.py tags to browse all available tag names and IDs across all four categories (Language, Area of Interest, Vulnerability, Technique β€” 400+ tags total).

Note: The script auto-corrects wrong flag usage β€” e.g. using --vuln "Pass the Hash" instead of --tech will still work and tell you the right flag to use next time.

Search & Info

python3 htb-cli.py search Forest           # search by name β€” works for retired machines too
python3 htb-cli.py info Forest             # details + 0xdf writeup description inline
python3 htb-cli.py info 212                # same by machine ID

info shows: 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 ID

Pulls 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:

  • Description β€” the opening summary paragraph
  • Techniques / Steps β€” all section headers from the writeup
  • Tags β€” tools and concepts covered

Works for any machine 0xdf has written up, active or retired.

Session Management

python3 htb-cli.py start Forest            # spawn a machine by name
python3 htb-cli.py start 212               # or by ID
python3 htb-cli.py stop                    # terminate active machine
python3 htb-cli.py reset                   # reset active machine
python3 htb-cli.py submit <flag>           # submit user or root flag
python3 htb-cli.py submit <flag> --diff 7  # with difficulty rating (1-10, default 5)

Rate Limits

The HTB API enforces 30 requests/minute per token. The script handles this transparently:

  • Shows a warning bar when fewer than 5 requests remain in the current window
  • On 429 Too Many Requests, automatically waits for the reset timestamp and retries β€” no action needed

Notes

  • Retired machines (listing) require a VIP+ subscription. The search and info/writeup commands work for retired machines on free accounts.
  • Tag filtering (--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.
  • Python 3.10+ required for structural pattern matching (match/case).
  • No third-party dependencies beyond requests.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment