Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions capabilities/network-ops/capability.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,39 @@ workers:
path: workers/coordinator.py

dependencies:
# apt tools, installed privileged by the SDK (dependencies.packages).
packages:
- nmap
- smbclient
- hashcat
- john
python:
- "loguru>=0.7.0"
- "impacket>=0.12.0"
- "certipy-ad"
- "bloodyAD"
# NetExec (nxc) is deliberately absent here: it is not published on PyPI. The
# install script below fetches the project's prebuilt standalone binary.
# Runtime-fetched assets apt/pip can't provide: the NetExec binary, wordlists,
# hashcat rules, and the NTLM coercion scripts.
scripts:
- scripts/install_coercion_tools.sh

checks:
- name: nmap
command: "command -v nmap >/dev/null 2>&1"
- name: smbclient
command: "command -v smbclient >/dev/null 2>&1"
- name: hashcat
command: "command -v hashcat >/dev/null 2>&1"
- name: john
command: "command -v john >/dev/null 2>&1"
- name: netexec
command: "command -v nxc >/dev/null 2>&1 || command -v netexec >/dev/null 2>&1"
- name: certipy
command: "command -v certipy >/dev/null 2>&1 || command -v certipy-ad >/dev/null 2>&1"
- name: bloodyad
command: "command -v bloodyAD >/dev/null 2>&1"
- name: petitpotam
command: "test -f /opt/PetitPotam/PetitPotam.py"
- name: dfscoerce
Expand All @@ -45,6 +69,10 @@ checks:
command: "test -f /opt/ShadowCoerce/shadowcoerce.py"
- name: rockyou-wordlist
command: "test -s /usr/share/wordlists/rockyou.txt"
- name: wordlist-10k
command: "test -s /usr/share/wordlists/10k-most-common.txt"
- name: hashcat-rules
command: "test -s /usr/share/hashcat/rules/OneRuleToRuleThemAll.rule"

keywords:
- network-ops
Expand Down
136 changes: 91 additions & 45 deletions capabilities/network-ops/scripts/install_coercion_tools.sh
Original file line number Diff line number Diff line change
@@ -1,54 +1,68 @@
#!/usr/bin/env bash
# Install dependencies for the network-ops capability.
# Runs at sandbox provision time via dependencies.scripts.
# Fetch the network-ops runtime assets that apt and pip cannot: the
# password-cracking wordlists, the hashcat rule set, and the three NTLM
# coercion scripts. Binaries and Python packages are declared separately in
# capability.yaml (dependencies.packages / dependencies.python).
#
# The target system directories (/usr/share/wordlists, /usr/share/hashcat,
# /opt) are not writable by the unprivileged provisioning user, so every write
# goes through ensure_writable_dir and stays non-fatal: with no egress or no
# sudo the capability still loads, just without these optional assets. The
# matching checks: in capability.yaml report any asset that did not land.
set -euo pipefail

# -- Impacket (ensure it's importable by the runtime Python) ----------------
# dependencies.python should handle this, but belt-and-suspenders: if the
# runtime Python can't import impacket, install it explicitly.
if ! python3 -c "import impacket" 2>/dev/null; then
echo "[+] Installing impacket into runtime Python"
python3 -m pip install --quiet "impacket>=0.12.0" \
|| python3 -m pip install --quiet --break-system-packages "impacket>=0.12.0"
else
echo "[*] impacket already importable, skipping"
fi
# Make a (possibly root-owned) system directory exist and be writable by the
# current user, escalating with `sudo -n` (non-interactive, like the SDK's own
# apt step) when a plain mkdir is not permitted. Returns non-zero without
# aborting the script when neither works, so callers skip the dependent write.
ensure_writable_dir() {
local dir="$1"
if mkdir -p "$dir" 2>/dev/null && [ -w "$dir" ]; then
return 0
fi
if command -v sudo >/dev/null 2>&1; then
if sudo -n mkdir -p "$dir" 2>/dev/null \
&& sudo -n chown "$(id -u):$(id -g)" "$dir" 2>/dev/null; then
return 0
fi
fi
echo "[!] Cannot create or write $dir (non-fatal, skipping)"
return 1
}

# -- Wordlists for password cracking -----------------------------------------
WORDLIST_DIR="/usr/share/wordlists"
mkdir -p "$WORDLIST_DIR"

# rockyou — the standard wordlist for cracking (14M passwords)
if [ -s "$WORDLIST_DIR/rockyou.txt" ]; then
echo "[*] rockyou.txt already exists, skipping"
elif [ -f "$WORDLIST_DIR/rockyou.txt.gz" ]; then
echo "[+] Decompressing rockyou.txt.gz"
rm -f "$WORDLIST_DIR/rockyou.txt"
gunzip -k "$WORDLIST_DIR/rockyou.txt.gz" \
|| echo "[!] Failed to decompress rockyou.txt.gz (non-fatal)"
else
echo "[+] Downloading rockyou.txt"
curl -fsSL --retry 3 --connect-timeout 10 "https://github.com/brannondorsey/naive-hashcat/releases/download/data/rockyou.txt" \
-o "$WORDLIST_DIR/rockyou.txt" \
|| echo "[!] Failed to download rockyou.txt (non-fatal)"
fi
if ensure_writable_dir "$WORDLIST_DIR"; then
# rockyou — the standard cracking wordlist (14M passwords)
if [ -s "$WORDLIST_DIR/rockyou.txt" ]; then
echo "[*] rockyou.txt already exists, skipping"
elif [ -f "$WORDLIST_DIR/rockyou.txt.gz" ]; then
echo "[+] Decompressing rockyou.txt.gz"
rm -f "$WORDLIST_DIR/rockyou.txt"
gunzip -k "$WORDLIST_DIR/rockyou.txt.gz" \
|| echo "[!] Failed to decompress rockyou.txt.gz (non-fatal)"
else
echo "[+] Downloading rockyou.txt"
curl -fsSL --retry 3 --connect-timeout 10 "https://github.com/brannondorsey/naive-hashcat/releases/download/data/rockyou.txt" \
-o "$WORDLIST_DIR/rockyou.txt" \
|| echo "[!] Failed to download rockyou.txt (non-fatal)"
fi

# Top 10k most common passwords — fast first-pass for spraying
# Non-fatal: wordlist download failures shouldn't block coercion script setup
if [ -s "$WORDLIST_DIR/10k-most-common.txt" ]; then
echo "[*] 10k-most-common.txt already exists, skipping"
else
echo "[+] Downloading SecLists 10k most common passwords"
curl -fsSL --retry 3 --connect-timeout 10 "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Passwords/Common-Credentials/10k-most-common.txt" \
-o "$WORDLIST_DIR/10k-most-common.txt" \
|| echo "[!] Failed to download 10k-most-common.txt (non-fatal)"
# Top 10k most common passwords — fast first-pass for spraying
if [ -s "$WORDLIST_DIR/10k-most-common.txt" ]; then
echo "[*] 10k-most-common.txt already exists, skipping"
else
echo "[+] Downloading SecLists 10k most common passwords"
curl -fsSL --retry 3 --connect-timeout 10 "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Passwords/Common-Credentials/10k-most-common.txt" \
-o "$WORDLIST_DIR/10k-most-common.txt" \
|| echo "[!] Failed to download 10k-most-common.txt (non-fatal)"
fi
fi

# OneRuleToRuleThemAll — hashcat rules that multiply wordlist effectiveness
# Combines rules from Hob0Rules, KoreLogic, NSA, and hashcat generated rules
# Non-fatal: rules dir may not be writable and that's OK
# OneRuleToRuleThemAll — hashcat rules that multiply wordlist effectiveness.
# Combines rules from Hob0Rules, KoreLogic, NSA, and hashcat generated rules.
RULES_DIR="/usr/share/hashcat/rules"
if mkdir -p "$RULES_DIR" 2>&1; then
if ensure_writable_dir "$RULES_DIR"; then
if [ -s "$RULES_DIR/OneRuleToRuleThemAll.rule" ]; then
echo "[*] OneRuleToRuleThemAll.rule already exists, skipping"
else
Expand All @@ -57,11 +71,37 @@ if mkdir -p "$RULES_DIR" 2>&1; then
-o "$RULES_DIR/OneRuleToRuleThemAll.rule" \
|| echo "[!] Failed to download OneRuleToRuleThemAll.rule (non-fatal)"
fi
fi

# -- NetExec (nxc) ----------------------------------------------------------
# Not on PyPI and awkward to build from source, so install the project's
# prebuilt standalone binary. v1.5.0 is pinned because it is the most recent
# release carrying a Linux build (v1.5.1 shipped none). The binary needs
# glibc >= 2.38, satisfied by the ubuntu:24.04 runtime base. The tools invoke
# it as `netexec`, so the single `nxc` binary is symlinked under both names.
NXC_BIN_DIR="$HOME/.local/bin"
NXC_URL="https://github.com/Pennyw0rth/NetExec/releases/download/v1.5.0/nxc-ubuntu-latest.zip"
if command -v nxc >/dev/null 2>&1; then
echo "[*] nxc already present, skipping"
else
echo "[!] Cannot create $RULES_DIR (non-fatal, skipping hashcat rules)"
echo "[+] Installing NetExec v1.5.0 (prebuilt binary)"
if mkdir -p "$NXC_BIN_DIR" \
&& curl -fsSL --retry 3 --connect-timeout 10 "$NXC_URL" -o /tmp/nxc.zip \
&& unzip -o -q /tmp/nxc.zip -d /tmp/nxc-extract \
&& install -m 0755 /tmp/nxc-extract/nxc "$NXC_BIN_DIR/nxc"; then
ln -sf nxc "$NXC_BIN_DIR/netexec" || echo "[!] netexec symlink failed (non-fatal)"
ln -sf nxc "$NXC_BIN_DIR/NetExec" || echo "[!] NetExec symlink failed (non-fatal)"
echo "[*] nxc installed to $NXC_BIN_DIR"
else
echo "[!] Failed to install NetExec (non-fatal)"
fi
rm -rf /tmp/nxc.zip /tmp/nxc-extract 2>/dev/null || true
fi

# -- Coercion scripts -------------------------------------------------------
# These /opt paths are hardcoded in three places that must stay in sync: here,
# the coercion-script checks: in capability.yaml, and the path defaults in the
# coercion tools. Change one, change all three.
REPOS=(
"https://github.com/topotam/PetitPotam /opt/PetitPotam"
"https://github.com/Wh04m1001/DFSCoerce /opt/DFSCoerce"
Expand All @@ -72,8 +112,14 @@ for entry in "${REPOS[@]}"; do
read -r url dest <<< "$entry"
if [ -d "$dest" ]; then
echo "[*] $dest already exists, skipping"
else
echo "[+] Cloning $(basename "$dest") to $dest"
git clone --depth 1 "$url" "$dest"
continue
fi
echo "[+] Cloning $(basename "$dest") to $dest"
if git clone --depth 1 "$url" "$dest" 2>/dev/null; then
continue
fi
if command -v sudo >/dev/null 2>&1 && sudo -n git clone --depth 1 "$url" "$dest" 2>/dev/null; then
continue
fi
echo "[!] Failed to clone $(basename "$dest") to $dest (non-fatal)"
done
130 changes: 130 additions & 0 deletions capabilities/network-ops/tests/test_provisioning.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""Provisioning contract tests for the network-ops capability.

The manifest must declare every binary its toolsets invoke (so provisioning
installs them and a check surfaces any that are missing), and the install
script must treat every write to a root-owned system path as non-fatal (so a
sandbox that cannot write or reach the network still loads the capability).
"""

import os
import re
import stat
import subprocess
from pathlib import Path

import yaml

CAP_DIR = Path(__file__).resolve().parent.parent
MANIFEST = CAP_DIR / "capability.yaml"
INSTALL_SCRIPT = CAP_DIR / "scripts" / "install_coercion_tools.sh"


def _manifest() -> dict:
return yaml.safe_load(MANIFEST.read_text())


def test_apt_tools_declared() -> None:
packages = _manifest()["dependencies"].get("packages", [])
for tool in ("nmap", "smbclient", "hashcat", "john"):
assert tool in packages, f"{tool} must be declared in dependencies.packages"


def test_pip_tools_declared() -> None:
python = _manifest()["dependencies"].get("python", [])
joined = " ".join(python)
for pkg in ("impacket", "certipy-ad", "bloodyAD"):
assert pkg in joined, f"{pkg} must be declared in dependencies.python"
# NetExec is not on PyPI; declaring it here hard-fails the SDK's combined
# pip install and takes the whole capability down. It must stay out.
assert "netexec" not in joined.lower(), "netexec is not pip-installable; provision it in the image"


def test_every_invoked_binary_has_a_check() -> None:
check_names = {c["name"] for c in _manifest().get("checks", [])}
# Binaries the toolsets shell out to: a missing one must show up in
# pre-flight, not as a raw command-not-found mid-run.
for name in ("nmap", "smbclient", "hashcat", "john", "netexec", "certipy", "bloodyad"):
assert name in check_names, f"missing checks: entry for {name}"


def test_fetched_assets_have_a_check() -> None:
check_names = {c["name"] for c in _manifest().get("checks", [])}
# Every asset the install script fetches is optional (its download is
# non-fatal), so each needs a check to report when it did not land.
for name in (
"rockyou-wordlist",
"wordlist-10k",
"hashcat-rules",
"petitpotam",
"dfscoerce",
"shadowcoerce",
):
assert name in check_names, f"missing checks: entry for {name}"


def test_install_script_defines_sudo_aware_helper() -> None:
assert "ensure_writable_dir()" in INSTALL_SCRIPT.read_text()


def test_netexec_provisioned_by_script() -> None:
# NetExec is not on PyPI, so the script fetches the prebuilt binary. The
# tools call it as `netexec`, so the single `nxc` binary must be symlinked.
script = INSTALL_SCRIPT.read_text()
assert "nxc-ubuntu-latest.zip" in script, "script must fetch the NetExec prebuilt binary"
assert "ln -sf nxc" in script, "the nxc binary must be symlinked to the `netexec` name"


def test_privilege_escalation_is_non_interactive() -> None:
# Bare `sudo` blocks on a password prompt on an image whose sudoers is not
# NOPASSWD; `sudo -n` fails fast instead, matching the SDK's apt step.
for line in INSTALL_SCRIPT.read_text().splitlines():
stripped = line.strip()
if re.search(r"\bsudo\b", stripped) and stripped.startswith(("sudo", "if sudo", "&& sudo")):
assert re.search(r"\bsudo -n\b", stripped), f"interactive sudo: {stripped!r}"


def test_no_unguarded_privileged_mkdir() -> None:
# A bare `mkdir -p` of a system path aborts the load as the non-root user;
# such paths may only be created inside ensure_writable_dir.
for line in INSTALL_SCRIPT.read_text().splitlines():
stripped = line.strip()
if stripped.startswith("mkdir ") and ("/usr/" in stripped or "/opt" in stripped):
raise AssertionError(f"unguarded privileged mkdir: {stripped!r}")


def test_clones_are_conditional() -> None:
# Every `git clone` must be the condition of an if/elif so a failure is
# caught; a bare `git clone` or `sudo git clone` statement aborts the script.
for line in INSTALL_SCRIPT.read_text().splitlines():
stripped = line.strip()
if "git clone" not in stripped:
continue
assert re.match(r"(if |elif )", stripped), f"unguarded git clone: {stripped!r}"


def test_script_is_non_fatal_when_every_privileged_op_fails(tmp_path: Path) -> None:
"""Run the real script with mkdir/sudo/git/curl stubbed to fail, so every
privileged and network operation errors; the script must still exit 0.

This is the behavioral guard the static checks above cannot give: a future
unguarded command (a redirect, cp, tar -C /usr/local, ...) that reintroduces
the load-failure bug fails here even if it dodges the text matchers.
"""
stub_dir = tmp_path / "stubs"
stub_dir.mkdir()
for name in ("mkdir", "sudo", "git", "curl", "gunzip"):
stub = stub_dir / name
stub.write_text("#!/bin/sh\nexit 1\n")
stub.chmod(stub.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)

env = dict(os.environ, PATH=f"{stub_dir}:{os.environ['PATH']}")
result = subprocess.run(
["bash", str(INSTALL_SCRIPT)],
env=env,
capture_output=True,
text=True,
timeout=60,
)
assert (
result.returncode == 0
), f"script aborted (exit {result.returncode}) when privileged ops failed:\n{result.stderr}"
Loading