Two critical advisories reached GitHub's database on 15 October, and they share a cause. In Dgraph, anyone who could reach the cluster coordinator's HTTP port could remove a node from the cluster with one unauthenticated GET request (CVE-2026-102748). In Vitess, a debug page on the tablet server's HTTP port reflected form values into HTML without escaping (CVE-2026-104252). That page sits next to pages showing recent queries and environment variables, and by default none of them check who's asking.
Neither is exotic. Both come from the same assumption: the admin port is internal, so it doesn't need the controls the main API has. This post argues that assumption should go, shows how Go programs pick up the same problem without anyone deciding to, and gives you a script to find it in your own code and hosts. It's for teams that run Go services or distributed databases, and for anyone who has written "debug only, not exposed" in a code comment.
The short answer
An admin or debug HTTP port is an API, and it needs authentication and a deliberate bind address like any other. Dgraph 25.4.0 (30 July) put the destructive Zero admin routes behind loopback, a token or an IP allowlist; earlier versions left them open on port 6080. Vitess 23.0.5 and 24.0.2 (24 June) escaped the reflected values on vttablet's /twopcz page, but Vitess's default access policy still lets anyone read its debug pages. In Go, importing net/http/pprof or expvar registers debug handlers on http.DefaultServeMux, and any server started with a nil handler serves them, including the program's command line. Serve debug handlers from a separate mux bound to loopback, and check both your source and your running hosts.
Two advisories, one assumption
| Dgraph | Vitess | |
|---|---|---|
| CVE | CVE-2026-102748 (GHSA-qj98-gp63-54xj) | CVE-2026-104252 (GHSA-v75v-6fpf-gvw8) |
| Component | Zero (the coordinator), HTTP port 6080 | vttablet HTTP port |
| Problem | /removeNode, /moveTablet, /assign and /state needed no authentication | /twopcz reflected Action and dtid into HTML unescaped |
| Severity in the advisory | Critical, CVSS 4.0 score 9.2 | Critical, CVSS 3.1 score 9.8 |
| Fixed | 25.4.0, released 30 July | 23.0.5 and 24.0.2, released 24 June |
| Repository advisory | 2 August | 27 July |
| GitHub advisory database | 15 October | 15 October |
| Reported by | 0d000721999 | xylove21 |
Dgraph. The reporter showed that on a default standalone deployment, /removeNode?id=1&group=0 took down the Zero control plane, and the database nodes then kept failing to reconnect. /assign could allocate IDs and timestamps, and /state returned the cluster layout. Zero binds to all interfaces by default, through the bindall flag, so "reachable" means reachable from wherever the network allows.
The fix is worth reading for its trade-off. It adds a --security flag to Zero with a token and an IP allowlist. The destructive routes, /removeNode and /moveTablet, now always require one of the two, or a loopback connection. But /state and /assign stay open until an operator configures the flag, because, as the commit says, dashboards and loaders read them over HTTP. That's a reasonable compatibility call, and it means the upgrade alone doesn't close the information route.
Vitess. The bug is one line: the handler wrote fmt.Fprintln(w, msg) where msg included two form values. The fix wraps it in html.EscapeString. On its own, a reflected XSS needs an operator to click a crafted link. What raises the stakes is what that script can then read with the operator's browser, from the same origin: the advisory lists /querylogz with recent queries and their bound values, /txlogz, and /debug/env, which can hold credentials passed through the environment.
Note the severity gap. The advisory scores it 9.8, a vector that assumes no user interaction. The reporter's own vector, quoted in the same advisory, counts the click and comes to 7.7. Your number depends on who can reach that port.
Vitess's access control explains why reaching the port is enough. Its --security-policy flag is empty by default, which, in the source's own words, means "everyone is allowed to do anything". The two built-in alternatives, deny-all and read-only, switch roles off globally but do no authentication. Real access control means writing and compiling in your own policy.
Both projects patched months before the global database listed the advisories, so a team watching only database-fed scanners heard about it in mid-October.
Go programs do this by accident
Dgraph and Vitess built their admin surfaces on purpose. Plenty of Go services get one without anyone deciding to. The Go standard library has two packages that register HTTP handlers when they're imported:
net/http/pprofregisters/debug/pprof/and its sub-pages onhttp.DefaultServeMux. These serve CPU and heap profiles, goroutine dumps, and/debug/pprof/cmdline, which returns the program's full command line.expvarregisters/debug/vars, which publishescmdline(again,os.Args) and memory statistics as JSON, plus anything else the program publishes.
Neither is a vulnerability. They're useful, and the pprof documentation says plainly that it's usually imported for that side effect. The problem is the combination with a server started like this:
log.Fatal(http.ListenAndServe(":8080", nil))
A nil handler means "serve DefaultServeMux", so every handler registered there, by your code or by an import, is on port 8080, on all interfaces. Command-line flags are where many services put database passwords and API tokens, so /debug/vars can hand them to anyone who asks. Profiles and goroutine dumps leak internals too, and a CPU profile request adds overhead for as long as it runs.
There's a useful contrast in golang.org/x/net/trace, which also registers debug pages on DefaultServeMux. Its default AuthRequest function only allows requests from localhost. The safe default exists in the ecosystem; it just isn't universal.
A lab: the same service, two ways
This program runs one service in two modes. In default mode it does the common thing. In split mode the public mux only has the app's routes, and the debug handlers are mounted on a second mux that listens on loopback:
// debuglab: how Go's debug handlers end up on a public port, and one way to keep them off it.
//
// go run . -mode=default -db-password=PLACEHOLDER # public port serves DefaultServeMux
// go run . -mode=split -db-password=PLACEHOLDER # public mux plus a loopback-only admin mux
package main
import (
"expvar"
"flag"
"fmt"
"log"
"net/http"
"net/http/pprof"
)
func main() {
mode := flag.String("mode", "default", "default or split")
public := flag.String("public", "0.0.0.0:8080", "public listen address")
admin := flag.String("admin", "127.0.0.1:6060", "admin listen address (split mode)")
_ = flag.String("db-password", "", "a secret passed on the command line, as many services do")
flag.Parse()
app := func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "hello") }
switch *mode {
case "default":
// Importing net/http/pprof or expvar registers their handlers on http.DefaultServeMux
// in init(). Passing a nil handler serves DefaultServeMux, debug pages included.
http.HandleFunc("GET /{$}", app)
log.Fatal(http.ListenAndServe(*public, nil))
case "split":
pub := http.NewServeMux()
pub.HandleFunc("GET /{$}", app)
adm := http.NewServeMux()
adm.HandleFunc("/debug/pprof/", pprof.Index)
adm.HandleFunc("/debug/pprof/profile", pprof.Profile)
adm.Handle("/debug/vars", expvar.Handler())
go func() { log.Fatal(http.ListenAndServe(*admin, adm)) }()
log.Fatal(http.ListenAndServe(*public, pub))
}
}
The script starts each mode and requests the debug paths from the public port. Both ports listen on loopback in the lab so nothing is opened on the network, and the "secret" is a placeholder:
#!/bin/sh
# Both "public" and "admin" listen on loopback here so the lab never opens a port on the network.
PUB=127.0.0.1:18080 ADM=127.0.0.1:16060
for mode in default split; do
./debuglab -mode=$mode -public=$PUB -admin=$ADM -db-password=PLACEHOLDER & pid=$!
sleep 1
echo "== mode=$mode"
for path in / /debug/pprof/ /debug/pprof/cmdline /debug/vars; do
code=$(curl -s -o body.txt -w '%{http_code}' "http://$PUB$path")
printf ' public %-22s %s\n' "$path" "$code"
done
printf ' public /debug/vars cmdline: %s\n' "$(curl -s http://$PUB/debug/vars | python3 -c 'import json,sys
try: print(json.load(sys.stdin)["cmdline"])
except Exception: print("(not served)")')"
[ $mode = split ] && printf ' admin %-22s %s\n' /debug/vars "$(curl -s -o /dev/null -w '%{http_code}' http://$ADM/debug/vars)"
kill $pid; wait $pid 2>/dev/null
done
rm -f body.txt
With Go 1.27.1:
$ ./run.sh
== mode=default
public / 200
public /debug/pprof/ 200
public /debug/pprof/cmdline 200
public /debug/vars 200
public /debug/vars cmdline: ['./debuglab', '-mode=default', '-public=127.0.0.1:18080', '-admin=127.0.0.1:16060', '-db-password=PLACEHOLDER']
== mode=split
public / 200
public /debug/pprof/ 404
public /debug/pprof/cmdline 404
public /debug/vars 404
public /debug/vars cmdline: (not served)
admin /debug/vars 200
In default mode, the public port serves the profiles and returns the command line with the password flag in it. In split mode, the same paths return 404 on the public port and work on the admin port. Note that split mode still imports both packages, so their handlers are still registered on DefaultServeMux. It's safe because nothing serves DefaultServeMux. If a dependency starts its own server with a nil handler, they come back, which is why the source check below is a starting point and the probe is the confirmation.
Check your code and your hosts
This script has two modes. src reads Go source, lists debug imports and finds servers that serve DefaultServeMux, flagging any not bound to loopback. probe sends plain GET requests for a fixed set of informational debug paths, including the Dgraph and Vitess ones above, and reports which answer. It never calls a state-changing route. Only probe hosts you own or are authorized to test. It uses the Python standard library:
#!/usr/bin/env python3
"""Find Go debug and admin HTTP surfaces before an attacker does.
admin_surface_check.py src DIR [DIR ...] scan Go source (read-only)
admin_surface_check.py probe URL [URL ...] GET well-known read-only debug paths on hosts you own
Standard library only. The probe sends plain GET requests to a fixed list of
informational paths and never calls state-changing admin routes.
"""
import pathlib
import re
import sys
import urllib.error
import urllib.request
DEBUG_IMPORTS = {
"net/http/pprof": "registers /debug/pprof/* (profiles, os.Args) on DefaultServeMux",
"expvar": "registers /debug/vars (os.Args, memstats, published vars) on DefaultServeMux",
"golang.org/x/net/trace": "registers /debug/requests and /debug/events (loopback-only by default)",
}
IMPORT_RE = re.compile(r'^\s*(?:import\s+)?(?:[\w.]+\s+)?"([^"]+)"', re.M)
# ListenAndServe(addr, nil), ListenAndServeTLS(addr, cert, key, nil), Serve(l, nil)
NIL_HANDLER_RE = re.compile(r'\b(ListenAndServe(?:TLS)?|Serve(?:TLS)?)\(([^()]*?),\s*nil\s*\)')
DEFAULT_MUX_RE = re.compile(r'\bhttp\.DefaultServeMux\b')
LOOPBACK = ("127.", "localhost:", "[::1]:")
PROBES = [
("/debug/pprof/", "Types of profiles available"),
("/debug/pprof/cmdline", None),
("/debug/vars", '"memstats"'),
("/debug/requests", None),
("/debug/env", None), # Vitess debug page
("/state", None), # Dgraph Zero (read-only route)
("/actuator/env", None), # Spring Boot
]
def imports_of(text):
block = re.findall(r'import\s*\((.*?)\)', text, re.S)
single = re.findall(r'^import\s+(?:[\w.]+\s+)?"([^"]+)"', text, re.M)
found = set(single)
for b in block:
found.update(IMPORT_RE.findall(b))
return found
def scan_src(dirs):
debug, servers = {}, []
for d in dirs:
for f in sorted(pathlib.Path(d).rglob("*.go")):
if f.name.endswith("_test.go") or "vendor" in f.parts:
continue
text = f.read_text(errors="replace")
for imp in imports_of(text) & DEBUG_IMPORTS.keys():
debug.setdefault(imp, []).append(str(f))
for n, line in enumerate(text.splitlines(), 1):
line = line.split("//", 1)[0]
for m in NIL_HANDLER_RE.finditer(line):
servers.append((str(f), n, m.group(1), m.group(2).split(",")[0].strip()))
if DEFAULT_MUX_RE.search(line):
servers.append((str(f), n, "DefaultServeMux", "?"))
for imp, files in sorted(debug.items()):
print(f"import {imp}: {DEBUG_IMPORTS[imp]}")
for f in files:
print(f" {f}")
has_debug = bool(debug.keys() - {"golang.org/x/net/trace"})
exposed = 0
for f, n, call, addr in servers:
if call == "DefaultServeMux":
print(f"REVIEW {f}:{n} references http.DefaultServeMux; check where it is served")
continue
lit = addr.strip('"') if addr.startswith('"') else None
if lit is not None and lit.startswith(LOOPBACK):
where = f"loopback {lit}"
elif lit is not None:
where = f"all interfaces {lit}" if lit.startswith((":", "0.0.0.0")) else lit
else:
where = f"address from {addr} (check its value)"
risky = has_debug and not where.startswith("loopback")
exposed += bool(risky)
tag = "EXPOSED" if risky else "ok "
print(f"{tag} {f}:{n} {call} serves DefaultServeMux on {where}")
if not servers:
print("no server found serving DefaultServeMux")
if debug and not servers:
print("debug handlers are registered but DefaultServeMux is not served here; "
"check that no dependency serves it either")
return 1 if exposed else 0
def probe(urls):
hits = 0
opener = urllib.request.build_opener(urllib.request.HTTPRedirectHandler())
for base in urls:
base = base.rstrip("/")
print(base)
for path, sig in PROBES:
req = urllib.request.Request(base + path, headers={"User-Agent": "admin-surface-check"})
try:
with opener.open(req, timeout=5) as r:
body = r.read(65536).decode("utf-8", "replace")
code, ctype = r.status, r.headers.get("Content-Type", "")
except urllib.error.HTTPError as e:
code, ctype, body = e.code, "", ""
except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
print(f" {path:24} unreachable ({getattr(e, 'reason', e)})")
continue
if code == 200 and (sig is None or sig in body):
hits += 1
note = "OPEN"
else:
note = "closed" if code != 200 else "200 but not the expected page"
print(f" {path:24} {code} {note} {ctype.split(';')[0]}")
return 1 if hits else 0
if __name__ == "__main__":
if len(sys.argv) < 3 or sys.argv[1] not in ("src", "probe"):
sys.exit(__doc__)
sys.exit(scan_src(sys.argv[2:]) if sys.argv[1] == "src" else probe(sys.argv[2:]))
On three small service fixtures and the lab:
$ python3 admin_surface_check.py src fx/orders
import net/http/pprof: registers /debug/pprof/* (profiles, os.Args) on DefaultServeMux
fx/orders/main.go
EXPOSED fx/orders/main.go:11 ListenAndServe serves DefaultServeMux on all interfaces :8080
(exit 1)
$ python3 admin_surface_check.py src fx/billing
import expvar: registers /debug/vars (os.Args, memstats, published vars) on DefaultServeMux
fx/billing/main.go
ok fx/billing/main.go:10 ListenAndServe serves DefaultServeMux on loopback 127.0.0.1:6060
(exit 0)
$ python3 admin_surface_check.py src fx/search
no server found serving DefaultServeMux
(exit 0)
$ python3 admin_surface_check.py src lab
import expvar: registers /debug/vars (os.Args, memstats, published vars) on DefaultServeMux
lab/main.go
import net/http/pprof: registers /debug/pprof/* (profiles, os.Args) on DefaultServeMux
lab/main.go
EXPOSED lab/main.go:30 ListenAndServe serves DefaultServeMux on address from *public (check its value)
(exit 1)
And probing the lab's public port in each mode:
# lab running with -mode=default
$ python3 admin_surface_check.py probe http://127.0.0.1:18080
http://127.0.0.1:18080
/debug/pprof/ 200 OPEN text/html
/debug/pprof/cmdline 200 OPEN text/plain
/debug/vars 200 OPEN application/json
/debug/requests 404 closed
/debug/env 404 closed
/state 404 closed
/actuator/env 404 closed
(exit 1)
# lab running with -mode=split
$ python3 admin_surface_check.py probe http://127.0.0.1:18080
http://127.0.0.1:18080
/debug/pprof/ 404 closed
/debug/pprof/cmdline 404 closed
/debug/vars 404 closed
/debug/requests 404 closed
/debug/env 404 closed
/state 404 closed
/actuator/env 404 closed
(exit 0)
The source scan is a heuristic. It reads one module's files, so it won't see a dependency that imports pprof or starts its own server. go list -deps ./... | grep -E '^(net/http/pprof|expvar)$' shows whether either package is anywhere in your build. The probe is the ground truth for a running service: point it at each port from outside the host, the way an attacker on your network would see it.
What to do
- Upgrade Dgraph to 25.4.0 or later and set Zero's
--securityflag with a token or an allowlist, so/stateand/assignare covered too. Don't expose 6080 beyond the cluster. - Upgrade Vitess to 23.0.5, 24.0.2 or later, make sure vttablet's HTTP port is reachable only from the hosts that need it, and consider a custom
--security-policyif people need the debug pages. - Never pass a nil handler on a public listener. Give the app its own
http.NewServeMux()and mount pprof and expvar on a separate mux bound to127.0.0.1. Reach it with port forwarding or an SSH tunnel. - Keep secrets off the command line. Read them from files or a secret store, since
os.Argsis published by two standard packages and visible in process listings. - Escape everything in admin pages. Use
html/templaterather thanfmt.Fprintto write HTML, even on a page you think only operators see. - Probe from outside. Run the probe, or
curl, against every service port from another host in the same network segment. Our post on Spring Boot Actuator exposure covers the Java version of the same mistake.
The fair view
Debug endpoints earn their place. A goroutine dump or heap profile from a live process is often the fastest way to find a deadlock or a leak. Both projects shipped fixes months before the global database listed them, and Dgraph's comes with regression tests for each case. Dgraph's choice to keep read routes open by default is a real compatibility trade-off, not negligence.
But "internal" describes a network, and networks change: a flat Kubernetes cluster, a sidecar, a misconfigured security group, or a browser that an operator also uses for the internet. Every advisory above starts with the same sentence in a different form: if the port is reachable, the rest follows. Treat the admin port as a public API with fewer users, and most of these bugs stop mattering. Our zero trust network guide covers the network half of that.