2222from lib .core .enums import CUSTOM_LOGGING
2323from lib .core .enums import POST_HINT
2424from lib .core .settings import ERROR_PARSING_REGEXES
25+ from lib .core .settings import GRAPHQL_ARG_WORDLIST
2526from lib .core .settings import GRAPHQL_ENDPOINT_PATHS
2627from lib .core .settings import GRAPHQL_ERROR_REGEX
28+ from lib .core .settings import GRAPHQL_FIELD_WORDLIST
2729from lib .core .settings import GRAPHQL_INTROSPECTION_QUERY
2830from lib .core .settings import NOSQL_ERROR_REGEX
2931from lib .core .settings import UPPER_RATIO_BOUND
@@ -354,6 +356,90 @@ def _introspect(endpoint):
354356 return None
355357
356358
359+ # --- Schema recovery via field suggestions (introspection disabled) ---------
360+
361+ def _gqlErrors (page ):
362+ # GraphQL error-envelope messages as a list of strings
363+ doc = _parseJSON (page )
364+ if not isinstance (doc , dict ):
365+ return []
366+ return [getUnicode (e .get ("message" , "" )) for e in (doc .get ("errors" ) or []) if isinstance (e , dict )]
367+
368+
369+ def _harvestSuggestions (message ):
370+ # Pull suggested identifiers out of a "Did you mean ..." GraphQL validation message,
371+ # handling both single- and double-quoted phrasings ('a', 'b', or 'c' / "a" or "b")
372+ idx = message .find ("Did you mean" )
373+ if idx < 0 :
374+ return []
375+ return re .findall (r"""['"]([A-Za-z_][A-Za-z0-9_]*)['"]""" , message [idx :])
376+
377+
378+ def _suggestFields (endpoint , op ):
379+ # Recover root field names for an operation via suggestion harvesting: probe a random
380+ # (guaranteed-unknown) field to collect the closest matches, then confirm/expand using a
381+ # seed wordlist. A seed that does NOT come back as "Cannot query field" is itself a real field.
382+ prefix = "" if op == "query" else "mutation "
383+ found = set ()
384+ probes = [randomStr (length = 10 , lowercase = True )] + list (GRAPHQL_FIELD_WORDLIST )
385+
386+ for seed in probes :
387+ page , _ = _gqlSend (endpoint , "%s{ %s }" % (prefix , seed ))
388+ doc = _parseJSON (page ) or {}
389+ for entry in (doc .get ("errors" ) or []):
390+ message = getUnicode (entry .get ("message" , "" )) if isinstance (entry , dict ) else ""
391+ if "Did you mean" in message and "on type" in message :
392+ found .update (_harvestSuggestions (message ))
393+ # a seeded name counts as a real field only if it actually resolved (appears in `data`);
394+ # "no unknown-field error" alone is too weak (lenient servers accept anything)
395+ data = doc .get ("data" )
396+ if seed in GRAPHQL_FIELD_WORDLIST and isinstance (data , dict ) and seed in data :
397+ found .add (seed )
398+
399+ return sorted (found )
400+
401+
402+ def _suggestArgs (endpoint , op , field ):
403+ # Recover an argument name for `field` from an "Unknown argument ... Did you mean ..." message
404+ prefix = "" if op == "query" else "mutation "
405+ bogus = randomStr (length = 10 , lowercase = True )
406+ page , _ = _gqlSend (endpoint , '%s{ %s(%s: 1) }' % (prefix , field , bogus ))
407+ found = set ()
408+ for message in _gqlErrors (page ):
409+ if "Unknown argument" in message :
410+ found .update (_harvestSuggestions (message ))
411+ return sorted (found )
412+
413+
414+ def _introspectViaSuggestions (endpoint ):
415+ # Fallback schema recovery when introspection is disabled but the server still leaks field/argument
416+ # names through "Did you mean" validation errors. Builds best-effort Slots: known scalar arg types
417+ # are unavailable here, so we default to the 'string' strategy (the most broadly injectable) and let
418+ # the per-slot injection oracle confirm which (field, argument) pairs are actually vulnerable.
419+
420+ probe = randomStr (length = 10 , lowercase = True )
421+ page , _ = _gqlSend (endpoint , "{ %s }" % probe )
422+ if not any ("Did you mean" in m for m in _gqlErrors (page )):
423+ return None
424+
425+ logger .info ("introspection is disabled; recovering the schema from field-suggestion errors" )
426+
427+ slots = []
428+ for op , parentName in (("query" , "Query" ), ("mutation" , "Mutation" )):
429+ fields = _suggestFields (endpoint , op )
430+ if not fields :
431+ continue
432+ logger .info ("recovered %d %s field(s) via suggestions: %s" % (
433+ len (fields ), op , ", " .join (fields )))
434+ for field in fields :
435+ args = _suggestArgs (endpoint , op , field ) or list (GRAPHQL_ARG_WORDLIST )
436+ for arg in args :
437+ # returnSel="" renders as "{ __typename }" (valid on any OBJECT); strategy="string"
438+ slots .append (Slot (op , parentName , field , [(arg , {}, None )],
439+ arg , "string" , "OBJECT" , "" , "" ))
440+ return slots or None
441+
442+
357443# --- Schema walking ---------------------------------------------------------
358444
359445def _extractSlots (schema ):
@@ -1087,11 +1173,11 @@ def graphqlScan():
10871173 global SENTINEL
10881174 SENTINEL = randomStr (length = 10 , lowercase = True )
10891175
1090- infoMsg = "'--graphql' is self-contained: it discovers the GraphQL endpoint, "
1091- infoMsg += "enumerates the schema, and injects SQL/NoSQL payloads into reachable "
1092- infoMsg += "argument slots. SQL enumeration switches (e.g. --banner, --dbs, "
1093- infoMsg += "--tables) are ignored"
1094- logger .info ( infoMsg )
1176+ debugMsg = "'--graphql' is self-contained: it discovers the GraphQL endpoint, "
1177+ debugMsg += "enumerates the schema, and injects SQL/NoSQL payloads into reachable "
1178+ debugMsg += "argument slots. SQL enumeration switches (e.g. --banner, --dbs, "
1179+ debugMsg += "--tables) are ignored"
1180+ logger .debug ( debugMsg )
10951181
10961182 url = conf .url .rstrip ("/" ) if conf .url else ""
10971183
@@ -1120,19 +1206,22 @@ def graphqlScan():
11201206 # 2. Schema introspection
11211207 logger .info ("introspecting the GraphQL schema" )
11221208 schema = _introspect (endpoint )
1123- if not schema :
1124- logger .error ("introspection failed (disabled or the endpoint rejected the query)" )
1125- return
1126-
1127- types = schema .get ("types" ) or []
1128- logger .info ("introspection returned %d types" % len (types ))
11291209
1130- # 3. Slot enumeration
1131- slots = _extractSlots (schema )
1132- if not slots :
1133- logger .warning ("no injectable argument slots found in the schema" )
1134- _dumpSchema (schema , endpoint )
1135- return
1210+ if schema :
1211+ types = schema .get ("types" ) or []
1212+ logger .info ("introspection returned %d types" % len (types ))
1213+ slots = _extractSlots (schema )
1214+ if not slots :
1215+ logger .warning ("no injectable argument slots found in the schema" )
1216+ _dumpSchema (schema , endpoint )
1217+ return
1218+ else :
1219+ # Introspection blocked: try to recover the schema from field-suggestion errors
1220+ logger .warning ("introspection failed (disabled or rejected); trying suggestion-based recovery" )
1221+ slots = _introspectViaSuggestions (endpoint )
1222+ if not slots :
1223+ logger .error ("could not recover the schema (introspection disabled and no field suggestions)" )
1224+ return
11361225
11371226 querySlots = [_ for _ in slots if _ .operation == "query" ]
11381227 mutationSlots = [_ for _ in slots if _ .operation == "mutation" ]
@@ -1141,8 +1230,10 @@ def graphqlScan():
11411230 len (slots ), len (querySlots ), len (mutationSlots )))
11421231
11431232 # 4. Schema dump (before detection -- matches regular sqlmap table/column
1144- # enumeration preceding data retrieval)
1145- _dumpSchema (schema , endpoint )
1233+ # enumeration preceding data retrieval). Only when introspection succeeded; the
1234+ # suggestion-recovered path has no full schema document to render.
1235+ if schema :
1236+ _dumpSchema (schema , endpoint )
11461237
11471238 if mutationSlots :
11481239 names = sorted (set ("%s(%s:)" % (_ .fieldName , _ .targetArg ) for _ in mutationSlots ))
0 commit comments