Research/sonic-gnmi-noauth
0-dayNo CVECriticalPublic

SONiC gNMI, unauthenticated writes on empty UserAuth

SONiC gNMI still passes --client_auth cert as a fail-closed default, then unsets cert when ca_crt is empty. authenticate() treats empty UserAuth as success. Native and translib writes are compiled in. SmartSwitch DPU with no certs is remote on :8080. Default ToR is loopback. No CVE yet.

Name
SONiC gNMI, unauthenticated writes on empty UserAuth
Type
0-day analysis
CVE
n/a
CVE Risk
critical
Disclosure Status
public
Vendor
SONiC
Affected
sonic-gnmi through master f13a08e0440f; launcher in sonic-buildimage 9495839742e4. Community SONiC NOS with ENABLE_NATIVE_WRITE=y. Not Dell Enterprise SONiC. Not SonicWall.
Published
30 Sept 2026
Updated
30 Sept 2026
Tags
0-day, sonic, gnmi, gnoi, cwe-306, cwe-287, unauthenticated

fail-closed, then unset

The proof of concept is on GitHub: abraxas/sonic-gnmi-noauth (loopback client; @abraxas_null). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh. Authorized lab only. It talks to loopback.

This is community SONiC gNMI (sonic-net/sonic-gnmi f13a08e0440f, launcher in sonic-net/sonic-buildimage 9495839742e4). SONiC Foundation / sonic-net. Not Dell Enterprise SONiC. Not SonicWall SonicOS. No CVE yet. The committee does not issue them. CWE-306 / CWE-287. 9.8 Critical on a SmartSwitch DPU with no certs (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H). Default ToR with no certs is the same skip on 127.0.0.1:8080 (High, local). Not a gNOI docker gadget.

How I found it

I started with SECURITY.md. sonic-net/SONiC is the wiki. The code is sibling sonic-net/* trees. Then those pins: CLI injection, REST auth, gNMI Set, hostcfgd PAM, RESTCONF, ZTP, image Redis/sshd/sudo, FEATURE defaults, GCU/SSTI, DHCP/LLDP/SNMP. No live switch. I was looking for Critical/High.

Most of that is already closed or not default. sonic-restapi is INCLUDE_RESTAPI ?= n. RESTCONF's production launcher forces PAM. Host Redis is 127.0.0.1 with no requirepass, which is a first hop, not a finding. The gnmi container is not optional.

rules/config compiles the writes in:

Plain text
INCLUDE_SYSTEM_GNMI = y
ENABLE_TRANSLIB_WRITE = y
ENABLE_NATIVE_WRITE = y

init_cfg.json.j2 then enables the FEATURE:

Plain text
{% do features.append(("gnmi", "enabled", true, "enabled")) %}

The gnmi docker is host net, host pid, host userns. docker_image_ctl.j2 sets NET="host" for that class of container. Loopback on the container is loopback on the switch.

The launcher is the interesting comment. gnmi-native.sh knows that omitting --client_auth used to disable authentication entirely, so when user_auth is missing it forces cert mode. Fail-closed. Then it never passes --ca_crt when certs are absent.

Go then unsets cert mode because there is no CA. authenticate() treats the empty map as success. Native and translib writes stay compiled in, so unauthenticated gnmi.gNMI/Set and gNOI Debug/Reboot/File are registered.

Two production flag sets, same skip. Default ToR with no certs: --noTLS --bind_address 127.0.0.1. SmartSwitch DPU with no certs: --insecure --allow_no_client_auth, empty bind, all interfaces, port 8080.

I did not stand up a full sonic-vs image. The lab is a thin gRPC wrapping SHA256-checked extracts of setupFlags and authenticate() from pin f13a08e0440f. Both listeners returned SONIC-GNMI-NOAUTH-WITNESS with auth_enabled=false and enable_native_write=true. Password UserAuth with no creds is Unauthenticated. That is the real skip, not a stub that always succeeds.

I am not printing a gNMI Set you can paste at someone else's :8080. Empty UserAuth after the cert unset is the useful part.

Wrong turns already recorded: KillProcess Unauthenticated (that RPC already rejects empty UserAuth - lab requires Set-path authenticate() skip); Probe OK when password UserAuth is on and no creds (then the oracle is a stub); a gNOI Debug docker gadget, nsenter payload, reverse shell. Theatre. Pointing this at a Dell Enterprise SONiC box, or a SonicWall. Wrong product.

cert, then nothing

No certs on a normal switch is loopback plaintext. No certs on a DPU is ephemeral TLS on every interface:

Plain text
if [[ "$DPU_EPHEMERAL_TLS" == "true" ]]; then
    TELEMETRY_ARGS+=" --insecure"
elif [ -n "$CERTS" ]; then
    # server_crt / server_key / optional ca_crt
else
    TELEMETRY_ARGS+=" --noTLS --bind_address 127.0.0.1"
fi

DPU also opts out of requiring a client cert:

Plain text
CLIENT_AUTH=$(extract_field "$GNMI" '.client_auth')
if [[ "$DPU_EPHEMERAL_TLS" == "true" || "$CLIENT_AUTH" == "false" ]]; then
    TELEMETRY_ARGS+=" --allow_no_client_auth"
fi

Then the fail-closed default, still with no CA:

Plain text
USER_AUTH=$(extract_field "$GNMI" '.user_auth')
# Fail-closed default: if user_auth is unset (missing GNMI CONFIG_DB entry or
# missing field), force cert mode. Without this, --client_auth is omitted and
# authentication ends up disabled entirely.
if [ -z "$USER_AUTH" ] || [ "$USER_AUTH" == "null" ]; then
    USER_AUTH="cert"
fi

setupFlags then strips it:

Go
if *telemetryCfg.CaCert == "" && telemetryCfg.UserAuth.Enabled("cert") {
	telemetryCfg.UserAuth.Unset("cert")
	log.V(2).Info("client_auth mode cert requires ca_crt option. Disabling cert mode authentication.")
}

startGNMIServer copies cfg.UserAuth only inside the TLS branch. --noTLS never copies it. --insecure is not --noTLS; the DPU path copies the already-empty map.

authenticate() is the punchline:

Go
if !config.UserAuth.Any() {
	//No Auth enabled
	rc.Auth.AuthEnabled = false
	return ctx, nil
}

gNOI writes register when either write flag is on:

Go
if srv.config.EnableTranslibWrite || srv.config.EnableNativeWrite {
	gnoi_system_pb.RegisterSystemServer(s, srv)
	gnoi_file_pb.RegisterFileServer(s, fileSrv)
	gnoi_os_pb.RegisterOSServer(s, osSrv)
	gnoi_containerz_pb.RegisterContainerzServer(s, containerzSrv)
	gnoi_debug_pb.RegisterDebugServer(s, debugSrv)
	gnoi_healthz_pb.RegisterHealthzServer(s, healthzSrv)
}

Debug is not a toy RPC. command.go nsenter's PID 1 and systemd-run --uid=admin sh -c. The default write whitelist in whitelist.go includes docker, config, redis-cli, sonic-installer, reboot. Default admin is in docker and %sudo. I am not printing a privileged docker run. docker ps / a disposable CONFIG_DB test key is enough to prove the skip.

KillProcess still returns Unauthenticated on empty UserAuth. Do not use that as the success oracle.

What an attacker can do

On a SmartSwitch DPU with no gNMI certs: from the network, TCP :8080, no JWT, no PAM, no client cert. Call gnmi.gNMI/Set (native CONFIG_DB / translib). Call gnoi.debug.Debug. Reboot. Factory reset. File.Put under /tmp, /var/tmp, /host.

On a default ToR/leaf with no certs: any process that can hit 127.0.0.1:8080 does the same. The gnmi container is host net, so that loopback is the host's.

That is switch config rewrite, and host commands as admin, without authenticating. Not Dell. Not SonicWall. Not the documented default admin password.

The lab (run this at home)

Source of truth is lab/. Thin gRPC, not a full DPU image. Pin extracts hashed at build against sonic-gnmi f13a08e0440f. Ports 18080 (ToR plaintext) and 18081 (DPU TLS-insecure). Bind them to loopback.

Dockerfile
# Loopback lab image for unpublished SONiC gNMI empty-UserAuth fail-open.
# Pin files are copied into the build context; no host home bind-mounts.
FROM golang:1.25-bookworm

RUN apt-get update && apt-get install -y --no-install-recommends \
        protobuf-compiler python3 ca-certificates \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /src
COPY pin/ /src/pin/
COPY lab/ /src/lab/
COPY check_extracts.py /src/check_extracts.py
RUN python3 /src/check_extracts.py

ENV GOPROXY=https://proxy.golang.org,direct
ENV PATH="/go/bin:${PATH}"
RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.8 \
    && go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.5.1

WORKDIR /src/lab
COPY certs/ /certs/
RUN protoc --go_out=. --go_opt=module=github.com/sonic-net/sonic-gnmi \
        --go-grpc_out=. --go-grpc_opt=module=github.com/sonic-net/sonic-gnmi \
        proto/probe.proto \
    && go mod tidy \
    && CGO_ENABLED=0 go build -tags gnmi_native_write,gnmi_translib_write \
        -o /usr/local/bin/labserver ./cmd/labserver

EXPOSE 18080 18081
CMD ["/usr/local/bin/labserver"]
YAML
# Loopback-only lab for unpublished SONiC gNMI empty UserAuth fail-open.
# Host publishes 127.0.0.1 only. No bind-mount of host home or client-reviews.

name: sonic-gnmi-noauth

services:
  gnmi:
    build:
      context: .
      dockerfile: Dockerfile
    image: sonic-gnmi-noauth:f13a08e
    ports:
      - "127.0.0.1:18080:18080"
      - "127.0.0.1:18081:18081"
    healthcheck:
      test:
        [
          "CMD-SHELL",
          "bash -c 'echo >/dev/tcp/127.0.0.1/18080' && bash -c 'echo >/dev/tcp/127.0.0.1/18081'",
        ]
      interval: 2s
      timeout: 2s
      retries: 30
      start_period: 4s
Plain text
git clone https://github.com/abraxas/sonic-gnmi-noauth
cd sonic-gnmi-noauth/lab
./run.sh

The proof of concept is written for 127.0.0.1:18080 and 127.0.0.1:18081. Do not publish the ports off loopback.

What the tree actually consumes

Unauthenticated Probe.Check with writeAccess=true on both production flag sets. Witness in the body. Password UserAuth forced, no creds, Unauthenticated.

A few other ways to lose without learning anything:

  • KillProcess Unauthenticated. That RPC already rejects empty UserAuth. Lab requires Set-path authenticate() skip.
  • Probe OK when password UserAuth is on and no creds. Then the oracle is a stub.
  • A gNOI Debug docker gadget, nsenter payload, reverse shell. Theatre. The witness is auth_enabled=false with writes compiled in.
  • Pointing this at a Dell Enterprise SONiC box, or a SonicWall. Wrong product.

Last lab run, trimmed:

Plain text
IOC tor-connect 127.0.0.1:18080 plaintext no-metadata
IOC tor-check witness=SONIC-GNMI-NOAUTH-WITNESS auth_enabled=false enable_native_write=true user_auth_any=false mode=tor
IOC dpu-connect 127.0.0.1:18081 tls-insecure no-client-cert no-metadata
IOC dpu-check witness=SONIC-GNMI-NOAUTH-WITNESS auth_enabled=false enable_native_write=true user_auth_any=false mode=dpu
IOC tor-negative status=UNAUTHENTICATED
IOC dpu-negative status=UNAUTHENTICATED
SUCCESS SONIC-GNMI-NOAUTH who=unauth tor=18080 dpu=18081 SONIC-GNMI-NOAUTH-WITNESS

The client that produced it is on GitHub. I am not reprinting a Set body.

What this is not

It is not "the launcher forgot --client_auth." The comment forces cert. setupFlags then unsets it. It is not unauthenticated on a generic ToR from the network: default ToR with no certs binds loopback. The remote case is the DPU with no certs. It is not Dell Enterprise SONiC and it is not SonicWall. It is not a full NOS image in the lab; the oracle is the write-path skip on the pin extracts.

The fix

If UserAuth is empty after stripping cert, do not serve Set/gNOI write, or do not listen off loopback. Copy UserAuth on the --noTLS path too. Re-run the loopback client against a patched extract: both listeners must return Unauthenticated for empty UserAuth on the write path. Until then: provision server certs and a CA so --ca_crt is set and cert mode is not unset; do not run SmartSwitch DPU no-cert (--insecure --allow_no_client_auth with empty CA) on a reachable :8080.

I am not going to print a gNMI Set or a Debug command you can paste at someone else's switch. Empty UserAuth is the useful part. If you own the box, run the proof of concept against loopback.

References

SONiC gNMI, unauthenticated writes on empty UserAuth · Abraxas Labs