
CVE-2026-19264 - Postiz(< 2.22.1)에서 인증되지 않은 경로 탐색(path traversal)으로 전체 인스턴스 장악까지 이어지는 치명적인 취약점. 기술 분석: 디코드 순서 우회(decode-order bypass), JWT_SECRET 권한 상승, 그리고 업스트림 수정 사항 분석.

작성자: Krithik Babu P (@DarkLycn1976)
게시일: 2026-08-10
CVE: CVE-2026-19264
심각도: 치명적(Critical) - CVSS 4.0 9.3 / CVSS 3.1 9.8
CWE: CWE-22 - 경로명을 제한된 디렉터리로 제한하지 못함
영향받는 버전: gitroomhq/postiz-app < 2.22.1
수정 버전: v2.22.1
Postiz는 로컬에 저장된 미디어를 업로드 디렉터리에 URL에서 제공된 경로 세그먼트를 이어 붙이고 그 결과를 다시 스트리밍하는 라우트를 통해 제공했습니다. 경로 정규화도, 포함 범위 검사도, 인증도 없었습니다.
명백한 경로 탐색 페이로드는 404를 반환합니다. Next.js가 라우팅 전에 ../ 세그먼트를 접어버리기 때문입니다. 하지만 , 파일시스템 호출로 가는 길에 정확히 한 번 더 디코딩되어 모든 검사의 반대쪽에서 경로 탐색을 복원합니다.
인증되지 않은 공격자는 애플리케이션 프로세스가 읽을 수 있는 모든 파일을 읽을 수 있습니다. 여기에는 JWT 서명 비밀값을 보관하는 프로세스 자신의 환경도 포함됩니다. Postiz는 세션 토큰을 그 비밀값으로 서명하고 만료 클레임 없이 발급하므로, 비밀값을 탈취하면 파일 읽기 프리미티브가 영구적이고 위조 가능한 세션으로 바뀌어 관리자를 포함한 모든 사용자로 가장할 수 있게 됩니다.
인증되지 않은 GET 요청 한 번으로 전체 인스턴스를 장악할 수 있습니다.
Postiz는 오픈소스 소셜 미디어 스케줄링 플랫폼입니다. 작성 시점 기준 약 34,000개의 GitHub 스타를 보유하고 있으며, Next.js 프론트엔드와 NestJS 백엔드로 구성되어 있습니다. 에이전시와 소규모 팀이 연결된 소셜 계정, 예약 콘텐츠, 결제를 관리하기 위해 널리 자체 호스팅합니다.
자체 호스팅 배포 환경에서는 업로드된 미디어를 객체 스토리지(object storage) 대신 로컬에 저장할 수 있습니다. 이 동작은 단일 환경 변수로 제어됩니다:
STORAGE_PROVIDER=local
이 값은 .env.example에 포함된 기본값이므로, S3나 Cloudflare R2를 의도적으로 구성하지 않는 한 대부분의 자체 호스팅 사용자가 이 설정으로 실행합니다.
로컬 스토리지가 활성화되면 next.config.js는 공개 경로 /uploads/:path*를 내부 API 라우트로 재작성합니다:
apps/frontend/src/app/(app)/api/uploads/[[...path]]/route.ts
버그가 개입하기 전에 이 라우트를 주목하게 만드는 두 가지 속성이 있습니다:
[[...path]] 선택적 캐치올 세그먼트는 나머지 모든 경로 구성 요소가 배열로 전달되어 핸들러가 자유롭게 해석할 수 있음을 의미합니다.STORAGE_PROVIDER가 local이 아닌 다른 값이면 재작성 결과가 /404를 가리키므로 핸들러에 도달할 수 없습니다. 이 구성 게이트가 배포 환경과 이 버그 사이를 가로막는 유일한 장벽입니다.
v2.22.1 이전의 핸들러:
export const GET = async (request: NextRequest, context) => {
const { path } = await context.params;
const filePath =
process.env.UPLOAD_DIRECTORY + '/' + (path ?? []).join('/');
const response = createReadStream(filePath);
const fileStats = statSync(filePath);
// ... stream the file back to the caller
};
네 줄에 세 가지 결함이 있습니다:
path.normalize(), path.resolve() - 둘 중 어느 것도 호출되지 않습니다. 도착한 세그먼트는 그대로 이어 붙여집니다.filePath가 여전히 UPLOAD_DIRECTORY 안에 있는지 확인하는 절차가 없습니다.+ '/' +는 구성 요소를 의미를 가진 경로가 아닌 텍스트로 취급합니다.결과는 곧바로 createReadStream()에 전달되고, 파일명에서 추론한 MIME 타입과 함께 바이트가 호출자에게 스트리밍됩니다. 확장자 허용 목록도 콘텐츠 필터도 없습니다.
교과서적인 공격은 다음과 같습니다:
GET /uploads/../../../etc/passwd
Postiz에서 이 요청은 404를 반환하며, 이 404가 바로 이 버그가 발견될 때까지 살아남은 전부 이유입니다.
Next.js는 라우팅 중에 요청 경로를 정규화합니다. 원시 ../ 세그먼트는 라우터가 어떤 핸들러를 호출할지 결정하기 전에 접혀버립니다. 요청이 캐치올에 도달할 때쯤이면 경로 탐색은 이미 제거되어 있습니다. 경로가 일치하는 라우트가 없는 곳으로 해석되거나, 점 세그먼트가 사라진 채 /uploads 내부로 다시 해석됩니다.
빠르게 테스트하는 사람에게 그 404는 *"프레임워크가 알아서 처리한다"*는 뜻으로 읽힙니다. 그것은 진짜로 동작하는 방어입니다. 문제는 방어가 없다는 것이 아니라 파이프라인의 어디에서 실행되느냐입니다.
라우트 매칭과 요청 핸들러는 퍼센트 디코딩(percent-decoding)을 동일한 횟수로 수행하지 않습니다.
구분자를 퍼센트 인코딩하면 라우트 매칭 중에는 그 시퀀스가 경로 구분자가 아닙니다. %2e%2e%2f는 불투명한 문자열일 뿐입니다. 정규화기가 건드릴 이유가 없는 무해한 텍스트입니다. 이 문자열은 라우팅을 온전히 통과하고, 캐치올에 매칭된 다음, 핸들러의 params로 들어가는 과정에서 디코딩되어 다시 ../이 됩니다.
그 시점에 이 문자열은 UPLOAD_DIRECTORY에 이어 붙여져 createReadStream()에 전달됩니다. 라우팅도, 정규화도, 이를 막을 수 있었던 모든 통제도 이미 지나간 뒤입니다.
동작하는 형태:
GET /uploads/%2e%2e%2fsecretdir%2fsecret.txt → 200, file outside the upload directory
GET /uploads/..%2f..%2f..%2fetc%2fpasswd → 200
GET /uploads/%2e%2e%2f%2e%2e%2f...%2fetc%2fpasswd → 200, returned the real /etc/passwd
이중 인코딩은 동작하지 않습니다. %252e는 단일 디코딩 과정을 거치는 동안 리터럴 그대로 남아 결코 점(dot)이 되지 않습니다. 정확히 한 겹의 인코딩이 최적의 지점이며, "더 세게 인코딩하기"는 전략이 될 수 없다는 유용한 교훈입니다.
기억해야 할 불변식:
디코딩이 완료되기 전에 실행되는 통제는 싱크(sink)를 보호하지 못합니다.
파일 읽기 프리미티브 자체도 High(높음)입니다. 이 취약점을 Critical(치명적)로 만드는 것은 그것이 도달하는 대상입니다.
1단계 - 환경 파일 읽기. Node 프로세스 자신의 구성은 배포 루트의 디스크에 있습니다. .env에서 다음을 비롯한 값을 얻을 수 있습니다:
JWT_SECRET - 세션 토큰 서명 키DATABASE_URL - 전체 Postgres 자격 증명2단계 - 세션 위조. Postiz는 jsonwebtoken을 통해 HS256으로 JWT_SECRET을 사용해 세션 토큰을 서명합니다. 결정적으로 토큰은 expiresIn 없이 발급되므로, 위조된 토큰은 무기한 유효합니다.
3단계 - 누구로든 위장. 인증 미들웨어는 id 클레임을 사용해 데이터베이스에서 사용자를 다시 조회합니다. 토큰의 isSuperAdmin 같은 클레임은 의도적으로 신뢰하지 않습니다. 좋은 설계이지만, 임의의 id에 서명할 수 있게 되면 그런 강화는 무의미해집니다. { id: <victim user id> }에 서명하면 정상 로그인과 구별할 수 없는 세션이 생성됩니다:
read .env → JWT_SECRET → sign({ id: victim }) → authenticated as victim, forever
저는 실제 jsonwebtoken 의존성을 사용해 프로젝트의 실제 검증 로직을 대상으로 이를 검증했습니다. 탈취한 비밀값으로 서명한 토큰은 수락되었고, 잘못된 비밀값으로 서명한 동일한 토큰은 거부되었습니다. 대조군(control case)이 중요합니다. 대조군 없이는 확정된 발견이 아니라 가정에 불과합니다.
4단계 - 병렬 경로. DATABASE_URL만으로도 Postgres에 직접 접근하기에 충분합니다. 연결된 모든 계정을 읽거나 관리자 플래그를 직접 뒤집을 수 있습니다.
비밀번호도, 사전 접근 권한도, 사용자 상호작용도 필요 없습니다. 단 한 번의 인증되지 않은 HTTP 요청입니다.
CVSS 4.0 9.3 AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N
CVSS 3.1 9.8 AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
PR:N과 UI:N이 결정적인 두 지표입니다. 이 라우트는 세션이 필요 없고 피해자의 상호작용도 필요 없습니다. 공격자는 기본 구성에 대해 네트워크를 통해 단독으로 공격합니다.
솔직하게 인정할 수 있는 유일한 제한 요소는 구성 게이트입니다. S3나 R2를 사용하는 배포는 라우트가 /404로 재작성되므로 노출되지 않습니다. 이는 영향받는 사용자 수를 줄이지만, 그 안에 있는 사람들에게 심각도를 낮추지는 않습니다. 그리고 local이 기본 배포 값입니다.
유지보수자들의 패치(7936062)는 여덟 줄이며 읽어볼 가치가 있습니다. 이러한 수정이 흔히 그렇지 않은 방식으로 올바르기 때문입니다:
+import { resolve, sep } from 'path';
...
- const filePath =
- process.env.UPLOAD_DIRECTORY + '/' + (path ?? []).join('/');
+ const base = resolve(process.env.UPLOAD_DIRECTORY!);
+ const filePath = resolve(base, (path ?? []).join('/'));
+ // Confine reads to UPLOAD_DIRECTORY. resolve() collapses any `..` segments
+ // (including URL-decoded ones), so this blocks every path-traversal variant.
+ if (filePath !== base && !filePath.startsWith(base + sep)) {
+ return new NextResponse('Not found', { status: 404 });
+ }
이 패치가 올바르게 처리한 두 가지:
resolve()는 디코딩이 완료된 후 ..을 접어버리므로 경로 탐색이 라우팅을 어떻게 통과해 밀반입되었는지는 중요하지 않습니다. 이제 검사는 위험이 있는 곳에 위치합니다.base가 아닌 base + sep와 비교합니다. 단순한 filePath.startsWith(base)는 /app/uploads-evil/x를 /app/uploads 내부로 받아들일 것입니다. 전형적인 접두어 일치(prefix-match) 우회입니다. 구분자를 추가하면 이를 차단하고, filePath !== base 절은 디렉터리 자체를 유효하게 유지합니다.이것이 포함 범위 검사의 올바른 형태입니다. resolve한 다음, 후행 구분자가 있는 base와 비교하는 것입니다.
별도 표기가 없는 한 모든 시간은 UTC, 2026-07-20 기준입니다.
| 시간 | 이벤트 |
|---|---|
| 05:55 | Postiz 팀에 보안 권고(advisory) 보고 |
| 07:44 | 유지보수자들이 확인 및 검증 |
| 12:18 | 수정 사항 커밋, 검증, 게시 |
| 2026-08-07 14:13 | Postiz(CNA)가 CVE-2026-19264 배정 |
| 2026-08-07 14:15 | GitHub Security Advisory 게시 |
보고부터 패치 배포까지 6시간 23분이 걸렸습니다. 버그 바운티(bug bounty)가 없는 오픈소스 프로젝트에서 말이죠. 저는 전담 보안 팀을 둔 조직에서 보고서가 몇 달 동안 방치된 것을 여러 번 겪었습니다. 조정을 담당한 Enno Gelhaus와 수정을 담당한 Nevo David에게 감사를 표합니다.
테스트를 통과하는 통제가 올바른 위치에 있다는 뜻은 아닙니다. 404는 실제였습니다. Next.js는 정말로 ../을 접어버립니다. 방어는 단지 입력의 디코딩이 끝나기 전에 실행되었을 뿐이며, 이는 파일시스템 호출이 아닌 라우터를 지키고 있었음을 의미합니다. 완화 조치를 발견하면 그것이 존재하는지뿐만 아니라 싱크를 기준으로 언제 실행되는지를 물어보십시오.
인코딩은 하나의 계층이며, 계층은 서로 다른 속도로 벗겨집니다. 요청 파이프라인의 두 구성 요소가 디코딩 횟수에 대해 서로 다르게 생각하는 순간, 그 사이의 간격은 악용될 수 있습니다. 라우트 매처, 미들웨어, 핸들러는 자주 서로 다르게 생각합니다.
파일 읽기 프리미티브는 프리미티브 자체가 아니라 프로세스가 도달할 수 있는 대상으로 평가하십시오. "임의 파일 읽기"는 정보 공개처럼 들립니다. 이것이 Critical이 된 이유는 환경 파일을 읽을 수 있었고, 그 안의 비밀값이 세션에 서명했으며, 그 세션은 만료되지 않았기 때문입니다. 점수를 매기기 전에 체인을 따라가 보십시오.
만료되지 않는 토큰은 유출을 영구적 침해로 바꿉니다. 수명이 짧은 토큰과 함께 서명 키가 유출되면 나쁜 하루일 뿐입니다. expiresIn이 없으면 비밀값을 교체하지 않는 한 회복할 수 없으며, 대부분의 운영자는 교체가 필요했다는 사실조차 알지 못할 것입니다.
대조군을 실행하십시오. 잘못된 비밀값으로 서명한 토큰이 거부되는지 확인하는 것이 입증된 발견과 추정된 발견을 구분 짓습니다.
Postiz를 자체 호스팅 중이라면:
JWT_SECRET이 유출되었다고 가정하십시오. STORAGE_PROVIDER=local로 공개적으로 접근 가능한 호스트에서 영향을 받는 버전을 실행했다면 말입니다. 교체하십시오. 토큰에 만료가 없으므로 교체만이 위조된 토큰을 무효화하는 유일한 방법입니다.DATABASE_URL 자격 증명과 연결된 공급자의 모든 OAuth 비밀값을 교체하십시오./uploads/에 대한 GET 요청 중 %2e 또는 %2f를 포함하는 요청을 확인하십시오.이 연구는 독립적으로 수행되었으며 조율된 공개(coordinated disclosure) 절차에 따라 공급업체에 공개되었습니다. 수정 사항이 배포되고 권고가 공개된 후 게시되었습니다. 제3자 시스템에는 접근하지 않았습니다. 모든 검증은 프로젝트 자체 소스로 구축한 로컬 인스턴스를 대상으로 수행되었습니다.
이 글은 CC BY 4.0 라이선스로 제공됩니다. 출처를 밝히면 자유롭게 공유하고 변경할 수 있습니다. gitroomhq/postiz-app의 코드 발췌문은 보안 분석을 위해 인용된 것이며 해당 프로젝트의 라이선스가 적용됩니다.
Krithik Babu P - @DarkLycn1976