From b32d4e02af72c9be7fabf4a99f4d0366ba80214a Mon Sep 17 00:00:00 2001 From: kyu Date: Sun, 1 Jun 2025 20:12:12 +0900 Subject: [PATCH 01/15] clientsecretCheck --- .../src/controller/clientsecretCheck.ts | 33 +++++++++++++++++++ packages/backend/src/index.ts | 6 ++++ 2 files changed, 39 insertions(+) create mode 100644 packages/backend/src/controller/clientsecretCheck.ts diff --git a/packages/backend/src/controller/clientsecretCheck.ts b/packages/backend/src/controller/clientsecretCheck.ts new file mode 100644 index 0000000..0a13917 --- /dev/null +++ b/packages/backend/src/controller/clientsecretCheck.ts @@ -0,0 +1,33 @@ +import type { SDK } from "caido:plugin"; +import type { Request } from "caido:utils"; + +export class ClientSecretController { + test(req: Request): boolean { + const query = req.getQuery() ?? ""; /* URL에서 검사 */ + + const bodyRaw = req.getBody(); /* BODY 에서 검사 */ + const body = typeof bodyRaw === "string" ? bodyRaw : Array.isArray(bodyRaw) ? bodyRaw.join("&") : ""; + + const authRaw = req.getHeader("authorization"); /* authz 헤더 에서 검사 */ + const auth = typeof authRaw === "string" ? authRaw : Array.isArray(authRaw) ? authRaw.join(" ") : ""; + + return ( + query.includes("client_secret=") || + body.includes("client_secret=") || + auth.toLowerCase().startsWith("basic ") + ); + } + + async report(sdk: SDK, req: Request): Promise { + const url = req.getUrl(); + + await sdk.findings.create({ + title: "Exposed client_secret", + description: `The request to \`${url}\` contains a potential exposure of the OAuth2 \`client_secret\`.`, + request: req, + reporter: "Client_Secret_Finder", + dedupeKey: "client_secret_exposure" + }); + } +} + diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 7633932..7022449 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -3,12 +3,14 @@ import type { Request } from "caido:utils"; import { ImplicitGrantController } from "./controller/implictGrant"; import { AuthZCodeGrantController } from "./controller/authZCodeGrant"; import { PKCECheck } from "./controller/PKCECheck"; +import { ClientSecretController } from "./controller/clientsecretCheck"; export type API = DefineAPI<{}>; const implicitGrantController = new ImplicitGrantController(); const authZCodeGrantController = new AuthZCodeGrantController(); const pkceCheck = new PKCECheck(); +const clientSecretController = new ClientSecretController(); // function matchSSORequest(req: Request): boolean { // const raw = req.getRaw().toString(); @@ -44,6 +46,10 @@ export function init(sdk: SDK) { reporter: "", }); } + if (clientSecretController.test(req)) { + await clientSecretController.report(sdk,req); + } + }); } From 2010b85c4df2d29a66be67c3cc6a5423867828a7 Mon Sep 17 00:00:00 2001 From: "tv0924@icloud.com" Date: Sun, 1 Jun 2025 20:14:10 +0900 Subject: [PATCH 02/15] =?UTF-8?q?[Fix]=20=ED=8A=B9=EC=A0=95=20=EA=B2=BD?= =?UTF-8?q?=EC=9A=B0=EC=97=90=EC=84=9C=20csrf=20=EB=B0=A9=EC=A7=80=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=EC=9D=B4=20=EC=97=86=EB=8B=A4=EA=B3=A0=20?= =?UTF-8?q?=ED=8C=90=EB=B3=84=ED=95=9C=20=EA=B2=83=EC=9D=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/controller/csrfCheck.ts | 61 ++++++++++---------- 1 file changed, 30 insertions(+), 31 deletions(-) diff --git a/packages/backend/src/controller/csrfCheck.ts b/packages/backend/src/controller/csrfCheck.ts index 727b65b..f5018d5 100644 --- a/packages/backend/src/controller/csrfCheck.ts +++ b/packages/backend/src/controller/csrfCheck.ts @@ -5,18 +5,27 @@ import { HttpUtils } from "../utils/http"; const httpUtils = new HttpUtils(); export class CsrfCheck { + private isTargetUri(uri: string): boolean { + if ( + uri.includes("client_id=") && + (uri.includes("response_type=") || + uri.includes("grant_type=") || + uri.includes("redirect_uri=") || + uri.includes("scope=") || + uri.includes("state=") || + uri.includes("nonce=")) + ) { + return true; + } + + return false; + } + private isOauthUri(request: Request): boolean { - const query = request.getQuery() || ""; + const uri = request.getUrl() || ""; // Check if the request is an OAuth authorization request - if ( - query.includes("client_id=") && - (query.includes("response_type=") || - query.includes("grant_type=") || - query.includes("redirect_uri=") || - query.includes("scope=") || - query.includes("state=")) - ) { + if (this.isTargetUri(uri)) { return true; } @@ -25,23 +34,10 @@ export class CsrfCheck { private isOauthRedirectResponse(response: Response): boolean { const status = response.getCode(); - const locationHeader = httpUtils.getHeaderValue( - response.getHeaders(), - "location" - ); + const uri = + httpUtils.getHeaderValue(response.getHeaders(), "location") || ""; - if ( - status >= 300 && - status < 400 && - locationHeader && - (locationHeader.includes("client_id=") || - locationHeader.includes("response_type=") || - locationHeader.includes("grant_type=") || - locationHeader.includes("redirect_uri=") || - locationHeader.includes("scope=") || - locationHeader.includes("state=") || - locationHeader.includes("code=")) // code is also common in OAuth redirects - ) { + if (status >= 300 && status < 400 && this.isTargetUri(uri)) { return true; } return false; @@ -49,7 +45,9 @@ export class CsrfCheck { private isStateInQuery(request: Request): boolean { const query = request.getQuery(); - const stateValue = httpUtils.getQueryParam(query || "", "state"); + const stateValue = + httpUtils.getQueryParam(query || "", "state") || + httpUtils.getQueryParam(query || "", "nonce"); if (!stateValue) { return false; } @@ -72,17 +70,18 @@ export class CsrfCheck { // 요청에서 보낸 state 추출 const query = request.getQuery() || ""; - const originalState = httpUtils.getQueryParam(query, "state"); + const originalState = + httpUtils.getQueryParam(query, "state") || + httpUtils.getQueryParam(query || "", "nonce"); // 리다이렉트 URL에서 쿼리 부분만 추출 const locationHeader = httpUtils.getHeaderValue( response.getHeaders(), "location" ); - const responseState = httpUtils.getQueryParamFromURI( - locationHeader || "", - "state" - ); + const responseState = + httpUtils.getQueryParamFromURI(locationHeader || "", "state") || + httpUtils.getQueryParamFromURI(locationHeader || "", "nonce"); // state가 없거나, 요청값과 다르면 CSRF 위험 if (!responseState) { From 77a65002f7209ca80c000d1abe3701047ebf04af Mon Sep 17 00:00:00 2001 From: KMINGON Date: Sun, 1 Jun 2025 20:59:48 +0900 Subject: [PATCH 03/15] =?UTF-8?q?[FIX]:=20=ED=83=90=EC=A7=80=20=ED=82=A4?= =?UTF-8?q?=EC=9B=8C=EB=93=9C=20=EC=A0=95=EC=83=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controller/accessTokenDetector.ts | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/controller/accessTokenDetector.ts b/packages/backend/src/controller/accessTokenDetector.ts index 8093a54..6e95120 100644 --- a/packages/backend/src/controller/accessTokenDetector.ts +++ b/packages/backend/src/controller/accessTokenDetector.ts @@ -51,7 +51,7 @@ export class AccessTokenLeakController { return { found: true, location: 'url', - title: "Access Token Leak in URL", + title: "Token Leak in URL", description: `요청 URL에 토큰이 포함되어 있습니다. (토큰: ${extractedTokenFromUrl.substring(0, 20)}...)`, value: url }; @@ -69,7 +69,7 @@ export class AccessTokenLeakController { return { found: true, location: 'body', - title: "Access Token Leak in Request Body", + title: "Token Leak in Request Body", description: `요청 Body에 토큰이이 포함되어 있습니다. (토큰: ${extractedTokenFromBody.substring(0, 20)}...)`, value: bodyText }; @@ -98,7 +98,7 @@ export class AccessTokenLeakController { return { found: true, location: 'header', - title: "Access Token Leak in Redirect URL", + title: "Token Leak in Redirect URL", description: `Location 헤더에 토큰이 노출되었습니다: ${locationHeaderStr} (토큰: ${extractedTokenFromHeader.substring(0, 20)}...)`, value: locationHeaderStr }; @@ -117,7 +117,7 @@ export class AccessTokenLeakController { return { found: true, location: 'body', - title: "Access Token Leak in Response Body", + title: "Token Leak in Response Body", description: `HTTP 응답 본문에 토큰이 노출되었습니다. (토큰: ${extractedTokenFromBody.substring(0, 20)}...)`, value: bodyText }; @@ -136,10 +136,18 @@ private extractTokenFromText(text: string): string | null { // 토큰 관련 키워드 리스트 const tokenKeys = [ 'access_token', - 'id_token', + 'accesstoken', + 'Access-Token', + 'Refresh_Token', + 'Refresh-Token', + 'RefreshToken', + 'Secret_Token', + 'Secret-Token', + 'SecretToken', + 'SSO_Auth', + 'SSO-Auth', + 'SSOAuth', 'auth_token', - 'token', - 'jwt', 'session_token' ]; From b8b7edb5ac8fbb9243b7ade4c6deaf016f9cc07a Mon Sep 17 00:00:00 2001 From: "tv0924@icloud.com" Date: Mon, 2 Jun 2025 10:50:11 +0900 Subject: [PATCH 04/15] =?UTF-8?q?[Update]=20oauth=20=ED=83=90=EC=A7=80=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=A0=95=EA=B5=90=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/controller/csrfCheck.ts | 56 +++++--- packages/backend/src/index.ts | 22 ++-- packages/backend/src/utils/http.ts | 18 +-- pnpm-lock.yaml | 129 ++++++++++++++++++- 4 files changed, 180 insertions(+), 45 deletions(-) diff --git a/packages/backend/src/controller/csrfCheck.ts b/packages/backend/src/controller/csrfCheck.ts index f5018d5..1826ddd 100644 --- a/packages/backend/src/controller/csrfCheck.ts +++ b/packages/backend/src/controller/csrfCheck.ts @@ -7,13 +7,13 @@ const httpUtils = new HttpUtils(); export class CsrfCheck { private isTargetUri(uri: string): boolean { if ( - uri.includes("client_id=") && - (uri.includes("response_type=") || - uri.includes("grant_type=") || - uri.includes("redirect_uri=") || - uri.includes("scope=") || - uri.includes("state=") || - uri.includes("nonce=")) + httpUtils.getQueryParamFromURI(uri, "client_id") && + (httpUtils.getQueryParamFromURI(uri, "response_type") || + httpUtils.getQueryParamFromURI(uri, "grant_type") || + httpUtils.getQueryParamFromURI(uri, "redirect_uri") || + httpUtils.getQueryParamFromURI(uri, "scope") || + httpUtils.getQueryParamFromURI(uri, "state") || + httpUtils.getQueryParamFromURI(uri, "nonce")) ) { return true; } @@ -151,15 +151,25 @@ export class CsrfCheck { let result = ``; // 쿼리에 state 파라미터가 없으면 CSRF 위험 - if (this.isOauthUri(request) && !this.isStateInQuery(request)) { - result += "CSRF risk: missing state parameter"; // CSRF risk: missing state parameter + try { + if (this.isOauthUri(request) && !this.isStateInQuery(request)) { + result += "CSRF risk: missing state parameter"; // CSRF risk: missing state parameter + } + } catch (error) { + sdk.console.error(`Error checking state in query: ${error}`); } // location 헤더에 state 파라미터가 없거나, 요청에서 보낸 state와 다르면 CSRF 위험 - const stateAtResponseLocationHeaderCheck = - this.checkStateAtResponseLocationHeader(request, response); - if (stateAtResponseLocationHeaderCheck !== 0) { - result += `, ${stateAtResponseLocationHeaderCheck.join(", ")}`; + try { + const stateAtResponseLocationHeaderCheck = + this.checkStateAtResponseLocationHeader(request, response); + if (stateAtResponseLocationHeaderCheck !== 0) { + result += `, ${stateAtResponseLocationHeaderCheck.join(", ")}`; + } + } catch (error) { + sdk.console.error( + `Error checking state in response location header: ${error}` + ); } // // 처음으로 state를 발급한 요청에서 state 파라미터를 바꿔서 보내기 @@ -168,13 +178,19 @@ export class CsrfCheck { // result += `, ${reusedStateCheck.join(", ")}`; // } - if (result) { - await sdk.findings.create({ - title: "csrf vuln", - description: `SSO-related parameters detected in response:\n\n${request.getMethod()} ${request.getUrl()} : ${result}`, - request, - reporter: "csrf reporter", - }); + result.replace(/^\s*,\s*|\s*$/, ""); // Remove leading/trailing commas + try { + if (result) { + await sdk.findings.create({ + title: "csrf vuln", + description: `SSO-related parameters detected in response:\n\n${request.getMethod()} ${request.getUrl()} : ${result}`, + request, + reporter: "csrf reporter", + }); + sdk.console.log("qq"); + } + } catch (error) { + sdk.console.error(`Error creating finding: ${error}`); } } } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 0165988..6ed4c7b 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -6,17 +6,15 @@ import { CsrfCheck } from "./controller/csrfCheck"; import { PKCECheck } from "./controller/PKCECheck"; import { AccessTokenLeakController } from "./controller/accessTokenDetector"; import { ScopeDetection } from "./controller/scopeDetection"; -import { NonceCheckController } from "./controller/nonceCheck"; +// import { NonceCheckController } from "./controller/nonceCheck"; export type API = DefineAPI<{}>; const csrfCheck = new CsrfCheck(); -// const implicitGrantController = new ImplicitGrantController(); -// const authZCodeGrantController = new AuthZCodeGrantController(); const pkceCheckController = new PKCECheck(); const tokenCheck = new AccessTokenLeakController(); const ScopeDetectionController = new ScopeDetection(); -const nonceCheckController = new NonceCheckController(); +// const nonceCheckController = new NonceCheckController(); export function init(sdk: SDK) { sdk.events.onInterceptResponse(async (sdk, req: Request, res: Response) => { @@ -26,14 +24,14 @@ export function init(sdk: SDK) { await tokenCheck.testResp(sdk, res, req); await ScopeDetectionController.scan(sdk, req.getUrl()); - if (NonceCheckController.isOidcFlow(req, res)) { - await sdk.findings.create({ - title: "OIDC Flow Detected", - description: "The request appears to be part of an OIDC flow.", - request: req, - reporter: "", - }); - } + // if (NonceCheckController.isOidcFlow(req, res)) { + // await sdk.findings.create({ + // title: "OIDC Flow Detected", + // description: "The request appears to be part of an OIDC flow.", + // request: req, + // reporter: "", + // }); + // } }); /* diff --git a/packages/backend/src/utils/http.ts b/packages/backend/src/utils/http.ts index 56a6fe1..9fcd741 100644 --- a/packages/backend/src/utils/http.ts +++ b/packages/backend/src/utils/http.ts @@ -48,8 +48,8 @@ export class HttpUtils { } getQueryParamFromURI(uri: string, key: string): string | null { - uri = uri.toLowerCase(); - key = key.toLowerCase(); + uri = this.decodeAndLower(uri); + key = this.decodeAndLower(key); try { const urlObj = new URL(uri); return urlObj.searchParams.get(key); @@ -66,8 +66,8 @@ export class HttpUtils { * @returns - 해당 파라미터 값, 없으면 null */ getQueryParam(query: string, key: string): string | null { - query = query.toLowerCase(); - key = key.toLowerCase(); + query = this.decodeAndLower(query); + key = this.decodeAndLower(key); const params = new URLSearchParams(query); return params.get(key); @@ -82,9 +82,9 @@ export class HttpUtils { * @returns - "a=1&b=2&c=3..." 형태의 새로운 쿼리 문자열 */ setQueryParam(query: string, key: string, value: string): string { - query = query.toLowerCase(); - key = key.toLowerCase(); - value = value.toLowerCase(); + query = this.decodeAndLower(query); + key = this.decodeAndLower(key); + value = this.decodeAndLower(value); const params = new URLSearchParams(query); params.set(key, value); @@ -99,8 +99,8 @@ export class HttpUtils { * @returns - 삭제된 상태의 새로운 쿼리 문자열 */ removeQueryParam(query: string, key: string): string { - query = query.toLowerCase(); - key = key.toLowerCase(); + query = this.decodeAndLower(query); + key = this.decodeAndLower(key); const params = new URLSearchParams(query); params.delete(key); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83609d4..1caa9d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,10 +7,17 @@ settings: importers: .: + dependencies: + '@types/jsonwebtoken': + specifier: ^9.0.9 + version: 9.0.9 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 devDependencies: '@caido-community/dev': specifier: ^0.1.3 - version: 0.1.5(postcss@8.5.3)(typescript@5.5.4) + version: 0.1.5(@types/node@22.15.29)(postcss@8.5.3)(typescript@5.5.4) '@caido/sdk-backend': specifier: ^0.48.1 version: 0.48.1 @@ -328,6 +335,15 @@ packages: '@types/estree@1.0.7': resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} + '@types/jsonwebtoken@9.0.9': + resolution: {integrity: sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@22.15.29': + resolution: {integrity: sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -364,6 +380,9 @@ packages: brace-expansion@2.0.1: resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -465,6 +484,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -622,9 +644,19 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} @@ -639,6 +671,27 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.sortby@4.7.0: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} @@ -837,6 +890,11 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + send@1.2.0: resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} engines: {node: '>= 18'} @@ -971,6 +1029,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -1065,7 +1126,7 @@ packages: snapshots: - '@caido-community/dev@0.1.5(postcss@8.5.3)(typescript@5.5.4)': + '@caido-community/dev@0.1.5(@types/node@22.15.29)(postcss@8.5.3)(typescript@5.5.4)': dependencies: '@caido/plugin-manifest': 0.3.0 chalk: 5.4.1 @@ -1076,7 +1137,7 @@ snapshots: jiti: 2.4.2 jszip: 3.10.1 tsup: 8.3.5(jiti@2.4.2)(postcss@8.5.3)(typescript@5.5.4) - vite: 6.0.7(jiti@2.4.2) + vite: 6.0.7(@types/node@22.15.29)(jiti@2.4.2) ws: 8.18.0 zod: 3.24.1 transitivePeerDependencies: @@ -1284,6 +1345,17 @@ snapshots: '@types/estree@1.0.7': {} + '@types/jsonwebtoken@9.0.9': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 22.15.29 + + '@types/ms@2.1.0': {} + + '@types/node@22.15.29': + dependencies: + undici-types: 6.21.0 + accepts@2.0.0: dependencies: mime-types: 3.0.1 @@ -1328,6 +1400,8 @@ snapshots: dependencies: balanced-match: 1.0.2 + buffer-equal-constant-time@1.0.1: {} + bundle-require@5.1.0(esbuild@0.24.2): dependencies: esbuild: 0.24.2 @@ -1401,6 +1475,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} emoji-regex@8.0.0: {} @@ -1605,6 +1683,19 @@ snapshots: json-schema-traverse@1.0.0: {} + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.2 + jszip@3.10.1: dependencies: lie: 3.3.0 @@ -1612,6 +1703,17 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 + jwa@1.4.2: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + lie@3.3.0: dependencies: immediate: 3.0.6 @@ -1622,6 +1724,20 @@ snapshots: load-tsconfig@0.2.5: {} + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + lodash.sortby@4.7.0: {} lru-cache@10.4.3: {} @@ -1801,6 +1917,8 @@ snapshots: safer-buffer@2.1.2: {} + semver@7.7.2: {} + send@1.2.0: dependencies: debug: 4.3.6 @@ -1968,6 +2086,8 @@ snapshots: typescript@5.5.4: {} + undici-types@6.21.0: {} + unpipe@1.0.0: {} util-deprecate@1.0.2: {} @@ -1976,12 +2096,13 @@ snapshots: vary@1.1.2: {} - vite@6.0.7(jiti@2.4.2): + vite@6.0.7(@types/node@22.15.29)(jiti@2.4.2): dependencies: esbuild: 0.24.2 postcss: 8.5.3 rollup: 4.41.0 optionalDependencies: + '@types/node': 22.15.29 fsevents: 2.3.3 jiti: 2.4.2 From 1c57ad1a390ff4ee45a4b707b9831973028d8b0d Mon Sep 17 00:00:00 2001 From: "tv0924@icloud.com" Date: Mon, 2 Jun 2025 10:56:42 +0900 Subject: [PATCH 05/15] =?UTF-8?q?[Update]=20oauth=20=ED=83=90=EC=A7=80=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=A0=95=EA=B5=90=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/controller/csrfCheck.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/controller/csrfCheck.ts b/packages/backend/src/controller/csrfCheck.ts index 1826ddd..5931428 100644 --- a/packages/backend/src/controller/csrfCheck.ts +++ b/packages/backend/src/controller/csrfCheck.ts @@ -7,13 +7,13 @@ const httpUtils = new HttpUtils(); export class CsrfCheck { private isTargetUri(uri: string): boolean { if ( - httpUtils.getQueryParamFromURI(uri, "client_id") && - (httpUtils.getQueryParamFromURI(uri, "response_type") || - httpUtils.getQueryParamFromURI(uri, "grant_type") || - httpUtils.getQueryParamFromURI(uri, "redirect_uri") || - httpUtils.getQueryParamFromURI(uri, "scope") || - httpUtils.getQueryParamFromURI(uri, "state") || - httpUtils.getQueryParamFromURI(uri, "nonce")) + httpUtils.getQueryParamFromURI(uri, "client_id") !== null && + (httpUtils.getQueryParamFromURI(uri, "response_type") !== null || + httpUtils.getQueryParamFromURI(uri, "grant_type") !== null || + httpUtils.getQueryParamFromURI(uri, "redirect_uri") !== null || + httpUtils.getQueryParamFromURI(uri, "scope") !== null || + httpUtils.getQueryParamFromURI(uri, "state") !== null || + httpUtils.getQueryParamFromURI(uri, "nonce") !== null) ) { return true; } From d3a0e8ae848dd41086fafe774f418a56c7b9b743 Mon Sep 17 00:00:00 2001 From: kyu Date: Mon, 2 Jun 2025 21:36:55 +0900 Subject: [PATCH 06/15] =?UTF-8?q?=EC=98=A4=EB=A5=98=EC=9E=A1=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/index.ts | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index b36d4e2..1d17c25 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -6,7 +6,7 @@ import { CsrfCheck } from "./controller/csrfCheck"; import { PKCECheck } from "./controller/PKCECheck"; import { AccessTokenLeakController } from "./controller/accessTokenDetector"; import { ScopeDetection } from "./controller/scopeDetection"; -import { NonceCheckController } from "./controller/nonceCheck"; +// import { NonceCheckController } from "./controller/nonceCheck"; import { ClientSecretController } from "./controller/clientsecretCheck"; export type API = DefineAPI<{}>; @@ -17,7 +17,7 @@ const csrfCheck = new CsrfCheck(); const pkceCheckController = new PKCECheck(); const tokenCheck = new AccessTokenLeakController(); const ScopeDetectionController = new ScopeDetection(); -const nonceCheckController = new NonceCheckController(); +// const nonceCheckController = new NonceCheckController(); const clientSecretController = new ClientSecretController(); export function init(sdk: SDK) { @@ -27,20 +27,26 @@ export function init(sdk: SDK) { await tokenCheck.testReq(sdk, req); await tokenCheck.testResp(sdk, res, req); await ScopeDetectionController.scan(sdk, req.getUrl()); - await clientSecretController.report(sdk,req); + // await clientSecretController.report(sdk,req); - if (NonceCheckController.isOidcFlow(req, res)) { - await sdk.findings.create({ - title: "OIDC Flow Detected", - description: "The request appears to be part of an OIDC flow.", - request: req, - reporter: "", - }); - } + // if (NonceCheckController.isOidcFlow(req, res)) { + // await sdk.findings.create({ + // title: "OIDC Flow Detected", + // description: "The request appears to be part of an OIDC flow.", + // request: req, + // reporter: "", + // }); + // } }); - /* + sdk.events.onInterceptRequest(async (sdk, req: Request) => { + if (clientSecretController.test(req)) { + await clientSecretController.report(sdk,req); + } + });/* + + await clientSecretController.report(sdk,req);}) const result = authZCodeGrantController.testReq(req) || implicitGrantController.testReq(req); From 5c6d9cb6004aaad65cb96bccb2909e3402575911 Mon Sep 17 00:00:00 2001 From: kyu Date: Mon, 2 Jun 2025 22:00:14 +0900 Subject: [PATCH 07/15] =?UTF-8?q?basic=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/controller/clientsecretCheck.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/controller/clientsecretCheck.ts b/packages/backend/src/controller/clientsecretCheck.ts index 0a13917..8bb01b3 100644 --- a/packages/backend/src/controller/clientsecretCheck.ts +++ b/packages/backend/src/controller/clientsecretCheck.ts @@ -14,7 +14,7 @@ export class ClientSecretController { return ( query.includes("client_secret=") || body.includes("client_secret=") || - auth.toLowerCase().startsWith("basic ") + auth.toLowerCase().startsWith("basic Y2xpZW50X3NlY3JldA") ); } From c72f103221e873c582db7b68c32a71d482681317 Mon Sep 17 00:00:00 2001 From: imnyang Date: Mon, 2 Jun 2025 22:03:52 +0900 Subject: [PATCH 08/15] =?UTF-8?q?FEAT:=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=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()); From 986c6e59b6438f83e022fb2341b67b11bda366e7 Mon Sep 17 00:00:00 2001 From: gyuu04 Date: Tue, 3 Jun 2025 12:26:03 +0900 Subject: [PATCH 09/15] Create redirect_uriBypass.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit redirect_uri 우회 탐지 로직 추가 --- .../src/controller/redirect_uriBypass.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 packages/backend/src/controller/redirect_uriBypass.ts diff --git a/packages/backend/src/controller/redirect_uriBypass.ts b/packages/backend/src/controller/redirect_uriBypass.ts new file mode 100644 index 0000000..8b4b436 --- /dev/null +++ b/packages/backend/src/controller/redirect_uriBypass.ts @@ -0,0 +1,40 @@ +import type { Request, Response } from "caido:utils"; + + +export class RedirectBypassController { + isRedirectUri(req: Request): boolean { + const query = req.getQuery(); + + + // redirect_uri 파라미터 정규식으로 추출 + const redirectUriMatch = query.match(/redirect_uri=([^&]+)/i); + if (!redirectUriMatch) return false; + + + // redirect_uri 파라미터의 URL 문자열을 디코딩 + const redirectUri = decodeURIComponent(redirectUriMatch[1]); + + + // 우회 키워드 + const bypassPatterns = [ + "%ff@", "/", "%2f@", "%0a@", "%0d@", "\\", ".evil.com", "@", "%2f..%2f" + ]; + + + return bypassPatterns.some(pattern => redirectUri.includes(pattern)); + } + + + isCodeIssued(res: Response): boolean { + const location = res.getHeader("Location") || ""; + return location.includes("code="); + } + + + test(req: Request, res: Response): string | false { + if (this.isRedirectUri(req) && this.isCodeIssued(res)) { + return "redirect_uri bypass detected"; + } + return false; + } +} From 78042ef30509c0745beb289dc456641ac57d6926 Mon Sep 17 00:00:00 2001 From: gyuu04 Date: Tue, 3 Jun 2025 12:44:48 +0900 Subject: [PATCH 10/15] =?UTF-8?q?[Add]=20RedirectBypassController=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8B=A4=ED=96=89=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - redirect_uri 우회 탐지용 RedirectBypassController 클래스 추가 - index.ts에 testAsync 연결 로직 삽입 --- .../src/controller/redirect_uriBypass.ts | 20 ++++++++++++------- packages/backend/src/index.ts | 3 +++ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/controller/redirect_uriBypass.ts b/packages/backend/src/controller/redirect_uriBypass.ts index 8b4b436..f77b324 100644 --- a/packages/backend/src/controller/redirect_uriBypass.ts +++ b/packages/backend/src/controller/redirect_uriBypass.ts @@ -1,40 +1,46 @@ import type { Request, Response } from "caido:utils"; - +import type { SDK } from "caido:plugin"; export class RedirectBypassController { isRedirectUri(req: Request): boolean { const query = req.getQuery(); - // redirect_uri 파라미터 정규식으로 추출 const redirectUriMatch = query.match(/redirect_uri=([^&]+)/i); if (!redirectUriMatch) return false; - // redirect_uri 파라미터의 URL 문자열을 디코딩 const redirectUri = decodeURIComponent(redirectUriMatch[1]); - // 우회 키워드 const bypassPatterns = [ "%ff@", "/", "%2f@", "%0a@", "%0d@", "\\", ".evil.com", "@", "%2f..%2f" ]; - return bypassPatterns.some(pattern => redirectUri.includes(pattern)); } - isCodeIssued(res: Response): boolean { const location = res.getHeader("Location") || ""; return location.includes("code="); } - test(req: Request, res: Response): string | false { if (this.isRedirectUri(req) && this.isCodeIssued(res)) { return "redirect_uri bypass detected"; } return false; } + + async testAsync(sdk: SDK, req: Request, res: Response) { + const result = this.test(req, res); + if (result) { + await sdk.findings.create({ + title: "Redirect URI Bypass Detected", + description: result, + request: req, + reporter: "gyu", + }); + } + } } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 44f817c..43d7516 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -7,6 +7,7 @@ import { PKCECheck } from "./controller/PKCECheck"; import { AccessTokenLeakController } from "./controller/accessTokenDetector"; import { ScopeDetection } from "./controller/scopeDetection"; // import { NonceCheckController } from "./controller/nonceCheck"; +import { RedirectBypassController } from "./controller/redirect_uriBypass"; export type API = DefineAPI<{}>; @@ -15,6 +16,7 @@ const pkceCheckController = new PKCECheck(); const tokenCheck = new AccessTokenLeakController(); const ScopeDetectionController = new ScopeDetection(); // const nonceCheckController = new NonceCheckController(); +const redirectBypassController = new RedirectBypassController(); export function init(sdk: SDK) { sdk.events.onInterceptResponse(async (sdk, req: Request, res: Response) => { @@ -23,6 +25,7 @@ export function init(sdk: SDK) { await tokenCheck.testReq(sdk, req); await tokenCheck.testResp(sdk, res, req); await ScopeDetectionController.scan(sdk, req.getUrl()); + await redirectBypassController.testAsync(sdk, req, res); // if (NonceCheckController.isOidcFlow(req, res)) { // await sdk.findings.create({ From 979dda299a720d9ac4bdaa6284eceb7895cf47fc Mon Sep 17 00:00:00 2001 From: gyuu04 Date: Tue, 3 Jun 2025 14:44:09 +0900 Subject: [PATCH 11/15] Update redirect_uriBypass.ts --- .../src/controller/redirect_uriBypass.ts | 45 ++++++++++++------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/packages/backend/src/controller/redirect_uriBypass.ts b/packages/backend/src/controller/redirect_uriBypass.ts index f77b324..ce521cb 100644 --- a/packages/backend/src/controller/redirect_uriBypass.ts +++ b/packages/backend/src/controller/redirect_uriBypass.ts @@ -2,42 +2,55 @@ import type { Request, Response } from "caido:utils"; import type { SDK } from "caido:plugin"; export class RedirectBypassController { - isRedirectUri(req: Request): boolean { + // redirect_uri를 확인하는 함수 + isRedirectUri(req: Request): { detected: boolean; redirectUri?: string } { + // ? 뒤에 오는 파라미터 모두 가져오고, 정규표현식으로 redirect_uri= 이후 주소만 뽑음(없으면 null) const query = req.getQuery(); - - // redirect_uri 파라미터 정규식으로 추출 const redirectUriMatch = query.match(/redirect_uri=([^&]+)/i); - if (!redirectUriMatch) return false; - // redirect_uri 파라미터의 URL 문자열을 디코딩 + // redirectUriMatch[1]은 ()로 감싼 부분 + // redirect_uri 파라미터가 없거나 있어도 주소가 문자열이 아니면 false + if (!redirectUriMatch || typeof redirectUriMatch[1] !== "string") { + return { detected: false }; + } + + // 인코딩된 주소를 원래대로 바꿈 (ex. https://~~) const redirectUri = decodeURIComponent(redirectUriMatch[1]); - // 우회 키워드 const bypassPatterns = [ - "%ff@", "/", "%2f@", "%0a@", "%0d@", "\\", ".evil.com", "@", "%2f..%2f" + "%ff@", "/", "%2f@", "%0a@", "%0d@", "\\", ".evil.com", "@", "%2f..%2f", ]; - - return bypassPatterns.some(pattern => redirectUri.includes(pattern)); + + // 위 패턴에 일치하는 게 있으면 true랑 redirectUri 반환 (false일 땐 undefined) + const detected = bypassPatterns.some(pattern => redirectUri.includes(pattern)); + return { detected, redirectUri: detected ? redirectUri : undefined }; } + // 응답에 인가 코드가 포함되어 있는지 확인하는 함수 isCodeIssued(res: Response): boolean { const location = res.getHeader("Location") || ""; return location.includes("code="); } - test(req: Request, res: Response): string | false { - if (this.isRedirectUri(req) && this.isCodeIssued(res)) { - return "redirect_uri bypass detected"; + // 위의 두 함수 모두 만족하면 true, 문제의 주소를 반환하는 함수 + test(req: Request, res: Response): { detected: boolean; redirectUri?: string } { + const redirectCheck = this.isRedirectUri(req); + const codeIssued = this.isCodeIssued(res); + + if (redirectCheck.detected && codeIssued) { + return { detected: true, redirectUri: redirectCheck.redirectUri }; } - return false; + + return { detected: false }; } - async testAsync(sdk: SDK, req: Request, res: Response) { + // 탐지된 결과 저장하는 함수 + async testAsync(sdk: SDK, req: Request, res: Response): Promise { const result = this.test(req, res); - if (result) { + if (result.detected) { await sdk.findings.create({ title: "Redirect URI Bypass Detected", - description: result, + description: `redirect_uri 우회 발견\nRedirect URI: ${result.redirectUri}`, request: req, reporter: "gyu", }); From efb89c668c6a785437d23560425310770507e190 Mon Sep 17 00:00:00 2001 From: "tv0924@icloud.com" Date: Wed, 4 Jun 2025 16:02:42 +0900 Subject: [PATCH 12/15] =?UTF-8?q?[Update]=20nonce=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=20=EA=B0=90=EC=A7=80=20=EB=B2=94=EC=9C=84=20?= =?UTF-8?q?=EB=8A=98=EB=A6=BC=20=EB=B0=8F=20nonce=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=20=EC=9E=AC=EC=82=AC=EC=9A=A9=EC=97=90?= =?UTF-8?q?=EB=8C=80=ED=95=9C=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/controller/csrfCheck.ts | 243 ++++++++++++------- packages/backend/src/utils/http.ts | 135 ++++++++++- playground/csrf/index.js | 24 +- 3 files changed, 312 insertions(+), 90 deletions(-) diff --git a/packages/backend/src/controller/csrfCheck.ts b/packages/backend/src/controller/csrfCheck.ts index 5931428..8a6f723 100644 --- a/packages/backend/src/controller/csrfCheck.ts +++ b/packages/backend/src/controller/csrfCheck.ts @@ -5,6 +5,15 @@ import { HttpUtils } from "../utils/http"; const httpUtils = new HttpUtils(); export class CsrfCheck { + private nonceParam = [ + "state", + "nonce", + "as", + "frame_id", + "csrf_token", + "csrf", + ]; + private isTargetUri(uri: string): boolean { if ( httpUtils.getQueryParamFromURI(uri, "client_id") !== null && @@ -43,105 +52,178 @@ export class CsrfCheck { return false; } - private isStateInQuery(request: Request): boolean { - const query = request.getQuery(); - const stateValue = - httpUtils.getQueryParam(query || "", "state") || - httpUtils.getQueryParam(query || "", "nonce"); - if (!stateValue) { - return false; + private isNonceInQuery(request: Request): boolean { + const query = request.getQuery() || ""; + + for (const param of this.nonceParam) { + if (httpUtils.getQueryParam(query, param) !== null) { + return true; // Nonce parameter is present in the query + } } - return true; + + return false; // No nonce parameter found in the query } - private checkStateAtResponseLocationHeader( + private getNonceParamName(url: string): string | null { + for (const param of this.nonceParam) { + if (httpUtils.getQueryParamFromURI(url, param) !== null) { + return param; // Return the first matching nonce parameter + } + } + + return null; // No nonce parameter found + } + + private checkNonceAtResponseLocationHeader( request: Request, response: Response ): string[] | 0 { + const nonceParamName = this.getNonceParamName(request.getUrl() || ""); + if ( - !( - this.isOauthUri(request) && - this.isStateInQuery(request) && - this.isOauthRedirectResponse(response) - ) + !this.isOauthUri(request) || + !this.isNonceInQuery(request) || + !this.isOauthRedirectResponse(response) || + !nonceParamName ) { return 0; // Not a target, no CSRF risk } - // 요청에서 보낸 state 추출 + // 요청에서 보낸 Nonce 추출 const query = request.getQuery() || ""; - const originalState = - httpUtils.getQueryParam(query, "state") || - httpUtils.getQueryParam(query || "", "nonce"); + const originalNonce = httpUtils.getQueryParam(query, nonceParamName); // 리다이렉트 URL에서 쿼리 부분만 추출 - const locationHeader = httpUtils.getHeaderValue( - response.getHeaders(), - "location" - ); - const responseState = - httpUtils.getQueryParamFromURI(locationHeader || "", "state") || - httpUtils.getQueryParamFromURI(locationHeader || "", "nonce"); + const locationHeader = + httpUtils.getHeaderValue(response.getHeaders(), "location") || ""; - // state가 없거나, 요청값과 다르면 CSRF 위험 - if (!responseState) { + const responseNonce = httpUtils.getQueryParamFromURI( + locationHeader || "", + nonceParamName + ); + + // Nonce가 없거나, 요청값과 다르면 CSRF 위험 + if (!responseNonce) { // missing state - return ["state parameter is missing in the response location header"]; + return ["Nonce parameter is missing in the response location header"]; } - if (originalState !== responseState) { + if (originalNonce !== responseNonce) { // mismatch - return ["state parameter mismatch between request and response"]; + return ["Nonce parameter mismatch between request and response"]; } return 0; // no CSRF risk detected } - // private async checkStateReuse( - // request: Request, - // originResponse: Response - // ): Promise { - // // uri에 oauth 관련 파라미터가 없지만, 응답이 oauth 리다이렉트 응답인지 확인 - // // 즉, 처음으로 state를 발급한 요청인지 확인 - // if ( - // !( - // !this.isOauthUri(request) && - // this.isOauthRedirectResponse(originResponse) - // ) - // ) { - // return 0; // Not a target, no CSRF risk - // } + private async checkNonceReuse( + sdk: SDK, {}>, + request: Request, + originResponse: Response + ): Promise { + // uri에 oauth 관련 파라미터가 없지만, 응답이 oauth 리다이렉트 응답인지 확인 + // 즉, 처음으로 Nonce를 발급한 요청인지 확인 + if ( + this.isOauthUri(request) || + !this.isOauthRedirectResponse(originResponse) + ) { + return 0; // Not a target, no CSRF risk + } - // const originResponseLocationHeader = httpUtils.getHeaderValue( - // originResponse.getHeaders(), - // "location" - // ); - // const originState = httpUtils.getQueryParamFromURI( - // originResponseLocationHeader || "", - // "state" - // ); + // 기존 응답의 location 헤더의 url에서 Nonce 파라미터 이름, nonce 파라미터 값, 쿼리 추출 + const originResponseLocationHeader = + httpUtils.getHeaderValue(originResponse.getHeaders(), "location") || ""; + const nonceParamName = + this.getNonceParamName(originResponseLocationHeader || "") || "state"; + const originLocationQuery = + httpUtils.getQueryFromURI(originResponseLocationHeader || "") || ""; + const originLocationNonce = httpUtils.getQueryParam( + originLocationQuery, + nonceParamName + ); - // const requestHeaders = request.getHeaders(); - // const noCookieHeaders = httpUtils.removeHeaders(requestHeaders, ["cookie"]); - // const newResponse = await httpUtils.resend(request, { - // headers: noCookieHeaders, - // }); - // const newLocationHeader = httpUtils.getHeaderValue( - // newResponse.getHeaders(), - // "location" - // ); - // const newState = httpUtils.getQueryParamFromURI( - // newLocationHeader || "", - // "state" - // ); + // 쿠키가 없는 헤더로 새로운 nonce를 발급받기 위해 요청 + const noCookieHeaders = httpUtils.removeHeaders(request.getHeaders(), [ + "cookie", + ]); + const noCookieResponse = await httpUtils.resend(sdk, request, { + headers: noCookieHeaders, + }); + if (!noCookieResponse || noCookieResponse?.getCode() >= 400) { + return 0; + } - // if (originState === newState) { - // return [ - // "State parameter reused in the response location header, indicating a potential CSRF risk", - // ]; - // } + // 쿠키가 없는 응답의 location 헤더 추출 및 Nonce 추출 + const noCookieLocationHeader = httpUtils.getHeaderValue( + noCookieResponse?.getHeaders() || {}, + "location" + ); + const newNonce = + httpUtils.getQueryParamFromURI( + noCookieLocationHeader || "", + nonceParamName + ) || ""; - // return 0; // no CSRF risk detected - // } + if (originLocationNonce === newNonce) { + return [ + "State parameter reused in the response location header, indicating a potential CSRF risk", + ]; + } + + // 기존 쿠키와 함께 새로운 Nonce로 요청 + const newQuery = httpUtils.setQueryParam( + originLocationQuery, + nonceParamName, + newNonce + ); + + // 기존 location 헤더의 uri 요청과 location 헤더에서 nonce값만 새로 발급한 값으로 바꾸어 요청한 결과를 비교 + const res1 = await httpUtils.customFetch( + sdk, + originResponseLocationHeader, + "GET", + originLocationQuery, + request.getHeaders() + ); + + const res2 = await httpUtils.customFetch( + sdk, + originResponseLocationHeader, + "GET", + newQuery, + request.getHeaders() + ); + + if ( + !res1 || + !res2 || + res1.getCode() >= 400 || + res2.getCode() >= 400 || + res1.getCode() !== res2.getCode() + ) { + return 0; + } + + if ( + res1.getCode() === res2.getCode() && + 300 <= res1.getCode() && + res1.getCode() < 400 + ) { + const res1LocationHeader = + httpUtils.getHeaderValue(res1.getHeaders(), "location") || ""; + const res2LocationHeader = + httpUtils.getHeaderValue(res2.getHeaders(), "location") || ""; + const res1ReirectPath = httpUtils.getPathFromURI(res1LocationHeader); + const res2ReirectPath = httpUtils.getPathFromURI(res2LocationHeader); + + if (res1ReirectPath === res2ReirectPath) { + return [ + "When nonce parameter reused in the response location header, it might not be verified. Indicating a potential CSRF risk", + ]; + } + } + + return 0; // no CSRF risk detected + } async checker( sdk: SDK, {}>, @@ -152,7 +234,7 @@ export class CsrfCheck { // 쿼리에 state 파라미터가 없으면 CSRF 위험 try { - if (this.isOauthUri(request) && !this.isStateInQuery(request)) { + if (this.isOauthUri(request) && !this.isNonceInQuery(request)) { result += "CSRF risk: missing state parameter"; // CSRF risk: missing state parameter } } catch (error) { @@ -162,7 +244,7 @@ export class CsrfCheck { // location 헤더에 state 파라미터가 없거나, 요청에서 보낸 state와 다르면 CSRF 위험 try { const stateAtResponseLocationHeaderCheck = - this.checkStateAtResponseLocationHeader(request, response); + this.checkNonceAtResponseLocationHeader(request, response); if (stateAtResponseLocationHeaderCheck !== 0) { result += `, ${stateAtResponseLocationHeaderCheck.join(", ")}`; } @@ -172,14 +254,14 @@ export class CsrfCheck { ); } - // // 처음으로 state를 발급한 요청에서 state 파라미터를 바꿔서 보내기 - // const reusedStateCheck = await this.checkStateReuse(request, response); - // if (reusedStateCheck !== 0) { - // result += `, ${reusedStateCheck.join(", ")}`; - // } + // 처음으로 state를 발급한 요청에서 state 파라미터를 바꿔서 보내기 + const reusedStateCheck = await this.checkNonceReuse(sdk, request, response); + if (reusedStateCheck !== 0) { + result += `, ${reusedStateCheck.join(", ")}`; + } - result.replace(/^\s*,\s*|\s*$/, ""); // Remove leading/trailing commas try { + result.replace(/^\s*,\s*|\s*$/, ""); // Remove leading/trailing commas if (result) { await sdk.findings.create({ title: "csrf vuln", @@ -187,7 +269,6 @@ export class CsrfCheck { request, reporter: "csrf reporter", }); - sdk.console.log("qq"); } } catch (error) { sdk.console.error(`Error creating finding: ${error}`); diff --git a/packages/backend/src/utils/http.ts b/packages/backend/src/utils/http.ts index 9fcd741..01e2cfc 100644 --- a/packages/backend/src/utils/http.ts +++ b/packages/backend/src/utils/http.ts @@ -1,3 +1,6 @@ +import type { SDK } from "caido:plugin"; +import { Body, RequestSpec, type Request, type Response } from "caido:utils"; + let instance: HttpUtils | null = null; export class HttpUtils { /** @@ -11,6 +14,14 @@ export class HttpUtils { return instance; } + encodeAndLower(value: string): string { + try { + return encodeURIComponent(value).toLowerCase(); + } catch { + return value.toLowerCase(); + } + } + /** * URI 디코딩 후 소문자로 변환하는 헬퍼 함수 * @param value - 디코딩하고 소문자로 변환할 문자열 @@ -47,12 +58,35 @@ export class HttpUtils { return result; } + getPathFromURI(uri: string): string | null { + uri = uri.toLowerCase(); + try { + const urlObj = new URL(uri); + const path = urlObj.pathname; + return path ? decodeURIComponent(path) : null; // 경로가 없으면 null 반환 + } catch (e) { + return null; // URL 파싱 실패 시 null 반환 + } + } + + getQueryFromURI(uri: string): string | null { + uri = uri.toLowerCase(); + try { + const urlObj = new URL(uri); + const query = urlObj.search; + return query ? decodeURIComponent(query.slice(1)) : null; // 쿼리 문자열에서 ? 제거 + } catch (e) { + return null; // URL 파싱 실패 시 null 반환 + } + } + getQueryParamFromURI(uri: string, key: string): string | null { - uri = this.decodeAndLower(uri); + uri = uri.toLowerCase(); key = this.decodeAndLower(key); try { const urlObj = new URL(uri); - return urlObj.searchParams.get(key); + const param = urlObj.searchParams.get(key); + return param ? decodeURIComponent(param) : null; } catch (e) { return null; } @@ -66,11 +100,12 @@ export class HttpUtils { * @returns - 해당 파라미터 값, 없으면 null */ getQueryParam(query: string, key: string): string | null { - query = this.decodeAndLower(query); + query = query.toLowerCase(); key = this.decodeAndLower(key); const params = new URLSearchParams(query); - return params.get(key); + const targetParam = params.get(key); + return targetParam ? decodeURIComponent(targetParam) : null; } /** @@ -82,12 +117,12 @@ export class HttpUtils { * @returns - "a=1&b=2&c=3..." 형태의 새로운 쿼리 문자열 */ setQueryParam(query: string, key: string, value: string): string { - query = this.decodeAndLower(query); + query = query.toLowerCase(); key = this.decodeAndLower(key); value = this.decodeAndLower(value); const params = new URLSearchParams(query); - params.set(key, value); + params.set(key, this.encodeAndLower(value)); return params.toString(); } @@ -99,7 +134,7 @@ export class HttpUtils { * @returns - 삭제된 상태의 새로운 쿼리 문자열 */ removeQueryParam(query: string, key: string): string { - query = this.decodeAndLower(query); + query = query.toLowerCase(); key = this.decodeAndLower(key); const params = new URLSearchParams(query); @@ -109,6 +144,7 @@ export class HttpUtils { // Headers /** + * !! 만약 request.getHeader(`${key}`)을 사용할 수 있다면 이 함수를 사용하지 마세요. * 주어진 헤더 맵에서 name에 해당하는 첫 번째 헤더 값을 반환합니다. * @param headers - Response.getHeaders() 가 반환하는 객체 * @param name - 꺼내고 싶은 헤더 이름 (예: "location", "Content-Type") @@ -207,4 +243,89 @@ export class HttpUtils { } return filtered; } + + async resend( + sdk: SDK, + request: Request, + options?: { + headers?: Record; + body?: Body; + method?: string; + query?: string; + } + ): Promise { + try { + const spec = new RequestSpec(request.getUrl()); + spec.setMethod(options?.method || request.getMethod() || "GET"); + if (options?.query) { + spec.setQuery(options.query); + } else { + spec.setQuery(request.getQuery() || ""); + } + + const originBody = request.getBody(); + if (options?.body) { + spec.setBody(options.body); + } else if (originBody) { + spec.setBody(originBody); + } + + const headers = request.getHeaders(); + if (options?.headers) { + // 기존 헤더에서 options.headers로 덮어쓰기 + const newHeaders = this.lowerCaseAllHeaders({ + ...headers, + ...options.headers, + }); + for (const [key, value] of Object.entries(newHeaders)) { + spec.setHeader(key, Array.isArray(value) ? value.join(", ") : value); + } + } else { + // 기존 헤더 그대로 사용 + for (const [key, value] of Object.entries(headers)) { + spec.setHeader(key, Array.isArray(value) ? value.join(", ") : value); + } + } + + const result = await sdk.requests.send(spec); + return result.response ?? null; + } catch (error) { + sdk.console.error( + `Error resending request to ${request.getUrl()}: ${String(error)}` + ); + return null; + } + } + + async customFetch( + sdk: SDK, + url: string, + method?: string, + query?: string, + headers?: Record, + body?: Body + ): Promise { + try { + const spec = new RequestSpec(url); + spec.setMethod(method || "GET"); + if (query) { + spec.setQuery(query); + } + if (body) { + spec.setBody(body); + } + + for (const [key, value] of Object.entries(headers || {})) { + spec.setHeader(key, Array.isArray(value) ? value.join(", ") : value); + } + + const result = await sdk.requests.send(spec); + return result.response ?? null; + } catch { + sdk.console.error( + `Error during custom fetch to ${url}: ${String(error)}` + ); + return null; + } + } } diff --git a/playground/csrf/index.js b/playground/csrf/index.js index 5c7a733..01c2bba 100644 --- a/playground/csrf/index.js +++ b/playground/csrf/index.js @@ -1,5 +1,6 @@ // app.js const express = require("express"); +const crypto = require("crypto"); const app = express(); const port = 8000; @@ -43,8 +44,6 @@ app.get("/authorize/mismatch-state", (req, res) => { ); const code = "authcode-67890"; - console.log(`[VULN] original state from client:`, originalState); - // 클라이언트 state와 다르게 'wrong-state'를 삽입 const wrongState = "wrong-state"; const location = `${redirectUri}?code=${code}&state=${wrongState}&client_id=${clientId}`; @@ -52,6 +51,24 @@ app.get("/authorize/mismatch-state", (req, res) => { res.status(302).send(`Redirecting to ${location}`); }); +/** + * 3) 랜덤 state를 생성하여 리다이렉트를 발생시키는 테스트용 엔드포인트 + * - /authorize/reuse-state-test 로 접근할 때마다 새로운 16진수 state를 생성 + * - 최초 요청에 OAuth 파라미터가 없으므로 isOauthUri(request) == false + * - 응답에 Location 헤더로 '...?state=<랜덤값>' 을 포함 + * -> Caido 플러그인의 checkNonceReuse 로직에서 새로운 state가 발급되었는지, + * 재사용되었는지를 검증할 수 있음 + * - 더하여 callback uri에서 해당 nonce의 유효성을 판단하지 않고 응답 시에 vuln + */ +app.get("/authorize/reuse-state-test", (req, res) => { + const state = crypto.randomBytes(16).toString("hex"); + + // 고정된 콜백 URI로 리다이렉트 (OAuth 파라미터는 여기서만 주입) + const location = `http://localhost:${port}/callback?state=${state}&client_id=123`; + res.set("Location", location); + res.status(302).send(`Redirecting to ${location}`); +}); + app.listen(port, () => { console.log( `Vulnerable OAuth test server listening at http://localhost:${port}` @@ -62,4 +79,7 @@ app.listen(port, () => { console.log( `2) Mismatch-State: http://localhost:${port}/authorize/mismatch-state?client_id=abc&state=xyz&redirect_uri=http://localhost:${port}/callback` ); + console.log( + `3) Reuse-State-Test: http://localhost:${port}/authorize/reuse-state-test` + ); }); From 1bc442b1d33a2dd4651510f402536cdff7fa08d6 Mon Sep 17 00:00:00 2001 From: KMINGON Date: Wed, 4 Jun 2025 17:01:32 +0900 Subject: [PATCH 13/15] =?UTF-8?q?[FIX]:=20tokenType=EA=B9=8C=EC=A7=80=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=ED=95=98=EC=97=AC=20OAuth=20Flow=EC=9D=B8?= =?UTF-8?q?=EC=A7=80=20=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controller/accessTokenDetector.ts | 71 ++++++++++++------- 1 file changed, 46 insertions(+), 25 deletions(-) diff --git a/packages/backend/src/controller/accessTokenDetector.ts b/packages/backend/src/controller/accessTokenDetector.ts index 6e95120..283b19b 100644 --- a/packages/backend/src/controller/accessTokenDetector.ts +++ b/packages/backend/src/controller/accessTokenDetector.ts @@ -132,34 +132,53 @@ export class AccessTokenLeakController { * @param text - 검사할 텍스트 * @returns 토큰 값이 있으면 해당 값, 없으면 null */ -private extractTokenFromText(text: string): string | null { + private extractTokenFromText(text: string): string | null { // 토큰 관련 키워드 리스트 const tokenKeys = [ - 'access_token', - 'accesstoken', - 'Access-Token', - 'Refresh_Token', - 'Refresh-Token', - 'RefreshToken', - 'Secret_Token', - 'Secret-Token', - 'SecretToken', - 'SSO_Auth', - 'SSO-Auth', - 'SSOAuth', - 'auth_token', - 'session_token' - ]; + 'access_token', + 'accesstoken', + 'Access-Token', + 'Refresh_Token', + 'Refresh-Token', + 'RefreshToken', + 'Secret_Token', + 'Secret-Token', + 'SecretToken', + 'SSO_Auth', + 'SSO-Auth', + 'SSOAuth', + 'auth_token', + 'session_token' + ]; - // 정규표현식 패턴 리스트 생성 + const tokenTypeKeys = [ + 'token_type', + 'tokenType' + ]; + + // 정규표현식 토큰 타입 유무 패턴 리스트 생성 + const tokenTypeRegexes: RegExp[] = []; + for (const key of tokenTypeKeys) { + // JSON 형식: "token_type": "Bearer" + tokenTypeRegexes.push(new RegExp(`"${key}"\\s*:\\s*"bearer"`, 'i')); + // 일반 key=value 형식: token_type=Bearer + tokenTypeRegexes.push(new RegExp(`${key}[=:]\\s*bearer`, 'i')); + // 공백 있는 형식: token_type : Bearer + tokenTypeRegexes.push(new RegExp(`${key}\\s*:\\s*bearer`, 'i')); + } + + // token_type=bearer 형태 중 하나라도 포함되는지 확인 + const hasTokenTypeBearer = tokenTypeRegexes.some(rx => rx.test(text)); + + // 정규표현식 토큰 유무 패턴 리스트 생성 const tokenPatterns: RegExp[] = []; for (const key of tokenKeys) { - // 1. key=token 또는 key: token - tokenPatterns.push(new RegExp(`${key}[=:]\\s*([a-zA-Z0-9\\-._~+/]+=*)`, 'i')); + // 1. key=token 또는 key: token + tokenPatterns.push(new RegExp(`${key}[=:]\\s*([a-zA-Z0-9\\-._~+/]+=*)`, 'i')); - // 2. JSON 형태의 "key": "token" - tokenPatterns.push(new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`, 'i')); + // 2. JSON 형태의 "key": "token" + tokenPatterns.push(new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`, 'i')); } // 3. Authorization: Bearer 형태 @@ -167,12 +186,14 @@ private extractTokenFromText(text: string): string | null { // 모든 패턴에 대해 검사 for (const pattern of tokenPatterns) { - const match = pattern.exec(text); - if (match && match[1]) { - return match[1]; + const match = pattern.exec(text); + if (match && match[1]) { + if(hasTokenTypeBearer){ + return match[1]; } + } } return null; - } + } } \ No newline at end of file From ac53cd4be5804952b16e4064ed4175fcbdc673c8 Mon Sep 17 00:00:00 2001 From: KMINGON Date: Wed, 4 Jun 2025 17:04:39 +0900 Subject: [PATCH 14/15] =?UTF-8?q?[FIX]:=20index=EC=9D=98=20response?= =?UTF-8?q?=EC=97=90=20=EC=9C=84=EC=B9=98=ED=95=98=EB=8D=98=20request?= =?UTF-8?q?=EA=B2=80=EC=82=AC=20=ED=95=A8=EC=88=98=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 43d7516..a5e9113 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -22,7 +22,6 @@ export function init(sdk: SDK) { sdk.events.onInterceptResponse(async (sdk, req: Request, res: Response) => { await csrfCheck.checker(sdk, req, res); //await pkceCheckController.test(sdk, req); - await tokenCheck.testReq(sdk, req); await tokenCheck.testResp(sdk, res, req); await ScopeDetectionController.scan(sdk, req.getUrl()); await redirectBypassController.testAsync(sdk, req, res); @@ -38,6 +37,7 @@ export function init(sdk: SDK) { }); sdk.events.onInterceptRequest(async (sdk, req: Request) => { + await tokenCheck.testReq(sdk, req); await pkceCheckController.test(sdk, req); }); /* From 195be25c2297ceedcafc59a24e216a3acd1295c8 Mon Sep 17 00:00:00 2001 From: KMINGON Date: Wed, 4 Jun 2025 22:36:37 +0900 Subject: [PATCH 15/15] =?UTF-8?q?[DOCS]=20:=20findings=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=EB=90=A0=20=EB=95=8C=20reporter=20=EA=B0=92=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/controller/accessTokenDetector.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/controller/accessTokenDetector.ts b/packages/backend/src/controller/accessTokenDetector.ts index 283b19b..c0570d0 100644 --- a/packages/backend/src/controller/accessTokenDetector.ts +++ b/packages/backend/src/controller/accessTokenDetector.ts @@ -19,7 +19,7 @@ export class AccessTokenLeakController { title: result.title, description: result.description, request, - reporter: "", + reporter: "AccessTokenLeak", }); } } @@ -31,7 +31,7 @@ export class AccessTokenLeakController { title: result.title, description: result.description, request, - reporter: "", + reporter: "AccessTokenLeak", }); } }