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` + ); });