redirect uri misconfig
This commit is contained in:
commit
8e33934951
21 changed files with 1019 additions and 6 deletions
|
|
@ -7,4 +7,4 @@
|
|||
"typecheck": "tsc --noEmit",
|
||||
"build": "vite build"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
144
packages/backend/src/controller/PKCECheck.ts
Normal file
144
packages/backend/src/controller/PKCECheck.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import type { SDK } from "caido:plugin";
|
||||
import { Body, RequestSpec, type Request } from "caido:utils";
|
||||
|
||||
export class PKCECheck {
|
||||
async test(sdk: SDK, req: Request): Promise<boolean> {
|
||||
const method = req.getMethod();
|
||||
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"];
|
||||
|
||||
if (!requiredKeys.every((key) => searchParams.has(key))) {
|
||||
sdk.console.log("[PKCEDowngradeCheck] Required PKCE parameters missing. Skipping.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const url = req.getUrl();
|
||||
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",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove PKCE parameters to simulate a downgraded request
|
||||
searchParams.delete("code_challenge");
|
||||
searchParams.delete("code_challenge_method");
|
||||
const downgradedQuery = searchParams.toString();
|
||||
const scheme = req.getUrl().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);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
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)}`);
|
||||
}
|
||||
|
||||
sdk.console.log("[PKCEDowngradeCheck] No PKCE downgrade detected.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
48
packages/backend/src/controller/authZCodeGrant.ts
Normal file
48
packages/backend/src/controller/authZCodeGrant.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type { Request } from "caido:utils";
|
||||
|
||||
export class AuthZCodeGrantController {
|
||||
constructor() {}
|
||||
|
||||
isAuthZReq(req: Request) {
|
||||
// const path = req.getPath();
|
||||
const query = req.getQuery();
|
||||
// const raw = req.getRaw().toString();
|
||||
|
||||
// 쿼리에 client_id and response_type=code 포함 확인
|
||||
if (query.includes("client_id=") && query.includes("response_type=code")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
isSendCodeToClient(req: Request) {
|
||||
const path = req.getPath();
|
||||
const query = req.getQuery();
|
||||
|
||||
// code & state 쌍 또는 path에 &code= 또는 쿼리로 code= 유사 일치 확인
|
||||
if (
|
||||
(query.includes("code=") && query.includes("state=")) ||
|
||||
path.includes("&code=") ||
|
||||
/code=%/i.test(query)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
testReq(req: Request) {
|
||||
if (this.isAuthZReq(req)) {
|
||||
return "isAuthZReq";
|
||||
}
|
||||
if (this.isSendCodeToClient(req)) {
|
||||
return "isSendCodeToClient";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// isAccessTokenReq(req: Response) {
|
||||
|
||||
// }
|
||||
}
|
||||
40
packages/backend/src/controller/implictGrant.ts
Normal file
40
packages/backend/src/controller/implictGrant.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { Request } from "caido:utils";
|
||||
|
||||
export class ImplicitGrantController {
|
||||
isImplicitGrantReq(req: Request) {
|
||||
const query = req.getQuery();
|
||||
// const raw = req.getRaw().toString();
|
||||
|
||||
// 쿼리에 client_id and response_type=token 포함 확인
|
||||
if (query.includes("client_id=") && query.includes("response_type=token")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
isSendTokenToClient(req: Request) {
|
||||
const path = req.getPath();
|
||||
const query = req.getQuery();
|
||||
|
||||
if (
|
||||
(query.includes("access_token=") && query.includes("state=")) ||
|
||||
path.includes("&access_token=") ||
|
||||
/access_token=%/i.test(query)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
testReq(req: Request) {
|
||||
if (this.isImplicitGrantReq(req)) {
|
||||
return "isImplicitGrantReq";
|
||||
}
|
||||
if (this.isSendTokenToClient(req)) {
|
||||
return "isSendTokenToClient";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
33
packages/backend/src/controller/nonceCheck.ts
Normal file
33
packages/backend/src/controller/nonceCheck.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { Request } from "caido:utils";
|
||||
import { TokenLeakCheck } from "./tokenLeakCheck";
|
||||
|
||||
export class NonceCheckController{
|
||||
/**
|
||||
* 응답이 OIDC(OpenID Connect) 플로우인지 확인하는 메서드
|
||||
*/
|
||||
|
||||
public static isOidcFlow(req: Request): boolean {
|
||||
if(TokenLeakCheck.extractIdToken(req)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
public static isNonceCheckRequest(req: Request): boolean {
|
||||
const id_token = decodeIdToken(req);
|
||||
|
||||
// 1. nonce 파라미터가 포함된 요청인지 확인
|
||||
if (id_token.includes("nonce=")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeIdToken(req: Request): string {
|
||||
// Implement actual decoding logic here. For now, return an empty string or mock value.
|
||||
return "";
|
||||
}
|
||||
|
||||
42
packages/backend/src/controller/tokenLeakCheck.ts
Normal file
42
packages/backend/src/controller/tokenLeakCheck.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { Request } from "caido:utils";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
export class TokenLeakCheck {
|
||||
public static extractIdToken(req: Request): string | null {
|
||||
// 1. Authorization 헤더 확인\\
|
||||
const header = req.getHeaders() as Record<string, string | string[] | undefined>;
|
||||
const authHeader = header["authorization"] || header["Authorization"];
|
||||
if (typeof authHeader === "string" && authHeader.startsWith("Bearer ")) {
|
||||
return authHeader.slice(7).trim();
|
||||
}
|
||||
|
||||
// 2. 쿼리 파라미터
|
||||
const query = req.getQuery();
|
||||
if (query && typeof query === "object" && "id_token" in query) {
|
||||
return (query as Record<string, any>).id_token;
|
||||
}
|
||||
|
||||
// 3. POST 바디 안에 id_token이 있을 경우
|
||||
const rawBody = req.getRaw();
|
||||
const body = rawBody ? rawBody.toString() : "";
|
||||
const match = body.match(/id_token=([^&\s]+)/);
|
||||
if (match && typeof match[1] === "string") {
|
||||
return decodeURIComponent(match[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static decodeIdToken(req: Request): Record<string, any> | null {
|
||||
const token = this.extractIdToken(req);
|
||||
if (!token) return null;
|
||||
|
||||
const decoded = jwt.decode(token, { complete: true });
|
||||
if (!decoded || typeof decoded !== "object") return null;
|
||||
|
||||
return {
|
||||
header: decoded.header,
|
||||
payload: decoded.payload,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -5,4 +5,4 @@
|
|||
"lib": ["ESNext", "DOM"]
|
||||
},
|
||||
"include": ["./src/**/*.ts"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue