
개요
| 항목 | 내용 |
| CVE ID | CVE-2026-107806 |
| 영향 제품 | Nginx UI (0xJacky/nginx-ui — nginx 관리용 웹 콘솔) |
| 영향 버전 | 2.3.8 이상, 2.5.0 미만 |
| 패치 버전 | 2.5.0 (커밋 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 |
| 취약점 유형 | CWE-94 (코드 생성 과정의 부적절한 통제, 코드 인젝션) |
| 인증 요구 여부 | 인증 필요 — 유효한 보안 세션을 가진 관리자 계정이 전제조건 |
Nginx UI는 nginx 설정을 웹 콘솔에서 관리할 수 있게 해주는 도구입니다. 리버스 프록시를 운영하는 콘솔이라는 위치 자체가 민감합니다. 트래픽을 종단하고, 인증서를 보관하고, 어느 백엔드로 요청을 보낼지 결정하며, 대개 네트워크 접근 범위도 넓은 호스트 위에서 동작합니다. 이 CVE가 특히 중요한 이유는, 콘솔 접근 권한을 그 호스트에서의 명령 실행 권한으로 바꿔주는 경로이기 때문입니다.
이 취약점은 인증을 우회하는 유형이 아닙니다. 공격자는 이미 유효한 관리자 세션(JWT)을 가지고 있어야 합니다. 대신 이 CVE는 인증된 사용자가 인증된 경로로 보낸 데이터라고 해서, 그 데이터 안의 모든 값까지 신뢰할 수 있는 것은 아니다"라는, 인증 이후의 신뢰 경계 실패를 보여줍니다. 구체적으로는, 백업을 복원할 때 그 백업이 변조되지 않았는지 검증하는 서명 메커니즘 자체가 공격자가 제출한 값으로부터 서명 검증 키를 유도하도록 설계되어 있었습니다. 자물쇠를 채우는 사람과 그 자물쇠를 검사하는 사람이 같은 열쇠 사본을 공격자에게서 받는다면, 그 자물쇠는 아무것도 증명하지 못합니다.
참고로 이 프로젝트에는 비슷한 이름의 취약점이 하나 더 있습니다. CVE-2026-42238은 설치 과정 중 인증 없이 복원 API에 접근할 수 있었던 문제로, 2.3.8에서 "복원 경로에 인증을 강제"하는 방식으로 고쳐졌습니다. 이번 CVE-2026-107806은 바로 그 인증이 걸린 이후에도 남아있던, 별개의 결함입니다. 즉 2.3.8의 수정은 이 문제를 막지 못했습니다.
취약점이 숨어있던 자리 - 백업을 암호화한 키를, 백업을 복원할 때도 그대로 신뢰한다
Nginx UI의 백업 기능은 GET /api/backup으로 백업 파일(zip)을 내려받을 수 있는데, 이때 그 백업을 암호화한 AES 키와 IV가 X-Backup-Security 응답 헤더에 key_b64:iv_b64 형태로 함께 전달됩니다. 이 설계 자체는 "휴대 가능한(portable) 백업"을 위한 합리적인 선택입니다. 서버가 키를 영구 보관하지 않고, 백업을 내려받는 그 순간의 사용자에게 키를 건네주는 방식입니다.
문제는 이 백업을 다시 복원할 때 벌어집니다. POST /api/restore는 업로드된 백업과 함께 security_token(=복호화에 쓸 키/IV)을 받는데, 이 흐름에서 백업이 변조되지 않았는지를 검증하는 manifest.sig를 만들고 확인하는 로직이, 바로 이 요청자가 제출한 AES 키로부터 서명 검증 키를 유도합니다.

단계별 소스코드 분석
1단계 — 백업 다운로드: 암호화 키가 응답으로 그대로 나간다

여기까지는 설계대로입니다. 백업을 받은 사람(정상적인 관리자든, 세션을 탈취하거나 권한을 얻은 공격자든)은 이 키로 nginx-ui.zip을 복호화해 그 안의 app.ini(nginx-ui 자체의 설정 파일)를 읽을 수 있습니다.
2단계 — 핵심 결함: 서명 검증 키가 "공격자가 보낸" 값으로부터 계산된다
GitHub 보안 권고가 명시한 근본 원인은 다음과 같습니다. /api/restore는 인증된 관리자만 호출할 수 있지만, 그 핸들러는 공격자가 제출한 백업 키 자료를 그대로 받아들이고, 그 키로부터 유도한 키로 manifest의 서명을 검증한 뒤, 역시 그 키로 백업 내용물을 복호화해서, 복호화된 app.ini를 운영 중인 설정 경로에 그대로 복사합니다.

이 함수만 따로 보면 평범한 HMAC 검증처럼 보입니다. 문제는 submittedAESKey가 서버가 미리 보관해 둔 비밀이 아니라, 이번 복원 요청에 security_token으로 함께 실려 온 값이라는 점입니다. 서명을 "검증하는 쪽"이 사용하는 키의 출처가, 서명을 "만든 쪽"(이 경우 공격자)이 통제할 수 있는 입력과 동일합니다. 이것은 사실상 JWT의 alg=none이나 대칭키·비대칭키 혼동 공격과 같은 급의 문제입니다. 신뢰의 뿌리가 되어야 할 비밀이, 검증 대상 데이터를 보낸 바로 그 사람으로부터 함께 제공되고 있습니다.
3단계 — 공격자가 자기 열쇠로 내용물을 바꾸고, 같은 공식으로 서명을 다시 계산한다
관리자 권한(또는 탈취한 관리자 세션)을 가진 공격자는 1단계에서 받은 키를 그대로 손에 쥐고 있습니다. 공개된 PoC가 보여주는 변조 과정은 다음과 같습니다.



서버가 "이 서명이 유효하면 변조되지 않은 백업"이라고 믿는 바로 그 계산식을, 공격자도 똑같이 수행할 수 있습니다. 서명이 지키려던 것은 "서버만 아는 비밀로 서명했다"는 보장이었는데, 그 비밀 자체가 공격자의 손에 있었기 때문입니다.
4단계 — 위조된 백업이 검증을 통과해, 보호된 설정까지 그대로 덮어써진다

hash_match: true는 서버가 "이 백업은 내가 발급한 키로 올바르게 서명되어 있다"고 판단했다는 뜻입니다. 복원 로직은 이 검증을 통과한 app.ini를 운영 중인 설정 파일에 그대로 반영하는데, 여기에는 평소 설정 API(/api/settings 등)로는 고칠 수 없게 막아둔 TestConfigCmd 같은 보호된 필드도 포함됩니다. 백업 복원이라는 "신뢰된 경로"를 통하면, 일반적인 설정 변경 경로의 제약이 전부 무의미해지는 셈입니다.
5단계 — 설정 검사 버튼 하나로 명령 실행
TestConfigCmd는 "nginx 설정 검사" 버튼을 눌렀을 때 nginx-ui가 셸을 통해 그대로 실행하는 명령어를 담는 설정값입니다. 복원으로 이 값을 공격자가 원하는 셸 명령으로 바꿔치기해 두었다면, 남은 건 그 버튼을 누르는 것뿐입니다.

백업을 내려받아 조작하고, 그 조작본을 "유효한" 것으로 서명해 복원한 뒤, 평범한 설정 검사 버튼을 누르는 것만으로 인증된 관리자 권한이 호스트 명령 실행으로 바뀝니다. 그 과정에서 비밀번호를 훔치거나 별도의 인증 우회 기법을 쓸 필요가 전혀 없습니다. 시스템이 공식적으로 제공하는 백업/복원 기능만 정직하게 사용했을 뿐입니다.
PoC 코드 전체 분석 — 각 단계가 왜 필요한가
GitHub 보안 권고에 공개된 PoC는 셸 스크립트와 두 개의 인라인 파이썬 스크립트로 이뤄져 있습니다. 전체를 블록 단위로 끊어서, 각 블록이 앞서 설명한 어느 단계에 해당하는지 짚어보겠습니다.
준비 — 원본 설정 백업과 변수 설정

BASE, TOKEN, CONTAINER는 각각 대상 서버 주소, 이미 확보한 관리자 JWT, 정리 작업에 쓸 컨테이너 이름입니다. set -eu는 스크립트 중간에 명령이 실패하거나 정의되지 않은 변수를 쓰면 즉시 중단하도록 하는 안전장치이고, trap 'rm -rf "$TMP"' EXIT는 스크립트가 어떻게 끝나든 임시 디렉터리를 정리합니다. docker cp로 원본 app.ini를 미리 복사해 두는 것은 재현·검증이 끝난 뒤 정상 설정으로 되돌리기 위한 용도로, 공격 성립에는 필요 없는 PoC 전용 안전장치입니다.
1단계 대응 — 백업을 받고, 응답 헤더에서 키를 뽑아낸다

curl -D로 응답 본문(backup.zip)과 헤더(backup.headers)를 분리해서 받습니다. awk로 X-Backup-Security 헤더 한 줄만 뽑아내고, ${SEC%%:*}(맨 처음 : 앞까지)와 ${SEC#*:}(맨 처음 : 뒤부터)로 key_b64:iv_b64 형식의 값을 키와 IV로 분리합니다. 이후 각각을 base64 디코딩해 xxd -p로 16진수 문자열로 바꿔, 뒤에서 openssl enc가 바로 쓸 수 있는 형태로 준비해 둡니다. 이 블록이 앞서 설명한 1단계(서버가 암호화 키를 응답으로 그대로 내어주는 지점) 그 자체입니다.
백업을 복호화해서 내용물을 꺼낸다

백업 구조는 두 겹입니다. 바깥쪽 backup.zip을 풀면 manifest.json, manifest.sig, 그리고 암호화된 nginx-ui.zip이 나오고, 그 nginx-ui.zip을 방금 얻은 키/IV로 AES-256-CBC 복호화해야 비로소 app.ini를 포함한 실제 설정 파일들(inner/ 디렉터리)에 접근할 수 있습니다. -nosalt 옵션은 서버가 솔트 없이 암호화했다는 뜻으로, 공격자가 같은 옵션으로 복호화·재암호화를 정확히 재현할 수 있다는 것도 이 취약점이 성립하는 조건 중 하나입니다.
2~3단계 대응 — app.ini에 악성 설정을 주입한다

이 파이썬 스크립트는 INI 파일을 한 줄씩 훑으면서 [nginx] 섹션을 찾습니다. 이미 TestConfigCmd 줄이 있으면 그 줄을 악성 명령으로 교체하고, 없으면 섹션이 끝나는 지점(다음 [...]가 나오기 직전)에 새로 삽입하며, [nginx] 섹션 자체가 파일에 없는 극단적인 경우에는 파일 끝에 섹션째로 추가합니다. 즉 기존 설정을 최대한 보존하면서 딱 한 줄만 바꿔치기하도록 설계되어 있어, 이후 manifest.json의 다른 파일 해시들은 건드릴 필요가 없습니다. printf restored-rce >/tmp/nginx-ui-restore-rce라는 페이로드 자체는 무해하게 골라, PoC 실행 여부를 "그 파일이 생겼는가"로만 확인하도록 한 것입니다(실제 공격이라면 이 자리에 임의의 셸 명령이 들어갈 수 있습니다).

수정된 inner/ 디렉터리를 다시 zip으로 묶고, 서버에서 받은 바로 그 키/IV로 재암호화해 원래 있던 outer/nginx-ui.zip을 덮어씁니다. 복호화와 똑같은 키로 암호화했으므로, 서버 입장에서 이 파일은 "정상적으로 그 키로 암호화된 백업"과 바이트 수준에서 구분할 방법이 없습니다.
manifest 재계산과 자가 서명 — 결함의 핵심이 코드로 드러나는 지점

이 블록이 사실상 이 취약점의 전부라고 해도 과언이 아닙니다. 앞 절반(파일별 sha256/size를 다시 계산해 manifest.json을 갱신하는 부분)은 "내용이 바뀌었으니 매니페스트도 그에 맞게 갱신한다"는, 그 자체로는 아주 평범한 로직입니다. 문제는 마지막 두 줄입니다. signing_key = SHA256(b"nginx-ui-backup-signing-v1:" + key)는 서버 소스코드의 deriveSigningKey()와 정확히 동일한 공식이고, 여기 쓰인 key는 공격자가 1단계에서 서버 응답으로 직접 받은 바로 그 값입니다. 서버만 알아야 할 "서명용 비밀"이 사실은 공격자의 손 안에 있는 값으로부터 양쪽에서 똑같이 계산 가능하다는 사실이, 코드 네 줄로 그대로 증명됩니다.
조작된 백업을 패키징해서 복원 API로 전송

재서명된 manifest.sig, 재암호화된 nginx-ui.zip, 손대지 않은 nginx.zip, 갱신된 manifest.json을 다시 하나의 zip으로 묶어 업로드합니다. restore_nginx=false로 nginx 자체의 설정은 건드리지 않고, restore_nginx_ui=true로 nginx-ui 자신의 설정(app.ini)만 복원 대상으로 지정합니다. verify_hash=true는 "검증을 생략하지 말고 제대로 하라"는 요청인데, 바로 그 검증 자체가 공격자도 통과시킬 수 있는 결함이라는 점이 이 PoC의 핵심입니다. security_token=$SEC로 1단계에서 받은 key:iv 값을 그대로 돌려보내, 서버가 "자기가 발급한 키"로 다시 검증하게 만듭니다.
반영 대기 후, 평범한 "설정 검사" 호출로 마무리

복원 직후 nginx-ui 프로세스가 설정을 다시 읽어들이는 데 약간의 시간이 걸리므로, /api/settings가 다시 정상 응답할 때까지 짧게 폴링합니다. 그 뒤 호출하는 POST /api/nginx/test는 공격 전용 엔드포인트가 아니라 평소에도 존재하는, "설정 검사" 버튼이 누르는 바로 그 API입니다. 여기까지 성공했다면 docker exec로 들여다본 /tmp/nginx-ui-restore-rce 파일에 restored-rce 문자열이 들어있는 것으로 명령 실행을 확인합니다.
{"nginx_ui_restored":true,"nginx_restored":false,"hash_match":true}
{"message":"","level":-1,"test_scope":"global"}
restored-rce
세 줄의 출력이 공격의 전체 흐름을 요약합니다. 복원이 "정상적으로"(hash_match: true) 승인되었고, 설정 검사 호출이 평소처럼 끝났으며, 그 평범해 보이는 호출 하나가 실제로는 공격자가 심어둔 명령을 실행했다는 증거가 파일로 남습니다.
패치 — 서명 검증의 뿌리를 "서버만 아는 비밀"로 되돌리다
Nginx UI 2.5.0은 이 문제를 구조적으로 고쳤습니다. GitHub 보안 권고가 밝힌 수정 방향은 다음과 같습니다.
- 백업을 신뢰 수준별로 구분합니다 — "현재 이 서버가 만든 백업"과 "어딘가에서 들여온 이식 가능한 백업"은 서로 다른 신뢰를 받아야 합니다.
- 현재 서버가 만든 백업은 서버가 직접 보관하는 비밀로 인증하고, 서명이 유효하지 않으면 거부합니다.
- 이식 가능한(portable)/AES 복원 경로에서도, TestConfigCmd처럼 보호되어야 할 설정값은 복원 여부와 무관하게 항상 보존됩니다 — 즉 백업 파일의 내용이 그런 보호된 필드를 덮어쓸 수 없도록 아예 분리했습니다.
- app.ini를 타입이 정해진 설정 구조로 파싱하고, 스테이징된 복원 트리 전체를 검증한 다음에야 실제 운영 경로에 반영하도록 바꿨습니다.
이 사례가 주는 원칙은 명확합니다. 서명이나 HMAC 같은 무결성 검증 메커니즘은, 검증에 쓰이는 비밀이 검증 대상 데이터를 제공하는 쪽의 통제를 벗어나 있을 때만 의미가 있습니다. "이 키로 서명했으니 안전하다"는 설계를 검토할 때는, 그 키가 정확히 어디서 왔는지 — 서버의 영구 비밀인지, 아니면 이번 요청에 섞여 들어온 값인지 — 를 반드시 추적해야 합니다. 그리고 복원·가져오기(import)처럼 "신뢰된 상태를 그대로 덮어쓰는" 기능은, 평소의 설정 변경 API보다 훨씬 강한 권한을 암묵적으로 행사한다는 점에서 별도의, 더 엄격한 검증 기준이 필요합니다.
대응 방안
- Nginx UI를 2.5.0 이상으로 즉시 업데이트합니다.
- 업데이트 전까지는 백업/복원 기능에 접근 가능한 관리자 계정 수를 최소화하고, 관리자 세션 토큰의 노출 가능성(공유 환경, 로그 노출 등)을 점검합니다.
- /api/restore, /api/backup, /api/nginx/test 접근 로그에서 비정상적으로 가까운 시간 간격으로 이어지는 백업-복원-테스트 호출 패턴이 있었는지 확인합니다.
- 이미 노출되었을 가능성이 있다면, app.ini의 [nginx] 섹션에 있는 TestConfigCmd를 비롯한 명령 실행 관련 설정값이 의도한 그대로인지 점검하고, 가능하다면 신뢰할 수 있는 시점의 백업으로 설정을 다시 복원합니다.
'CVE 취약점' 카테고리의 다른 글
| CVE-2026-99999(PwnEscape) 분석: 체크섬 하나가 IPv4 헤더를 바꿔치기해 가상머신 격리를 무너뜨린 사건 (0) | 2026.10.20 |
|---|---|
| CVE-2026-53365 분석: "마지막 조각에만" 완료 추적을 붙였던 커널 vsock 제로카피의 함정 (0) | 2026.10.18 |
| CVE-2026-15748 분석: 블록리스트의 "정확한 일치" 검사가 파이프 문자 하나에 뚫린 이유 (0) | 2026.10.17 |
| CVE-2026-1357 분석: "복호화 실패"를 확인하지 않은 대가, WPvivid 백업 플러그인 RCE (0) | 2026.10.15 |
| [CVE-2026-49869] "접미사만 같으면 통과" — Kestra를 무너뜨린 한 줄의 비교 연산자 (0) | 2026.10.15 |