diff --git a/internal/modules/zabbix_api_exposure_test.go b/internal/modules/zabbix_api_exposure_test.go new file mode 100644 index 00000000..95db3082 --- /dev/null +++ b/internal/modules/zabbix_api_exposure_test.go @@ -0,0 +1,84 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package modules_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/vmfunc/sif/internal/modules" +) + +// zabbix-api-exposure fires on a target that answers apiinfo.version with a +// dotted-version json-rpc result. the discriminator is the result shape, not the +// content-type; the module explains why. a service that speaks json-rpc but +// returns something other than a version must stay silent. +func TestZabbixAPIExposureModule(t *testing.T) { + const mod = "../../modules/recon/zabbix-api-exposure.yaml" + def, err := modules.ParseYAMLModule(mod) + if err != nil { + t.Fatalf("parse: %v", err) + } + + run := func(contentType, body string) *modules.Result { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", contentType) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, + modules.Options{Timeout: 5 * time.Second, Threads: 2}) + if err != nil { + t.Fatalf("execute: %v", err) + } + return res + } + + // what a live zabbix actually puts on the wire. + fire := run("application/json", `{"jsonrpc":"2.0","result":"7.0.5","id":1}`) + if len(fire.Findings) == 0 { + t.Error("fire-on-real failed: exposed apiinfo.version not detected") + } + var version string + for _, f := range fire.Findings { + if v := f.Extracted["zabbix_api_version"]; v != "" { + version = v + } + } + if version != "7.0.5" { + t.Errorf("version extraction: got %q, want 7.0.5", version) + } + + // key order is not guaranteed; a reordered body must still match. + if res := run("application/json", `{"id":1,"jsonrpc":"2.0","result":"7.0.5"}`); len(res.Findings) == 0 { + t.Error("fire-on-reordered-keys failed: exposed apiinfo.version not detected") + } + + // older deployments and proxies that echo the request type back still fire. + if res := run("application/json-rpc", `{"jsonrpc":"2.0","result":"7.0.5","id":1}`); len(res.Findings) == 0 { + t.Error("fire-on-json-rpc-content-type failed: exposed apiinfo.version not detected") + } + + if res := run("application/json", `{"jsonrpc":"2.0","result":"pong","id":1}`); len(res.Findings) != 0 { + t.Errorf("silent-on-foreign-jsonrpc failed: %d findings for a non-version result", len(res.Findings)) + } + + // a json-rpc error (auth required, method not found) is not a version leak. + if res := run("application/json", `{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid params."},"id":1}`); len(res.Findings) != 0 { + t.Errorf("silent-on-jsonrpc-error failed: %d findings", len(res.Findings)) + } +} diff --git a/modules/info/icinga-panel.yaml b/modules/info/icinga-panel.yaml new file mode 100644 index 00000000..7409d6a9 --- /dev/null +++ b/modules/info/icinga-panel.yaml @@ -0,0 +1,28 @@ +id: icinga-panel +info: + name: Icinga Web 2 Login Panel + author: sif + severity: info + description: Detects exposed Icinga Web 2 monitoring frontend login panels + tags: [icinga, monitoring, panel, login, detection, info] + +type: http + +http: + method: GET + paths: + - "{{BaseURL}}/icingaweb2/authentication/login" + - "{{BaseURL}}/authentication/login" + - "{{BaseURL}}" + + matchers: + - type: status + status: + - 200 + + - type: word + part: body + condition: and + words: + - 'id="icinga-logo"' + - 'class="login-form"' diff --git a/modules/info/zabbix-panel.yaml b/modules/info/zabbix-panel.yaml new file mode 100644 index 00000000..b069e9f1 --- /dev/null +++ b/modules/info/zabbix-panel.yaml @@ -0,0 +1,36 @@ +id: zabbix-panel +info: + name: Zabbix Login Panel + author: sif + severity: info + description: Detects exposed Zabbix monitoring frontend login panels + tags: [zabbix, monitoring, panel, login, detection, info] + +type: http + +http: + method: GET + paths: + - "{{BaseURL}}/index.php" + - "{{BaseURL}}/zabbix/index.php" + - "{{BaseURL}}" + + matchers: + - type: status + status: + - 200 + + - type: word + part: body + condition: and + words: + - 'class="signin-container"' + - 'class="signin-logo"' + + extractors: + - type: regex + name: zabbix_server_name + part: body + regex: + - '
([^<]*)
' + group: 1 diff --git a/modules/recon/zabbix-api-exposure.yaml b/modules/recon/zabbix-api-exposure.yaml new file mode 100644 index 00000000..6424d84d --- /dev/null +++ b/modules/recon/zabbix-api-exposure.yaml @@ -0,0 +1,49 @@ +id: zabbix-api-exposure +info: + name: Zabbix API Exposure + author: sif + severity: low + description: Detects an exposed Zabbix JSON-RPC API that discloses its version pre-authentication + tags: [zabbix, monitoring, api, json-rpc, exposure, recon] + +type: http + +http: + method: POST + paths: + - "{{BaseURL}}/api_jsonrpc.php" + - "{{BaseURL}}/zabbix/api_jsonrpc.php" + + # api_jsonrpc.php answers 412 Precondition Failed unless the request carries + # one of application/json-rpc, application/json or application/jsonrequest. + headers: + Content-Type: application/json-rpc + + body: '{"jsonrpc":"2.0","method":"apiinfo.version","params":{},"id":1}' + + # no response content-type matcher: that type is a request precondition, and + # zabbix always answers `header('Content-Type: application/json')`. requiring + # application/json-rpc back never matched a live instance. the jsonrpc-2.0 plus + # dotted-result shape below is specific to apiinfo.version on its own. + matchers: + - type: status + status: + - 200 + + # two and'd patterns rather than one: json object key order is not + # guaranteed, so a serializer that emits id before jsonrpc would miss a + # single pattern that requires them adjacent. + - type: regex + part: body + condition: and + regex: + - '"jsonrpc"\s*:\s*"2\.0"' + - '"result"\s*:\s*"[0-9]+\.[0-9]+(\.[0-9]+)?"' + + extractors: + - type: regex + name: zabbix_api_version + part: body + regex: + - '"result"\s*:\s*"([0-9]+\.[0-9]+(?:\.[0-9]+)?)"' + group: 1