diff --git a/docs/bug-sheet-review.md b/docs/bug-sheet-review.md index f9eb77d1..9d243e33 100644 --- a/docs/bug-sheet-review.md +++ b/docs/bug-sheet-review.md @@ -28,6 +28,8 @@ | 19 WebShell 口令明文回显 | 列表和创建/更新响应返回 Password | 响应掩码;保存时保留/替换/清空;执行按有权限的 connection_id 取服务端凭据 | | Markdown 智能体字段与重复 ID | 保存缺少关键字段和 ID 唯一性检查 | 创建/更新统一校验 name、ID、指令;ID 不允许文件间重复;创建使用排他文件写入 | | 辅助文件编辑描述丢失 | 辅助文件保存接口不更新 SKILL.md 元数据 | 辅助文件禁用描述编辑;描述未保存时阻止切换文件,提示先保存 SKILL.md | +| C2 终端输出粘连/尾部分号语法错误 | Unix 探测直接追加 `&& pwd`,结果按最后一行切分 | 使用随机分隔标记和换行探测,保留用户命令的输出及退出状态 | +| 事实正文的关系语法未生成图谱 | 关系字段是显式结构化输入,普通正文不是通用关系解析器 | 按问题表给出的替代方案,明确提示关系应填写到关系字段;保持既有正文语义 | ## 不适用 @@ -46,3 +48,4 @@ - WebShell 路径、错误输出、目录树测试与通知查看/待审批区分测试通过。 - 配置凭据处理保留既有字段与空字符串清除语义;掩码标记只用于已保存的同路径凭据,新通道不能直接保存掩码。通道探活新增可选 `channel_id`,模型列表/审查测试支持可选凭据范围,避免同 URL 多密钥选择错误。 - 新增 GET 配置不泄漏/不变更内存、掩码保留/替换/清空、歧义拒绝、WebShell 回显及执行凭据、Markdown 创建/更新唯一性、技能描述切换测试。 +- C2 Shell 回归只提取模板中的 Shell 辅助函数,不运行传输循环。验证无换行、有换行、尾部分号、注释、失败退出、带空格目录切换;Unix 系统通过,Windows 路径未修改。模板修复适用于重新生成的客户端。 diff --git a/docs/bug-sheet-status.md b/docs/bug-sheet-status.md index 8b8831f6..9a530361 100644 --- a/docs/bug-sheet-status.md +++ b/docs/bug-sheet-status.md @@ -51,18 +51,18 @@ | 46 | — | Agent ID 可重复- 两个不同文件可使用同一个 ID。 | 已修复并有针对性验证 | | 47 | — | nmap 参数缺少前置校验 | 待核对 | | 48 | — | 对单个资产发起“只读连通性与服务指纹核验”后,Agent 已通过 nmap 和 HTTP 指纹工 | 待核对 | -| 49 | — | C2 会话终端执行没有换行结尾的命令时,系统内部用于获取当前目录的 pwd 输出会与用户命令输出 | 待核对 | +| 49 | — | C2 会话终端执行没有换行结尾的命令时,系统内部用于获取当前目录的 pwd 输出会与用户命令输出 | 已修复,Unix Shell 回归通过 | | 50 | — | 监听器编辑后点击保存没有反应 | 待核对 | | 51 | — | 创建 Malleable Profile 时 UA、响应头和 Jitter 配置被静默丢弃 | 已修复并有针对性验证 | | 52 | — | C2 载荷生成未校验回连地址,非法地址仍能生成 Oneliner | 待核对 | | 53 | — | 知识库索引构建失败原因不怎么太清晰 | 待核对 | | 54 | — | Agent 未挂载知识检索工具 | 待核对 | -| 55 | — | - 选择其专用测试会话,打开「终端」。 | 待核对 | +| 55 | — | - 选择其专用测试会话,打开「终端」。 | 已修复,Unix Shell 回归通过 | | 56 | — | AI看代码给提的问题 | 已修复并有针对性验证 | | 57 | — | 审计日志搜索无结果后,点击“清空”不会清除关键词或恢复列表,无反应 | 待核对 | | 58 | — | 任务失败,也显示已完成 | 待核对 | | 59 | — | 批量任务在“用户并发配额已满”时进入等待调度后,即使后来停止容器、腾出配额,队列仍停在“执行中 | 当前仓库未实现,不扩展能力 | | 60 | — | 资产扫描任务在后续工具调用的 HITL 审批阶段已被系统中断,审批卡片明确显示“任务已中断、审批 | 待核对 | -| 61 | — | 问题:事实正文里的 discovered_on: target/xxx 只会当普通文本保存,不会 | 待核对 | +| 61 | — | 问题:事实正文里的 discovered_on: target/xxx 只会当普通文本保存,不会 | 已按建议明确关系字段说明 | | 62 | — | 无效工作流仍显示“启用”: 即使工作流校验不通过、无法试运行,列表和详情仍显示为“启用”,容易让 | 待核对 | | 63 | — | 编辑辅助文件时,描述修改未保存 | 已修复并有针对性验证 | diff --git a/internal/c2/beacon_shell_template_test.go b/internal/c2/beacon_shell_template_test.go new file mode 100644 index 00000000..e4d87a78 --- /dev/null +++ b/internal/c2/beacon_shell_template_test.go @@ -0,0 +1,86 @@ +package c2 + +import ( + "bytes" + "go/ast" + "go/format" + "go/parser" + "go/token" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "testing" +) + +// Execute only the shell helpers from the template, never its transport loop. +func TestBeaconShellTemplateSeparatesCwdAndPreservesExitStatus(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix shell regression") + } + raw, err := os.ReadFile("payload_templates/beacon.go.tmpl") + if err != nil { + t.Fatal(err) + } + raw = regexp.MustCompile(`\{\{\.(SleepSeconds|JitterPercent)\}\}`).ReplaceAll(raw, []byte("5")) + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "template.go", raw, 0) + if err != nil { + t.Fatal(err) + } + var code bytes.Buffer + code.WriteString(`package main +import("crypto/rand";"fmt";"os";"os/exec";"path/filepath";"runtime";"strings";"sync";"time") +var cwdMu sync.Mutex +var currentCwd string +func prepareHiddenCmd(cmd *exec.Cmd) {} +`) + needed := map[string]bool{"taskShell": true, "runWithTimeout": true, "getTimeoutFromPayload": true, "shellByOS": true, "shellFlag": true} + for _, declaration := range file.Decls { + if fn, ok := declaration.(*ast.FuncDecl); ok && needed[fn.Name.Name] { + if err := format.Node(&code, fset, fn); err != nil { + t.Fatal(err) + } + code.WriteByte('\n') + delete(needed, fn.Name.Name) + } + } + if len(needed) != 0 { + t.Fatal("missing template helpers") + } + dir := t.TempDir() + for name, content := range map[string]string{ + "go.mod": "module shellregression\ngo 1.23\n", + "shell.go": code.String(), + "shell_test.go": `package main +import("os";"path/filepath";"testing") +func TestShell(t *testing.T) { + currentCwd=t.TempDir() + cases:=[]struct{cmd,want string; failed bool}{ + {"printf hello","hello",false}, + {"printf 'hello\\n'","hello\n",false}, + {"printf hello;","hello",false}, + {"sleep 0.01;","",false}, + {"printf hello; false;","hello",true}, + {"printf hello # comment","hello",false}, + } + for _, c:=range cases {out,_,_,failure:=taskShell(map[string]interface{}{"command":c.cmd}); if out!=c.want || (failure!="")!=c.failed {t.Fatalf("%q output=%q failure=%q",c.cmd,out,failure)} } + target:=filepath.Join(currentCwd,"directory with spaces") + if err:=os.Mkdir(target,0700);err!=nil{t.Fatal(err)} + out,_,_,failure:=taskShell(map[string]interface{}{"command":"cd '"+target+"'; printf OK"}) + if out!="OK" || failure!="" || currentCwd!=target {t.Fatalf("cwd/output failure: %q %q",out,failure)} +} +`, + } { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0600); err != nil { + t.Fatal(err) + } + } + cmd := exec.Command("go", "test", "-count=1", ".") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOPROXY=off", "GOWORK=off") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("shell regression: %v\n%s", err, output) + } +} diff --git a/internal/c2/payload_templates/beacon.go.tmpl b/internal/c2/payload_templates/beacon.go.tmpl index c927bba5..d0c5d9e5 100644 --- a/internal/c2/payload_templates/beacon.go.tmpl +++ b/internal/c2/payload_templates/beacon.go.tmpl @@ -783,10 +783,18 @@ func taskShell(payload map[string]interface{}) (string, string, string, string) // Append a pwd/cd probe to the command so we can capture the real cwd // after the user's command runs (e.g. "cd /tmp && ls" → cwd becomes /tmp). var probe string + var cwdMarker string if runtime.GOOS == "windows" { probe = " && cd" } else { - probe = " && pwd" + markerBytes := make([]byte, 12) + if _, err := rand.Read(markerBytes); err != nil { + return "", "", "", "cannot prepare cwd probe" + } + cwdMarker = fmt.Sprintf("__CSAI_CWD_%x__", markerBytes) + // A newline accepts a trailing semicolon/comment. Preserve the command's + // exit status and separate probe output even when stdout has no newline. + probe = "\n__csai_command_status=$?\nprintf '\\n" + cwdMarker + "'; pwd\nexit \"$__csai_command_status\"" } combined := cmdStr + probe @@ -794,15 +802,29 @@ func taskShell(payload map[string]interface{}) (string, string, string, string) // The last line of output is the cwd from the probe command. // Split it off so we don't return the probe output to the operator. - lines := strings.Split(strings.TrimRight(out, "\r\n"), "\n") - if len(lines) > 0 { - candidate := strings.TrimSpace(lines[len(lines)-1]) - if filepath.IsAbs(candidate) { - if info, statErr := os.Stat(candidate); statErr == nil && info.IsDir() { - cwdMu.Lock() - currentCwd = candidate - cwdMu.Unlock() - out = strings.Join(lines[:len(lines)-1], "\n") + if cwdMarker != "" { + if idx := strings.LastIndex(out, "\n"+cwdMarker); idx >= 0 { + candidate := strings.TrimRight(out[idx+len(cwdMarker)+1:], "\r\n") + if filepath.IsAbs(candidate) { + if info, statErr := os.Stat(candidate); statErr == nil && info.IsDir() { + cwdMu.Lock() + currentCwd = candidate + cwdMu.Unlock() + out = out[:idx] + } + } + } + } else { + lines := strings.Split(strings.TrimRight(out, "\r\n"), "\n") + if len(lines) > 0 { + candidate := strings.TrimSpace(lines[len(lines)-1]) + if filepath.IsAbs(candidate) { + if info, statErr := os.Stat(candidate); statErr == nil && info.IsDir() { + cwdMu.Lock() + currentCwd = candidate + cwdMu.Unlock() + out = strings.Join(lines[:len(lines)-1], "\n") + } } } } diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index b4f968d4..bbf57bdb 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -341,6 +341,7 @@ "graphLegendExploits": "exploits", "graphLegendTentative": "Tentative (dashed)", "factLinksLabel": "Links (from → this fact)", + "factBodyRelationsHint": "Enter relationships such as discovered_on in the Links field below to create graph edges.", "factLinksPlaceholder": "discovered_on: target/primary_domain\nexploits: exploit/upload-rce", "factLinksHint": "One per line: type: source_fact_key (source → this fact). Common types: discovered_on, depends_on, leads_to, enables, exploits. Saving replaces all links.", "factIncomingLinksLabel": "Incoming links (read-only)", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index 5aa82463..4e6177b6 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -353,6 +353,7 @@ "graphLegendExploits": "exploits", "graphLegendTentative": "Предварительно (пунктир)", "factLinksLabel": "Связи (откуда → этот факт)", + "factBodyRelationsHint": "Укажите отношения, например discovered_on, в поле связей ниже, чтобы создать рёбра графа.", "factLinksPlaceholder": "discovered_on: target/primary_domain\nexploits: exploit/upload-rce", "factLinksHint": "По одной строке: тип: source_fact_key (источник → этот факт). Частые типы: discovered_on, depends_on, leads_to, enables, exploits. При сохранении все связи заменяются.", "factIncomingLinksLabel": "Входящие связи (только чтение)", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 66801818..f24783ef 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -329,6 +329,7 @@ "graphLegendExploits": "exploits", "graphLegendTentative": "待确认(虚线)", "factLinksLabel": "关系边(from → 本事实)", + "factBodyRelationsHint": "正文中的 discovered_on 等关系请填写到下方“关系边”字段,才能生成图谱连线。", "factLinksPlaceholder": "discovered_on: target/primary_domain\nexploits: exploit/upload-rce", "factLinksHint": "每行一条:type: source_fact_key(来源 → 当前事实)。常用 type:discovered_on、depends_on、leads_to、enables、exploits。保存时替换全部关系边。", "factIncomingLinksLabel": "入边(只读)", diff --git a/web/templates/index.html b/web/templates/index.html index 102a6f20..6a41cc69 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -6778,6 +6778,7 @@
+ 正文中的 discovered_on 等关系请填写到下方“关系边”字段,才能生成图谱连线。