- Published on
JWT alg=none·kid 조작 취약점 재현과 차단법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 JWT를 인증 수단으로 사용할 때 가장 위험한 순간은 “토큰을 파싱했다”가 아니라 “검증했다고 믿었다”는 착각이 생길 때입니다. alg=none 우회와 kid 조작은 그 착각을 노리는 대표적인 공격 벡터입니다. 둘 다 헤더를 조작해 검증 로직이 잘못된 키를 쓰거나, 아예 서명을 건너뛰게 만드는 방식이라서, 구현이 조금만 느슨해도 관리자 권한 탈취까지 이어집니다.
이 글에서는 두 취약점을 재현 가능한 형태로 설명하고, 서비스에서 실제로 막는 체크리스트를 코드와 함께 정리합니다. kid·JWKS·키회전 이슈는 별도로 더 깊게 다룬 글도 있으니 함께 참고하면 좋습니다: JWT 서명 검증 실패 - kid·JWKS 캐시·키회전, Spring Security JWT 401 원인 - 시계오차·키롤오버
JWT 검증의 핵심: “파싱”과 “검증”을 분리하라
JWT는 header.payload.signature 3부분을 점(.)으로 연결한 문자열입니다.
header: 알고리즘(alg), 키 식별자(kid), 타입(typ) 등payload: 클레임(사용자 식별, 권한, 만료 등)signature:header.payload에 대한 서명
취약점은 보통 다음 중 하나에서 발생합니다.
- 헤더의
alg를 신뢰해서 검증 전략을 결정함 - 헤더의
kid를 신뢰해서 키를 로드함(파일/URL/DB 등) - “파싱 성공”을 “검증 성공”으로 착각함
- 라이브러리의 기본값/옵션 때문에
none또는 알고리즘 혼동이 허용됨
이제 공격을 재현해 보겠습니다.
취약점 1: alg=none 우회 (서명 없는 토큰 승인)
공격 아이디어
JWT 헤더의 alg를 none으로 두면 “서명 없음” 토큰이 됩니다. 정상적인 검증기는 이를 거부해야 합니다. 하지만 다음과 같은 구현은 위험합니다.
- 헤더의
alg를 읽고 그 값에 따라 검증을 분기 alg=none이면 검증을 스킵- 또는 라이브러리 옵션으로
none을 허용
재현: 서명 없는 JWT 만들기
아래는 교육 목적의 예시입니다. 실제 공격에 사용하지 마세요.
// node
function b64url(input) {
return Buffer.from(input)
.toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
const header = { alg: 'none', typ: 'JWT' };
const payload = { sub: 'admin', role: 'ADMIN', iat: Math.floor(Date.now()/1000) };
const token = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}.`;
console.log(token);
서명 파트가 비어 있는 토큰이 출력됩니다. 서버가 이 토큰을 “유효”로 받아들이면 즉시 권한 상승이 가능합니다.
흔한 취약 코드 (의도적으로 나쁜 예)
// Express 예시 (취약)
import jwt from 'jsonwebtoken';
export function auth(req, res, next) {
const token = (req.headers.authorization || '').replace('Bearer ', '');
const decoded = jwt.decode(token, { complete: true }); // 검증 아님
// 헤더를 신뢰해서 분기
if (decoded?.header?.alg === 'none') {
req.user = decoded.payload; // 서명 검증 없이 통과
return next();
}
// 그 외에는 검증
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
}
위 코드의 문제는 decode로 읽은 헤더를 신뢰한다는 점입니다. 공격자는 헤더를 마음대로 바꿀 수 있습니다.
차단 1: 알고리즘을 “허용 목록”으로 고정
대부분의 라이브러리는 verify 시 허용 알고리즘을 명시할 수 있습니다.
import jwt from 'jsonwebtoken';
export function auth(req, res, next) {
const token = (req.headers.authorization || '').replace('Bearer ', '');
const payload = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'], // 허용 목록 고정
});
req.user = payload;
next();
}
alg를 토큰 헤더에서 읽어 “선택”하지 말고- 서버 설정에서 “고정”하세요.
차단 2: decode는 로깅/디버깅 외에는 최소화
decode는 검증을 하지 않습니다. 운영 코드에서 decode 결과를 인증/인가에 사용하면 위험합니다.
차단 3: typ 같은 부가 헤더도 신뢰하지 말기
typ=JWT 여부는 보안 기능이 아닙니다. 단지 힌트입니다.
취약점 2: kid 조작 (잘못된 키 선택, 키 로드 경로 악용)
kid는 무엇이고 왜 위험한가
kid는 Key ID로, 서버가 여러 키를 운용할 때 “어떤 키로 검증할지” 찾는 인덱스입니다. 문제는 kid도 헤더에 있으므로 공격자가 조작 가능하다는 점입니다.
취약점은 보통 다음 패턴에서 발생합니다.
kid로 로컬 파일을 읽어 키를 로드 (디렉터리 트래버설)kid로 URL을 구성해 원격에서 키를 가져옴 (SSRF)kid로 DB를 조회하되 검증/화이트리스트 없이 임의 레코드 접근kid가 없거나 못 찾으면 “기본 키”로 폴백하는데, 그 기본 키가 공격자에게 유리
재현 시나리오 A: 파일 경로 기반 키 로딩
취약한 구현 예시는 다음과 같습니다.
// 취약 예시: kid를 파일명으로 사용
import fs from 'fs';
import jwt from 'jsonwebtoken';
export function auth(req, res, next) {
const token = (req.headers.authorization || '').replace('Bearer ', '');
const decoded = jwt.decode(token, { complete: true });
const kid = decoded?.header?.kid;
const keyPath = `/app/keys/${kid}.pem`; // kid 조작 가능
const pubKey = fs.readFileSync(keyPath, 'utf8');
req.user = jwt.verify(token, pubKey, { algorithms: ['RS256'] });
next();
}
공격자는 kid를 ../.. 형태로 조작해 임의 파일을 읽게 만들거나, 키 디렉터리 밖의 파일을 읽게 만들 수 있습니다. 읽은 파일이 “키로서 파싱 가능”한 형태가 아니더라도, 에러 메시지/타이밍으로 추가 정보를 얻는 경우가 많습니다.
차단: kid를 경로로 쓰지 말고 “매핑 테이블”로만 사용
import fs from 'fs';
import jwt from 'jsonwebtoken';
const KEYRING = {
'2026-01': fs.readFileSync('/app/keys/2026-01.pem', 'utf8'),
'2026-02': fs.readFileSync('/app/keys/2026-02.pem', 'utf8'),
};
export function auth(req, res, next) {
const token = (req.headers.authorization || '').replace('Bearer ', '');
const decoded = jwt.decode(token, { complete: true });
const kid = decoded?.header?.kid;
if (!kid || !KEYRING[kid]) {
return res.status(401).json({ message: 'invalid kid' });
}
const pubKey = KEYRING[kid];
const payload = jwt.verify(token, pubKey, { algorithms: ['RS256'] });
req.user = payload;
next();
}
포인트는 다음입니다.
kid는 키 선택을 위한 인덱스일 뿐- 파일명/경로/URL로 직접 이어 붙이지 않기
- 존재하지 않는
kid는 폴백하지 말고 즉시 거부
재현 시나리오 B: 원격 JWKS 조회에서 kid 악용
정석은 “고정된 issuer의 JWKS 엔드포인트”에서 키를 가져오고 캐시하는 것입니다. 그런데 일부 구현은 kid를 이용해 URL을 만들거나, 헤더의 jku 같은 값을 신뢰해 JWKS URL을 바꿉니다.
kid또는jku로 URL을 만들면 SSRF 가능- 내부 메타데이터 서비스나 사설망으로 요청이 나갈 수 있음
차단: JWKS URL은 서버 설정으로 고정, kid는 키셋 내부에서만 탐색
issuer고정jwksUri고정- 토큰 헤더의
jku/x5u는 무시 - 캐시 및 키 회전 정책 명확화
kid·JWKS 캐시·키 회전에서 자주 터지는 운영 이슈는 다음 글에서 더 자세히 다룹니다: JWT 서명 검증 실패 - kid·JWKS 캐시·키회전
알고리즘 혼동(Algorithm Confusion)도 함께 막아야 한다
alg=none과 kid 외에도 자주 언급되는 것이 알고리즘 혼동입니다.
- 서버는
RS256(비대칭)로 검증해야 하는데 - 구현이 헤더의
alg를 보고HS256(대칭)로도 검증해버리면 - 공격자가 공개키를
HS256의 시크릿처럼 사용해 서명을 위조할 수 있습니다.
즉, “허용 알고리즘 목록 고정”은 none 차단뿐 아니라 혼동 공격 차단에도 핵심입니다.
Spring Security/Java에서의 차단 포인트
Spring Security에서 JWT 리소스 서버를 쓸 때도 원칙은 같습니다.
알고리즘 및 issuer/audience 고정
issuer검증은 필수- 필요하면
aud도 검증 - 키는 JWKS URI를 설정으로 고정
예시(개념 코드):
// Spring Security Resource Server 개념 예시
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.decoder(jwtDecoder())
)
);
return http.build();
}
@Bean
JwtDecoder jwtDecoder() {
// issuer 기반 구성 또는 jwkSetUri를 '설정값으로 고정'
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withJwkSetUri("https://idp.example.com/.well-known/jwks.json")
.build();
// issuer/audience 검증 추가
OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(
"https://idp.example.com/"
);
decoder.setJwtValidator(withIssuer);
return decoder;
}
Spring에서 401이 자주 발생하는 케이스(시계 오차, 키 롤오버, 캐시 등)는 보안과 운영이 맞물린 문제라서 아래 글을 같이 보면 트러블슈팅에 도움이 됩니다.
실전 체크리스트: 재현 후 바로 막는 12가지
아래 항목을 만족하면 alg=none과 kid 조작의 대부분은 현실적으로 차단됩니다.
- 검증 시 허용 알고리즘을 서버 설정으로 고정 (
HS256또는RS256등) alg=none은 무조건 거부 (허용 목록에 포함시키지 않기)decode결과로 인증/인가 결정을 하지 않기kid는 화이트리스트(매핑 테이블)로만 사용하고 경로/URL 조합 금지kid미존재 시 폴백 금지, 즉시 401- JWKS URL은 설정으로 고정, 토큰 헤더의
jku/x5u무시 iss검증 필수, 가능하면aud도 검증exp/nbf검증 필수, 허용 clock skew를 작게- 키 캐시는 TTL과 갱신 전략을 명확히(키 회전 대비)
- 에러 메시지에 키 경로/내부 URL 등 민감정보 노출 금지
- 관리자 권한은 JWT 클레임만 믿지 말고 서버 측 권한 데이터와 교차검증 고려
- 보안 테스트에 “헤더 변조” 케이스를 포함(특히
alg,kid)
간단한 재현 테스트(보안 회귀 테스트) 아이디어
CI에서 매번 돌릴 수 있는 수준으로, “취약 구현이 다시 들어오지 않게” 하는 게 중요합니다.
alg=none 회귀 테스트 예시(의사 코드)
import request from 'supertest';
function makeNoneToken(payload) {
// 앞서 만든 b64url 함수로 header.alg='none' 토큰 생성
}
test('rejects alg none', async () => {
const token = makeNoneToken({ sub: 'admin', role: 'ADMIN' });
await request(app)
.get('/admin')
.set('Authorization', `Bearer ${token}`)
.expect(401);
});
kid 조작 회귀 테스트 예시
test('rejects unknown kid', async () => {
const token = makeSignedToken({ sub: 'user' }, { kid: 'does-not-exist' });
await request(app)
.get('/me')
.set('Authorization', `Bearer ${token}`)
.expect(401);
});
핵심은 “서명 자체가 맞느냐”만 테스트하는 게 아니라, 헤더 변조를 했을 때도 반드시 거부되는지를 테스트 케이스로 고정하는 것입니다.
마무리
alg=none과 kid 조작은 JWT의 구조적 특성(헤더가 공격자 제어 가능) 때문에 반복해서 등장하는 고전 취약점입니다. 방어도 복잡하지 않습니다.
- 알고리즘은 허용 목록으로 고정
kid는 화이트리스트 매핑으로만 사용- JWKS/issuer/audience는 서버 설정으로 고정
- 폴백과 자동 추론을 제거
이 원칙을 지키면 “JWT를 쓴다”가 아니라 “JWT를 검증한다”에 가까운 구현이 됩니다.