Prometheus v3.15.0, unsanitized __unix_socket__ Dial from SD
v3.15.0 Unix domain socket scrape takes the path from target label __unix_socket__. http_sd copies it as-is. DialContext unix has no Clean, no allowlist, no reject of ../ or @. Labbed. No CVE yet.
- Name
- Prometheus v3.15.0, unsanitized __unix_socket__ Dial from SD
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- Prometheus
- Affected
- Prometheus v3.15.0 (5241a27) when http_sd or file_sd can set __unix_socket__. Stock config does not set the label. TCP __address__ with / is still rejected.
- Published
- 12 Oct 2026
- Updated
- 12 Oct 2026
- Tags
- 0-day, prometheus, ssrf, unix-socket, cwe-918, cwe-22, http-sd
The new label skipped the hostname check
I am @abraxas_null. The proof of concept is on GitHub: abraxas/prometheus-unix-socket-dial (loopback client). The lab stack is lab/: docker-compose.yml, prometheus.yml, run.sh, poc.py, sidecar.py. Authorized lab only. It talks to loopback.
This is Prometheus v3.15.0 (5241a27fe3c6983549fccc32f6e65917408c63cd). Image prom/prometheus:v3.15.0. No CVE yet. CWE-918 / CWE-22. 8.6 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N). Remote SSRF. SD-gated.
v3.15.0 added Unix domain socket scrape. The path is a target label __unix_socket__, not an operator allowlist. http_sd and file_sd copy that label through as-is. PopulateLabels never validates it. newUnixSocketScrapeClient then calls DialContext(ctx, "unix", socketPath) with no filepath.Clean, no allowlist, and no reject of ../ or Linux abstract @ names.
TCP scrape cannot do this. CheckTargetAddress still drops __address__ values that contain /. unix:///var/run/docker.sock as an address is rejected. The new label is the hole.
Stock config does not set the label. Unauth HTTP to Prometheus cannot add jobs. This is not a default-install High. It is the leftover of a FEATURE that trusted SD labels with a new primitive.
Why this leftover, on the same pin
I was already on v3.15.0 for the remote_write Bearer leftover. The 3.15 FEATURE line for UDS scrape is the other place a new Dial shows up. One hunt scored it as in-model NONE because SD already chooses TCP scrape targets. That is a reasonable local call. The new primitive is host IPC that __address__ could not name.
I only wanted Critical or High. Local is in-scope. Medium stays on the floor unless it chains.
Pinning v3.15.0
Same tag as the Bearer pack. 5241a27. Image prom/prometheus:v3.15.0. Admin API and lifecycle stay off. Example config scrapes localhost:9090 over TCP.
Intake is maintainers plus CC security@prometheus.io. GitHub private advisory. Unpaid.
The skip list is half the hunt
Same skip list as the Bearer pack. CVE-2026-42151, CVE-2026-42154, CVE-2026-40179, CVE-2026-44903, toolkit basic-auth, /new redirect, STACKIT secrets, snappy caps, grpc v1.83.2. I did not re-file any of that.
Unauth PromQL, /metrics, /federate, and scrape of operator-configured TCP targets are intended. Pure DoS is not a private security report.
Nineteen surfaces, then the FEATURE
Default OSS on this pin: NONE unpublished Crit/High. The Bearer leftover is config-gated remote_write. This leftover is config-gated SD.
In-tree k8s/consul/docker SD emit __meta_* only. Those prefixes are deleted after relabel. __unix_socket__ is not __meta_*, so it survives the meta wipe. PopulateDiscoveredLabels copies every http_sd group label. PopulateLabels checks __address__ only.
func (sp *scrapePool) clientForTarget(t *Target) *http.Client {
socketPath := t.labels.Get(UnixSocketLabel)
if socketPath == "" {
return sp.client
}
client, err := newUnixSocketScrapeClient(socketPath, ...)func newUnixSocketScrapeClient(socketPath string, ...) (*http.Client, error) {
optFuncs = append([]config_util.HTTPClientOption{config_util.WithDialContextFunc(
func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
},
)}, optFuncs...)URL() keeps scheme/host/path from __scheme__ / __address__ / __metrics_path__. The socket is dial-only. Scrape is still GET. __metrics_path__ is the HTTP path on that socket.
Whoever can write the SD JSON picks a filesystem socket the Prometheus UID can open, or an abstract name in the same network namespace.
What I almost filed instead
Treating unix:///var/run/docker.sock as __address__ as the hole. It is rejected. The lab's third http_sd group is that address. prometheus_target_sync_failed_total went to 1. is not a valid hostname. Not an active target.
Scoring this as in-model NONE because SD already chooses TCP targets. Vendor model lets SD choose scrape targets. The leftover is host IPC that the hostname check was written to keep off the wire.
Claiming default example config is this leftover. __unix_socket__ is unset. Example scrapes TCP.
Mutating Docker through scrape. Scrape is GET-only. JSON APIs usually fail exposition parse, so the High is the connect/GET, not TSDB exfil of Docker JSON. This lab does not mount a real engine socket.
Adding scrape jobs over unauth Prometheus HTTP. /-/reload and admin API default off. Reload has no body.
The lab
prom/prometheus:v3.15.0 shares a netns with a Python sidecar. Sidecar serves http_sd at 127.0.0.1:8080/targets.json and two unix HTTP oracles: filesystem /sockets/oracle.sock and abstract @prom-lab-oracle. Prometheus is published at 127.0.0.1:19390. Admin API and lifecycle stay off.
http_sd returns three groups: ../ traversal, abstract @, and unix:///var/run/docker.sock as __address__ with no unix-socket label.
Prometheus scraped both unix targets (health up, lab_witness ingested). The TCP slash target was not created.
Replay:
git clone https://github.com/abraxas/prometheus-unix-socket-dial
cd prometheus-unix-socket-dial/lab
./run.shBind it to loopback. The loopback client is lab/run.sh with a banner on it.
SUCCESS prometheus-unix-socket-dial sd=http_sd fs-traversal=yes abstract=yes tcp-slash-rejected=yes how=sd-labels PROMETHEUS-UNIX-SOCKET-DIAL-WITNESSIOC: scrapeUrl=http://localhost:1/fs-traversal and /abstract, both health up. TCP slash never became an active target.
Wrong turns, in the order I made them
In-model NONE. The scrape-uds hunt scored it that way. I sat on the FEATURE line anyway. CheckTargetAddress rejecting / is the argument that the new label is a hole.
Docker.sock as address. That is the closed surface. The open surface is __unix_socket__=/var/run/docker.sock.
Expecting TSDB to hold Docker JSON. Exposition parse fails. The High is the Dial.
Default YAML. Not this leftover.
A reverse shell. Theatre. The oracle is scrape health up on sockets the SD JSON named.
What an attacker can do
Control of http_sd JSON (or writable file_sd) is enough. The SD URL is operator config. The leftover is that group labels may set __unix_socket__ and Prometheus will Dial it.
| Privilege | Method / sink | Lab-proven or inferred |
|---|---|---|
| Connect to a host unix socket the Prometheus UID can open | clientForTarget -> DialContext(ctx, "unix", socketPath) from __unix_socket__ |
Lab-proven. Path /sockets/jail/../oracle.sock from http_sd; scrape health up |
Follow ../ in that path |
no filepath.Clean / no .. reject |
Lab-proven. Kernel resolved the traversal to /sockets/oracle.sock |
| Dial a Linux abstract socket | same Dial of @name |
Lab-proven. __unix_socket__=@prom-lab-oracle; scrape health up |
| HTTP GET on that socket as the Prometheus UID | scrape is GET; __metrics_path__ is the request path |
Lab-proven. GET /fs-traversal and /abstract; User-Agent Prometheus/; lab_witness ingested |
| Reach sockets TCP scrape cannot name | CheckTargetAddress rejects / in __address__ |
Lab-proven. unix:///var/run/docker.sock as __address__ logged is not a valid hostname; not an active target; prometheus_target_sync_failed_total=1 |
| GET Docker Engine / kubelet HTTP APIs if those sockets are in scope | __unix_socket__=/var/run/docker.sock plus a Docker API path |
Inferred. Same unsanitized Dial. This lab does not mount a real engine socket |
| Mutate Docker (create container, write) | scrape verbs | Closed. Scrape is GET-only |
| Add scrape jobs over unauth Prometheus HTTP | /-/reload, admin API |
Closed. Both flags default off |
| Use this on default example config | __unix_socket__ unset |
Closed |
Vendor security model already lets SD choose TCP scrape targets. The new primitive is host IPC that __address__ could not name.
The fix
Do not Dial __unix_socket__ from SD until it is a sanitized absolute filesystem path. Reject empty values, NUL, Linux abstract @ names, relative paths, and any .. after filepath.Clean. An allowlist of prefixes from scrape YAML is stronger. Dropping the label from PopulateDiscoveredLabels unless it came from operator config also closes the SD injection.
Until then, do not let untrusted SD set __unix_socket__. Relabel that copies untrusted meta into that label is the same leftover.
I am not going to print an http_sd JSON you can paste at someone else's Prometheus. The missing filepath.Clean is the useful part. If you own the process, run the proof of concept against loopback.
References
- Proof of concept: abraxas/prometheus-unix-socket-dial · prometheus-unix-socket-dial-Abraxas-Labs.py
- Lab:
lab/· docker-compose.yml · prometheus.yml · run.sh · poc.py · sidecar.py - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CWE-918 · CWE-22
- Tree: prometheus/prometheus v3.15.0 (
5241a27fe3c6983549fccc32f6e65917408c63cd) ·scrape/scrape.go·scrape/target.go·config/config.go - UDS scrape #18091
- Sibling leftover: prometheus-bearer-redirect