fix: preserve shell output and clarify fact relationships

This commit is contained in:
Ed1s0nZ committed 2026-10-06 02:12:28 +08:00
1 parent 76da98944c
commit ab17ffda87
8 files changed
+128 -13

No files matched your search

+3
View File
@@ -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 路径未修改。模板修复适用于重新生成的客户端。
+3 -3
View File
@@ -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 | — | 编辑辅助文件时,描述修改未保存 | 已修复并有针对性验证 |
+86
View File
@@ -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)
}
}
+32 -10
View File
@@ -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")
}
}
}
}
+1
View File
@@ -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)",
+1
View File
@@ -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": "Входящие связи (только чтение)",
+1
View File
@@ -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": "入边(只读)",
+1
View File
@@ -6778,6 +6778,7 @@
</div>
<textarea id="fact-modal-body" class="form-input fact-modal-body-input" rows="14" placeholder="攻击链步骤、HTTP/命令 POC、响应现象、证据…" oninput="updateFactFormHints()"></textarea>
<p id="fact-modal-body-hint" class="projects-field-hint" role="status"></p>
<small class="form-hint" data-i18n="projects.factBodyRelationsHint">正文中的 discovered_on 等关系请填写到下方“关系边”字段,才能生成图谱连线。</small>
</div>
<div class="projects-form-field">
<label class="projects-filter-check projects-pin-toggle">