From 959201d937d03c3c81c5e4dfd609d4e8f3a723bd Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:09:21 -0700 Subject: [PATCH 1/3] feat(modules): add zabbix and icinga monitoring exposure modules zabbix-panel and icinga-panel fingerprint the monitoring frontends by two framework-specific login-page classes under an and-condition, so a generic 200 cannot trip them. zabbix-api-exposure posts the unauthenticated apiinfo.version json-rpc call and requires both the json-rpc content-type and a dotted-version result, so a foreign json-rpc service or a version served under another content-type stays silent. --- internal/modules/zabbix_api_exposure_test.go | 60 ++++++++++++++++++++ modules/info/icinga-panel.yaml | 30 ++++++++++ modules/info/zabbix-panel.yaml | 38 +++++++++++++ modules/recon/zabbix-api-exposure.yaml | 45 +++++++++++++++ 4 files changed, 173 insertions(+) create mode 100644 internal/modules/zabbix_api_exposure_test.go create mode 100644 modules/info/icinga-panel.yaml create mode 100644 modules/info/zabbix-panel.yaml create mode 100644 modules/recon/zabbix-api-exposure.yaml diff --git a/internal/modules/zabbix_api_exposure_test.go b/internal/modules/zabbix_api_exposure_test.go new file mode 100644 index 00000000..5c4aca31 --- /dev/null +++ b/internal/modules/zabbix_api_exposure_test.go @@ -0,0 +1,60 @@ +package modules_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/vmfunc/sif/internal/modules" +) + +// zabbix-api-exposure fires only when the target answers apiinfo.version with a +// json-rpc content-type AND a dotted-version result. A foreign service that +// speaks json-rpc but returns a non-version result, or serves the version under +// the wrong content-type, 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 + } + + fire := run("application/json-rpc", `{"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) + } + + if res := run("application/json-rpc", `{"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)) + } + + if res := run("application/json", `{"jsonrpc":"2.0","result":"7.0.5","id":1}`); len(res.Findings) != 0 { + t.Errorf("silent-on-wrong-content-type 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..b8d5e40e --- /dev/null +++ b/modules/info/icinga-panel.yaml @@ -0,0 +1,30 @@ +# Icinga Web 2 Login Panel Detection Module + +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..dcff5e23 --- /dev/null +++ b/modules/info/zabbix-panel.yaml @@ -0,0 +1,38 @@ +# Zabbix Login Panel Detection Module + +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..0310bc2e --- /dev/null +++ b/modules/recon/zabbix-api-exposure.yaml @@ -0,0 +1,45 @@ +# Zabbix API Exposure Detection Module + +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" + + headers: + Content-Type: application/json-rpc + + body: '{"jsonrpc":"2.0","method":"apiinfo.version","params":{},"id":1}' + + matchers: + - type: status + status: + - 200 + + - type: word + part: header + words: + - "application/json-rpc" + + - type: regex + part: body + regex: + - '"jsonrpc"\s*:\s*"2\.0"\s*,\s*"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 From f7d322f97334c3a9a03f60392ff0baa21b5a5ea7 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:48:20 -0700 Subject: [PATCH 2/3] chore(modules): trim redundant module header comments drop top-line comments that just restate the id/name fields already present in the same file --- modules/info/icinga-panel.yaml | 2 -- modules/info/zabbix-panel.yaml | 2 -- modules/recon/zabbix-api-exposure.yaml | 2 -- 3 files changed, 6 deletions(-) diff --git a/modules/info/icinga-panel.yaml b/modules/info/icinga-panel.yaml index b8d5e40e..7409d6a9 100644 --- a/modules/info/icinga-panel.yaml +++ b/modules/info/icinga-panel.yaml @@ -1,5 +1,3 @@ -# Icinga Web 2 Login Panel Detection Module - id: icinga-panel info: name: Icinga Web 2 Login Panel diff --git a/modules/info/zabbix-panel.yaml b/modules/info/zabbix-panel.yaml index dcff5e23..b069e9f1 100644 --- a/modules/info/zabbix-panel.yaml +++ b/modules/info/zabbix-panel.yaml @@ -1,5 +1,3 @@ -# Zabbix Login Panel Detection Module - id: zabbix-panel info: name: Zabbix Login Panel diff --git a/modules/recon/zabbix-api-exposure.yaml b/modules/recon/zabbix-api-exposure.yaml index 0310bc2e..f7e36b65 100644 --- a/modules/recon/zabbix-api-exposure.yaml +++ b/modules/recon/zabbix-api-exposure.yaml @@ -1,5 +1,3 @@ -# Zabbix API Exposure Detection Module - id: zabbix-api-exposure info: name: Zabbix API Exposure From 5be161410b92f247ec9057cbb07929984664d730 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:18:55 -0700 Subject: [PATCH 3/3] fix(modules): drop the zabbix response content-type matcher application/json-rpc is a request precondition, not a response type. ui/api_jsonrpc.php accepts application/json-rpc, application/json or application/jsonrequest and answers 412 otherwise, then always sets `header('Content-Type: application/json')`. matchers are and'd, so requiring json-rpc back meant the module never fired against a live instance. the test served the mock as application/json-rpc and asserted the real application/json case produced no findings, so it locked the dead behaviour in. it now runs the real shape and keeps json-rpc as a second firing case for proxies that echo the request type. status 200 plus the jsonrpc-2.0 dotted-result regex is already specific to apiinfo.version; a json-rpc error body stays silent. --- internal/modules/zabbix_api_exposure_test.go | 40 ++++++++++++++++---- modules/recon/zabbix-api-exposure.yaml | 18 ++++++--- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/internal/modules/zabbix_api_exposure_test.go b/internal/modules/zabbix_api_exposure_test.go index 5c4aca31..95db3082 100644 --- a/internal/modules/zabbix_api_exposure_test.go +++ b/internal/modules/zabbix_api_exposure_test.go @@ -1,3 +1,15 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + package modules_test import ( @@ -10,10 +22,10 @@ import ( "github.com/vmfunc/sif/internal/modules" ) -// zabbix-api-exposure fires only when the target answers apiinfo.version with a -// json-rpc content-type AND a dotted-version result. A foreign service that -// speaks json-rpc but returns a non-version result, or serves the version under -// the wrong content-type, must stay silent. +// 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) @@ -36,7 +48,8 @@ func TestZabbixAPIExposureModule(t *testing.T) { return res } - fire := run("application/json-rpc", `{"jsonrpc":"2.0","result":"7.0.5","id":1}`) + // 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") } @@ -50,11 +63,22 @@ func TestZabbixAPIExposureModule(t *testing.T) { t.Errorf("version extraction: got %q, want 7.0.5", version) } - if res := run("application/json-rpc", `{"jsonrpc":"2.0","result":"pong","id":1}`); len(res.Findings) != 0 { + // 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)) } - if res := run("application/json", `{"jsonrpc":"2.0","result":"7.0.5","id":1}`); len(res.Findings) != 0 { - t.Errorf("silent-on-wrong-content-type failed: %d findings", 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/recon/zabbix-api-exposure.yaml b/modules/recon/zabbix-api-exposure.yaml index f7e36b65..6424d84d 100644 --- a/modules/recon/zabbix-api-exposure.yaml +++ b/modules/recon/zabbix-api-exposure.yaml @@ -14,25 +14,31 @@ http: - "{{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 - - type: word - part: header - words: - - "application/json-rpc" - + # 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"\s*,\s*"result"\s*:\s*"[0-9]+\.[0-9]+(\.[0-9]+)?"' + - '"jsonrpc"\s*:\s*"2\.0"' + - '"result"\s*:\s*"[0-9]+\.[0-9]+(\.[0-9]+)?"' extractors: - type: regex