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