- Published on
JWT alg=none·kid 인젝션 취약점 재현·방어
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
JWT는 인증·인가에서 사실상 표준처럼 쓰이지만, 검증 로직이 조금만 느슨해져도 서명 무력화 혹은 키 선택 조작으로 바로 뚫립니다. 특히 오래된 구현에서 자주 보이는 두 가지가 alg=none 우회와 kid 인젝션입니다.
이 글에서는 다음을 목표로 합니다.
alg=none이 왜 위험한지, 어떤 조건에서 우회가 되는지kid헤더가 왜 공격 표면이 되는지, 실제로 어떤 형태로 터지는지- Node.js 예시로
재현 가능한최소 PoC를 만들고, 방어 코드를 함께 제시 - 운영 환경에서 재발을 막기 위한 키 관리·JWKS·로깅·테스트 체크리스트
JWT 자체의 상세 스펙 설명은 최소화하고, 실전에서 터지는 지점만 집중합니다.
JWT 검증이 깨지는 대표 패턴 2가지
1) alg=none 우회(서명 검증 스킵)
JWT 헤더의 alg는 서명 알고리즘을 의미합니다. 원칙적으로 서버는 자신이 허용한 알고리즘만 검증해야 합니다.
문제는 다음처럼 구현하면 생깁니다.
- 토큰 헤더의
alg를 그대로 신뢰하고 alg=none일 때 서명 검증을 건너뛰거나- 라이브러리 옵션을 잘못 줘서
none을 허용
공격자는 서명 없이도 페이로드를 마음대로 바꾼 JWT를 만들어 관리자 권한으로 위장할 수 있습니다.
2) kid 인젝션(키 선택 조작)
JWT 헤더의 kid는 Key ID입니다. 서버가 여러 키를 운용할 때 kid로 어떤 키로 검증할지 선택합니다.
취약해지는 조건은 보통 이렇습니다.
kid값을 그대로 파일 경로로 붙여서 키 파일을 읽음(경로 조작)kid를 DB 쿼리/캐시 키/템플릿에 그대로 넣음(인젝션)kid가 가리키는 원격 URL에서 키를 가져오도록 구현(SSRF)
핵심은 kid는 신뢰할 수 없는 입력이라는 점입니다. 헤더는 공격자가 마음대로 만들 수 있습니다.
실습 환경: 취약한 JWT 검증 서버 만들기
아래는 교육 목적의 취약 예시입니다. 실제 서비스에 절대 그대로 적용하면 안 됩니다.
프로젝트 준비
mkdir jwt-lab && cd jwt-lab
npm init -y
npm i express jsonwebtoken
취약한 서버 코드
alg를 토큰 헤더에서 읽어 그대로 사용kid를 파일명으로 사용해 키를 로드
// server-vuln.js
const express = require('express');
const fs = require('fs');
const path = require('path');
const jwt = require('jsonwebtoken');
const app = express();
function loadKeyByKid(kid) {
// 취약: kid를 그대로 경로에 사용
const p = path.join(__dirname, 'keys', kid + '.pem');
return fs.readFileSync(p, 'utf8');
}
app.get('/admin', (req, res) => {
const auth = req.headers.authorization || '';
const token = auth.startsWith('Bearer ') ? auth.slice(7) : null;
if (!token) return res.status(401).send('no token');
const header = jwt.decode(token, { complete: true })?.header;
if (!header) return res.status(401).send('bad token');
const alg = header.alg; // 취약: alg를 신뢰
const kid = header.kid || 'default';
let key;
try {
key = loadKeyByKid(kid);
} catch (e) {
return res.status(401).send('unknown kid');
}
try {
const payload = jwt.verify(token, key, {
algorithms: [alg] // 취약: 토큰이 선언한 alg를 그대로 허용
});
if (payload.role !== 'admin') return res.status(403).send('forbidden');
return res.json({ ok: true, payload });
} catch (e) {
return res.status(401).send('verify failed');
}
});
app.listen(3000, () => console.log('vuln server on :3000'));
키 디렉터리를 만들고 기본 키를 넣습니다.
mkdir -p keys
openssl genrsa -out keys/default.pem 2048
openssl rsa -in keys/default.pem -pubout -out keys/default.pub.pem
여기서는 단순화를 위해 verify에 private key를 넣었지만, 실제는 public key로 검증해야 합니다. 어쨌든 취약점 포인트는 alg와 kid 처리입니다.
서버 실행:
node server-vuln.js
취약점 재현 1: alg=none 토큰으로 서명 없이 통과시키기
alg=none 공격이 통하는지는 라이브러리/옵션에 따라 다릅니다. 과거에는 none이 기본 허용되거나, 개발자가 algorithms를 잘못 지정해 허용되는 경우가 있었습니다.
위 취약 서버는 algorithms: [alg]로 토큰이 선언한 알고리즘을 그대로 허용하므로, 라이브러리가 none을 받아들이는 구성이라면 그대로 뚫릴 수 있습니다.
alg=none JWT 만들기(교육용)
JWT는 base64url(header).base64url(payload).signature 구조입니다. none이면 signature가 빈 문자열이 됩니다.
// make-none-jwt.js
function b64url(obj) {
return Buffer.from(JSON.stringify(obj))
.toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
const header = { alg: 'none', typ: 'JWT', kid: 'default' };
const payload = { sub: 'attacker', role: 'admin', iat: Math.floor(Date.now() / 1000) };
const token = `${b64url(header)}.${b64url(payload)}.`;
console.log(token);
실행:
node make-none-jwt.js
요청:
curl -H "Authorization: Bearer $(node make-none-jwt.js)" http://localhost:3000/admin
- 만약 통과된다면 즉시 취약입니다.
- 통과되지 않더라도, 실무에서는 다른 형태로
알고리즘 혼동이 자주 터집니다. 예를 들어 RS256을 기대하는데 HS256으로 바꿔 public key를 HMAC secret처럼 쓰게 만드는 혼동이 대표적입니다.
취약점 재현 2: kid 인젝션으로 키 로딩을 조작하기
kid는 보통 “어떤 키로 검증할지”를 선택하는 라우팅 값입니다. 그런데 이를 파일 경로로 직결하면 경로 조작이 됩니다.
위 서버는 다음 코드가 위험합니다.
path.join(__dirname, 'keys', kid + '.pem')
공격자가 kid에 ../ 패턴을 넣으면 keys 밖 파일을 읽으려 시도할 수 있습니다. 운영 환경에서 키 디렉터리 외부에 민감 파일이 있거나, 우연히 서명 검증이 통과되는 키를 읽게 되면 위험해집니다.
kid 경로 조작 예시
아래는 개념 예시입니다. 실제로는 경로가 존재해야 하며, 읽힌 내용이 검증 키로 사용 가능한 형태여야 합니다.
// make-kid-traversal.js
const jwt = require('jsonwebtoken');
// 정상 토큰을 만들되, kid를 조작
const header = { kid: '../../somewhere/secret', alg: 'RS256', typ: 'JWT' };
const payload = { sub: 'attacker', role: 'admin' };
// 여기서는 토큰 생성 자체가 목적이 아니라 kid 조작 형태를 보여주기 위함
const token = jwt.sign(payload, 'dummy', { header, algorithm: 'HS256' });
console.log(token);
이 토큰은 서버가 RS256을 기대하는데 HS256으로 만들었으니 그대로는 통과하지 않을 가능성이 큽니다. 하지만 실무에서는 다음이 결합되면 위험해집니다.
alg를 토큰에서 읽어 허용(알고리즘 혼동)kid로 로드한 키를 HMAC secret처럼 사용- 키 로딩이 파일/원격/JWKS 캐시 등 복잡해지며 예외 케이스가 발생
즉 kid 인젝션은 단독보다, alg 처리 미흡과 함께 폭발하는 경우가 많습니다.
방어 1: 서버가 허용하는 알고리즘을 고정하고 none을 금지
가장 중요한 원칙은 이것입니다.
alg는 토큰이 정하는 값이 아니라 서버 정책이다.
안전한 검증 코드 예시(Node.js)
// verify-safe.js
const jwt = require('jsonwebtoken');
const ALLOWED_ALGS = ['RS256'];
function verifyAccessToken(token, publicKey) {
// jwt.verify는 header.alg를 참고하더라도, algorithms 옵션으로 제한 가능
return jwt.verify(token, publicKey, {
algorithms: ALLOWED_ALGS,
issuer: 'your-issuer',
audience: 'your-audience'
});
}
module.exports = { verifyAccessToken };
추가로 다음을 권장합니다.
typ가 JWT인지 확인하되, 이것만으로 신뢰하지 말 것iss,aud,exp,nbf를 반드시 검증- 토큰이 길거나 헤더가 비정상적으로 크면 즉시 거부(DoS 완화)
방어 2: kid는 화이트리스트 매핑으로만 처리
kid를 파일명/URL/쿼리로 직접 쓰지 말고, “허용된 키 집합”에서만 선택해야 합니다.
안전한 kid 매핑 예시
// key-resolver-safe.js
const fs = require('fs');
const path = require('path');
const KEYRING = {
'key-2025-01': path.join(__dirname, 'keys', 'key-2025-01.pub.pem'),
'key-2025-02': path.join(__dirname, 'keys', 'key-2025-02.pub.pem')
};
function resolvePublicKey(kid) {
const p = KEYRING[kid];
if (!p) {
const err = new Error('unknown kid');
err.code = 'UNKNOWN_KID';
throw err;
}
return fs.readFileSync(p, 'utf8');
}
module.exports = { resolvePublicKey };
핵심 포인트:
kid는KEYRING의 키로만 사용- 파일 경로 조합을 공격자 입력으로 만들지 않음
unknown kid는 401로 처리하되, 내부 경로/스택트레이스를 노출하지 않음
방어 3: JWKS를 쓴다면 “고정된 JWKS URL”과 강한 캐시 정책
실무에서는 IdP가 JWKS(JSON Web Key Set)를 제공하고, 서비스는 이를 가져와 검증합니다. 이때도 kid는 “JWKS에서 어떤 키를 고를지”의 선택자일 뿐입니다.
주의할 점:
- JWKS URL은 서버 설정으로 고정(토큰 헤더의
jku같은 값으로 바꾸지 않기) kid가 JWKS에 없으면 실패- JWKS fetch는 타임아웃, 리트라이, 캐시를 명확히
- 캐시 미스 폭증 시 외부 호출이 급증하며 장애로 이어질 수 있음
운영에서 캐시/콜드스타트가 겹치면 인증 계층이 병목이 되기 쉽습니다. 비슷한 장애 대응 관점은 GCP Cloud Run 503·콜드스타트 폭증 해결 가이드도 참고할 만합니다.
방어 4: 알고리즘 혼동(HS256 vs RS256)까지 같이 막기
alg=none이 막혔는데도 뚫리는 케이스가 바로 알고리즘 혼동입니다.
- 서버는 RS256(public key 검증)을 기대
- 공격자는 HS256(HMAC)로 토큰을 만들고
- 서버가
alg를 신뢰하거나, 키를 잘못 다뤄 public key를 HMAC secret처럼 사용
대응:
algorithms를 단일 값으로 고정(예: RS256만)- RS 계열 검증 시에는 반드시 public key만 사용
- 라이브러리에서
key type을 검증하거나, 키 로딩 단계에서 PEM 타입을 확인
방어 5: 로깅·모니터링·테스트로 재발 방지
취약점은 “코드 한 줄”이 아니라 “팀의 습관”에서 재발합니다. 다음을 추천합니다.
로깅
unknown kid발생률- 허용되지 않은
alg시도 횟수 - JWT 헤더 크기, 토큰 길이 분포(비정상 급증 감지)
민감정보 유출을 막기 위해 토큰 전체를 로그에 남기지 말고, 해시나 일부만 남기세요.
보안 테스트(회귀 테스트)
alg=none토큰은 항상 401- 허용되지 않은
alg는 항상 401 kid에 특수문자/경로 구분자/비정상 길이를 넣어도 항상 401
CI에서 이런 케이스를 자동화하면, 리팩터링 중 옵션이 바뀌어도 즉시 잡힙니다. 캐시/키 관리가 CI에서 엇나가는 문제는 GitHub Actions 캐시 미스? 키·경로 함정 9가지처럼 “경로/키 문자열”이 원인인 경우가 많아, kid 화이트리스트 전략과도 결이 같습니다.
실전 체크리스트
algorithms는 서버 정책으로 고정(가능하면 단일 알고리즘)none은 어떤 경우에도 허용하지 않기kid는 화이트리스트 매핑으로만 처리(파일 경로/URL/쿼리에 직접 사용 금지)- JWKS를 쓴다면 JWKS URL은 설정으로 고정하고 강한 캐시·타임아웃 적용
iss,aud,exp,nbf검증 필수- 토큰 크기 제한 및 비정상 헤더 방어
unknown kid,invalid alg를 모니터링하고 회귀 테스트에 포함
결론
alg=none과 kid 인젝션은 “JWT는 서명만 맞으면 된다”는 오해에서 시작합니다. 실제로는 어떤 알고리즘을 허용할지, 어떤 키를 선택할지라는 정책 결정이 검증 로직의 핵심입니다.
alg는 서버가 고정kid는 화이트리스트로 제한- JWKS/캐시/운영 관점까지 포함해 검증 파이프라인을 설계
이 3가지만 지켜도 JWT 관련 사고의 대부분을 구조적으로 줄일 수 있습니다.