Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions internal/modules/zabbix_api_exposure_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
28 changes: 28 additions & 0 deletions modules/info/icinga-panel.yaml
Original file line number Diff line number Diff line change
@@ -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"'
36 changes: 36 additions & 0 deletions modules/info/zabbix-panel.yaml
Original file line number Diff line number Diff line change
@@ -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:
- '<div class="server-name">([^<]*)</div>'
group: 1
49 changes: 49 additions & 0 deletions modules/recon/zabbix-api-exposure.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading