CVE-2026-61628: nginx-ignition, unauthenticated RCE
nginx-ignition 2.41.0 registers POST /api/users/onboarding/finish as anonymous. The handler checks OnboardingCompleted, then Save()s a user with Users=READ_WRITE and returns a JWT. No lock. A virgin instance, or a race that mints two admins. Patched in 2.41.1.
- Name
- CVE-2026-61628: nginx-ignition, unauthenticated RCE
- Type
- N-day analysis
- CVE
- CVE-2026-61628
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- lucasdillmann
- Affected
- nginx-ignition through 2.41.0; patched in 2.41.1
- Published
- 22 Sept 2026
- Updated
- 22 Sept 2026
- Tags
- n-day, nginx, toctou, cwe-362, unauthenticated, rce
Onboarding is anonymous
I am @abraxas_null. The proof of concept is on GitHub: abraxas/CVE-2026-61628 (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml. Authorized lab only. It talks to loopback.
CVE-2026-61628 (NVD, GHSA) is unauthenticated administrator creation in nginx-ignition 2.41.0. CWE-362. 8.1 High (CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H). Patched in 2.41.1. This is not a WordPress plugin.
The advisory names a check-then-act on first-run onboarding. HTTP is POST /api/users/onboarding/finish. The route is AllowAnonymous. The handler asks OnboardingCompleted, then Saves a user with every permission ReadWrite, including Users, then returns a JWT. There is no lock between the count and the insert. A box that has never been onboarded is an unauthenticated admin mint. Two concurrent POSTs can both see "not finished" and both get 200. That JWT can rewrite nginx hosts. That is RCE on the reverse proxy.
I am not publishing a spray of default dashboards. The map is the anonymous route plus the TOCTOU. Isolated lab, loopback only. The stack is in the CVE repo so you can run it at home. The client is the repo above.
The lab (run this at home)
Source of truth is lab/ on GitHub. The Dockerfile pins dillmann/nginx-ignition:2.41.0. Compose adds postgres:16-alpine. Port 18090 on loopback. Virgin database: no users yet.
# Loopback lab image pin for CVE-2026-61628. Full stack: docker-compose.yml
FROM dillmann/nginx-ignition:2.41.0name: cve-2026-61628
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: nginx_ignition
POSTGRES_USER: nginx_ignition
POSTGRES_PASSWORD: lab-db-pass
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nginx_ignition"]
interval: 3s
timeout: 3s
retries: 20
ignition:
image: dillmann/nginx-ignition:2.41.0
ports:
- "127.0.0.1:18090:8090"
environment:
NGINX_IGNITION_DATABASE_DRIVER: postgres
NGINX_IGNITION_DATABASE_HOST: postgres
NGINX_IGNITION_DATABASE_PORT: 5432
NGINX_IGNITION_DATABASE_NAME: nginx_ignition
NGINX_IGNITION_DATABASE_SSL_MODE: disable
NGINX_IGNITION_DATABASE_USERNAME: nginx_ignition
NGINX_IGNITION_DATABASE_PASSWORD: lab-db-pass
NGINX_IGNITION_SECURITY_JWT_SECRET: "labjwtsecretlabjwtsecretlabjwtsecretlabjwtsecretlabjwtsecretlabj"
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8090/api/users/onboarding/status || exit 1"]
interval: 5s
timeout: 5s
retries: 36
start_period: 20sBring-up from the CVE repo lab/. Do not finish the UI wizard first. The sink is the empty user table.
git clone https://github.com/abraxas/CVE-2026-61628
cd CVE-2026-61628/lab
docker compose up -d --force-recreate
python3 ../CVE-2026-61628-Abraxas-Labs.pyThe proof of concept is written for 127.0.0.1:18090. Do not publish the port off loopback. Recreate the stack if you already clicked through onboarding.
What the tree actually registers
routes.go in 2.41.0:
onboardingPath := basePath.Group("/onboarding")
onboardingPath.GET("/status", onboardingStatusHandler{commands}.handle)
onboardingPath.POST("/finish", onboardingFinishHandler{commands, authorizer}.handle)
authorizer.AllowAnonymous(http.MethodGet, "/api/users/onboarding/status")
authorizer.AllowAnonymous(http.MethodPost, "/api/users/onboarding/finish")
authorizer.AllowAnonymous(http.MethodPost, "/api/users/login")Login being anonymous is normal. Finish being anonymous is the first-run wizard. The problem is that the wizard is the only thing standing between the internet and Users: ReadWrite.
onboarding_finish_handler.go in 2.41.0:
func (h onboardingFinishHandler) handle(ctx *gin.Context) {
alreadyFinished, err := h.commands.OnboardingCompleted(ctx.Request.Context())
if alreadyFinished {
ctx.Status(http.StatusForbidden)
return
}
// BindJSON name/username/password
domainModel.Enabled = true
domainModel.Permissions = user.Permissions{
Users: user.ReadWriteAccessLevel,
NginxServer: user.ReadWriteAccessLevel,
// ... the rest ReadWrite ...
}
if err = h.commands.Save(ctx.Request.Context(), domainModel, nil); err != nil {
panic(err)
}
// Authenticate, GenerateToken, 200 JSON { token }
}OnboardingCompleted is a count. Save is an insert. Nothing atomic sits between them. Two goroutines, two counts of zero, two admins, two JWTs.
2.41.1 switches that write to FinishOnboarding and maps ErrOnboardingAlreadyCompleted to 403. That is the patch. Update.
The 200 that was already onboarded
The first client I pointed at this was polite. It POSTed after I had already clicked the wizard. Every request 403. That is the product working. The instance is no longer virgin.
A few other ways to lose without learning anything:
- GET. The route is POST JSON
name/username/password. - Hitting a host that finished onboarding last week. The race is for first boot, or for two requests that both see an empty table.
- A reverse shell. Theatre. The witness is a JWT and
Users=READ_WRITEon/api/users/current. - One 200 and calling it a race. A single 200 on a virgin box is the unauthenticated-admin bug.
n200>=2distinct usernames is the TOCTOU.
The tell is small JSON with a token field, not an HTML dashboard. Then Authorization: Bearer against /api/users/current.
What I actually did
Treat the on-disk product as the spec. NVD named the anonymous finish handler. I read AllowAnonymous, then the check-then-Save, then fired concurrent POSTs at a fresh compose stack. Proof of concept: CVE-2026-61628-Abraxas-Labs.py.
Status, then finish, then current. GET /api/users/onboarding/status until finished=false. POST /api/users/onboarding/finish with a unique username. GET /api/users/current with the JWT. For the race, fire many concurrent finishes before anyone has committed a user.
Witness. Unauthenticated 200, JWT, users_perm=READ_WRITE. Race: n200>=2 and more than one admin username. I am not reprinting the tokens.
Last lab run, trimmed:
SUCCESS CVE-2026-61628 unauth onboarding race minted multiple admin JWTs
n200=3 n403=13 n_jwt=3
current_user=attacker04 users_perm=READ_WRITE
dillmann/nginx-ignition:2.41.0 postgres 127.0.0.1:18090The client that produced it is on GitHub.
What this is not
It is not "first-run wizards are always a bug." It is an anonymous route that writes a full-privilege user with a TOCTOU instead of an atomic "insert if empty." It is not RCE in the nginx worker. It is an admin JWT for the control plane that writes nginx config. Close enough.
Update to 2.41.1. Re-run the loopback client against the patched build: the witness must not appear. Do not leave a 2.41.0 image on a public IP before the wizard. A WAF signature is delay.
I am not going to print a JSON body you can paste at someone else's /api/users/onboarding/finish. The anonymous route and the unlocked count are the useful part. If you own the box, run the proof of concept against loopback.
Client bugs look like product bugs. A helper that shadows http.client never sends a packet. .recv() on an HTTPResponse is not .read(). I mention that once because it cost time and looked, for a minute, like the wizard was fine.
References
- Proof of concept: abraxas/CVE-2026-61628 · CVE-2026-61628-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml - @abraxas_null · github.com/abraxas · abraxaslabs.tech
- CVE-2026-61628 · NVD · CWE-362
- 2.41.0:
routes.goAllowAnonymous,onboarding_finish_handler.go - Patch: 2.41.1
FinishOnboarding· release 2.41.1 - Image:
dillmann/nginx-ignition:2.41.0