FEAT: 리팩토링

This commit is contained in:
imnyang 2025-06-02 22:03:52 +09:00
commit c72f103221
2 changed files with 93 additions and 97 deletions

View file

@ -2,138 +2,94 @@ import type { SDK } from "caido:plugin";
import { Body, RequestSpec, type Request } from "caido:utils"; import { Body, RequestSpec, type Request } from "caido:utils";
export class PKCECheck { export class PKCECheck {
// 필요한 PKCE 파라미터 목록
private readonly requiredPKCEKeys = ["client_id", "response_type", "code_challenge", "code_challenge_method"];
// PKCE 취약점 테스트 메인 함수
async test(sdk: SDK, req: Request): Promise<boolean> { async test(sdk: SDK, req: Request): Promise<boolean> {
const method = req.getMethod(); const method = req.getMethod();
const url = req.getUrl();
// GET 요청이 아니면 검사하지 않음
if (method !== "GET") { if (method !== "GET") {
sdk.console.log("[PKCEDowngradeCheck] Not a GET request. Skipping."); sdk.console.log("[PKCEDowngradeCheck] Not a GET request. Skipping.");
return false; return false;
} }
const query = req.getQuery(); const searchParams = new URLSearchParams(req.getQuery());
const searchParams = new URLSearchParams(query);
const requiredKeys = ["client_id", "response_type", "code_challenge", "code_challenge_method"];
if (!requiredKeys.every((key) => searchParams.has(key))) { // 필수 PKCE 파라미터들이 모두 있는지 확인
if (!this.requiredPKCEKeys.every(key => searchParams.has(key))) {
sdk.console.log("[PKCEDowngradeCheck] Required PKCE parameters missing. Skipping."); sdk.console.log("[PKCEDowngradeCheck] Required PKCE parameters missing. Skipping.");
return false; return false;
} }
const url = req.getUrl(); // OpenID 여부 확인
const isOpenID = searchParams.get("scope")?.includes("openid") || url.includes("id_token"); const isOpenID = searchParams.get("scope")?.includes("openid") || url.includes("id_token");
const methodVal = searchParams.get("code_challenge_method"); const methodVal = searchParams.get("code_challenge_method");
const challengeVal = searchParams.get("code_challenge"); const challengeVal = searchParams.get("code_challenge");
// 파라미터가 없으면 경고 리포트 생성
if (!methodVal || !challengeVal) { if (!methodVal || !challengeVal) {
sdk.console.log("[PKCEDowngradeCheck] code_challenge or method missing. Skipping."); await this.reportFinding(sdk, req, url, isOpenID, "[WARN] PKCE Parameters Missing", "PKCE parameters are missing or incomplete.");
await sdk.findings.create({
title: isOpenID
? "[WARN] OpenID Flow PKCE Parameters Missing"
: "[WARN] OAuth2 Flow PKCE Parameters Missing",
description: `PKCE parameters are missing or incomplete for ${url}. This may indicate a misconfiguration.`,
request: req,
reporter: "PKCE Checker",
});
return false; return false;
} }
// code_challenge_method가 'plain'이면 취약할 수 있음
if (methodVal === "plain") { if (methodVal === "plain") {
sdk.console.log("[PKCEDowngradeCheck] code_challenge_method is 'plain'. Skipping."); await this.reportFinding(sdk, req, url, isOpenID, "[WARN] PKCE Method is 'plain'", "PKCE method is set to 'plain'. This may indicate a downgrade vulnerability.");
await sdk.findings.create({
title: isOpenID
? "[WARN] OpenID Flow PKCE Method is 'plain'"
: "[WARN] OAuth2 Flow PKCE Method is 'plain'",
description: `PKCE method is set to 'plain' for ${url}. This may indicate a downgrade vulnerability.`,
request: req,
reporter: "PKCE Checker",
});
return false; return false;
} }
// Remove PKCE parameters to simulate a downgraded request // PKCE 관련 파라미터 제거하여 다운그레이드된 URL 생성
searchParams.delete("code_challenge"); searchParams.delete("code_challenge");
searchParams.delete("code_challenge_method"); searchParams.delete("code_challenge_method");
const downgradedQuery = searchParams.toString(); const downgradedQuery = searchParams.toString();
const scheme = req.getUrl().startsWith("https") ? "https" : "http"; const scheme = url.startsWith("https") ? "https" : "http";
const downgradedUrl = `${scheme}://${req.getHost()}:${req.getPort()}${req.getPath()}?${downgradedQuery}`; const downgradedUrl = `${scheme}://${req.getHost()}:${req.getPort()}${req.getPath()}?${downgradedQuery}`;
sdk.console.log(`${req.getHost()} Original URL: ` + url); sdk.console.log(`${req.getHost()} Original URL: ${url}`);
sdk.console.log(`${req.getHost()} Downgraded URL: ` + downgradedUrl); sdk.console.log(`${req.getHost()} Downgraded URL: ${downgradedUrl}`);
try { try {
// Use Caido Replay SDK to replay the original request // 원래 요청과 다운그레이드된 요청 각각 전송
const spec = new RequestSpec(downgradedUrl); const downgradedResponse = await this.sendRequest(sdk, req, downgradedUrl, downgradedQuery);
spec.setBody(req.getBody() as Body); const originalResponse = await this.sendRequest(sdk, req, url, req.getQuery());
for (const [key, value] of Object.entries(req.getHeaders())) {
if (Array.isArray(value)) { if (downgradedResponse && originalResponse) {
spec.setHeader(key, value.join(', ')); // or another suitable delimiter const originalCode = originalResponse.getCode();
} else { const downgradedCode = downgradedResponse.getCode();
spec.setHeader(key, value);
const originalLoc = originalResponse.getHeader("location") || "";
const downgradedLoc = downgradedResponse.getHeader("location") || "";
sdk.console.log(`${req.getHost()} Original Status: ${originalCode}`);
sdk.console.log(`${req.getHost()} Downgraded Status: ${downgradedCode}`);
sdk.console.log(`${req.getHost()} Original Location: ${originalLoc}`);
sdk.console.log(`${req.getHost()} Downgraded Location: ${downgradedLoc}`);
// 두 응답 모두 리디렉션이면서 code= 파라미터 포함 시 취약점 리포트 생성
const bothRedirect = [301, 302].includes(originalCode) && [301, 302].includes(downgradedCode);
const bothContainCode = originalLoc.includes("code=") && downgradedLoc.includes("code=");
if (bothRedirect && bothContainCode) {
const title = isOpenID
? "[CRITICAL] OpenID Flow PKCE Downgrade Vulnerability"
: "[CRITICAL] OAuth2 Flow PKCE Downgrade Vulnerability";
const reference = isOpenID
? "https://openid.net/specs/openid-igov-oauth2-1_0-02.html#rfc.section.3.1.7"
: "https://datatracker.ietf.org/doc/html/rfc7636";
await sdk.findings.create({
title,
description: `PKCE downgrade vulnerability detected!\n\nOriginal URL: ${url}\nDowngraded URL: ${downgradedUrl}\n\nBoth requests returned authorization codes, indicating the server accepts requests without PKCE protection.\n\nReference: ${reference}`,
request: req,
reporter: "PKCE Checker",
});
return true;
} }
} }
spec.setHost(req.getHost());
spec.setMethod(req.getMethod());
spec.setPath(req.getPath());
spec.setQuery(downgradedQuery);
spec.setTls(req.getTls());
spec.setPort(req.getPort());
let sendDowngradedRequest = await sdk.requests.send(spec);
if (sendDowngradedRequest.response) {
let domain = spec.getHost();
let port = spec.getPort();
let path = spec.getPath();
let query = spec.getQuery();
let id = sendDowngradedRequest.response.getId();
let code = sendDowngradedRequest.response.getCode();
sdk.console.log(`REQ ${id}: ${domain}:${port}${path}${query} received a status code of ${code}`);
}
if (sendDowngradedRequest.response?.getCode() === 302) {
await sdk.findings.create({
title: isOpenID
? "[CRITICAL] OpenID Flow PKCE Downgrade Vulnerability"
: "[CRITICAL] OAuth2 Flow PKCE Downgrade Vulnerability",
description: `The request to ${url} is vulnerable to a PKCE downgrade attack. This may indicate a configuration error.`,
request: req,
reporter: "PKCE Checker",
});
}
/*
sdk.console.log(`${req.getHost()} Original Status: ` + resOriginal.status);
sdk.console.log(`${req.getHost()} Downgraded Status: ` + resDowngraded.status);
sdk.console.log(`${req.getHost()} Original Headers: ` + JSON.stringify(resOriginal.headers));
sdk.console.log(`${req.getHost()} Downgraded Headers: ` + JSON.stringify(resDowngraded.headers));
// Caido Dev Docs 기준으로, 리다이렉트된 URL은 Response 객체의 url 속성에 저장되어 있음
const locationOriginal = resOriginal.url ?? "";
const locationDowngraded = resDowngraded.url ?? "";
sdk.console.log(`${req.getHost()} Original Location: ` + locationOriginal);
sdk.console.log(`${req.getHost()} Downgraded Location: ` + locationDowngraded);
const statusEqual = resOriginal.status === resDowngraded.status;
const codeInRedirects = locationOriginal.includes("code=") && locationDowngraded.includes("code=");
if (statusEqual && codeInRedirects) {
const title = isOpenID
? "[CRITICAL] OpenID Flow PKCE Downgraded to Plaintext"
: "[CRITICAL] OAuth2 Flow PKCE Downgraded to Plaintext";
const reference = isOpenID
? "https://openid.net/specs/openid-igov-oauth2-1_0-02.html#rfc.section.3.1.7"
: "https://datatracker.ietf.org/doc/html/rfc7636";
await sdk.findings.create({
title,
description: `PKCE downgrade detected for ${url}.\n\nDowngraded URL: ${downgradedUrl}\n\nRedirect contained code=.\n\nReference: ${reference}`,
request: req,
reporter: "",
});
return true;
}*/
} catch (err) { } catch (err) {
sdk.console.error(`PKCE downgrade check failed for ${url}: ${String(err)}`); sdk.console.error(`PKCE downgrade check failed for ${url}: ${String(err)}`);
} }
@ -141,4 +97,41 @@ export class PKCECheck {
sdk.console.log("[PKCEDowngradeCheck] No PKCE downgrade detected."); sdk.console.log("[PKCEDowngradeCheck] No PKCE downgrade detected.");
return false; return false;
} }
// 요청 전송 도우미 함수
private async sendRequest(sdk: SDK, req: Request, url: string, query: string) {
const spec = new RequestSpec(url);
spec.setMethod(req.getMethod());
spec.setPath(req.getPath());
spec.setQuery(query);
spec.setBody(req.getBody() as Body);
spec.setHost(req.getHost());
spec.setPort(req.getPort());
spec.setTls(req.getTls());
for (const [key, value] of Object.entries(req.getHeaders())) {
spec.setHeader(key, Array.isArray(value) ? value.join(', ') : value);
}
const result = await sdk.requests.send(spec);
return result.response ?? null;
}
// 경고 리포트 생성 함수
private async reportFinding(
sdk: SDK,
req: Request,
url: string,
isOpenID: boolean,
title: string,
message: string
) {
const fullTitle = isOpenID ? `[WARN] OpenID Flow ${title}` : `[WARN] OAuth2 Flow ${title}`;
await sdk.findings.create({
title: fullTitle,
description: `${message} (${url})`,
request: req,
reporter: "PKCE Checker",
});
}
} }

View file

@ -30,10 +30,13 @@ export function init(sdk: SDK<API>) {
// } // }
// }); // });
sdk.events.onInterceptRequest(async (sdk, req: Request) => {
await pkceCheckController.test(sdk, req);
});
sdk.events.onInterceptResponse( sdk.events.onInterceptResponse(
async (sdk: SDK<DefineAPI<{}>, {}>, req: Request, resp: Response) => { async (sdk: SDK<DefineAPI<{}>, {}>, req: Request, resp: Response) => {
await csrfCheck.checker(sdk, req, resp); await csrfCheck.checker(sdk, req, resp);
await pkceCheckController.test(sdk, req);
await tokenCheck.testReq(sdk, req); await tokenCheck.testReq(sdk, req);
await tokenCheck.testResp(sdk, resp, req); await tokenCheck.testResp(sdk, resp, req);
await ScopeDetectionController.scan(sdk, req.getUrl()); await ScopeDetectionController.scan(sdk, req.getUrl());