From c72f103221e873c582db7b68c32a71d482681317 Mon Sep 17 00:00:00 2001 From: imnyang Date: Mon, 2 Jun 2025 22:03:52 +0900 Subject: [PATCH] =?UTF-8?q?FEAT:=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/controller/PKCECheck.ts | 191 +++++++++---------- packages/backend/src/index.ts | 5 +- 2 files changed, 96 insertions(+), 100 deletions(-) diff --git a/packages/backend/src/controller/PKCECheck.ts b/packages/backend/src/controller/PKCECheck.ts index 8fc5671..6fd4ee7 100644 --- a/packages/backend/src/controller/PKCECheck.ts +++ b/packages/backend/src/controller/PKCECheck.ts @@ -2,138 +2,94 @@ import type { SDK } from "caido:plugin"; import { Body, RequestSpec, type Request } from "caido:utils"; export class PKCECheck { + // 필요한 PKCE 파라미터 목록 + private readonly requiredPKCEKeys = ["client_id", "response_type", "code_challenge", "code_challenge_method"]; + + // PKCE 취약점 테스트 메인 함수 async test(sdk: SDK, req: Request): Promise { const method = req.getMethod(); + const url = req.getUrl(); + + // GET 요청이 아니면 검사하지 않음 if (method !== "GET") { sdk.console.log("[PKCEDowngradeCheck] Not a GET request. Skipping."); return false; } - const query = req.getQuery(); - const searchParams = new URLSearchParams(query); - const requiredKeys = ["client_id", "response_type", "code_challenge", "code_challenge_method"]; + const searchParams = new URLSearchParams(req.getQuery()); - 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."); return false; } - const url = req.getUrl(); + // OpenID 여부 확인 const isOpenID = searchParams.get("scope")?.includes("openid") || url.includes("id_token"); const methodVal = searchParams.get("code_challenge_method"); const challengeVal = searchParams.get("code_challenge"); + // 파라미터가 없으면 경고 리포트 생성 if (!methodVal || !challengeVal) { - sdk.console.log("[PKCEDowngradeCheck] code_challenge or method missing. Skipping."); - 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", - }); + await this.reportFinding(sdk, req, url, isOpenID, "[WARN] PKCE Parameters Missing", "PKCE parameters are missing or incomplete."); return false; } + // code_challenge_method가 'plain'이면 취약할 수 있음 if (methodVal === "plain") { - sdk.console.log("[PKCEDowngradeCheck] code_challenge_method is 'plain'. Skipping."); - 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", - }); + await this.reportFinding(sdk, req, url, isOpenID, "[WARN] PKCE Method is 'plain'", "PKCE method is set to 'plain'. This may indicate a downgrade vulnerability."); return false; } - // Remove PKCE parameters to simulate a downgraded request + // PKCE 관련 파라미터 제거하여 다운그레이드된 URL 생성 searchParams.delete("code_challenge"); searchParams.delete("code_challenge_method"); 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}`; - sdk.console.log(`${req.getHost()} Original URL: ` + url); - sdk.console.log(`${req.getHost()} Downgraded URL: ` + downgradedUrl); + sdk.console.log(`${req.getHost()} Original URL: ${url}`); + sdk.console.log(`${req.getHost()} Downgraded URL: ${downgradedUrl}`); try { - // Use Caido Replay SDK to replay the original request - const spec = new RequestSpec(downgradedUrl); - spec.setBody(req.getBody() as Body); - for (const [key, value] of Object.entries(req.getHeaders())) { - if (Array.isArray(value)) { - spec.setHeader(key, value.join(', ')); // or another suitable delimiter - } else { - spec.setHeader(key, value); + // 원래 요청과 다운그레이드된 요청 각각 전송 + const downgradedResponse = await this.sendRequest(sdk, req, downgradedUrl, downgradedQuery); + const originalResponse = await this.sendRequest(sdk, req, url, req.getQuery()); + + if (downgradedResponse && originalResponse) { + const originalCode = originalResponse.getCode(); + const downgradedCode = downgradedResponse.getCode(); + + 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) { 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."); 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", + }); + } } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index bab0ee0..3072b06 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -30,10 +30,13 @@ export function init(sdk: SDK) { // } // }); + sdk.events.onInterceptRequest(async (sdk, req: Request) => { + await pkceCheckController.test(sdk, req); + }); + sdk.events.onInterceptResponse( async (sdk: SDK, {}>, req: Request, resp: Response) => { await csrfCheck.checker(sdk, req, resp); - await pkceCheckController.test(sdk, req); await tokenCheck.testReq(sdk, req); await tokenCheck.testResp(sdk, resp, req); await ScopeDetectionController.scan(sdk, req.getUrl());